Commit 1d54e11a authored by AravindR-K's avatar AravindR-K

feat: changed the layout of the application

parent 324b5c4d
const mongoose = require('mongoose');
const User = require('../models/User');
const createAdmin = async () => {
const createDefaultUsers = async () => {
// Create default admin
const adminExists = await User.findOne({ email: 'admin@quizapp.com' });
if (!adminExists) {
// Pass plain password - User model's pre-save hook will hash it
await User.create({
name: 'Admin',
name: 'Administrator',
email: 'admin@quizapp.com',
password: 'admin123',
role: 'admin'
});
console.log('Default admin created: admin@quizapp.com / admin123');
}
// Create default HR
const hrExists = await User.findOne({ email: 'hr@quizapp.com' });
if (!hrExists) {
await User.create({
name: 'HR Manager',
email: 'hr@quizapp.com',
password: 'hr1234',
role: 'hr'
});
console.log('Default HR created: hr@quizapp.com / hr1234');
}
// Migrate any existing 'student' role users to 'candidate'
const migrated = await User.updateMany(
{ role: 'student' },
{ $set: { role: 'candidate' } }
);
if (migrated.modifiedCount > 0) {
console.log(`Migrated ${migrated.modifiedCount} student(s) to candidate role`);
}
};
const connectDB = async () => {
try {
const conn = await mongoose.connect(process.env.MONGODB_URI);
console.log(`MongoDB Connected: ${conn.connection.host}`);
await createAdmin();
await createDefaultUsers();
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
......
......@@ -18,9 +18,7 @@ const protect = async (req, res, next) => {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
console.log("DECODED:", decoded);
req.user = await User.findById(decoded.id).select('-password');
console.log("USER ROLE:", req.user.role);
if (!req.user) {
return res.status(401).json({ message: 'User not found' });
}
......
......@@ -24,28 +24,36 @@ const quizSchema = new mongoose.Schema({
type: Boolean,
default: true
},
// 🔥 ADD THESE (important)
category: {
type: String,
default: 'General'
default: 'General',
trim: true
},
assignToAll: {
type: Boolean,
default: true
default: false
},
assignees: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
}],
assignedGroups: [{
type: String,
trim: true
}],
difficulty: {
type: String,
enum: ['Beginner', 'Intermediate', 'Hard']
enum: ['Beginner', 'Intermediate', 'Advanced'],
default: 'Intermediate'
},
topic: {
type: String
type: String,
trim: true
},
generatedByAI: {
type: Boolean,
default: false
}
}, {
timestamps: true
});
......
......@@ -21,8 +21,13 @@ const userSchema = new mongoose.Schema({
},
role: {
type: String,
enum: ['admin', 'student'],
default: 'student'
enum: ['admin', 'hr', 'candidate'],
default: 'candidate'
},
group: {
type: String,
default: 'General',
trim: true
},
isLoggedIn: {
type: Boolean,
......
......@@ -12,12 +12,16 @@ const router = express.Router();
// All admin routes require authentication + admin role
router.use(protect, authorize('admin'));
// ============ USER MANAGEMENT ============
// @route GET /api/admin/users
// @desc Get all students (logged in users)
// @desc Get all candidates and HR users
// @access Admin
router.get('/users', async (req, res) => {
try {
const users = await User.find({ role: 'student' })
const { role } = req.query;
const filter = role ? { role } : { role: { $in: ['candidate', 'hr'] } };
const users = await User.find(filter)
.select('-password')
.sort({ createdAt: -1 });
......@@ -28,11 +32,11 @@ router.get('/users', async (req, res) => {
});
// @route GET /api/admin/users/logged-in
// @desc Get all currently logged-in students
// @desc Get all currently logged-in users
// @access Admin
router.get('/users/logged-in', async (req, res) => {
try {
const users = await User.find({ role: 'student', isLoggedIn: true })
const users = await User.find({ role: { $in: ['candidate', 'hr'] }, isLoggedIn: true })
.select('-password')
.sort({ createdAt: -1 });
......@@ -42,8 +46,59 @@ router.get('/users/logged-in', async (req, res) => {
}
});
// @route POST /api/admin/users/create-hr
// @desc Create an HR user
// @access Admin
router.post('/users/create-hr', async (req, res) => {
try {
const { name, email, password } = req.body;
if (!name || !email || !password) {
return res.status(400).json({ message: 'Please provide name, email, and password' });
}
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(400).json({ message: 'User with this email already exists' });
}
const user = await User.create({ name, email, password, role: 'hr' });
res.status(201).json({
message: 'HR user created successfully',
user: {
id: user._id,
name: user.name,
email: user.email,
role: user.role
}
});
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route DELETE /api/admin/users/:userId
// @desc Delete a user
// @access Admin
router.delete('/users/:userId', async (req, res) => {
try {
const { userId } = req.params;
const user = await User.findById(userId);
if (!user) return res.status(404).json({ message: 'User not found' });
if (user.role === 'admin') return res.status(403).json({ message: 'Cannot delete admin users' });
await Submission.deleteMany({ studentId: userId });
await User.findByIdAndDelete(userId);
res.json({ message: 'User deleted successfully' });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/admin/users/:userId/history
// @desc Get a student's test history
// @desc Get a user's test history
// @access Admin
router.get('/users/:userId/history', async (req, res) => {
try {
......@@ -55,7 +110,7 @@ router.get('/users/:userId/history', async (req, res) => {
}
const submissions = await Submission.find({ studentId: userId })
.populate('quizId', 'title timer totalQuestions')
.populate('quizId', 'title timer totalQuestions category')
.sort({ submittedAt: -1 });
res.json({ user, submissions });
......@@ -119,12 +174,14 @@ router.get('/submissions/:submissionId', async (req, res) => {
}
});
// ============ QUIZ MANAGEMENT ============
// @route POST /api/admin/quiz/create
// @desc Create quiz with Excel upload
// @access Admin
router.post('/quiz/create', upload.single('questionsFile'), async (req, res) => {
try {
const { title, timer } = req.body;
const { title, timer, category, difficulty, topic, assignToAll, assignees, assignedGroups } = req.body;
if (!title || !timer) {
return res.status(400).json({ message: 'Please provide quiz title and timer' });
......@@ -141,51 +198,37 @@ router.post('/quiz/create', upload.single('questionsFile'), async (req, res) =>
const data = xlsx.utils.sheet_to_json(sheet);
if (data.length === 0) {
// Clean up uploaded file
fs.unlinkSync(req.file.path);
return res.status(400).json({ message: 'Excel file is empty' });
}
// Parse assignees
let parsedAssignees = [];
if (assignees) {
try { parsedAssignees = JSON.parse(assignees); } catch (e) { parsedAssignees = []; }
}
let parsedGroups = [];
if (assignedGroups) {
try { parsedGroups = JSON.parse(assignedGroups); } catch (e) { parsedGroups = []; }
}
// Create quiz
const quiz = await Quiz.create({
title,
timer: parseInt(timer),
totalQuestions: data.length,
createdBy: req.user._id
createdBy: req.user._id,
category: category || 'General',
difficulty: difficulty || 'Intermediate',
topic: topic || '',
assignToAll: assignToAll === 'true' || assignToAll === true,
assignees: parsedAssignees,
assignedGroups: parsedGroups
});
// Parse and create questions
const questions = data.map(row => {
// Get column values (handle different header names)
const question = row['Question'] || row['question'] || '';
const option1 = row['Option1'] || row['option1'] || row['Option 1'] || '';
const option2 = row['Option2'] || row['option2'] || row['Option 2'] || '';
const option3 = row['Option3'] || row['option3'] || row['Option 3'] || '';
const option4 = row['Option4'] || row['option4'] || row['Option 4'] || '';
const correct = row['Correct'] || row['correct'] || row['Answer'] || row['answer'] || '';
// Determine if single or multi-correct
const correctStr = correct.toString().trim();
const correctAnswers = correctStr.includes(',')
? correctStr.split(',').map(a => a.trim())
: [correctStr];
const type = correctAnswers.length > 1 ? 'mcq' : 'single';
return {
quizId: quiz._id,
question: question.toString().trim(),
options: [
option1.toString().trim(),
option2.toString().trim(),
option3.toString().trim(),
option4.toString().trim()
],
correctAnswers,
type
};
});
const questions = parseExcelQuestions(data, quiz._id);
await Question.insertMany(questions);
// Clean up uploaded file
......@@ -197,11 +240,11 @@ router.post('/quiz/create', upload.single('questionsFile'), async (req, res) =>
id: quiz._id,
title: quiz.title,
timer: quiz.timer,
totalQuestions: quiz.totalQuestions
totalQuestions: quiz.totalQuestions,
category: quiz.category
}
});
} catch (error) {
// Clean up file on error
if (req.file && fs.existsSync(req.file.path)) {
fs.unlinkSync(req.file.path);
}
......@@ -209,8 +252,64 @@ router.post('/quiz/create', upload.single('questionsFile'), async (req, res) =>
}
});
// @route POST /api/admin/quiz/create-manual
// @desc Create quiz manually (for AI-generated quizzes or manual entry)
// @access Admin
router.post('/quiz/create-manual', async (req, res) => {
try {
const { title, timer, category, difficulty, topic, assignToAll, assignees, assignedGroups, questions, generatedByAI } = req.body;
if (!title || !timer) {
return res.status(400).json({ message: 'Please provide quiz title and timer' });
}
if (!questions || questions.length === 0) {
return res.status(400).json({ message: 'Please provide at least one question' });
}
// Create quiz
const quiz = await Quiz.create({
title,
timer: parseInt(timer),
totalQuestions: questions.length,
createdBy: req.user._id,
category: category || 'General',
difficulty: difficulty || 'Intermediate',
topic: topic || '',
assignToAll: assignToAll || false,
assignees: assignees || [],
assignedGroups: assignedGroups || [],
generatedByAI: generatedByAI || false
});
// Create questions
const questionDocs = questions.map(q => ({
quizId: quiz._id,
question: q.question,
options: q.options,
correctAnswers: q.correctAnswers,
type: q.correctAnswers.length > 1 ? 'mcq' : 'single'
}));
await Question.insertMany(questionDocs);
res.status(201).json({
message: 'Quiz created successfully',
quiz: {
id: quiz._id,
title: quiz.title,
timer: quiz.timer,
totalQuestions: quiz.totalQuestions,
category: quiz.category
}
});
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/admin/quizzes
// @desc Get all quizzes
// @desc Get all quizzes with attempt count
// @access Admin
router.get('/quizzes', async (req, res) => {
try {
......@@ -218,21 +317,135 @@ router.get('/quizzes', async (req, res) => {
.populate('createdBy', 'name email')
.sort({ createdAt: -1 });
res.json({ quizzes });
// Get attempt counts for each quiz
const quizzesWithAttempts = await Promise.all(
quizzes.map(async (quiz) => {
const attemptCount = await Submission.countDocuments({ quizId: quiz._id });
return { ...quiz.toObject(), attemptCount };
})
);
res.json({ quizzes: quizzesWithAttempts });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/admin/quiz/:quizId
// @desc Get quiz with its questions for editing
// @access Admin
router.get('/quiz/:quizId', async (req, res) => {
try {
const { quizId } = req.params;
const quiz = await Quiz.findById(quizId)
.populate('createdBy', 'name email')
.populate('assignees', 'name email');
if (!quiz) {
return res.status(404).json({ message: 'Quiz not found' });
}
const questions = await Question.find({ quizId });
const attemptCount = await Submission.countDocuments({ quizId });
res.json({ quiz, questions, attemptCount });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route PUT /api/admin/quiz/:quizId
// @desc Edit quiz (only if no attempts have been made)
// @access Admin
router.put('/quiz/:quizId', async (req, res) => {
try {
const { quizId } = req.params;
// Check if any attempts exist
const attemptCount = await Submission.countDocuments({ quizId });
if (attemptCount > 0) {
return res.status(403).json({
message: 'This quiz cannot be edited because it has already been attempted by users.',
attemptCount
});
}
const { title, timer, category, difficulty, topic, assignToAll, assignees, assignedGroups, questions } = req.body;
// Update quiz metadata
const updateData = {};
if (title) updateData.title = title;
if (timer) updateData.timer = parseInt(timer);
if (category) updateData.category = category;
if (difficulty) updateData.difficulty = difficulty;
if (topic !== undefined) updateData.topic = topic;
if (assignToAll !== undefined) updateData.assignToAll = assignToAll;
if (assignees) updateData.assignees = assignees;
if (assignedGroups) updateData.assignedGroups = assignedGroups;
// If questions are provided, replace them
if (questions && questions.length > 0) {
await Question.deleteMany({ quizId });
const questionDocs = questions.map(q => ({
quizId,
question: q.question,
options: q.options,
correctAnswers: q.correctAnswers,
type: q.correctAnswers.length > 1 ? 'mcq' : 'single'
}));
await Question.insertMany(questionDocs);
updateData.totalQuestions = questions.length;
}
const quiz = await Quiz.findByIdAndUpdate(quizId, updateData, { new: true });
res.json({ message: 'Quiz updated successfully', quiz });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route PUT /api/admin/quiz/:quizId/assign
// @desc Assign quiz to users/groups
// @access Admin
router.put('/quiz/:quizId/assign', async (req, res) => {
try {
const { quizId } = req.params;
const { assignToAll, assignees, assignedGroups } = req.body;
const quiz = await Quiz.findByIdAndUpdate(quizId, {
assignToAll: assignToAll || false,
assignees: assignees || [],
assignedGroups: assignedGroups || []
}, { new: true });
if (!quiz) return res.status(404).json({ message: 'Quiz not found' });
res.json({ message: 'Quiz assignment updated', quiz });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route DELETE /api/admin/quiz/:quizId
// @desc Delete a quiz and its questions
// @desc Delete a quiz (only if no attempts have been made)
// @access Admin
router.delete('/quiz/:quizId', async (req, res) => {
try {
const { quizId } = req.params;
// Check if any attempts exist
const attemptCount = await Submission.countDocuments({ quizId });
if (attemptCount > 0) {
return res.status(403).json({
message: 'This quiz cannot be deleted because it has already been attempted by users.',
attemptCount
});
}
await Question.deleteMany({ quizId });
await Submission.deleteMany({ quizId });
await Quiz.findByIdAndDelete(quizId);
res.json({ message: 'Quiz deleted successfully' });
......@@ -241,7 +454,90 @@ router.delete('/quiz/:quizId', async (req, res) => {
}
});
// Helper function to compare arrays reliably
// @route GET /api/admin/categories
// @desc Get all unique quiz categories
// @access Admin
router.get('/categories', async (req, res) => {
try {
const categories = await Quiz.distinct('category');
res.json({ categories });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/admin/groups
// @desc Get all unique user groups
// @access Admin
router.get('/groups', async (req, res) => {
try {
const groups = await User.distinct('group');
res.json({ groups });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/admin/stats
// @desc Get dashboard statistics
// @access Admin
router.get('/stats', async (req, res) => {
try {
const totalUsers = await User.countDocuments({ role: 'candidate' });
const totalHR = await User.countDocuments({ role: 'hr' });
const totalQuizzes = await Quiz.countDocuments();
const totalSubmissions = await Submission.countDocuments();
const onlineUsers = await User.countDocuments({ isLoggedIn: true, role: { $in: ['candidate', 'hr'] } });
// Recent submissions
const recentSubmissions = await Submission.find()
.populate('studentId', 'name email')
.populate('quizId', 'title')
.sort({ submittedAt: -1 })
.limit(5);
res.json({
stats: { totalUsers, totalHR, totalQuizzes, totalSubmissions, onlineUsers },
recentSubmissions
});
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// ============ HELPER FUNCTIONS ============
function parseExcelQuestions(data, quizId) {
return data.map(row => {
const question = row['Question'] || row['question'] || '';
const option1 = row['Option1'] || row['option1'] || row['Option 1'] || '';
const option2 = row['Option2'] || row['option2'] || row['Option 2'] || '';
const option3 = row['Option3'] || row['option3'] || row['Option 3'] || '';
const option4 = row['Option4'] || row['option4'] || row['Option 4'] || '';
const correct = row['Correct'] || row['correct'] || row['Answer'] || row['answer'] || '';
const correctStr = correct.toString().trim();
const correctAnswers = correctStr.includes(',')
? correctStr.split(',').map(a => a.trim())
: [correctStr];
const type = correctAnswers.length > 1 ? 'mcq' : 'single';
return {
quizId,
question: question.toString().trim(),
options: [
option1.toString().trim(),
option2.toString().trim(),
option3.toString().trim(),
option4.toString().trim()
],
correctAnswers,
type
};
});
}
function checkAnswersMatch(arr1, arr2) {
if (!arr1 || !arr2 || arr1.length !== arr2.length) return false;
const a = arr1.map(x => x.toString().trim().toLowerCase()).sort().join('||');
......
......@@ -12,7 +12,7 @@ const generateToken = (id) => {
};
// @route POST /api/auth/register
// @desc Register a new student
// @desc Register a new candidate
// @access Public
router.post('/register', async (req, res) => {
try {
......@@ -29,8 +29,8 @@ router.post('/register', async (req, res) => {
return res.status(400).json({ message: 'User with this email already exists' });
}
// Create user (role defaults to 'student')
const user = await User.create({ name, email, password, role: 'student' });
// Create user (role defaults to 'candidate')
const user = await User.create({ name, email, password, role: 'candidate' });
res.status(201).json({
message: 'Registration successful',
......@@ -47,7 +47,7 @@ router.post('/register', async (req, res) => {
});
// @route POST /api/auth/login
// @desc Login user (admin or student)
// @desc Login user (admin, hr, or candidate)
// @access Public
router.post('/login', async (req, res) => {
try {
......@@ -82,7 +82,8 @@ router.post('/login', async (req, res) => {
id: user._id,
name: user.name,
email: user.email,
role: user.role
role: user.role,
group: user.group
}
});
} catch (error) {
......@@ -95,8 +96,7 @@ router.post('/login', async (req, res) => {
// @access Private
router.post('/logout', protect, async (req, res) => {
try {
req.user.isLoggedIn = false;
await req.user.save();
await User.updateOne({ _id: req.user._id }, { isLoggedIn: false });
res.json({ message: 'Logout successful' });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
......
const express = require('express');
const User = require('../models/User');
const Quiz = require('../models/Quiz');
const Question = require('../models/Question');
const Submission = require('../models/Submission');
const { protect, authorize } = require('../middleware/auth');
const router = express.Router();
// All candidate routes require authentication + candidate role
router.use(protect, authorize('candidate'));
// @route GET /api/candidate/quizzes
// @desc Get assigned and available quizzes for the candidate
// @access Candidate
router.get('/quizzes', async (req, res) => {
try {
// Find quizzes assigned to this user (by direct assignment, group, or public)
const user = await User.findById(req.user._id);
const quizzes = await Quiz.find({
isActive: true,
$or: [
{ assignToAll: true },
{ assignees: req.user._id },
{ assignedGroups: user.group }
]
})
.select('title timer totalQuestions createdAt category difficulty')
.sort({ createdAt: -1 });
// Check which quizzes the candidate has already taken
const submissions = await Submission.find({ studentId: req.user._id })
.select('quizId score totalMarks percentage');
const submissionMap = {};
submissions.forEach(sub => {
submissionMap[sub.quizId.toString()] = {
taken: true,
score: sub.score,
totalMarks: sub.totalMarks,
percentage: sub.percentage
};
});
const quizzesWithStatus = quizzes.map(quiz => ({
id: quiz._id,
title: quiz.title,
timer: quiz.timer,
totalQuestions: quiz.totalQuestions,
createdAt: quiz.createdAt,
category: quiz.category,
difficulty: quiz.difficulty,
...(submissionMap[quiz._id.toString()] || { taken: false })
}));
res.json({ quizzes: quizzesWithStatus });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/candidate/quiz/:quizId
// @desc Get quiz questions for taking the quiz
// @access Candidate
router.get('/quiz/:quizId', async (req, res) => {
try {
const { quizId } = req.params;
// Check if candidate already took this quiz
const existingSubmission = await Submission.findOne({
studentId: req.user._id,
quizId
});
if (existingSubmission) {
return res.status(400).json({ message: 'You have already taken this quiz' });
}
const quiz = await Quiz.findById(quizId);
if (!quiz || !quiz.isActive) {
return res.status(404).json({ message: 'Quiz not found or not available' });
}
// Check assignment - is this candidate allowed to take this quiz?
const user = await User.findById(req.user._id);
const isAssigned = quiz.assignToAll ||
quiz.assignees.some(a => a.toString() === req.user._id.toString()) ||
quiz.assignedGroups.includes(user.group);
if (!isAssigned) {
return res.status(403).json({ message: 'You are not assigned to this quiz' });
}
// Get questions without correct answers
const questions = await Question.find({ quizId })
.select('question options type');
res.json({
quiz: {
id: quiz._id,
title: quiz.title,
timer: quiz.timer,
totalQuestions: quiz.totalQuestions
},
questions
});
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route POST /api/candidate/quiz/:quizId/submit
// @desc Submit quiz answers and auto-evaluate
// @access Candidate
router.post('/quiz/:quizId/submit', async (req, res) => {
try {
const { quizId } = req.params;
const { answers, timeTaken } = req.body;
// Check if candidate already submitted
const existingSubmission = await Submission.findOne({
studentId: req.user._id,
quizId
});
if (existingSubmission) {
return res.status(400).json({ message: 'You have already submitted this quiz' });
}
// Get correct answers for evaluation
const questions = await Question.find({ quizId });
const quiz = await Quiz.findById(quizId);
if (!quiz) {
return res.status(404).json({ message: 'Quiz not found' });
}
// Auto-evaluate
let score = 0;
const totalMarks = questions.length;
questions.forEach(q => {
const studentAnswer = answers.find(
a => a.questionId === q._id.toString()
);
if (studentAnswer && studentAnswer.selectedAnswers) {
const isMatch = checkAnswersMatch(q.correctAnswers, studentAnswer.selectedAnswers);
if (isMatch) {
score++;
}
}
});
const percentage = totalMarks > 0 ? Math.round((score / totalMarks) * 100) : 0;
// Create submission
const submission = await Submission.create({
studentId: req.user._id,
quizId,
answers,
score,
totalMarks,
percentage,
timeTaken: timeTaken || 0
});
res.status(201).json({
message: 'Quiz submitted successfully',
result: {
score,
totalMarks,
percentage,
timeTaken: submission.timeTaken
}
});
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/candidate/profile
// @desc Get candidate profile with test results
// @access Candidate
router.get('/profile', async (req, res) => {
try {
const user = await User.findById(req.user._id).select('-password');
const submissions = await Submission.find({ studentId: req.user._id })
.populate('quizId', 'title timer totalQuestions category')
.sort({ submittedAt: -1 });
res.json({ user, submissions });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/candidate/results/:submissionId
// @desc Get detailed results for a specific quiz submission
// @access Candidate
router.get('/results/:submissionId', async (req, res) => {
try {
const { submissionId } = req.params;
const submission = await Submission.findOne({
_id: submissionId,
studentId: req.user._id
}).populate('quizId', 'title timer totalQuestions');
if (!submission) {
return res.status(404).json({ message: 'Submission not found' });
}
// Get questions with correct answers
const questions = await Question.find({ quizId: submission.quizId._id });
const detailedResults = questions.map(q => {
const studentAnswer = submission.answers.find(
a => a.questionId.toString() === q._id.toString()
);
return {
question: q.question,
options: q.options,
type: q.type,
correctAnswers: q.correctAnswers,
studentAnswers: studentAnswer ? studentAnswer.selectedAnswers : [],
isCorrect: (studentAnswer && studentAnswer.selectedAnswers)
? checkAnswersMatch(q.correctAnswers, studentAnswer.selectedAnswers)
: false
};
});
res.json({
quiz: submission.quizId,
score: submission.score,
totalMarks: submission.totalMarks,
percentage: submission.percentage,
timeTaken: submission.timeTaken,
submittedAt: submission.submittedAt,
detailedResults
});
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// Helper function
function checkAnswersMatch(arr1, arr2) {
if (!arr1 || !arr2 || arr1.length !== arr2.length) return false;
const a = arr1.map(x => x.toString().trim().toLowerCase()).sort().join('||');
const b = arr2.map(x => x.toString().trim().toLowerCase()).sort().join('||');
return a === b;
}
module.exports = router;
const express = require('express');
const xlsx = require('xlsx');
const fs = require('fs');
const User = require('../models/User');
const Quiz = require('../models/Quiz');
const Question = require('../models/Question');
const Submission = require('../models/Submission');
const { protect, authorize } = require('../middleware/auth');
const upload = require('../middleware/upload');
const router = express.Router();
// All HR routes require authentication + hr role
router.use(protect, authorize('hr'));
// ============ QUIZ MANAGEMENT ============
// @route POST /api/hr/quiz/create
// @desc Create quiz with Excel upload
// @access HR
router.post('/quiz/create', upload.single('questionsFile'), async (req, res) => {
try {
const { title, timer, category, difficulty, topic, assignToAll, assignees, assignedGroups } = req.body;
if (!title || !timer) {
return res.status(400).json({ message: 'Please provide quiz title and timer' });
}
if (!req.file) {
return res.status(400).json({ message: 'Please upload an Excel file with questions' });
}
const workbook = xlsx.readFile(req.file.path);
const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName];
const data = xlsx.utils.sheet_to_json(sheet);
if (data.length === 0) {
fs.unlinkSync(req.file.path);
return res.status(400).json({ message: 'Excel file is empty' });
}
let parsedAssignees = [];
if (assignees) {
try { parsedAssignees = JSON.parse(assignees); } catch (e) { parsedAssignees = []; }
}
let parsedGroups = [];
if (assignedGroups) {
try { parsedGroups = JSON.parse(assignedGroups); } catch (e) { parsedGroups = []; }
}
const quiz = await Quiz.create({
title,
timer: parseInt(timer),
totalQuestions: data.length,
createdBy: req.user._id,
category: category || 'General',
difficulty: difficulty || 'Intermediate',
topic: topic || '',
assignToAll: assignToAll === 'true' || assignToAll === true,
assignees: parsedAssignees,
assignedGroups: parsedGroups
});
const questions = parseExcelQuestions(data, quiz._id);
await Question.insertMany(questions);
fs.unlinkSync(req.file.path);
res.status(201).json({
message: 'Quiz created successfully',
quiz: { id: quiz._id, title: quiz.title, timer: quiz.timer, totalQuestions: quiz.totalQuestions, category: quiz.category }
});
} catch (error) {
if (req.file && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path);
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route POST /api/hr/quiz/create-manual
// @desc Create quiz manually (for AI-generated quizzes)
// @access HR
router.post('/quiz/create-manual', async (req, res) => {
try {
const { title, timer, category, difficulty, topic, assignToAll, assignees, assignedGroups, questions, generatedByAI } = req.body;
if (!title || !timer) return res.status(400).json({ message: 'Please provide quiz title and timer' });
if (!questions || questions.length === 0) return res.status(400).json({ message: 'Please provide at least one question' });
const quiz = await Quiz.create({
title, timer: parseInt(timer), totalQuestions: questions.length, createdBy: req.user._id,
category: category || 'General', difficulty: difficulty || 'Intermediate', topic: topic || '',
assignToAll: assignToAll || false, assignees: assignees || [], assignedGroups: assignedGroups || [],
generatedByAI: generatedByAI || false
});
const questionDocs = questions.map(q => ({
quizId: quiz._id, question: q.question, options: q.options,
correctAnswers: q.correctAnswers, type: q.correctAnswers.length > 1 ? 'mcq' : 'single'
}));
await Question.insertMany(questionDocs);
res.status(201).json({
message: 'Quiz created successfully',
quiz: { id: quiz._id, title: quiz.title, timer: quiz.timer, totalQuestions: quiz.totalQuestions, category: quiz.category }
});
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/hr/quizzes
// @desc Get quizzes created by this HR
// @access HR
router.get('/quizzes', async (req, res) => {
try {
const quizzes = await Quiz.find({ createdBy: req.user._id })
.populate('createdBy', 'name email')
.sort({ createdAt: -1 });
const quizzesWithAttempts = await Promise.all(
quizzes.map(async (quiz) => {
const attemptCount = await Submission.countDocuments({ quizId: quiz._id });
return { ...quiz.toObject(), attemptCount };
})
);
res.json({ quizzes: quizzesWithAttempts });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/hr/quiz/:quizId
// @desc Get quiz details for editing
// @access HR
router.get('/quiz/:quizId', async (req, res) => {
try {
const quiz = await Quiz.findOne({ _id: req.params.quizId, createdBy: req.user._id })
.populate('assignees', 'name email');
if (!quiz) return res.status(404).json({ message: 'Quiz not found' });
const questions = await Question.find({ quizId: quiz._id });
const attemptCount = await Submission.countDocuments({ quizId: quiz._id });
res.json({ quiz, questions, attemptCount });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route PUT /api/hr/quiz/:quizId
// @desc Edit quiz (only if no attempts)
// @access HR
router.put('/quiz/:quizId', async (req, res) => {
try {
const { quizId } = req.params;
const quiz = await Quiz.findOne({ _id: quizId, createdBy: req.user._id });
if (!quiz) return res.status(404).json({ message: 'Quiz not found' });
const attemptCount = await Submission.countDocuments({ quizId });
if (attemptCount > 0) {
return res.status(403).json({ message: 'This quiz cannot be edited because it has already been attempted.', attemptCount });
}
const { title, timer, category, difficulty, topic, assignToAll, assignees, assignedGroups, questions } = req.body;
const updateData = {};
if (title) updateData.title = title;
if (timer) updateData.timer = parseInt(timer);
if (category) updateData.category = category;
if (difficulty) updateData.difficulty = difficulty;
if (topic !== undefined) updateData.topic = topic;
if (assignToAll !== undefined) updateData.assignToAll = assignToAll;
if (assignees) updateData.assignees = assignees;
if (assignedGroups) updateData.assignedGroups = assignedGroups;
if (questions && questions.length > 0) {
await Question.deleteMany({ quizId });
const questionDocs = questions.map(q => ({
quizId, question: q.question, options: q.options,
correctAnswers: q.correctAnswers, type: q.correctAnswers.length > 1 ? 'mcq' : 'single'
}));
await Question.insertMany(questionDocs);
updateData.totalQuestions = questions.length;
}
const updated = await Quiz.findByIdAndUpdate(quizId, updateData, { new: true });
res.json({ message: 'Quiz updated successfully', quiz: updated });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route DELETE /api/hr/quiz/:quizId
// @desc Delete quiz (only if no attempts)
// @access HR
router.delete('/quiz/:quizId', async (req, res) => {
try {
const { quizId } = req.params;
const quiz = await Quiz.findOne({ _id: quizId, createdBy: req.user._id });
if (!quiz) return res.status(404).json({ message: 'Quiz not found' });
const attemptCount = await Submission.countDocuments({ quizId });
if (attemptCount > 0) {
return res.status(403).json({ message: 'This quiz cannot be deleted because it has already been attempted.', attemptCount });
}
await Question.deleteMany({ quizId });
await Quiz.findByIdAndDelete(quizId);
res.json({ message: 'Quiz deleted successfully' });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/hr/candidates
// @desc Get all candidates
// @access HR
router.get('/candidates', async (req, res) => {
try {
const candidates = await User.find({ role: 'candidate' }).select('-password').sort({ createdAt: -1 });
res.json({ users: candidates });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/hr/candidates/:userId/history
// @desc Get candidate test history
// @access HR
router.get('/candidates/:userId/history', async (req, res) => {
try {
const user = await User.findById(req.params.userId).select('-password');
if (!user) return res.status(404).json({ message: 'User not found' });
const submissions = await Submission.find({ studentId: req.params.userId })
.populate('quizId', 'title timer totalQuestions category')
.sort({ submittedAt: -1 });
res.json({ user, submissions });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/hr/submissions/:submissionId
// @desc Get detailed submission
// @access HR
router.get('/submissions/:submissionId', async (req, res) => {
try {
const submission = await Submission.findById(req.params.submissionId)
.populate('studentId', 'name email')
.populate('quizId', 'title timer totalQuestions');
if (!submission) return res.status(404).json({ message: 'Submission not found' });
const questions = await Question.find({ quizId: submission.quizId._id });
const detailedAnswers = questions.map(q => {
const studentAnswer = submission.answers.find(a => a.questionId.toString() === q._id.toString());
return {
questionId: q._id, question: q.question, options: q.options, type: q.type,
correctAnswers: q.correctAnswers,
studentAnswers: studentAnswer ? studentAnswer.selectedAnswers : [],
isCorrect: (studentAnswer && studentAnswer.selectedAnswers) ? checkAnswersMatch(studentAnswer.selectedAnswers, q.correctAnswers) : false
};
});
res.json({
submission: {
id: submission._id, student: submission.studentId, quiz: submission.quizId,
score: submission.score, totalMarks: submission.totalMarks, percentage: submission.percentage,
timeTaken: submission.timeTaken, submittedAt: submission.submittedAt
},
detailedAnswers
});
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/hr/categories
router.get('/categories', async (req, res) => {
try {
const categories = await Quiz.distinct('category');
res.json({ categories });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/hr/groups
router.get('/groups', async (req, res) => {
try {
const groups = await User.distinct('group');
res.json({ groups });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/hr/stats
router.get('/stats', async (req, res) => {
try {
const totalCandidates = await User.countDocuments({ role: 'candidate' });
const myQuizzes = await Quiz.countDocuments({ createdBy: req.user._id });
const myQuizIds = (await Quiz.find({ createdBy: req.user._id }).select('_id')).map(q => q._id);
const totalSubmissions = await Submission.countDocuments({ quizId: { $in: myQuizIds } });
res.json({ stats: { totalCandidates, myQuizzes, totalSubmissions } });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
function parseExcelQuestions(data, quizId) {
return data.map(row => {
const question = row['Question'] || row['question'] || '';
const option1 = row['Option1'] || row['option1'] || row['Option 1'] || '';
const option2 = row['Option2'] || row['option2'] || row['Option 2'] || '';
const option3 = row['Option3'] || row['option3'] || row['Option 3'] || '';
const option4 = row['Option4'] || row['option4'] || row['Option 4'] || '';
const correct = row['Correct'] || row['correct'] || row['Answer'] || row['answer'] || '';
const correctStr = correct.toString().trim();
const correctAnswers = correctStr.includes(',') ? correctStr.split(',').map(a => a.trim()) : [correctStr];
return {
quizId, question: question.toString().trim(),
options: [option1.toString().trim(), option2.toString().trim(), option3.toString().trim(), option4.toString().trim()],
correctAnswers, type: correctAnswers.length > 1 ? 'mcq' : 'single'
};
});
}
function checkAnswersMatch(arr1, arr2) {
if (!arr1 || !arr2 || arr1.length !== arr2.length) return false;
const a = arr1.map(x => x.toString().trim().toLowerCase()).sort().join('||');
const b = arr2.map(x => x.toString().trim().toLowerCase()).sort().join('||');
return a === b;
}
module.exports = router;
......@@ -50,11 +50,14 @@ app.use(cookieParser());
// Routes
app.use('/api/auth', require('./routes/auth'));
app.use('/api/admin', require('./routes/admin'));
app.use('/api/student', require('./routes/student'));
app.use('/api/hr', require('./routes/hr'));
app.use('/api/candidate', require('./routes/candidate'));
// Keep backward compatibility for old student endpoints
app.use('/api/student', require('./routes/candidate'));
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'OK', message: 'Quiz App API is running' });
res.json({ status: 'OK', message: 'QuizMaster Pro API is running' });
});
// Error handling middleware
......
......@@ -51,7 +51,7 @@
"input": "public"
}
],
"styles": ["src/styles.css"]
"styles": ["src/material-theme.scss", "src/styles.css"]
},
"configurations": {
"production": {
......
......@@ -8,10 +8,12 @@
"name": "quiz-app",
"version": "0.0.0",
"dependencies": {
"@angular/cdk": "^21.2.6",
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0",
"@angular/material": "^21.2.6",
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"rxjs": "~7.8.0",
......@@ -417,6 +419,22 @@
}
}
},
"node_modules/@angular/cdk": {
"version": "21.2.6",
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.6.tgz",
"integrity": "sha512-1PBzFf+um/VZ1dFF6cT72Zsq+9C/ZWF9m5dP0uHJgo4psX3yMBoZlZu5YomBiAQ/ePSkqCuryv1vrelK+yd3Mw==",
"license": "MIT",
"dependencies": {
"parse5": "^8.0.0",
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": "^21.0.0 || ^22.0.0",
"@angular/core": "^21.0.0 || ^22.0.0",
"@angular/platform-browser": "^21.0.0 || ^22.0.0",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/cli": {
"version": "21.2.6",
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.6.tgz",
......@@ -557,6 +575,23 @@
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/material": {
"version": "21.2.6",
"resolved": "https://registry.npmjs.org/@angular/material/-/material-21.2.6.tgz",
"integrity": "sha512-V4hblb5ekgXb5x+UXKRs2yiB0hZUkUJbYwGseMglkCeWQlLM4u6amlsUzP4uOwIWFOkM/ZYl9qz4YGZnvMAyjw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/cdk": "21.2.6",
"@angular/common": "^21.0.0 || ^22.0.0",
"@angular/core": "^21.0.0 || ^22.0.0",
"@angular/forms": "^21.0.0 || ^22.0.0",
"@angular/platform-browser": "^21.0.0 || ^22.0.0",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/platform-browser": {
"version": "21.2.7",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.7.tgz",
......@@ -4554,7 +4589,6 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
"integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
......@@ -4608,7 +4642,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
......
......@@ -13,10 +13,12 @@
"node": ">=18.0.0"
},
"dependencies": {
"@angular/cdk": "^21.2.6",
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0",
"@angular/material": "^21.2.6",
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"rxjs": "~7.8.0",
......
import { Routes } from '@angular/router';
import { adminGuard, studentGuard, guestGuard } from './guards/auth.guard';
import { adminGuard, hrGuard, candidateGuard, guestGuard } from './guards/auth.guard';
import { LayoutComponent } from './components/layout/layout';
export const routes: Routes = [
// Default redirect
{ path: '', redirectTo: '/login', pathMatch: 'full' },
// Auth routes (guest only)
// Auth routes (guest only — no sidebar)
{
path: 'login',
// canActivate: [guestGuard],
canActivate: [guestGuard],
loadComponent: () => import('./pages/login/login').then(m => m.LoginComponent)
},
{
path: 'register',
// canActivate: [guestGuard],
canActivate: [guestGuard],
loadComponent: () => import('./pages/register/register').then(m => m.RegisterComponent)
},
// Admin routes
// ========== ADMIN ROUTES (with shared layout) ==========
{
path: 'admin/dashboard',
path: 'admin',
component: LayoutComponent,
canActivate: [adminGuard],
loadComponent: () => import('./pages/admin/dashboard/dashboard').then(m => m.AdminDashboardComponent)
},
{
path: 'admin/users',
// canActivate: [adminGuard],
loadComponent: () => import('./pages/admin/users/users').then(m => m.AdminUsersComponent)
},
{
path: 'admin/users/:userId/history',
// canActivate: [adminGuard],
loadComponent: () => import('./pages/admin/user-history/user-history').then(m => m.UserHistoryComponent)
},
{
path: 'admin/submissions/:submissionId',
// canActivate: [adminGuard],
loadComponent: () => import('./pages/admin/submission-detail/submission-detail').then(m => m.SubmissionDetailComponent)
},
{
path: 'admin/generate-quiz',
// canActivate: [adminGuard],
loadComponent: () => import('./pages/admin/generate-quiz/generate-quiz').then(m => m.GenerateQuizComponent)
children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{
path: 'dashboard',
loadComponent: () => import('./pages/admin/dashboard/dashboard').then(m => m.AdminDashboardComponent)
},
{
path: 'users',
loadComponent: () => import('./pages/admin/users/users').then(m => m.AdminUsersComponent)
},
{
path: 'users/:userId/history',
loadComponent: () => import('./pages/admin/user-history/user-history').then(m => m.UserHistoryComponent)
},
{
path: 'submissions/:submissionId',
loadComponent: () => import('./pages/admin/submission-detail/submission-detail').then(m => m.SubmissionDetailComponent)
},
{
path: 'quizzes',
loadComponent: () => import('./pages/admin/quizzes/quizzes').then(m => m.AdminQuizzesComponent)
},
{
path: 'create-quiz',
loadComponent: () => import('./pages/admin/create-quiz/create-quiz').then(m => m.CreateQuizComponent)
},
{
path: 'quiz/:quizId/edit',
loadComponent: () => import('./pages/admin/edit-quiz/edit-quiz').then(m => m.EditQuizComponent)
},
]
},
// Student routes
{
path: 'student/dashboard',
// canActivate: [studentGuard],
loadComponent: () => import('./pages/student/dashboard/dashboard').then(m => m.StudentDashboardComponent)
},
// ========== HR ROUTES (with shared layout) ==========
{
path: 'student/quiz/:quizId',
// canActivate: [studentGuard],
loadComponent: () => import('./pages/student/take-quiz/take-quiz').then(m => m.TakeQuizComponent)
},
{
path: 'student/profile',
// canActivate: [studentGuard],
loadComponent: () => import('./pages/student/profile/profile').then(m => m.StudentProfileComponent)
path: 'hr',
component: LayoutComponent,
canActivate: [hrGuard],
children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{
path: 'dashboard',
loadComponent: () => import('./pages/hr/dashboard/dashboard').then(m => m.HRDashboardComponent)
},
{
path: 'quizzes',
loadComponent: () => import('./pages/hr/quizzes/quizzes').then(m => m.HRQuizzesComponent)
},
{
path: 'create-quiz',
loadComponent: () => import('./pages/hr/create-quiz/create-quiz').then(m => m.HRCreateQuizComponent)
},
{
path: 'candidates',
loadComponent: () => import('./pages/hr/candidates/candidates').then(m => m.HRCandidatesComponent)
},
{
path: 'candidates/:userId/history',
loadComponent: () => import('./pages/hr/candidate-history/candidate-history').then(m => m.HRCandidateHistoryComponent)
},
{
path: 'submissions/:submissionId',
loadComponent: () => import('./pages/hr/submission-detail/submission-detail').then(m => m.HRSubmissionDetailComponent)
},
]
},
// ========== CANDIDATE ROUTES (with shared layout) ==========
{
path: 'student/results/:submissionId',
// canActivate: [studentGuard],
loadComponent: () => import('./pages/student/results/results').then(m => m.StudentResultsComponent)
path: 'candidate',
component: LayoutComponent,
canActivate: [candidateGuard],
children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{
path: 'dashboard',
loadComponent: () => import('./pages/candidate/dashboard/dashboard').then(m => m.CandidateDashboardComponent)
},
{
path: 'quiz/:quizId',
loadComponent: () => import('./pages/candidate/take-quiz/take-quiz').then(m => m.TakeQuizComponent)
},
{
path: 'profile',
loadComponent: () => import('./pages/candidate/profile/profile').then(m => m.CandidateProfileComponent)
},
{
path: 'results/:submissionId',
loadComponent: () => import('./pages/candidate/results/results').then(m => m.CandidateResultsComponent)
},
]
},
// Backward compat: old student routes redirect to candidate
{ path: 'student', redirectTo: '/candidate', pathMatch: 'prefix' },
{ path: 'student/dashboard', redirectTo: '/candidate/dashboard' },
{ path: 'student/profile', redirectTo: '/candidate/profile' },
// Catch-all
{ path: '**', redirectTo: '/login' }
];
/* ============================================================
Shared Layout — Sidebar + Main Content
============================================================ */
.app-layout {
display: flex;
min-height: 100vh;
}
/* ---- SIDEBAR ---- */
.sidebar {
width: var(--sidebar-width);
background: var(--sidebar-bg);
border-right: 1px solid var(--border-color);
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 100;
transition: transform 0.3s ease, background 0.25s ease;
overflow: hidden;
}
.sidebar-inner {
display: flex;
flex-direction: column;
height: 100%;
padding: 20px 12px;
overflow-y: auto;
}
/* Logo */
.sidebar-header {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px 20px;
flex-wrap: wrap;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
}
.logo-icon {
font-size: 28px;
color: var(--accent-primary);
}
.logo-text {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.3px;
}
/* Role badges */
.role-admin {
background: var(--accent-gradient);
color: #fff;
}
.role-hr {
background: linear-gradient(135deg, #f59e0b, #ef7c00);
color: #fff;
}
.role-candidate {
background: linear-gradient(135deg, #22c55e, #16a34a);
color: #fff;
}
/* Navigation items */
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
padding: 0 0 16px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
text-decoration: none;
transition: all 0.15s ease;
position: relative;
}
.nav-item:hover {
background: var(--sidebar-hover-bg);
color: var(--text-primary);
}
.nav-item.active {
background: var(--sidebar-active-bg);
color: var(--sidebar-active-text);
font-weight: 600;
}
.nav-item.active::before {
content: '';
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
width: 3px;
border-radius: 0 3px 3px 0;
background: var(--accent-primary);
}
.nav-icon {
font-size: 20px;
flex-shrink: 0;
}
.nav-label {
white-space: nowrap;
}
/* Sidebar sections */
.sidebar-section {
padding: 0 12px 16px;
}
.section-label {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 8px;
}
/* Theme Switcher */
.theme-switcher {
display: flex;
gap: 4px;
background: var(--bg-tertiary);
border-radius: var(--radius-md);
padding: 4px;
}
.theme-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
border-radius: var(--radius-sm);
color: var(--text-muted);
transition: all 0.15s ease;
cursor: pointer;
border: none;
background: transparent;
}
.theme-btn .material-symbols-rounded {
font-size: 18px;
}
.theme-btn:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.theme-btn.active {
background: var(--bg-card);
color: var(--accent-primary);
box-shadow: var(--shadow-sm);
}
/* User footer */
.sidebar-footer {
border-top: 1px solid var(--border-color);
padding-top: 16px;
}
.user-card {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
margin-bottom: 8px;
}
.user-avatar {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: var(--accent-gradient);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 700;
font-size: 15px;
flex-shrink: 0;
}
.user-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.user-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-email {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.logout-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px;
border-radius: var(--radius-md);
background: var(--danger-light);
border: 1px solid var(--danger-border);
color: var(--danger);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
font-family: inherit;
}
.logout-btn .material-symbols-rounded {
font-size: 18px;
}
.logout-btn:hover {
background: var(--danger);
color: #fff;
border-color: var(--danger);
}
/* ---- MAIN CONTENT ---- */
.main-content {
flex: 1;
margin-left: var(--sidebar-width);
min-height: 100vh;
transition: margin-left 0.3s ease;
}
/* ---- MOBILE HEADER ---- */
.mobile-header {
display: none;
position: sticky;
top: 0;
z-index: 90;
background: var(--bg-card);
border-bottom: 1px solid var(--border-color);
padding: 12px 16px;
align-items: center;
gap: 12px;
}
.mobile-menu-btn,
.icon-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
}
.mobile-menu-btn:hover,
.icon-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.mobile-logo {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
flex: 1;
}
.mobile-logo .material-symbols-rounded {
color: var(--accent-primary);
font-size: 24px;
}
.mobile-actions {
display: flex;
gap: 4px;
}
.sidebar-overlay {
display: none;
}
/* ---- RESPONSIVE ---- */
@media (max-width: 768px) {
.mobile-header {
display: flex;
}
.sidebar {
transform: translateX(-100%);
width: 280px;
z-index: 200;
box-shadow: var(--shadow-xl);
}
.app-layout.sidebar-open .sidebar {
transform: translateX(0);
}
.app-layout.sidebar-open .sidebar-overlay {
display: block;
position: fixed;
inset: 0;
background: var(--bg-overlay);
z-index: 150;
}
.main-content {
margin-left: 0;
}
}
<!-- Shared Layout: Sidebar + Main Content -->
<div class="app-layout" [class.sidebar-open]="mobileSidebarOpen">
<!-- Mobile Top Bar -->
<header class="mobile-header">
<button class="mobile-menu-btn" (click)="toggleMobileSidebar()">
<span class="material-symbols-rounded">menu</span>
</button>
<span class="mobile-logo">
<span class="material-symbols-rounded filled">quiz</span>
QuizMaster
</span>
<div class="mobile-actions">
<button class="icon-btn" (click)="toggleThemeMenu()">
<span class="material-symbols-rounded">
{{ themeService.currentTheme() === 'light' ? 'light_mode' : themeService.currentTheme() === 'dark' ? 'dark_mode' : 'water' }}
</span>
</button>
</div>
</header>
<!-- Sidebar Overlay (mobile) -->
<div class="sidebar-overlay" (click)="toggleMobileSidebar()"></div>
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-inner">
<!-- Logo -->
<div class="sidebar-header">
<div class="logo">
<span class="material-symbols-rounded filled logo-icon">quiz</span>
<span class="logo-text">QuizMaster</span>
</div>
<span class="badge" [ngClass]="roleBadge().class">{{ roleBadge().label }}</span>
</div>
<!-- Navigation -->
<nav class="sidebar-nav">
@for (item of navItems(); track item.route) {
<a [routerLink]="item.route"
routerLinkActive="active"
[routerLinkActiveOptions]="{exact: item.route.includes('dashboard')}"
class="nav-item"
(click)="mobileSidebarOpen = false">
<span class="material-symbols-rounded nav-icon">{{ item.icon }}</span>
<span class="nav-label">{{ item.label }}</span>
</a>
}
</nav>
<!-- Theme Switcher -->
<div class="sidebar-section">
<div class="section-label">Theme</div>
<div class="theme-switcher">
@for (t of themes; track t.id) {
<button
class="theme-btn"
[class.active]="themeService.currentTheme() === t.id"
(click)="setTheme(t.id)"
[title]="t.label">
<span class="material-symbols-rounded">{{ t.icon }}</span>
</button>
}
</div>
</div>
<!-- User Footer -->
<div class="sidebar-footer">
<div class="user-card">
<div class="user-avatar">{{ userInitial() }}</div>
<div class="user-info">
<span class="user-name">{{ authService.currentUser()?.name }}</span>
<span class="user-email">{{ authService.currentUser()?.email }}</span>
</div>
</div>
<button class="logout-btn" (click)="logout()">
<span class="material-symbols-rounded">logout</span>
<span>Sign Out</span>
</button>
</div>
</div>
</aside>
<!-- Main Content Area -->
<main class="main-content">
<router-outlet />
</main>
</div>
import { Component, computed, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink, RouterLinkActive, RouterOutlet, Router } from '@angular/router';
import { AuthService } from '../../services/auth.service';
import { ThemeService, ThemeMode } from '../../services/theme.service';
interface NavItem {
icon: string;
label: string;
route: string;
}
@Component({
selector: 'app-layout',
standalone: true,
imports: [CommonModule, RouterLink, RouterLinkActive, RouterOutlet],
templateUrl: './layout.html',
styleUrl: './layout.css'
})
export class LayoutComponent {
authService = inject(AuthService);
themeService = inject(ThemeService);
router = inject(Router);
showThemeMenu = false;
mobileSidebarOpen = false;
themes: { id: ThemeMode; label: string; icon: string }[] = [
{ id: 'light', label: 'Light', icon: 'light_mode' },
{ id: 'dark', label: 'Dark', icon: 'dark_mode' },
{ id: 'blue', label: 'Navy', icon: 'water' }
];
navItems = computed<NavItem[]>(() => {
const role = this.authService.getUserRole();
switch (role) {
case 'admin':
return [
{ icon: 'dashboard', label: 'Dashboard', route: '/admin/dashboard' },
{ icon: 'group', label: 'Users', route: '/admin/users' },
{ icon: 'quiz', label: 'Quizzes', route: '/admin/quizzes' },
{ icon: 'add_circle', label: 'Create Quiz', route: '/admin/create-quiz' },
];
case 'hr':
return [
{ icon: 'dashboard', label: 'Dashboard', route: '/hr/dashboard' },
{ icon: 'quiz', label: 'My Quizzes', route: '/hr/quizzes' },
{ icon: 'add_circle', label: 'Create Quiz', route: '/hr/create-quiz' },
{ icon: 'people', label: 'Candidates', route: '/hr/candidates' },
];
case 'candidate':
return [
{ icon: 'dashboard', label: 'Dashboard', route: '/candidate/dashboard' },
{ icon: 'person', label: 'Profile', route: '/candidate/profile' },
];
default:
return [];
}
});
roleBadge = computed(() => {
const role = this.authService.getUserRole();
switch (role) {
case 'admin': return { label: 'Admin', class: 'role-admin' };
case 'hr': return { label: 'HR', class: 'role-hr' };
case 'candidate': return { label: 'Candidate', class: 'role-candidate' };
default: return { label: '', class: '' };
}
});
userInitial = computed(() => {
return this.authService.currentUser()?.name?.charAt(0)?.toUpperCase() || '?';
});
setTheme(theme: ThemeMode): void {
this.themeService.setTheme(theme);
this.showThemeMenu = false;
}
toggleThemeMenu(): void {
this.showThemeMenu = !this.showThemeMenu;
}
toggleMobileSidebar(): void {
this.mobileSidebarOpen = !this.mobileSidebarOpen;
}
logout(): void {
this.authService.logout();
}
}
......@@ -2,7 +2,8 @@ import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const authGuard: CanActivateFn = (route, state) => {
// Generic auth guard - just checks if logged in
export const authGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
......@@ -13,7 +14,8 @@ export const authGuard: CanActivateFn = (route, state) => {
return true;
};
export const adminGuard: CanActivateFn = (route, state) => {
// Admin only
export const adminGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
......@@ -23,14 +25,15 @@ export const adminGuard: CanActivateFn = (route, state) => {
}
if (authService.getUserRole() !== 'admin') {
router.navigate(['/student/dashboard']);
router.navigate([authService.getDashboardRoute()]);
return false;
}
return true;
};
export const studentGuard: CanActivateFn = (route, state) => {
// HR only
export const hrGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
......@@ -39,25 +42,58 @@ export const studentGuard: CanActivateFn = (route, state) => {
return false;
}
if (authService.getUserRole() !== 'student') {
router.navigate(['/admin/dashboard']);
if (authService.getUserRole() !== 'hr') {
router.navigate([authService.getDashboardRoute()]);
return false;
}
return true;
};
export const guestGuard: CanActivateFn = (route, state) => {
// Candidate only
export const candidateGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
if (!authService.isLoggedIn()) {
router.navigate(['/login']);
return false;
}
if (authService.getUserRole() !== 'candidate') {
router.navigate([authService.getDashboardRoute()]);
return false;
}
return true;
};
// Admin or HR
export const adminOrHrGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
if (!authService.isLoggedIn()) {
router.navigate(['/login']);
return false;
}
const role = authService.getUserRole();
if (role !== 'admin' && role !== 'hr') {
router.navigate([authService.getDashboardRoute()]);
return false;
}
return true;
};
// Guest only (redirect logged-in users to their dashboard)
export const guestGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.isLoggedIn()) {
const role = authService.getUserRole();
if (role === 'admin') {
router.navigate(['/admin/dashboard']);
} else {
router.navigate(['/student/dashboard']);
}
router.navigate([authService.getDashboardRoute()]);
return false;
}
......
.page-container { padding: 32px 40px; max-width: 800px; }
.page-header { margin-bottom: 28px; }
.page-header h1 { font-size: 26px; font-weight: 700; color: var(--text-primary); margin: 8px 0 4px; }
.page-subtitle { color: var(--text-muted); font-size: 14px; }
.back-link { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); font-weight: 500; transition: color 0.15s; }
.back-link:hover { color: var(--accent-primary); }
.back-link .material-symbols-rounded { font-size: 18px; }
.form-card { margin-bottom: 32px; }
.quiz-form { display: flex; flex-direction: column; gap: 20px; }
.form-group { display: flex; flex-direction: column; flex: 1; }
.form-row { display: flex; gap: 16px; }
.file-upload {
display: flex; align-items: center; gap: 12px; padding: 20px;
background: var(--bg-input); border: 2px dashed var(--border-color);
border-radius: var(--radius-md); cursor: pointer; transition: all 0.2s;
}
.file-upload:hover { border-color: var(--accent-primary); background: var(--accent-primary-light); }
.upload-icon { font-size: 28px; color: var(--accent-primary); }
.file-name { font-size: 14px; color: var(--text-primary); font-weight: 500; }
.file-placeholder { font-size: 14px; color: var(--text-muted); }
@media (max-width: 768px) {
.page-container { padding: 20px 16px; }
.form-row { flex-direction: column; }
}
<div class="page-container animate-fade-in">
<div class="page-header">
<a routerLink="/admin/quizzes" class="back-link">
<span class="material-symbols-rounded">arrow_back</span> Back to Quizzes
</a>
<h1>Create New Quiz</h1>
<p class="page-subtitle">Upload an Excel file with questions to create a quiz</p>
</div>
@if (error()) {
<div class="alert alert-error"><span class="material-symbols-rounded">error</span> {{ error() }}</div>
}
@if (success()) {
<div class="alert alert-success"><span class="material-symbols-rounded">check_circle</span> {{ success() }}</div>
}
<div class="card card-padding form-card">
<form (ngSubmit)="onSubmit()" class="quiz-form">
<div class="form-group">
<label class="form-label">Quiz Title *</label>
<input class="form-input" [(ngModel)]="title" name="title" placeholder="e.g. JavaScript Basics">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Time Limit (minutes) *</label>
<input class="form-input" type="number" [(ngModel)]="timer" name="timer" min="1">
</div>
<div class="form-group">
<label class="form-label">Difficulty</label>
<select class="form-select" [(ngModel)]="difficulty" name="difficulty">
<option value="easy">Easy</option>
<option value="medium">Medium</option>
<option value="hard">Hard</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Category</label>
<input class="form-input" [(ngModel)]="category" name="category" placeholder="e.g. Programming">
</div>
<div class="form-group">
<label class="form-label">Topic</label>
<input class="form-input" [(ngModel)]="topic" name="topic" placeholder="e.g. Arrays & Loops">
</div>
</div>
<div class="form-group">
<label class="form-label">Questions File (Excel) *</label>
<div class="file-upload" (click)="fileInput.click()">
<input #fileInput type="file" accept=".xlsx,.xls" (change)="onFileSelected($event)" hidden>
<span class="material-symbols-rounded upload-icon">upload_file</span>
@if (fileName()) {
<span class="file-name">{{ fileName() }}</span>
} @else {
<span class="file-placeholder">Click to select an Excel file</span>
}
</div>
</div>
<button type="submit" class="btn btn-primary btn-lg" [disabled]="loading()" style="width: 100%;">
@if (loading()) {
<div class="spinner"></div> Creating...
} @else {
<span class="material-symbols-rounded">add_circle</span> Create Quiz
}
</button>
</form>
</div>
</div>
import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-create-quiz',
standalone: true,
imports: [CommonModule, FormsModule, RouterLink],
templateUrl: './create-quiz.html',
styleUrl: './create-quiz.css'
})
export class CreateQuizComponent {
title = '';
timer = 30;
category = '';
difficulty = 'medium';
topic = '';
selectedFile: File | null = null;
fileName = signal('');
loading = signal(false);
success = signal('');
error = signal('');
constructor(private quizService: QuizService, private router: Router) {}
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
this.selectedFile = input.files[0];
this.fileName.set(this.selectedFile.name);
}
}
onSubmit(): void {
if (!this.title.trim()) { this.error.set('Please enter a quiz title'); return; }
if (!this.timer || this.timer < 1) { this.error.set('Timer must be at least 1 minute'); return; }
if (!this.selectedFile) { this.error.set('Please upload an Excel file'); return; }
this.loading.set(true);
this.error.set('');
this.success.set('');
const formData = new FormData();
formData.append('title', this.title);
formData.append('timer', this.timer.toString());
formData.append('questionsFile', this.selectedFile);
if (this.category) formData.append('category', this.category);
if (this.difficulty) formData.append('difficulty', this.difficulty);
if (this.topic) formData.append('topic', this.topic);
this.quizService.createQuiz(formData).subscribe({
next: (res) => {
this.loading.set(false);
this.success.set(`Quiz "${res.quiz.title}" created with ${res.quiz.totalQuestions} questions!`);
setTimeout(() => this.router.navigate(['/admin/quizzes']), 1500);
},
error: (err) => {
this.loading.set(false);
this.error.set(err.error?.message || 'Failed to create quiz');
}
});
}
}
.dashboard-layout {
display: flex;
min-height: 100vh;
background: #0f1117;
}
/* Sidebar */
.sidebar {
width: 260px;
background: rgba(255, 255, 255, 0.03);
border-right: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
flex-direction: column;
padding: 24px 16px;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 10;
/* Admin Dashboard */
.page {
padding: 32px;
max-width: 1200px;
margin: 0 auto;
}
.sidebar-header {
display: flex;
align-items: center;
gap: 10px;
padding: 0 8px;
.page-header {
margin-bottom: 32px;
flex-wrap: wrap;
}
.sidebar-header .logo-icon { font-size: 28px; }
.sidebar-header h2 {
font-size: 20px;
.page-header h1 {
font-size: 24px;
font-weight: 700;
color: #fff;
margin: 0;
color: var(--text-primary);
margin: 0 0 4px;
}
.role-badge {
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff;
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: 20px;
letter-spacing: 0.5px;
text-transform: uppercase;
.page-subtitle {
font-size: 14px;
color: var(--text-muted);
margin: 0;
}
.sidebar-nav {
/* Loading */
.loading-center {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 12px;
color: rgba(255, 255, 255, 0.6);
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.06);
color: #fff;
justify-content: center;
padding: 80px 20px;
gap: 16px;
}
.nav-item.active {
background: rgba(102, 126, 234, 0.15);
color: #667eea;
.loading-text {
font-size: 14px;
color: var(--text-muted);
}
.nav-icon { font-size: 18px; }
.sidebar-footer {
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-top: 16px;
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.user-info {
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
margin-bottom: 12px;
}
.user-avatar {
width: 38px;
height: 38px;
border-radius: 10px;
background: linear-gradient(135deg, #667eea, #764ba2);
gap: 16px;
padding: 20px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
}
.stat-icon-wrap {
width: 48px;
height: 48px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 700;
font-size: 16px;
}
.user-details {
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.user-name {
.stat-icon-wrap .material-symbols-rounded {
font-size: 24px;
color: #fff;
font-size: 13px;
font-weight: 600;
}
.user-email {
color: rgba(255, 255, 255, 0.4);
font-size: 11px;
}
.stat-icon-wrap.blue { background: linear-gradient(135deg, #4f6ef7, #3b5bdb); }
.stat-icon-wrap.purple { background: linear-gradient(135deg, #7c5cfc, #6033e0); }
.stat-icon-wrap.green { background: linear-gradient(135deg, #22c55e, #16a34a); }
.stat-icon-wrap.orange { background: linear-gradient(135deg, #f59e0b, #d97706); }
.stat-icon-wrap.teal { background: linear-gradient(135deg, #14b8a6, #0d9488); }
.logout-btn {
width: 100%;
padding: 10px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 10px;
color: #f87171;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
.stat-body {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-family: inherit;
}
.logout-btn:hover {
background: rgba(239, 68, 68, 0.2);
flex-direction: column;
}
/* Main Content */
.main-content {
flex: 1;
margin-left: 260px;
padding: 40px;
.stat-value {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
}
.page-header {
margin-bottom: 40px;
.stat-label {
font-size: 13px;
color: var(--text-muted);
font-weight: 500;
}
.page-header h1 {
font-size: 28px;
font-weight: 700;
color: #fff;
margin: 0 0 8px;
/* Sections */
.section {
margin-bottom: 32px;
}
.page-header p {
color: rgba(255, 255, 255, 0.5);
font-size: 15px;
margin: 0;
.section-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 16px;
}
.dashboard-cards {
/* Quick Actions */
.actions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 24px;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.dash-card {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
padding: 32px;
text-decoration: none;
.action-card {
display: flex;
align-items: flex-start;
gap: 20px;
transition: all 0.3s ease;
cursor: pointer;
position: relative;
overflow: hidden;
}
.dash-card::before {
content: '';
position: absolute;
inset: 0;
opacity: 0;
transition: opacity 0.3s;
border-radius: 20px;
}
.card-users::before {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.08), rgba(118, 75, 162, 0.05));
align-items: center;
gap: 16px;
text-decoration: none;
color: inherit;
}
.card-quiz::before {
background: linear-gradient(135deg, rgba(79, 172, 254, 0.08), rgba(0, 242, 254, 0.05));
.action-icon {
font-size: 28px;
color: var(--accent-primary);
flex-shrink: 0;
}
.dash-card:hover {
border-color: rgba(255, 255, 255, 0.15);
transform: translateY(-4px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
.action-info {
flex: 1;
}
.dash-card:hover::before { opacity: 1; }
.card-icon-wrap {
width: 56px;
height: 56px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
z-index: 1;
.action-info h3 {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 4px;
}
.card-users .card-icon-wrap {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.2), rgba(118, 75, 162, 0.15));
.action-info p {
font-size: 13px;
color: var(--text-muted);
margin: 0;
}
.card-quiz .card-icon-wrap {
background: linear-gradient(135deg, rgba(79, 172, 254, 0.2), rgba(0, 242, 254, 0.15));
.action-arrow {
color: var(--text-muted);
font-size: 20px;
transition: transform 0.2s;
}
.card-icon { font-size: 28px; }
.card-content {
flex: 1;
position: relative;
z-index: 1;
.action-card:hover .action-arrow {
transform: translateX(4px);
color: var(--accent-primary);
}
.card-content h3 {
font-size: 18px;
font-weight: 700;
color: #fff;
margin: 0 0 8px;
/* Table helpers */
.user-cell {
display: flex;
flex-direction: column;
}
.card-content p {
color: rgba(255, 255, 255, 0.5);
.user-cell-name {
font-weight: 600;
font-size: 14px;
margin: 0;
line-height: 1.5;
}
.card-arrow {
font-size: 24px;
color: rgba(255, 255, 255, 0.2);
transition: all 0.3s;
position: relative;
z-index: 1;
align-self: center;
.user-cell-email {
font-size: 12px;
color: var(--text-muted);
}
.dash-card:hover .card-arrow {
color: rgba(255, 255, 255, 0.6);
transform: translateX(5px);
.text-muted {
color: var(--text-muted);
font-size: 13px;
}
@media (max-width: 768px) {
.sidebar {
width: 100%;
position: relative;
border-right: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.main-content {
margin-left: 0;
padding: 24px;
}
.dashboard-layout {
flex-direction: column;
}
.dashboard-cards {
grid-template-columns: 1fr;
}
.page { padding: 20px; }
.stats-grid { grid-template-columns: 1fr 1fr; }
.actions-grid { grid-template-columns: 1fr; }
}
<div class="dashboard-layout">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<span class="logo-icon">📝</span>
<h2>QuizMaster</h2>
<span class="role-badge">Admin</span>
<!-- Admin Dashboard -->
<div class="page animate-fade-in">
<div class="page-header">
<div>
<h1>Dashboard</h1>
<p class="page-subtitle">Overview of your assessment platform</p>
</div>
<nav class="sidebar-nav">
<a routerLink="/admin/dashboard" class="nav-item active">
<span class="nav-icon">🏠</span>
<span>Dashboard</span>
</a>
<a routerLink="/admin/users" class="nav-item">
<span class="nav-icon">👥</span>
<span>Users</span>
</a>
<a routerLink="/admin/generate-quiz" class="nav-item">
<span class="nav-icon"></span>
<span>Generate Quiz</span>
</a>
</nav>
<div class="sidebar-footer">
<div class="user-info">
<div class="user-avatar">{{ authService.currentUser()?.name?.charAt(0) || 'A' }}</div>
<div class="user-details">
<span class="user-name">{{ authService.currentUser()?.name }}</span>
<span class="user-email">{{ authService.currentUser()?.email }}</span>
</div>
@if (loading()) {
<div class="loading-center">
<div class="spinner spinner-lg"></div>
<p class="loading-text">Loading statistics...</p>
</div>
} @else if (stats()) {
<!-- Stats Cards -->
<div class="stats-grid stagger-children">
<div class="stat-card">
<div class="stat-icon-wrap blue">
<span class="material-symbols-rounded">group</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().totalUsers }}</span>
<span class="stat-label">Candidates</span>
</div>
</div>
<button class="logout-btn" (click)="logout()">
<span>🚪</span> Logout
</button>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<div class="page-header">
<h1>Admin Dashboard</h1>
<p>Manage quizzes and monitor student activity</p>
</div>
<div class="stat-card">
<div class="stat-icon-wrap purple">
<span class="material-symbols-rounded">badge</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().totalHR }}</span>
<span class="stat-label">HR Users</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon-wrap green">
<span class="material-symbols-rounded">quiz</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().totalQuizzes }}</span>
<span class="stat-label">Quizzes</span>
</div>
</div>
<div class="dashboard-cards">
<a routerLink="/admin/users" class="dash-card card-users">
<div class="card-icon-wrap">
<span class="card-icon">👥</span>
<div class="stat-card">
<div class="stat-icon-wrap orange">
<span class="material-symbols-rounded">assignment_turned_in</span>
</div>
<div class="card-content">
<h3>Users</h3>
<p>View registered students, check who's logged in, and review their test history</p>
<div class="stat-body">
<span class="stat-value">{{ stats().totalSubmissions }}</span>
<span class="stat-label">Submissions</span>
</div>
<div class="card-arrow"></div>
</a>
</div>
<a routerLink="/admin/generate-quiz" class="dash-card card-quiz">
<div class="card-icon-wrap">
<span class="card-icon">📋</span>
<div class="stat-card">
<div class="stat-icon-wrap teal">
<span class="material-symbols-rounded">circle</span>
</div>
<div class="card-content">
<h3>Generate Quiz</h3>
<p>Create a new quiz by uploading questions from an Excel file with timer settings</p>
<div class="stat-body">
<span class="stat-value">{{ stats().onlineUsers }}</span>
<span class="stat-label">Online Now</span>
</div>
<div class="card-arrow"></div>
</a>
</div>
</div>
</main>
<!-- Quick Actions -->
<div class="section">
<h2 class="section-title">Quick Actions</h2>
<div class="actions-grid">
<a routerLink="/admin/users" class="action-card card card-hover card-padding">
<span class="material-symbols-rounded action-icon">group</span>
<div class="action-info">
<h3>Manage Users</h3>
<p>View candidates, HR users, and online status</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
<a routerLink="/admin/quizzes" class="action-card card card-hover card-padding">
<span class="material-symbols-rounded action-icon">quiz</span>
<div class="action-info">
<h3>Manage Quizzes</h3>
<p>View, edit, assign, and delete quizzes</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
<a routerLink="/admin/create-quiz" class="action-card card card-hover card-padding">
<span class="material-symbols-rounded action-icon">add_circle</span>
<div class="action-info">
<h3>Create Quiz</h3>
<p>Upload Excel or generate with AI</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
</div>
</div>
<!-- Recent Submissions -->
@if (recentSubmissions().length > 0) {
<div class="section">
<h2 class="section-title">Recent Submissions</h2>
<div class="table-container">
<table>
<thead>
<tr>
<th>Candidate</th>
<th>Quiz</th>
<th>Score</th>
<th>Date</th>
</tr>
</thead>
<tbody>
@for (sub of recentSubmissions(); track sub._id) {
<tr>
<td>
<div class="user-cell">
<span class="user-cell-name">{{ sub.studentId?.name }}</span>
<span class="user-cell-email">{{ sub.studentId?.email }}</span>
</div>
</td>
<td>{{ sub.quizId?.title }}</td>
<td>
<span class="badge" [ngClass]="{
'badge-success': sub.percentage >= 70,
'badge-warning': sub.percentage >= 40 && sub.percentage < 70,
'badge-danger': sub.percentage < 40
}">{{ sub.percentage }}%</span>
</td>
<td class="text-muted">{{ sub.submittedAt | date:'short' }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
</div>
import { Component } from '@angular/core';
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { AuthService } from '../../../services/auth.service';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-admin-dashboard',
......@@ -10,10 +10,21 @@ import { AuthService } from '../../../services/auth.service';
templateUrl: './dashboard.html',
styleUrl: './dashboard.css'
})
export class AdminDashboardComponent {
constructor(public authService: AuthService) {}
export class AdminDashboardComponent implements OnInit {
stats = signal<any>(null);
recentSubmissions = signal<any[]>([]);
loading = signal(true);
logout(): void {
this.authService.logout();
constructor(private quizService: QuizService) {}
ngOnInit(): void {
this.quizService.getAdminStats().subscribe({
next: (res) => {
this.stats.set(res.stats);
this.recentSubmissions.set(res.recentSubmissions || []);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
}
.page-container { padding: 32px 40px; max-width: 900px; }
.page-header { margin-bottom: 28px; }
.page-header h1 { font-size: 26px; font-weight: 700; color: var(--text-primary); margin: 8px 0 0; }
.back-link { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); font-weight: 500; }
.back-link:hover { color: var(--accent-primary); }
.loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px 0; gap: 16px; }
.loading-center p { color: var(--text-muted); }
.form-card { margin-bottom: 24px; }
.quiz-form { display: flex; flex-direction: column; gap: 20px; }
.form-group { display: flex; flex-direction: column; flex: 1; }
.form-row { display: flex; gap: 16px; }
.questions-header { display: flex; align-items: center; justify-content: space-between; margin: 24px 0 16px; }
.questions-header h2 { font-size: 18px; font-weight: 600; color: var(--text-primary); }
.question-card { margin-bottom: 16px; display: flex; flex-direction: column; gap: 14px; }
.question-header { display: flex; align-items: center; justify-content: space-between; }
.q-number { font-size: 13px; font-weight: 700; color: var(--accent-primary); background: var(--accent-primary-light); padding: 4px 12px; border-radius: var(--radius-full); }
.options-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.option-input-wrap { display: flex; align-items: center; gap: 8px; }
.option-letter { font-size: 12px; font-weight: 700; color: var(--text-muted); width: 20px; text-align: center; flex-shrink: 0; }
.save-btn { width: 100%; margin-top: 24px; }
@media (max-width: 768px) {
.page-container { padding: 20px 16px; }
.form-row, .options-grid { flex-direction: column; grid-template-columns: 1fr; }
}
<div class="page-container animate-fade-in">
<div class="page-header">
<a routerLink="/admin/quizzes" class="back-link">
<span class="material-symbols-rounded">arrow_back</span> Back to Quizzes
</a>
<h1>Edit Quiz</h1>
</div>
@if (error()) {
<div class="alert alert-error"><span class="material-symbols-rounded">error</span> {{ error() }}</div>
}
@if (success()) {
<div class="alert alert-success"><span class="material-symbols-rounded">check_circle</span> {{ success() }}</div>
}
@if (locked()) {
<div class="alert alert-warning"><span class="material-symbols-rounded">lock</span> This quiz has been attempted. Questions cannot be modified.</div>
}
@if (loading()) {
<div class="loading-center"><div class="spinner spinner-lg"></div><p>Loading quiz...</p></div>
} @else {
<div class="card card-padding form-card">
<div class="quiz-form">
<div class="form-group">
<label class="form-label">Quiz Title</label>
<input class="form-input" [(ngModel)]="title" placeholder="Quiz title">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Timer (min)</label>
<input class="form-input" type="number" [(ngModel)]="timer" min="1">
</div>
<div class="form-group">
<label class="form-label">Difficulty</label>
<select class="form-select" [(ngModel)]="difficulty">
<option value="easy">Easy</option>
<option value="medium">Medium</option>
<option value="hard">Hard</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Category</label>
<input class="form-input" [(ngModel)]="category" placeholder="Category">
</div>
<div class="form-group">
<label class="form-label">Topic</label>
<input class="form-input" [(ngModel)]="topic" placeholder="Topic">
</div>
</div>
</div>
</div>
@if (!locked()) {
<div class="questions-header">
<h2>Questions ({{ questions().length }})</h2>
<button class="btn btn-outline btn-sm" (click)="addQuestion()">
<span class="material-symbols-rounded">add</span> Add Question
</button>
</div>
@for (q of questions(); track $index; let i = $index) {
<div class="card card-padding question-card">
<div class="question-header">
<span class="q-number">Q{{ i + 1 }}</span>
<button class="btn btn-ghost btn-sm" (click)="removeQuestion(i)">
<span class="material-symbols-rounded">delete</span>
</button>
</div>
<div class="form-group">
<input class="form-input" [ngModel]="q.question" (ngModelChange)="updateQuestion(i, 'question', $event)" placeholder="Question text">
</div>
<div class="options-grid">
@for (opt of q.options; track $index; let j = $index) {
<div class="option-input-wrap">
<span class="option-letter">{{ ['A','B','C','D'][j] }}</span>
<input class="form-input" [ngModel]="opt" (ngModelChange)="updateOption(i, j, $event)" placeholder="Option {{ j+1 }}">
</div>
}
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Correct Answer</label>
<input class="form-input" [ngModel]="q.correctAnswer" (ngModelChange)="updateQuestion(i, 'correctAnswer', $event)" placeholder="Correct answer text">
</div>
<div class="form-group" style="max-width: 120px;">
<label class="form-label">Marks</label>
<input class="form-input" type="number" [ngModel]="q.marks" (ngModelChange)="updateQuestion(i, 'marks', $event)" min="1">
</div>
</div>
</div>
}
}
<button class="btn btn-primary btn-lg save-btn" (click)="onSave()" [disabled]="saving()">
@if (saving()) {
<div class="spinner"></div> Saving...
} @else {
<span class="material-symbols-rounded">save</span> Save Changes
}
</button>
}
</div>
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-edit-quiz',
standalone: true,
imports: [CommonModule, FormsModule, RouterLink],
templateUrl: './edit-quiz.html',
styleUrl: './edit-quiz.css'
})
export class EditQuizComponent implements OnInit {
quizId = '';
title = '';
timer = 30;
category = '';
difficulty = 'medium';
topic = '';
questions = signal<any[]>([]);
loading = signal(true);
saving = signal(false);
success = signal('');
error = signal('');
locked = signal(false);
constructor(
private route: ActivatedRoute,
private router: Router,
private quizService: QuizService
) {}
ngOnInit(): void {
this.quizId = this.route.snapshot.params['quizId'];
this.loadQuiz();
}
loadQuiz(): void {
this.quizService.getAdminQuiz(this.quizId).subscribe({
next: (res) => {
const q = res.quiz;
this.title = q.title;
this.timer = q.timer;
this.category = q.category || '';
this.difficulty = q.difficulty || 'medium';
this.topic = q.topic || '';
this.questions.set(q.questions || []);
this.locked.set(res.hasAttempts || false);
this.loading.set(false);
},
error: (err) => {
this.error.set(err.error?.message || 'Failed to load quiz');
this.loading.set(false);
}
});
}
onSave(): void {
if (!this.title.trim()) { this.error.set('Title is required'); return; }
this.saving.set(true);
this.error.set('');
const data = {
title: this.title,
timer: this.timer,
category: this.category,
difficulty: this.difficulty,
topic: this.topic,
questions: this.questions()
};
this.quizService.updateQuiz(this.quizId, data).subscribe({
next: () => {
this.saving.set(false);
this.success.set('Quiz updated successfully!');
setTimeout(() => this.router.navigate(['/admin/quizzes']), 1200);
},
error: (err) => {
this.saving.set(false);
this.error.set(err.error?.message || 'Failed to update quiz');
}
});
}
updateQuestion(index: number, field: string, value: any): void {
const q = [...this.questions()];
q[index] = { ...q[index], [field]: value };
this.questions.set(q);
}
updateOption(qIndex: number, optIndex: number, value: string): void {
const q = [...this.questions()];
const opts = [...(q[qIndex].options || [])];
opts[optIndex] = value;
q[qIndex] = { ...q[qIndex], options: opts };
this.questions.set(q);
}
removeQuestion(index: number): void {
const q = this.questions().filter((_, i) => i !== index);
this.questions.set(q);
}
addQuestion(): void {
this.questions.set([...this.questions(), {
question: '', options: ['', '', '', ''], correctAnswer: '', marks: 1
}]);
}
}
.page-container { padding: 32px 40px; max-width: 1200px; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 28px; flex-wrap: wrap; gap: 16px; }
.page-header h1 { font-size: 26px; font-weight: 700; color: var(--text-primary); margin: 0; }
.page-subtitle { color: var(--text-muted); font-size: 14px; margin: 4px 0 0; }
.loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px 0; gap: 16px; }
.loading-center p { color: var(--text-muted); }
.quiz-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 20px; }
.quiz-card { padding: 24px; display: flex; flex-direction: column; gap: 16px; }
.quiz-card-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.quiz-title { font-size: 16px; font-weight: 600; color: var(--text-primary); margin: 0; line-height: 1.4; }
.quiz-meta { display: flex; flex-wrap: wrap; gap: 16px; }
.meta-item { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); }
.meta-item .material-symbols-rounded { font-size: 18px; color: var(--text-muted); }
.quiz-topic { font-size: 13px; color: var(--text-muted); background: var(--bg-tertiary); padding: 6px 12px; border-radius: var(--radius-sm); }
.quiz-card-actions { display: flex; gap: 8px; margin-top: auto; padding-top: 8px; border-top: 1px solid var(--border-subtle); }
.btn-primary:hover {
color: #fff; /* force text to stay white */
}
.btn-primary .material-symbols-rounded {
color: inherit;
}
.btn-primary:hover .material-symbols-rounded {
color: #fff;
}
@media (max-width: 768px) {
.page-container { padding: 20px 16px; }
.quiz-grid { grid-template-columns: 1fr; }
}
<div class="page-container animate-fade-in">
<div class="page-header">
<div>
<h1>Quizzes</h1>
<p class="page-subtitle">Manage all quizzes in the system</p>
</div>
<a routerLink="/admin/create-quiz" class="btn btn-primary">
<span class="material-symbols-rounded">add</span> Create Quiz
</a>
</div>
@if (error()) {
<div class="alert alert-error">
<span class="material-symbols-rounded">error</span> {{ error() }}
</div>
}
@if (loading()) {
<div class="loading-center">
<div class="spinner spinner-lg"></div>
<p>Loading quizzes...</p>
</div>
} @else if (quizzes().length === 0) {
<div class="empty-state">
<span class="material-symbols-rounded">quiz</span>
<h3>No quizzes yet</h3>
<p>Create your first quiz to get started.</p>
<a routerLink="/admin/create-quiz" class="btn btn-primary" style="margin-top: 16px;">Create Quiz</a>
</div>
} @else {
<div class="quiz-grid stagger-children">
@for (quiz of quizzes(); track quiz._id) {
<div class="card card-hover quiz-card">
<div class="quiz-card-header">
<h3 class="quiz-title">{{ quiz.title }}</h3>
<span class="badge" [ngClass]="getDifficultyClass(quiz.difficulty)">
{{ quiz.difficulty || 'General' }}
</span>
</div>
<div class="quiz-meta">
<div class="meta-item">
<span class="material-symbols-rounded">help</span>
{{ quiz.totalQuestions }} questions
</div>
<div class="meta-item">
<span class="material-symbols-rounded">timer</span>
{{ quiz.timer }} min
</div>
<div class="meta-item">
<span class="material-symbols-rounded">category</span>
{{ quiz.category || 'Uncategorized' }}
</div>
</div>
@if (quiz.topic) {
<div class="quiz-topic">{{ quiz.topic }}</div>
}
<div class="quiz-card-actions">
<a [routerLink]="['/admin/quiz', quiz._id, 'edit']" class="btn btn-outline btn-sm">
<span class="material-symbols-rounded">edit</span> Edit
</a>
<button class="btn btn-danger btn-sm" (click)="deleteQuiz(quiz._id)">
<span class="material-symbols-rounded">delete</span> Delete
</button>
</div>
</div>
}
</div>
}
</div>
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-admin-quizzes',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './quizzes.html',
styleUrl: './quizzes.css'
})
export class AdminQuizzesComponent implements OnInit {
quizzes = signal<any[]>([]);
loading = signal(true);
error = signal('');
constructor(private quizService: QuizService) {}
ngOnInit(): void {
this.loadQuizzes();
}
loadQuizzes(): void {
this.loading.set(true);
this.quizService.getAdminQuizzes().subscribe({
next: (res) => { this.quizzes.set(res.quizzes); this.loading.set(false); },
error: () => this.loading.set(false)
});
}
deleteQuiz(quizId: string): void {
if (!confirm('Are you sure you want to delete this quiz?')) return;
this.quizService.deleteQuiz(quizId).subscribe({
next: () => this.loadQuizzes(),
error: (err) => this.error.set(err.error?.message || 'Cannot delete quiz')
});
}
getDifficultyClass(d: string): string {
switch (d?.toLowerCase()) {
case 'easy': return 'badge-success';
case 'medium': return 'badge-warning';
case 'hard': return 'badge-danger';
default: return 'badge-primary';
}
}
}
.dashboard-layout { display: flex; min-height: 100vh; background: #0f1117; }
.sidebar { width: 260px; background: rgba(255,255,255,0.03); border-right: 1px solid rgba(255,255,255,0.06); display: flex; flex-direction: column; padding: 24px 16px; position: fixed; top: 0; left: 0; bottom: 0; z-index: 10; }
.sidebar-header { display: flex; align-items: center; gap: 10px; padding: 0 8px; margin-bottom: 32px; flex-wrap: wrap; }
.sidebar-header .logo-icon { font-size: 28px; }
.sidebar-header h2 { font-size: 20px; font-weight: 700; color: #fff; margin: 0; }
.role-badge { background: linear-gradient(135deg, #667eea, #764ba2); color: #fff; font-size: 11px; font-weight: 600; padding: 3px 10px; border-radius: 20px; text-transform: uppercase; }
.sidebar-nav { display: flex; flex-direction: column; gap: 4px; flex: 1; }
.nav-item { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-radius: 12px; color: rgba(255,255,255,0.6); text-decoration: none; font-size: 14px; font-weight: 500; transition: all 0.2s; }
.nav-item:hover { background: rgba(255,255,255,0.06); color: #fff; }
.nav-item.active { background: rgba(102,126,234,0.15); color: #667eea; }
.nav-icon { font-size: 18px; }
.sidebar-footer { border-top: 1px solid rgba(255,255,255,0.06); padding-top: 16px; }
.user-info { display: flex; align-items: center; gap: 12px; padding: 8px; margin-bottom: 12px; }
.user-avatar { width: 38px; height: 38px; border-radius: 10px; background: linear-gradient(135deg, #667eea, #764ba2); display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 700; font-size: 16px; }
.user-details { display: flex; flex-direction: column; }
.user-name { color: #fff; font-size: 13px; font-weight: 600; }
.user-email { color: rgba(255,255,255,0.4); font-size: 11px; }
.logout-btn { width: 100%; padding: 10px; background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); border-radius: 10px; color: #f87171; font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; gap: 8px; font-family: inherit; }
.logout-btn:hover { background: rgba(239,68,68,0.2); }
.main-content { flex: 1; margin-left: 260px; padding: 40px; }
.breadcrumb { margin-bottom: 24px; }
.breadcrumb a { color: #667eea; text-decoration: none; font-size: 14px; font-weight: 500; transition: color 0.2s; }
.breadcrumb a:hover { color: #8b9cf7; }
.student-header { display: flex; align-items: center; gap: 20px; margin-bottom: 32px; }
.student-avatar { width: 64px; height: 64px; border-radius: 18px; background: linear-gradient(135deg, #667eea, #764ba2); display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 700; font-size: 28px; }
.student-header h1 { font-size: 24px; font-weight: 700; color: #fff; margin: 0 0 4px; }
.student-header p { color: rgba(255,255,255,0.5); font-size: 14px; margin: 0; }
.section-title { color: #fff; font-size: 18px; font-weight: 600; margin: 0 0 20px; }
.loading-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 80px 0; gap: 16px; }
.loader { width: 40px; height: 40px; border: 3px solid rgba(255,255,255,0.1); border-top-color: #667eea; border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.loading-state p { color: rgba(255,255,255,0.5); }
.empty-state { text-align: center; padding: 80px 0; }
.empty-icon { font-size: 48px; display: block; margin-bottom: 16px; }
.empty-state h3 { color: #fff; font-size: 18px; margin: 0 0 8px; }
.empty-state p { color: rgba(255,255,255,0.5); font-size: 14px; margin: 0; }
.history-table-wrap { overflow-x: auto; border-radius: 16px; border: 1px solid rgba(255,255,255,0.08); }
.history-table { width: 100%; border-collapse: collapse; }
.history-table thead { background: rgba(255,255,255,0.04); }
.history-table th { padding: 14px 20px; text-align: left; color: rgba(255,255,255,0.5); font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
.history-table td { padding: 16px 20px; border-top: 1px solid rgba(255,255,255,0.06); color: rgba(255,255,255,0.8); font-size: 14px; }
.history-table tbody tr { transition: background 0.2s; }
.history-table tbody tr:hover { background: rgba(255,255,255,0.03); }
.quiz-name { font-weight: 600; color: #fff !important; }
.score-badge { background: rgba(102,126,234,0.15); color: #667eea; padding: 4px 12px; border-radius: 8px; font-weight: 600; font-size: 13px; }
.percent-bar { width: 80px; height: 6px; background: rgba(255,255,255,0.1); border-radius: 3px; overflow: hidden; display: inline-block; vertical-align: middle; margin-right: 8px; }
.percent-fill { height: 100%; border-radius: 3px; transition: width 0.5s ease; }
.percent-fill.good { background: linear-gradient(90deg, #4ade80, #22c55e); }
.percent-fill.avg { background: linear-gradient(90deg, #fbbf24, #f59e0b); }
.percent-fill.poor { background: linear-gradient(90deg, #f87171, #ef4444); }
.percent-text { color: rgba(255,255,255,0.6); font-size: 13px; }
.view-btn { color: #667eea; text-decoration: none; font-weight: 600; font-size: 13px; transition: color 0.2s; }
.view-btn:hover { color: #8b9cf7; }
.dashboard-layout {
display: flex;
min-height: 100vh;
background: var(--bg-secondary);
}
.sidebar {
width: 260px;
background: var(--bg-card);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: 24px 16px;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 10;
}
.sidebar-header {
display: flex;
align-items: center;
gap: 10px;
padding: 0 8px;
margin-bottom: 32px;
flex-wrap: wrap;
}
.sidebar-header .logo-icon {
font-size: 28px;
}
.sidebar-header h2 {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.role-badge {
background: linear-gradient(135deg, #667eea, #764ba2);
color: var(--text-primary);
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: 20px;
text-transform: uppercase;
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 12px;
color: var(--text-secondary);
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--text-primary);
}
.nav-item.active {
background: rgba(102, 126, 234, 0.15);
color: #667eea;
}
.nav-icon {
font-size: 18px;
}
.sidebar-footer {
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-top: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
margin-bottom: 12px;
}
.user-avatar {
width: 38px;
height: 38px;
border-radius: 10px;
background: linear-gradient(135deg, #667eea, #764ba2);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
font-weight: 700;
font-size: 16px;
}
.user-details {
display: flex;
flex-direction: column;
}
.user-name {
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
}
.user-email {
color: rgba(255, 255, 255, 0.4);
font-size: 11px;
}
.logout-btn {
width: 100%;
padding: 10px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 10px;
color: #f87171;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-family: inherit;
}
.logout-btn:hover {
background: rgba(239, 68, 68, 0.2);
}
.main-content {
flex: 1;
margin-left: 260px;
padding: 24px 32px;
}
.breadcrumb {
margin-bottom: 24px;
}
.breadcrumb a {
color: #667eea;
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: color 0.2s;
}
.breadcrumb a:hover {
color: #8b9cf7;
}
.student-header {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 32px;
}
.student-avatar {
width: 64px;
height: 64px;
border-radius: 18px;
background: linear-gradient(135deg, #667eea, #764ba2);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
font-weight: 700;
font-size: 28px;
}
.student-header h1 {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 4px;
}
.student-header p {
color: var(--text-secondary);
font-size: 14px;
margin: 0;
}
.section-title {
color: var(--text-primary);
font-size: 18px;
font-weight: 600;
margin: 0 0 20px;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 0;
gap: 16px;
}
.loader {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.1);
border-top-color: #667eea;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-state p {
color: var(--text-secondary);
}
.empty-state {
text-align: center;
padding: 80px 0;
}
.empty-icon {
font-size: 48px;
display: block;
margin-bottom: 16px;
}
.empty-state h3 {
color: var(--text-primary);
font-size: 18px;
margin: 0 0 8px;
}
.empty-state p {
color: var(--text-secondary);
font-size: 14px;
margin: 0;
}
.history-table-wrap {
overflow-x: auto;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.history-table {
width: 100%;
border-collapse: collapse;
}
.history-table thead {
background: var(--bg-hover);
}
.history-table th {
padding: 14px 20px;
text-align: left;
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.history-table td {
padding: 16px 20px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
color: var(--text-primary);
font-size: 14px;
}
.history-table tbody tr {
transition: background 0.2s;
}
.history-table tbody tr:hover {
background: rgba(255, 255, 255, 0.03);
}
.quiz-name {
font-weight: 600;
color: #fff !important;
}
.score-badge {
background: rgba(102, 126, 234, 0.15);
color: #667eea;
padding: 4px 12px;
border-radius: 8px;
font-weight: 600;
font-size: 13px;
}
.percent-bar {
width: 80px;
height: 6px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
overflow: hidden;
display: inline-block;
vertical-align: middle;
margin-right: 8px;
}
.percent-fill {
height: 100%;
border-radius: 3px;
transition: width 0.5s ease;
}
.percent-fill.good {
background: linear-gradient(90deg, #4ade80, #22c55e);
}
.percent-fill.avg {
background: linear-gradient(90deg, #fbbf24, #f59e0b);
}
.percent-fill.poor {
background: linear-gradient(90deg, #f87171, #ef4444);
}
.percent-text {
color: var(--text-secondary);
font-size: 13px;
}
.view-btn {
color: #667eea;
text-decoration: none;
font-weight: 600;
font-size: 13px;
transition: color 0.2s;
}
.view-btn:hover {
color: #8b9cf7;
}
@media (max-width: 768px) {
.sidebar { width: 100%; position: relative; border-right: none; border-bottom: 1px solid rgba(255,255,255,0.06); }
.main-content { margin-left: 0; padding: 24px; }
.dashboard-layout { flex-direction: column; }
.sidebar {
width: 100%;
position: relative;
border-right: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.main-content {
margin-left: 0;
padding: 24px;
}
.dashboard-layout {
flex-direction: column;
}
}
.content-wrapper {
max-width: 1100px;
margin: 0; /* force left alignment */
}
\ No newline at end of file
<div class="dashboard-layout">
<aside class="sidebar">
<div class="sidebar-header">
<span class="logo-icon">📝</span>
<h2>QuizMaster</h2>
<span class="role-badge">Admin</span>
</div>
<nav class="sidebar-nav">
<a routerLink="/admin/dashboard" class="nav-item"><span class="nav-icon">🏠</span><span>Dashboard</span></a>
<a routerLink="/admin/users" class="nav-item active"><span class="nav-icon">👥</span><span>Users</span></a>
<a routerLink="/admin/generate-quiz" class="nav-item"><span class="nav-icon"></span><span>Generate Quiz</span></a>
</nav>
<div class="sidebar-footer">
<div class="user-info">
<div class="user-avatar">{{ authService.currentUser()?.name?.charAt(0) || 'A' }}</div>
<div class="user-details">
<span class="user-name">{{ authService.currentUser()?.name }}</span>
<span class="user-email">{{ authService.currentUser()?.email }}</span>
</div>
</div>
<button class="logout-btn" (click)="logout()"><span>🚪</span> Logout</button>
</div>
</aside>
<main class="main-content">
<div class="content-wrapper">
<div class="breadcrumb">
<a routerLink="/admin/users">← Back to Users</a>
</div>
......@@ -90,5 +70,6 @@
</div>
}
}
</main>
</div>
</div>
</main>
\ No newline at end of file
.dashboard-layout { display: flex; min-height: 100vh; background: #0f1117; }
.dashboard-layout {
display: flex;
min-height: 100vh;
background: var(--bg-secondary);
}
.sidebar {
width: 260px; background: rgba(255,255,255,0.03); border-right: 1px solid rgba(255,255,255,0.06);
display: flex; flex-direction: column; padding: 24px 16px; position: fixed; top: 0; left: 0; bottom: 0; z-index: 10;
}
.sidebar-header { display: flex; align-items: center; gap: 10px; padding: 0 8px; margin-bottom: 32px; flex-wrap: wrap; }
.sidebar-header .logo-icon { font-size: 28px; }
.sidebar-header h2 { font-size: 20px; font-weight: 700; color: #fff; margin: 0; }
.role-badge { background: linear-gradient(135deg, #667eea, #764ba2); color: #fff; font-size: 11px; font-weight: 600; padding: 3px 10px; border-radius: 20px; text-transform: uppercase; }
.sidebar-nav { display: flex; flex-direction: column; gap: 4px; flex: 1; }
.nav-item { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-radius: 12px; color: rgba(255,255,255,0.6); text-decoration: none; font-size: 14px; font-weight: 500; transition: all 0.2s; }
.nav-item:hover { background: rgba(255,255,255,0.06); color: #fff; }
.nav-item.active { background: rgba(102,126,234,0.15); color: #667eea; }
.nav-icon { font-size: 18px; }
.sidebar-footer { border-top: 1px solid rgba(255,255,255,0.06); padding-top: 16px; }
.user-info { display: flex; align-items: center; gap: 12px; padding: 8px; margin-bottom: 12px; }
.user-avatar { width: 38px; height: 38px; border-radius: 10px; background: linear-gradient(135deg, #667eea, #764ba2); display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 700; font-size: 16px; }
.user-details { display: flex; flex-direction: column; }
.user-name { color: #fff; font-size: 13px; font-weight: 600; }
.user-email { color: rgba(255,255,255,0.4); font-size: 11px; }
.logout-btn { width: 100%; padding: 10px; background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); border-radius: 10px; color: #f87171; font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; gap: 8px; font-family: inherit; }
.logout-btn:hover { background: rgba(239,68,68,0.2); }
.main-content { flex: 1; margin-left: 260px; padding: 40px; }
.page-header { margin-bottom: 32px; }
.page-header h1 { font-size: 28px; font-weight: 700; color: #fff; margin: 0 0 8px; }
.page-header p { color: rgba(255,255,255,0.5); font-size: 15px; margin: 0; }
.filter-tabs { display: flex; gap: 8px; margin-bottom: 24px; }
width: 260px;
background: var(--bg-card);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: 24px 16px;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 10;
}
.sidebar-header {
display: flex;
align-items: center;
gap: 10px;
padding: 0 8px;
margin-bottom: 32px;
flex-wrap: wrap;
}
.sidebar-header .logo-icon {
font-size: 28px;
}
.sidebar-header h2 {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.role-badge {
background: linear-gradient(135deg, #667eea, #764ba2);
color: var(--text-primary);
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: 20px;
text-transform: uppercase;
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 12px;
color: var(--text-secondary);
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--text-primary);
}
.nav-item.active {
background: rgba(102, 126, 234, 0.15);
color: var(--text-secondary);
}
.nav-icon {
font-size: 18px;
}
.sidebar-footer {
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-top: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
margin-bottom: 12px;
}
.user-avatar {
width: 38px;
height: 38px;
border-radius: 10px;
background: linear-gradient(135deg, #667eea, #764ba2);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
font-weight: 700;
font-size: 16px;
}
.user-details {
display: flex;
flex-direction: column;
}
.user-name {
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
}
.user-email {
color: rgba(255, 255, 255, 0.4);
font-size: 11px;
}
.logout-btn {
width: 100%;
padding: 10px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 10px;
color: #f87171;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-family: inherit;
}
.logout-btn:hover {
background: rgba(239, 68, 68, 0.2);
}
.main-content {
flex: 1;
margin-left: 0px;
padding: 40px;
}
.page-header {
margin-bottom: 32px;
}
.page-header h1 {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 8px;
}
.page-header p {
color: var(--text-secondary);
font-size: 15px;
margin: 0;
}
.filter-tabs {
display: flex;
gap: 8px;
margin-bottom: 24px;
}
.tab {
padding: 10px 20px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.1);
background: transparent; color: rgba(255,255,255,0.5); font-size: 13px; font-weight: 600;
cursor: pointer; transition: all 0.2s; font-family: inherit;
padding: 10px 20px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: transparent;
color: var(--text-secondary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
.tab:hover {
border-color: rgba(255, 255, 255, 0.2);
color: var(--text-primary);
}
.tab.active {
background: rgba(102, 126, 234, 0.15);
border-color: rgba(102, 126, 234, 0.3);
color: #667eea;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 0;
gap: 16px;
}
.tab:hover { border-color: rgba(255,255,255,0.2); color: #fff; }
.tab.active { background: rgba(102,126,234,0.15); border-color: rgba(102,126,234,0.3); color: #667eea; }
.loading-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 80px 0; gap: 16px; }
.loader { width: 40px; height: 40px; border: 3px solid rgba(255,255,255,0.1); border-top-color: #667eea; border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.loading-state p { color: rgba(255,255,255,0.5); }
.loader {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.1);
border-top-color: #667eea;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-state p {
color: var(--text-secondary);
}
.empty-state {
text-align: center;
padding: 80px 0;
}
.empty-icon {
font-size: 48px;
display: block;
margin-bottom: 16px;
}
.empty-state h3 {
color: var(--text-primary);
font-size: 18px;
margin: 0 0 8px;
}
.empty-state { text-align: center; padding: 80px 0; }
.empty-icon { font-size: 48px; display: block; margin-bottom: 16px; }
.empty-state h3 { color: #fff; font-size: 18px; margin: 0 0 8px; }
.empty-state p { color: rgba(255,255,255,0.5); font-size: 14px; margin: 0; }
.empty-state p {
color: var(--text-secondary);
font-size: 14px;
margin: 0;
}
.users-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
.users-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.user-card {
display: flex; align-items: center; gap: 16px; padding: 20px;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
border-radius: 16px; text-decoration: none; transition: all 0.25s; cursor: pointer;
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 16px;
text-decoration: none;
transition: all 0.25s;
cursor: pointer;
}
.user-card:hover {
background: var(--bg-hover);
border-color: rgba(255, 255, 255, 0.15);
transform: translateY(-2px);
}
.user-card:hover { background: rgba(255,255,255,0.07); border-color: rgba(255,255,255,0.15); transform: translateY(-2px); }
.user-card-avatar {
width: 48px; height: 48px; border-radius: 14px;
width: 48px;
height: 48px;
border-radius: 14px;
background: linear-gradient(135deg, #667eea, #764ba2);
display: flex; align-items: center; justify-content: center;
color: #fff; font-weight: 700; font-size: 20px; flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
font-weight: 700;
font-size: 20px;
flex-shrink: 0;
}
.user-card-info {
flex: 1;
}
.user-card-info h4 {
color: var(--text-primary);
font-size: 15px;
font-weight: 600;
margin: 0 0 4px;
}
.user-card-info p {
color: var(--text-secondary);
font-size: 13px;
margin: 0 0 4px;
}
.user-card-info { flex: 1; }
.user-card-info h4 { color: #fff; font-size: 15px; font-weight: 600; margin: 0 0 4px; }
.user-card-info p { color: rgba(255,255,255,0.5); font-size: 13px; margin: 0 0 4px; }
.user-joined { color: rgba(255,255,255,0.3); font-size: 11px; }
.user-joined {
color: var(--text-muted);
font-size: 11px;
}
.status-dot {
width: 10px; height: 10px; border-radius: 50%;
background: rgba(255,255,255,0.2); flex-shrink: 0;
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
flex-shrink: 0;
}
.status-dot.online {
background: #4ade80;
box-shadow: 0 0 8px rgba(74, 222, 128, 0.4);
}
.status-dot.online { background: #4ade80; box-shadow: 0 0 8px rgba(74,222,128,0.4); }
.view-arrow { color: rgba(255,255,255,0.2); font-size: 20px; transition: all 0.2s; }
.user-card:hover .view-arrow { color: rgba(255,255,255,0.6); transform: translateX(4px); }
.view-arrow {
color: rgba(255, 255, 255, 0.2);
font-size: 20px;
transition: all 0.2s;
}
@media (max-width: 768px) {
.sidebar { width: 100%; position: relative; border-right: none; border-bottom: 1px solid rgba(255,255,255,0.06); }
.main-content { margin-left: 0; padding: 24px; }
.dashboard-layout { flex-direction: column; }
.users-grid { grid-template-columns: 1fr; }
.user-card:hover .view-arrow {
color: var(--text-secondary);
transform: translateX(4px);
}
@media (max-width: 768px) {
.sidebar {
width: 100%;
position: relative;
border-right: 1px solid var(--border-color);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.main-content {
margin-left: 0;
padding: 24px;
}
.dashboard-layout {
flex-direction: column;
}
.users-grid {
grid-template-columns: 1fr;
}
}
\ No newline at end of file
/* Candidate Dashboard */
.page { padding: 32px; max-width: 1200px; margin: 0 auto; }
.page-header { margin-bottom: 32px; }
.page-header h1 { font-size: 24px; font-weight: 700; color: var(--text-primary); margin: 0 0 4px; }
.page-subtitle { font-size: 14px; color: var(--text-muted); margin: 0; }
.loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px 20px; gap: 16px; }
.loading-text { font-size: 14px; color: var(--text-muted); }
.quiz-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 20px; }
.quiz-card { padding: 24px; display: flex; flex-direction: column; gap: 12px; }
.quiz-card.taken { opacity: 0.85; }
.quiz-card-header { display: flex; align-items: center; justify-content: space-between; }
.quiz-icon-wrap {
width: 40px; height: 40px; border-radius: var(--radius-md);
background: var(--accent-primary-light); display: flex; align-items: center; justify-content: center;
}
.quiz-icon-wrap .material-symbols-rounded { font-size: 22px; color: var(--accent-primary); }
.quiz-icon-wrap.completed { background: var(--success-light); }
.quiz-icon-wrap.completed .material-symbols-rounded { color: var(--success); }
.quiz-meta { display: flex; gap: 6px; }
.quiz-title { font-size: 16px; font-weight: 600; color: var(--text-primary); margin: 0; }
.quiz-details { display: flex; gap: 16px; }
.quiz-detail { display: flex; align-items: center; gap: 4px; font-size: 13px; color: var(--text-muted); }
.quiz-detail .material-symbols-rounded { font-size: 16px; }
.quiz-result { display: flex; flex-direction: column; gap: 6px; }
.result-bar-track { width: 100%; height: 6px; background: var(--bg-tertiary); border-radius: 3px; overflow: hidden; }
.result-bar-fill { height: 100%; border-radius: 3px; transition: width 0.5s ease; }
.fill-good { background: var(--success); }
.fill-avg { background: var(--warning); }
.fill-poor { background: var(--danger); }
.result-score { font-size: 13px; color: var(--text-secondary); font-weight: 500; }
.quiz-card-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 4px; }
@media (max-width: 768px) {
.page { padding: 20px; }
.quiz-grid { grid-template-columns: 1fr; }
}
<!-- Candidate Dashboard -->
<div class="page animate-fade-in">
<div class="page-header">
<div>
<h1>Welcome, {{ authService.currentUser()?.name }}!</h1>
<p class="page-subtitle">Your assigned quizzes are listed below</p>
</div>
</div>
@if (loading()) {
<div class="loading-center">
<div class="spinner spinner-lg"></div>
<p class="loading-text">Loading quizzes...</p>
</div>
} @else if (quizzes().length === 0) {
<div class="empty-state">
<span class="material-symbols-rounded">assignment</span>
<h3>No quizzes assigned</h3>
<p>Check back later for new assessments</p>
</div>
} @else {
<div class="quiz-grid stagger-children">
@for (quiz of quizzes(); track quiz.id) {
<div class="quiz-card card" [class.taken]="quiz.taken">
<div class="quiz-card-header">
<div class="quiz-icon-wrap" [class.completed]="quiz.taken">
<span class="material-symbols-rounded">{{ quiz.taken ? 'check_circle' : 'quiz' }}</span>
</div>
<div class="quiz-meta">
@if (quiz.category) {
<span class="badge badge-primary">{{ quiz.category }}</span>
}
@if (quiz.difficulty) {
<span class="badge" [ngClass]="{
'badge-success': quiz.difficulty === 'Beginner',
'badge-warning': quiz.difficulty === 'Intermediate',
'badge-danger': quiz.difficulty === 'Advanced'
}">{{ quiz.difficulty }}</span>
}
</div>
</div>
<h3 class="quiz-title">{{ quiz.title }}</h3>
<div class="quiz-details">
<span class="quiz-detail">
<span class="material-symbols-rounded">timer</span>
{{ quiz.timer }} min
</span>
<span class="quiz-detail">
<span class="material-symbols-rounded">help</span>
{{ quiz.totalQuestions }} questions
</span>
</div>
@if (quiz.taken) {
<div class="quiz-result">
<div class="result-bar-track">
<div class="result-bar-fill"
[style.width.%]="quiz.percentage"
[ngClass]="{
'fill-good': quiz.percentage >= 70,
'fill-avg': quiz.percentage >= 40 && quiz.percentage < 70,
'fill-poor': quiz.percentage < 40
}"></div>
</div>
<span class="result-score">Score: {{ quiz.score }}/{{ quiz.totalMarks }} ({{ quiz.percentage }}%)</span>
</div>
<div class="quiz-card-footer">
<span class="badge badge-success">
<span class="material-symbols-rounded" style="font-size: 14px">check</span>
Completed
</span>
</div>
} @else {
<div class="quiz-card-footer">
<a [routerLink]="['/candidate/quiz', quiz.id]" class="btn btn-primary btn-sm">
Start Quiz
<span class="material-symbols-rounded" style="font-size: 16px">arrow_forward</span>
</a>
</div>
}
</div>
}
</div>
}
</div>
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { AuthService } from '../../../services/auth.service';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-candidate-dashboard',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './dashboard.html',
styleUrl: './dashboard.css'
})
export class CandidateDashboardComponent implements OnInit {
quizzes = signal<any[]>([]);
loading = signal(true);
constructor(public authService: AuthService, private quizService: QuizService) {}
ngOnInit(): void {
this.quizService.getAvailableQuizzes().subscribe({
next: (res) => {
this.quizzes.set(res.quizzes);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
}
.page { padding: 32px; max-width: 1000px; margin: 0 auto; }
.loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px 20px; gap: 16px; }
.loading-text { font-size: 14px; color: var(--text-muted); }
.profile-header { display: flex; align-items: center; gap: 24px; margin-bottom: 24px; }
.profile-avatar {
width: 72px; height: 72px; border-radius: var(--radius-lg);
background: var(--accent-gradient); display: flex; align-items: center; justify-content: center;
color: #fff; font-weight: 700; font-size: 32px; flex-shrink: 0;
}
.profile-info h1 { font-size: 22px; font-weight: 700; color: var(--text-primary); margin: 0 0 4px; }
.profile-email { color: var(--text-secondary); font-size: 14px; margin: 0 0 2px; }
.profile-joined { color: var(--text-muted); font-size: 12px; margin: 0; }
.stats-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 32px; }
.stat-card { display: flex; flex-direction: column; align-items: center; gap: 8px; text-align: center; }
.stat-icon { font-size: 28px; }
.stat-icon.blue { color: var(--accent-primary); }
.stat-icon.green { color: var(--success); }
.stat-icon.orange { color: var(--warning); }
.stat-value { font-size: 28px; font-weight: 700; color: var(--text-primary); }
.stat-label { font-size: 13px; color: var(--text-muted); }
.section { margin-bottom: 32px; }
.section-title { font-size: 16px; font-weight: 600; color: var(--text-primary); margin: 0 0 16px; }
.results-list { display: flex; flex-direction: column; gap: 12px; }
.result-item { display: flex; align-items: center; gap: 16px; }
.result-item:hover { border-color: var(--border-strong); }
.result-icon-wrap {
width: 44px; height: 44px; border-radius: var(--radius-md);
background: var(--accent-primary-light); display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.result-icon-wrap .material-symbols-rounded { font-size: 22px; color: var(--accent-primary); }
.result-info { flex: 1; min-width: 0; }
.result-info h4 { font-size: 14px; font-weight: 600; color: var(--text-primary); margin: 0 0 2px; }
.result-info p { font-size: 12px; color: var(--text-muted); margin: 0; }
.result-score-wrap { text-align: center; margin-right: 8px; }
.score-circle {
display: flex; align-items: center; justify-content: center;
width: 48px; height: 48px; border-radius: 50%; font-weight: 700; font-size: 13px; margin-bottom: 2px;
}
.score-circle.good { background: var(--success-light); color: var(--success); border: 2px solid var(--success-border); }
.score-circle.avg { background: var(--warning-light); color: var(--warning); border: 2px solid var(--warning-border); }
.score-circle.poor { background: var(--danger-light); color: var(--danger); border: 2px solid var(--danger-border); }
.score-detail { font-size: 11px; color: var(--text-muted); }
@media (max-width: 768px) {
.page { padding: 20px; }
.profile-header { flex-direction: column; text-align: center; }
.stats-row { grid-template-columns: 1fr; }
.result-item { flex-wrap: wrap; }
}
<div class="page animate-fade-in">
@if (loading()) {
<div class="loading-center"><div class="spinner spinner-lg"></div><p class="loading-text">Loading profile...</p></div>
} @else if (user()) {
<div class="profile-header card card-padding">
<div class="profile-avatar">{{ user().name?.charAt(0)?.toUpperCase() }}</div>
<div class="profile-info">
<h1>{{ user().name }}</h1>
<p class="profile-email">{{ user().email }}</p>
<p class="profile-joined">Member since {{ user().createdAt | date:'mediumDate' }}</p>
</div>
</div>
<div class="stats-row">
<div class="stat-card card card-padding">
<span class="material-symbols-rounded stat-icon blue">assignment</span>
<span class="stat-value">{{ testsTaken }}</span>
<span class="stat-label">Tests Taken</span>
</div>
<div class="stat-card card card-padding">
<span class="material-symbols-rounded stat-icon green">trending_up</span>
<span class="stat-value">{{ avgScore }}%</span>
<span class="stat-label">Average Score</span>
</div>
<div class="stat-card card card-padding">
<span class="material-symbols-rounded stat-icon orange">emoji_events</span>
<span class="stat-value">{{ bestScore }}%</span>
<span class="stat-label">Best Score</span>
</div>
</div>
<div class="section">
<h2 class="section-title">Test Results</h2>
@if (submissions().length === 0) {
<div class="empty-state">
<span class="material-symbols-rounded">assignment</span>
<h3>No results yet</h3>
<p>Take a quiz to see results here</p>
</div>
} @else {
<div class="results-list">
@for (sub of submissions(); track sub._id) {
<div class="result-item card card-padding">
<div class="result-icon-wrap">
<span class="material-symbols-rounded">quiz</span>
</div>
<div class="result-info">
<h4>{{ sub.quizId?.title }}</h4>
<p>{{ sub.submittedAt | date:'medium' }} · {{ sub.timeTaken ? (sub.timeTaken + 's') : '—' }}</p>
</div>
<div class="result-score-wrap">
<span class="score-circle" [ngClass]="{
'good': sub.percentage >= 70,
'avg': sub.percentage >= 40 && sub.percentage < 70,
'poor': sub.percentage < 40
}">{{ sub.percentage }}%</span>
<span class="score-detail">{{ sub.score }}/{{ sub.totalMarks }}</span>
</div>
<a [routerLink]="['/candidate/results', sub._id]" class="btn btn-outline btn-sm">View</a>
</div>
}
</div>
}
</div>
}
</div>
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { AuthService } from '../../../services/auth.service';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-candidate-profile',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './profile.html',
styleUrl: './profile.css'
})
export class CandidateProfileComponent implements OnInit {
user = signal<any>(null);
submissions = signal<any[]>([]);
loading = signal(true);
constructor(public authService: AuthService, private quizService: QuizService) {}
ngOnInit(): void {
this.quizService.getCandidateProfile().subscribe({
next: (res) => {
this.user.set(res.user);
this.submissions.set(res.submissions || []);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
get testsTaken(): number { return this.submissions().length; }
get avgScore(): number {
const subs = this.submissions();
if (subs.length === 0) return 0;
return Math.round(subs.reduce((a, s) => a + s.percentage, 0) / subs.length);
}
get bestScore(): number {
const subs = this.submissions();
if (subs.length === 0) return 0;
return Math.max(...subs.map(s => s.percentage));
}
}
.page { padding: 32px; max-width: 900px; margin: 0 auto; }
.loading-center { display: flex; align-items: center; justify-content: center; min-height: 60vh; }
.back-link { display: inline-flex; align-items: center; gap: 6px; color: var(--text-muted); font-size: 13px; font-weight: 500; margin-bottom: 16px; transition: color 0.2s; }
.back-link:hover { color: var(--accent-primary); }
.back-link .material-symbols-rounded { font-size: 18px; }
.results-header { margin-bottom: 32px; }
.results-header h1 { font-size: 22px; font-weight: 700; color: var(--text-primary); margin: 0 0 20px; }
.result-summary { display: flex; align-items: center; gap: 32px; flex-wrap: wrap; }
.big-score { font-size: 56px; font-weight: 800; line-height: 1; }
.big-score.good { color: var(--success); }
.big-score.avg { color: var(--warning); }
.big-score.poor { color: var(--danger); }
.score-meta { display: flex; flex-direction: column; gap: 8px; }
.score-meta span { display: flex; align-items: center; gap: 8px; color: var(--text-secondary); font-size: 14px; }
.score-meta .material-symbols-rounded { font-size: 18px; color: var(--text-muted); }
.section-title { font-size: 16px; font-weight: 600; color: var(--text-primary); margin: 0 0 16px; }
.answers-list { display: flex; flex-direction: column; gap: 16px; }
.answer-card { border-left: 4px solid transparent; }
.answer-card.correct { border-left-color: var(--success); }
.answer-card.wrong { border-left-color: var(--danger); }
.answer-header { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
.q-num { font-weight: 700; font-size: 14px; color: var(--accent-primary); background: var(--accent-primary-light); padding: 4px 12px; border-radius: var(--radius-sm); }
.q-text { font-size: 15px; font-weight: 600; color: var(--text-primary); margin: 0 0 16px; line-height: 1.5; }
.options-review { display: flex; flex-direction: column; gap: 8px; }
.opt-row { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-radius: var(--radius-sm); font-size: 14px; color: var(--text-secondary); background: var(--bg-input); border: 1px solid transparent; }
.opt-row.is-correct { background: var(--success-light); color: var(--success); border-color: var(--success-border); }
.opt-row.is-wrong { background: var(--danger-light); color: var(--danger); border-color: var(--danger-border); }
.opt-icon { font-size: 20px; }
.opt-row.is-correct .opt-icon { color: var(--success); }
.opt-row.is-wrong .opt-icon { color: var(--danger); }
@media (max-width: 768px) { .page { padding: 20px; } .big-score { font-size: 40px; } }
<div class="page animate-fade-in">
@if (loading()) {
<div class="loading-center"><div class="spinner spinner-lg"></div></div>
} @else if (quizInfo()) {
<a routerLink="/candidate/profile" class="back-link"><span class="material-symbols-rounded">arrow_back</span> Back to Profile</a>
<div class="results-header card card-padding">
<h1>{{ quizInfo().title }}</h1>
<div class="result-summary">
<div class="big-score" [ngClass]="{ 'good': percentage() >= 70, 'avg': percentage() >= 40 && percentage() < 70, 'poor': percentage() < 40 }">
{{ percentage() }}%
</div>
<div class="score-meta">
<span><span class="material-symbols-rounded">check_circle</span> {{ score() }}/{{ totalMarks() }} correct</span>
<span><span class="material-symbols-rounded">timer</span> {{ formatTime(timeTaken()) }}</span>
<span><span class="material-symbols-rounded">calendar_today</span> {{ submittedAt() | date:'medium' }}</span>
</div>
</div>
</div>
<h2 class="section-title">Answer Review</h2>
<div class="answers-list stagger-children">
@for (r of detailedResults(); track $index; let i = $index) {
<div class="answer-card card card-padding" [class.correct]="r.isCorrect" [class.wrong]="!r.isCorrect">
<div class="answer-header">
<span class="q-num">Q{{ i + 1 }}</span>
<span class="badge" [ngClass]="r.isCorrect ? 'badge-success' : 'badge-danger'">
{{ r.isCorrect ? 'Correct' : 'Incorrect' }}
</span>
</div>
<p class="q-text">{{ r.question }}</p>
<div class="options-review">
@for (opt of r.options; track opt) {
<div class="opt-row"
[class.is-correct]="r.correctAnswers.includes(opt)"
[class.is-wrong]="r.studentAnswers.includes(opt) && !r.correctAnswers.includes(opt)"
[class.is-selected]="r.studentAnswers.includes(opt)">
<span class="material-symbols-rounded opt-icon">
{{ r.correctAnswers.includes(opt) ? 'check_circle' : (r.studentAnswers.includes(opt) ? 'cancel' : 'radio_button_unchecked') }}
</span>
<span>{{ opt }}</span>
</div>
}
</div>
</div>
}
</div>
}
</div>
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-candidate-results',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './results.html',
styleUrl: './results.css'
})
export class CandidateResultsComponent implements OnInit {
quizInfo = signal<any>(null);
score = signal(0);
totalMarks = signal(0);
percentage = signal(0);
timeTaken = signal(0);
submittedAt = signal('');
detailedResults = signal<any[]>([]);
loading = signal(true);
constructor(private route: ActivatedRoute, private quizService: QuizService) {}
ngOnInit(): void {
const submissionId = this.route.snapshot.params['submissionId'];
this.quizService.getResultDetails(submissionId).subscribe({
next: (res) => {
this.quizInfo.set(res.quiz);
this.score.set(res.score);
this.totalMarks.set(res.totalMarks);
this.percentage.set(res.percentage);
this.timeTaken.set(res.timeTaken);
this.submittedAt.set(res.submittedAt);
this.detailedResults.set(res.detailedResults);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}m ${s}s`;
}
}
.quiz-page { padding: 24px 40px; max-width: 900px; margin: 0 auto; }
.loading-center { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 60vh; gap: 16px; }
.loading-text { font-size: 14px; color: var(--text-muted); }
.error-state, .success-state { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 60vh; gap: 16px; text-align: center; }
.error-state h2, .success-state h2 { color: var(--text-primary); font-size: 22px; margin: 0; }
.success-state p { color: var(--text-muted); }
.quiz-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 16px; }
.quiz-title-area h1 { color: var(--text-primary); font-size: 20px; font-weight: 700; margin: 0 0 4px; }
.q-counter { color: var(--text-muted); font-size: 13px; }
.timer {
display: flex; align-items: center; gap: 8px;
background: var(--bg-card); border: 1px solid var(--border-color);
padding: 10px 20px; border-radius: var(--radius-md); transition: all 0.3s;
}
.timer .material-symbols-rounded { font-size: 20px; color: var(--text-muted); }
.timer.warning { border-color: var(--warning-border); background: var(--warning-light); }
.timer.danger { border-color: var(--danger-border); background: var(--danger-light); animation: pulse 1s infinite; }
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.7; } }
.timer-value { color: var(--text-primary); font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; }
.timer.warning .timer-value { color: var(--warning); }
.timer.danger .timer-value { color: var(--danger); }
.progress-track { width: 100%; height: 4px; background: var(--bg-tertiary); border-radius: 2px; margin-bottom: 24px; overflow: hidden; }
.progress-fill { height: 100%; background: var(--accent-gradient); border-radius: 2px; transition: width 0.3s ease; }
.question-card { margin-bottom: 24px; }
.q-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.q-badge { background: var(--accent-primary-light); color: var(--accent-primary); padding: 6px 14px; border-radius: var(--radius-sm); font-weight: 700; font-size: 14px; }
.q-text { color: var(--text-primary); font-size: 17px; font-weight: 600; line-height: 1.6; margin: 0 0 24px; }
.options-list { display: flex; flex-direction: column; gap: 10px; }
.option-btn {
display: flex; align-items: center; gap: 14px;
width: 100%; padding: 14px 18px; background: var(--bg-input);
border: 1px solid var(--border-color); border-radius: var(--radius-md);
color: var(--text-primary); font-size: 14px; cursor: pointer;
transition: all 0.2s; text-align: left; font-family: inherit;
}
.option-btn:hover { background: var(--bg-hover); border-color: var(--border-strong); }
.option-btn.selected { background: var(--accent-primary-light); border-color: var(--accent-primary); }
.option-letter {
width: 32px; height: 32px; border-radius: var(--radius-sm);
background: var(--bg-tertiary); display: flex; align-items: center;
justify-content: center; font-weight: 700; font-size: 13px; flex-shrink: 0;
color: var(--text-secondary); transition: all 0.2s;
}
.option-btn.selected .option-letter { background: var(--accent-primary); color: #fff; }
.option-text { flex: 1; }
.check-icon { color: var(--accent-primary); font-size: 20px; }
.quiz-nav { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; }
.question-dots { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; }
.dot {
width: 32px; height: 32px; border-radius: var(--radius-sm); border: 1px solid var(--border-color);
background: transparent; color: var(--text-muted); font-size: 12px; font-weight: 600;
cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; font-family: inherit;
}
.dot:hover { border-color: var(--border-strong); color: var(--text-primary); }
.dot.active { background: var(--accent-primary-light); border-color: var(--accent-primary); color: var(--accent-primary); }
.dot.answered { background: var(--success-light); border-color: var(--success-border); color: var(--success); }
.dot.active.answered { background: var(--accent-primary-light); border-color: var(--accent-primary); color: var(--accent-primary); }
@media (max-width: 640px) {
.quiz-page { padding: 16px; }
.question-card { padding: 20px; }
.quiz-nav { flex-direction: column; }
.question-dots { order: -1; }
}
<!-- Take Quiz -->
<div class="quiz-page">
@if (loading()) {
<div class="loading-center"><div class="spinner spinner-lg"></div><p class="loading-text">Loading quiz...</p></div>
} @else if (error() && !quiz()) {
<div class="error-state">
<span class="material-symbols-rounded" style="font-size:48px;color:var(--danger)">error</span>
<h2>{{ error() }}</h2>
<a routerLink="/candidate/dashboard" class="btn btn-outline">Back to Dashboard</a>
</div>
} @else if (submitted()) {
<div class="success-state animate-scale-in">
<span class="material-symbols-rounded" style="font-size:64px;color:var(--success)">check_circle</span>
<h2>Quiz Submitted!</h2>
<p>Redirecting to dashboard...</p>
</div>
} @else if (quiz() && currentQuestion()) {
<!-- Quiz Header -->
<div class="quiz-header">
<div class="quiz-title-area">
<h1>{{ quiz().title }}</h1>
<span class="q-counter">Question {{ currentIndex() + 1 }} of {{ questions().length }}</span>
</div>
<div class="timer" [class.warning]="timeLeft() < 300 && timeLeft() >= 60" [class.danger]="timeLeft() < 60">
<span class="material-symbols-rounded">timer</span>
<span class="timer-value">{{ formattedTime() }}</span>
</div>
</div>
<!-- Progress -->
<div class="progress-track"><div class="progress-fill" [style.width.%]="progress()"></div></div>
@if (error()) { <div class="alert alert-error" style="margin-bottom:16px"><span class="material-symbols-rounded">error</span>{{ error() }}</div> }
<!-- Question Card -->
<div class="question-card card card-padding animate-fade-in">
<div class="q-header">
<span class="q-badge">Q{{ currentIndex() + 1 }}</span>
<span class="q-type badge badge-primary">{{ currentQuestion().type === 'mcq' ? 'Multiple Correct' : 'Single Correct' }}</span>
</div>
<p class="q-text">{{ currentQuestion().question }}</p>
<div class="options-list">
@for (option of currentQuestion().options; track option; let i = $index) {
<button class="option-btn"
[class.selected]="isSelected(currentQuestion()._id, option)"
(click)="selectOption(currentQuestion()._id, option, currentQuestion().type)">
<span class="option-letter">{{ 'ABCD'[i] }}</span>
<span class="option-text">{{ option }}</span>
@if (isSelected(currentQuestion()._id, option)) {
<span class="material-symbols-rounded check-icon">check_circle</span>
}
</button>
}
</div>
</div>
<!-- Navigation -->
<div class="quiz-nav">
<button class="btn btn-outline" (click)="prev()" [disabled]="currentIndex() === 0">
<span class="material-symbols-rounded">arrow_back</span> Previous
</button>
<div class="question-dots">
@for (q of questions(); track q._id; let i = $index) {
<button class="dot" [class.active]="i === currentIndex()" [class.answered]="isAnswered(i)" (click)="goTo(i)">{{ i + 1 }}</button>
}
</div>
@if (currentIndex() < questions().length - 1) {
<button class="btn btn-primary" (click)="next()">Next <span class="material-symbols-rounded">arrow_forward</span></button>
} @else {
<button class="btn btn-primary" style="background:var(--success)" (click)="submitQuiz()" [disabled]="submitting()">
@if (submitting()) { <div class="spinner"></div> } @else { Submit }
</button>
}
</div>
}
</div>
import { Component, OnInit, OnDestroy, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-take-quiz',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './take-quiz.html',
styleUrl: './take-quiz.css'
})
export class TakeQuizComponent implements OnInit, OnDestroy {
quiz = signal<any>(null);
questions = signal<any[]>([]);
currentIndex = signal<number>(0);
answers = signal<Map<string, string[]>>(new Map());
timeLeft = signal<number>(0);
timerInterval: any;
startTime = 0;
loading = signal(true);
submitting = signal(false);
error = signal('');
submitted = signal(false);
currentQuestion = computed(() => this.questions()[this.currentIndex()]);
progress = computed(() => {
const total = this.questions().length;
return total > 0 ? Math.round(((this.currentIndex() + 1) / total) * 100) : 0;
});
formattedTime = computed(() => {
const t = this.timeLeft();
const m = Math.floor(t / 60);
const s = t % 60;
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
});
constructor(
private route: ActivatedRoute,
private router: Router,
private quizService: QuizService
) {}
ngOnInit(): void {
const quizId = this.route.snapshot.params['quizId'];
this.loadQuiz(quizId);
}
ngOnDestroy(): void {
if (this.timerInterval) clearInterval(this.timerInterval);
}
loadQuiz(quizId: string): void {
this.quizService.getQuizForTaking(quizId).subscribe({
next: (res) => {
this.quiz.set(res.quiz);
this.questions.set(res.questions);
this.timeLeft.set(res.quiz.timer * 60);
this.startTime = Date.now();
this.loading.set(false);
this.startTimer();
},
error: (err) => {
this.error.set(err.error?.message || 'Failed to load quiz');
this.loading.set(false);
}
});
}
startTimer(): void {
this.timerInterval = setInterval(() => {
const current = this.timeLeft();
if (current <= 1) {
clearInterval(this.timerInterval);
this.timeLeft.set(0);
this.submitQuiz();
} else {
this.timeLeft.set(current - 1);
}
}, 1000);
}
selectOption(questionId: string, option: string, type: string): void {
const currentAnswers = new Map(this.answers());
if (type === 'mcq') {
const existing = currentAnswers.get(questionId) || [];
if (existing.includes(option)) {
currentAnswers.set(questionId, existing.filter(o => o !== option));
} else {
currentAnswers.set(questionId, [...existing, option]);
}
} else {
currentAnswers.set(questionId, [option]);
}
this.answers.set(currentAnswers);
}
isSelected(questionId: string, option: string): boolean {
const selected = this.answers().get(questionId);
return selected ? selected.includes(option) : false;
}
isAnswered(index: number): boolean {
const q = this.questions()[index];
if (!q) return false;
const ans = this.answers().get(q._id);
return !!ans && ans.length > 0;
}
goTo(index: number): void {
if (index >= 0 && index < this.questions().length) this.currentIndex.set(index);
}
prev(): void { this.goTo(this.currentIndex() - 1); }
next(): void { this.goTo(this.currentIndex() + 1); }
submitQuiz(): void {
if (this.submitting() || this.submitted()) return;
this.submitting.set(true);
this.error.set('');
clearInterval(this.timerInterval);
const timeTaken = Math.round((Date.now() - this.startTime) / 1000);
const answersArray = Array.from(this.answers().entries()).map(([questionId, selectedAnswers]) => ({
questionId, selectedAnswers
}));
this.quizService.submitQuiz(this.quiz().id, answersArray, timeTaken).subscribe({
next: () => {
this.submitted.set(true);
this.submitting.set(false);
setTimeout(() => this.router.navigate(['/candidate/dashboard']), 2000);
},
error: (err) => {
this.submitting.set(false);
this.error.set(err.error?.message || 'Failed to submit quiz');
}
});
}
}
.page-container { padding: 32px 40px; max-width: 1100px; }
.back-link { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); font-weight: 500; margin-bottom: 20px; }
.back-link:hover { color: var(--accent-primary); }
.loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px 0; gap: 16px; }
.loading-center p { color: var(--text-muted); }
.user-header { display: flex; align-items: center; gap: 16px; margin-bottom: 32px; }
.user-avatar {
width: 56px; height: 56px; border-radius: var(--radius-lg);
background: var(--accent-gradient); display: flex; align-items: center;
justify-content: center; color: #fff; font-weight: 700; font-size: 24px;
}
.user-header h1 { font-size: 24px; font-weight: 700; color: var(--text-primary); margin: 0; }
.user-email { font-size: 14px; color: var(--text-muted); margin: 2px 0 0; }
.section-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin-bottom: 16px; }
.percent-bar { width: 80px; height: 6px; background: var(--bg-tertiary); border-radius: 3px; overflow: hidden; display: inline-block; vertical-align: middle; margin-right: 8px; }
.percent-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
.percent-fill.good { background: var(--success); }
.percent-fill.avg { background: var(--warning); }
.percent-fill.poor { background: var(--danger); }
.percent-text { font-size: 13px; font-weight: 600; color: var(--text-secondary); }
@media (max-width: 768px) { .page-container { padding: 20px 16px; } }
<div class="page-container animate-fade-in">
<a routerLink="/hr/candidates" class="back-link">
<span class="material-symbols-rounded">arrow_back</span> Back to Candidates
</a>
@if (loading()) {
<div class="loading-center"><div class="spinner spinner-lg"></div><p>Loading history...</p></div>
} @else {
@if (user()) {
<div class="user-header">
<div class="user-avatar">{{ user().name?.charAt(0)?.toUpperCase() }}</div>
<div>
<h1>{{ user().name }}</h1>
<p class="user-email">{{ user().email }}</p>
</div>
</div>
}
<h2 class="section-title">Quiz History</h2>
@if (submissions().length === 0) {
<div class="empty-state">
<span class="material-symbols-rounded">assignment</span>
<h3>No quizzes taken</h3>
<p>This candidate hasn't taken any quizzes yet.</p>
</div>
} @else {
<div class="table-container">
<table>
<thead>
<tr>
<th>Quiz</th>
<th>Score</th>
<th>Percentage</th>
<th>Time</th>
<th>Date</th>
<th>Details</th>
</tr>
</thead>
<tbody>
@for (sub of submissions(); track sub._id) {
<tr>
<td><strong>{{ sub.quizId?.title || 'Deleted Quiz' }}</strong></td>
<td><span class="badge badge-primary">{{ sub.score }}/{{ sub.totalMarks }}</span></td>
<td>
<div class="percent-bar">
<div class="percent-fill" [style.width.%]="sub.percentage"
[class.good]="sub.percentage >= 70"
[class.avg]="sub.percentage >= 40 && sub.percentage < 70"
[class.poor]="sub.percentage < 40"></div>
</div>
<span class="percent-text">{{ sub.percentage }}%</span>
</td>
<td>{{ formatTime(sub.timeTaken) }}</td>
<td>{{ sub.submittedAt | date:'mediumDate' }}</td>
<td><a [routerLink]="['/hr/submissions', sub._id]" class="btn btn-outline btn-sm">View</a></td>
</tr>
}
</tbody>
</table>
</div>
}
}
</div>
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-hr-candidate-history',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './candidate-history.html',
styleUrl: './candidate-history.css'
})
export class HRCandidateHistoryComponent implements OnInit {
userId = '';
user = signal<any>(null);
submissions = signal<any[]>([]);
loading = signal(true);
constructor(private route: ActivatedRoute, private quizService: QuizService) {}
ngOnInit(): void {
this.userId = this.route.snapshot.params['userId'];
this.quizService.getHRCandidateHistory(this.userId).subscribe({
next: (res) => {
this.user.set(res.user);
this.submissions.set(res.submissions);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}m ${s}s`;
}
}
.page-container { padding: 32px 40px; max-width: 1200px; }
.page-header { margin-bottom: 28px; }
.page-header h1 { font-size: 26px; font-weight: 700; color: var(--text-primary); margin: 0 0 4px; }
.page-subtitle { color: var(--text-muted); font-size: 14px; }
.loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px 0; gap: 16px; }
.loading-center p { color: var(--text-muted); }
.candidates-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 16px; }
.candidate-card {
display: flex; align-items: center; gap: 16px; padding: 20px;
text-decoration: none; cursor: pointer;
}
.candidate-avatar {
width: 48px; height: 48px; border-radius: var(--radius-md);
background: var(--accent-gradient); display: flex; align-items: center;
justify-content: center; color: #fff; font-weight: 700; font-size: 20px; flex-shrink: 0;
}
.candidate-info { flex: 1; min-width: 0; }
.candidate-info h4 { font-size: 15px; font-weight: 600; color: var(--text-primary); margin: 0 0 2px; }
.candidate-info p { font-size: 13px; color: var(--text-secondary); margin: 0 0 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.candidate-meta { font-size: 12px; color: var(--text-muted); display: flex; align-items: center; gap: 8px; }
.arrow-icon { color: var(--text-muted); font-size: 22px; transition: transform 0.2s; }
.candidate-card:hover .arrow-icon { transform: translateX(4px); color: var(--accent-primary); }
@media (max-width: 768px) {
.page-container { padding: 20px 16px; }
.candidates-grid { grid-template-columns: 1fr; }
}
<div class="page-container animate-fade-in">
<div class="page-header">
<h1>Candidates</h1>
<p class="page-subtitle">View all candidates and their quiz history</p>
</div>
@if (loading()) {
<div class="loading-center"><div class="spinner spinner-lg"></div><p>Loading candidates...</p></div>
} @else if (candidates().length === 0) {
<div class="empty-state">
<span class="material-symbols-rounded">people</span>
<h3>No candidates found</h3>
<p>No candidates have registered yet.</p>
</div>
} @else {
<div class="candidates-grid stagger-children">
@for (user of candidates(); track user._id) {
<a [routerLink]="['/hr/candidates', user._id, 'history']" class="card card-hover candidate-card">
<div class="candidate-avatar">{{ user.name?.charAt(0)?.toUpperCase() || '?' }}</div>
<div class="candidate-info">
<h4>{{ user.name }}</h4>
<p>{{ user.email }}</p>
<span class="candidate-meta">
@if (user.group) { <span class="badge badge-primary">{{ user.group }}</span> }
Joined {{ user.createdAt | date:'mediumDate' }}
</span>
</div>
<span class="material-symbols-rounded arrow-icon">chevron_right</span>
</a>
}
</div>
}
</div>
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-hr-candidates',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './candidates.html',
styleUrl: './candidates.css'
})
export class HRCandidatesComponent implements OnInit {
candidates = signal<any[]>([]);
loading = signal(true);
constructor(private quizService: QuizService) {}
ngOnInit(): void {
this.quizService.getHRCandidates().subscribe({
next: (res) => { this.candidates.set(res.candidates || res.users || []); this.loading.set(false); },
error: () => this.loading.set(false)
});
}
}
.page-container { padding: 32px 40px; max-width: 800px; }
.page-header { margin-bottom: 28px; }
.page-header h1 { font-size: 26px; font-weight: 700; color: var(--text-primary); margin: 8px 0 4px; }
.page-subtitle { color: var(--text-muted); font-size: 14px; }
.back-link { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); font-weight: 500; }
.back-link:hover { color: var(--accent-primary); }
.form-card { margin-bottom: 32px; }
.quiz-form { display: flex; flex-direction: column; gap: 20px; }
.form-group { display: flex; flex-direction: column; flex: 1; }
.form-row { display: flex; gap: 16px; }
.file-upload {
display: flex; align-items: center; gap: 12px; padding: 20px;
background: var(--bg-input); border: 2px dashed var(--border-color);
border-radius: var(--radius-md); cursor: pointer; transition: all 0.2s;
}
.file-upload:hover { border-color: var(--accent-primary); background: var(--accent-primary-light); }
.upload-icon { font-size: 28px; color: var(--accent-primary); }
.file-name { font-size: 14px; color: var(--text-primary); font-weight: 500; }
.file-placeholder { font-size: 14px; color: var(--text-muted); }
@media (max-width: 768px) {
.page-container { padding: 20px 16px; }
.form-row { flex-direction: column; }
}
<div class="page-container animate-fade-in">
<div class="page-header">
<a routerLink="/hr/quizzes" class="back-link">
<span class="material-symbols-rounded">arrow_back</span> Back to Quizzes
</a>
<h1>Create New Quiz</h1>
<p class="page-subtitle">Upload an Excel file with questions to create a quiz</p>
</div>
@if (error()) {
<div class="alert alert-error"><span class="material-symbols-rounded">error</span> {{ error() }}</div>
}
@if (success()) {
<div class="alert alert-success"><span class="material-symbols-rounded">check_circle</span> {{ success() }}</div>
}
<div class="card card-padding form-card">
<form (ngSubmit)="onSubmit()" class="quiz-form">
<div class="form-group">
<label class="form-label">Quiz Title *</label>
<input class="form-input" [(ngModel)]="title" name="title" placeholder="e.g. JavaScript Basics">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Time Limit (minutes) *</label>
<input class="form-input" type="number" [(ngModel)]="timer" name="timer" min="1">
</div>
<div class="form-group">
<label class="form-label">Difficulty</label>
<select class="form-select" [(ngModel)]="difficulty" name="difficulty">
<option value="easy">Easy</option>
<option value="medium">Medium</option>
<option value="hard">Hard</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Category</label>
<input class="form-input" [(ngModel)]="category" name="category" placeholder="e.g. Programming">
</div>
<div class="form-group">
<label class="form-label">Topic</label>
<input class="form-input" [(ngModel)]="topic" name="topic" placeholder="e.g. Arrays & Loops">
</div>
</div>
<div class="form-group">
<label class="form-label">Questions File (Excel) *</label>
<div class="file-upload" (click)="fileInput.click()">
<input #fileInput type="file" accept=".xlsx,.xls" (change)="onFileSelected($event)" hidden>
<span class="material-symbols-rounded upload-icon">upload_file</span>
@if (fileName()) {
<span class="file-name">{{ fileName() }}</span>
} @else {
<span class="file-placeholder">Click to select an Excel file</span>
}
</div>
</div>
<button type="submit" class="btn btn-primary btn-lg" [disabled]="loading()" style="width: 100%;">
@if (loading()) {
<div class="spinner"></div> Creating...
} @else {
<span class="material-symbols-rounded">add_circle</span> Create Quiz
}
</button>
</form>
</div>
</div>
import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-hr-create-quiz',
standalone: true,
imports: [CommonModule, FormsModule, RouterLink],
templateUrl: './create-quiz.html',
styleUrl: './create-quiz.css'
})
export class HRCreateQuizComponent {
title = '';
timer = 30;
category = '';
difficulty = 'medium';
topic = '';
selectedFile: File | null = null;
fileName = signal('');
loading = signal(false);
success = signal('');
error = signal('');
constructor(private quizService: QuizService, private router: Router) {}
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
this.selectedFile = input.files[0];
this.fileName.set(this.selectedFile.name);
}
}
onSubmit(): void {
if (!this.title.trim()) { this.error.set('Please enter a quiz title'); return; }
if (!this.timer || this.timer < 1) { this.error.set('Timer must be at least 1 minute'); return; }
if (!this.selectedFile) { this.error.set('Please upload an Excel file'); return; }
this.loading.set(true);
this.error.set('');
this.success.set('');
const formData = new FormData();
formData.append('title', this.title);
formData.append('timer', this.timer.toString());
formData.append('questionsFile', this.selectedFile);
if (this.category) formData.append('category', this.category);
if (this.difficulty) formData.append('difficulty', this.difficulty);
if (this.topic) formData.append('topic', this.topic);
this.quizService.createHRQuiz(formData).subscribe({
next: (res) => {
this.loading.set(false);
this.success.set(`Quiz "${res.quiz.title}" created with ${res.quiz.totalQuestions} questions!`);
setTimeout(() => this.router.navigate(['/hr/quizzes']), 1500);
},
error: (err) => {
this.loading.set(false);
this.error.set(err.error?.message || 'Failed to create quiz');
}
});
}
}
.page { padding: 32px; max-width: 1200px; margin: 0 auto; }
.page-header { margin-bottom: 32px; }
.page-header h1 { font-size: 24px; font-weight: 700; color: var(--text-primary); margin: 0 0 4px; }
.page-subtitle { font-size: 14px; color: var(--text-muted); margin: 0; }
.loading-center { display: flex; align-items: center; justify-content: center; padding: 80px; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-bottom: 32px; }
.stat-card { display: flex; align-items: center; gap: 16px; }
.stat-ic { font-size: 32px; }
.stat-ic.blue { color: var(--accent-primary); }
.stat-ic.green { color: var(--success); }
.stat-ic.orange { color: var(--warning); }
.stat-body { display: flex; flex-direction: column; }
.stat-value { font-size: 24px; font-weight: 700; color: var(--text-primary); }
.stat-label { font-size: 13px; color: var(--text-muted); }
.actions-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
.action-card { display: flex; align-items: center; gap: 16px; text-decoration: none; color: inherit; }
.action-icon { font-size: 28px; color: var(--accent-primary); }
.action-info { flex: 1; }
.action-info h3 { font-size: 15px; font-weight: 600; color: var(--text-primary); margin: 0 0 4px; }
.action-info p { font-size: 13px; color: var(--text-muted); margin: 0; }
.arrow { color: var(--text-muted); font-size: 20px; transition: transform 0.2s; }
.action-card:hover .arrow { transform: translateX(4px); color: var(--accent-primary); }
@media (max-width: 768px) { .page { padding: 20px; } .actions-grid { grid-template-columns: 1fr; } }
<div class="page animate-fade-in">
<div class="page-header"><h1>HR Dashboard</h1><p class="page-subtitle">Manage quizzes and review candidates</p></div>
@if (loading()) { <div class="loading-center"><div class="spinner spinner-lg"></div></div> }
@else if (stats()) {
<div class="stats-grid stagger-children">
<div class="stat-card card card-padding"><span class="material-symbols-rounded stat-ic blue">quiz</span><div class="stat-body"><span class="stat-value">{{ stats().myQuizzes }}</span><span class="stat-label">My Quizzes</span></div></div>
<div class="stat-card card card-padding"><span class="material-symbols-rounded stat-ic green">people</span><div class="stat-body"><span class="stat-value">{{ stats().totalCandidates }}</span><span class="stat-label">Candidates</span></div></div>
<div class="stat-card card card-padding"><span class="material-symbols-rounded stat-ic orange">assignment_turned_in</span><div class="stat-body"><span class="stat-value">{{ stats().totalSubmissions }}</span><span class="stat-label">Submissions</span></div></div>
</div>
<div class="actions-grid">
<a routerLink="/hr/quizzes" class="action-card card card-hover card-padding">
<span class="material-symbols-rounded action-icon">quiz</span><div class="action-info"><h3>My Quizzes</h3><p>View and manage your quizzes</p></div><span class="material-symbols-rounded arrow">arrow_forward</span></a>
<a routerLink="/hr/create-quiz" class="action-card card card-hover card-padding">
<span class="material-symbols-rounded action-icon">add_circle</span><div class="action-info"><h3>Create Quiz</h3><p>Upload or generate new assessments</p></div><span class="material-symbols-rounded arrow">arrow_forward</span></a>
<a routerLink="/hr/candidates" class="action-card card card-hover card-padding">
<span class="material-symbols-rounded action-icon">people</span><div class="action-info"><h3>Candidates</h3><p>View candidate results and history</p></div><span class="material-symbols-rounded arrow">arrow_forward</span></a>
</div>
}
</div>
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-hr-dashboard',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './dashboard.html',
styleUrl: './dashboard.css'
})
export class HRDashboardComponent implements OnInit {
stats = signal<any>(null);
loading = signal(true);
constructor(private quizService: QuizService) {}
ngOnInit(): void {
this.quizService.getHRStats().subscribe({
next: (res) => { this.stats.set(res.stats); this.loading.set(false); },
error: () => this.loading.set(false)
});
}
}
.page-container { padding: 32px 40px; max-width: 1200px; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 28px; flex-wrap: wrap; gap: 16px; }
.page-header h1 { font-size: 26px; font-weight: 700; color: var(--text-primary); margin: 0; }
.page-subtitle { color: var(--text-muted); font-size: 14px; margin: 4px 0 0; }
.loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px 0; gap: 16px; }
.loading-center p { color: var(--text-muted); }
@media (max-width: 768px) { .page-container { padding: 20px 16px; } }
<div class="page-container animate-fade-in">
<div class="page-header">
<div>
<h1>My Quizzes</h1>
<p class="page-subtitle">Manage quizzes you've created</p>
</div>
<a routerLink="/hr/create-quiz" class="btn btn-primary">
<span class="material-symbols-rounded">add</span> Create Quiz
</a>
</div>
@if (error()) {
<div class="alert alert-error"><span class="material-symbols-rounded">error</span> {{ error() }}</div>
}
@if (loading()) {
<div class="loading-center"><div class="spinner spinner-lg"></div><p>Loading quizzes...</p></div>
} @else if (quizzes().length === 0) {
<div class="empty-state">
<span class="material-symbols-rounded">quiz</span>
<h3>No quizzes yet</h3>
<p>Create your first quiz to get started.</p>
</div>
} @else {
<div class="table-container">
<table>
<thead>
<tr>
<th>Title</th>
<th>Questions</th>
<th>Timer</th>
<th>Category</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (quiz of quizzes(); track quiz._id) {
<tr>
<td><strong>{{ quiz.title }}</strong></td>
<td>{{ quiz.totalQuestions }}</td>
<td>{{ quiz.timer }} min</td>
<td>{{ quiz.category || '—' }}</td>
<td>{{ quiz.createdAt | date:'mediumDate' }}</td>
<td>
<button class="btn btn-danger btn-sm" (click)="deleteQuiz(quiz._id)">
<span class="material-symbols-rounded">delete</span>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-hr-quizzes',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './quizzes.html',
styleUrl: './quizzes.css'
})
export class HRQuizzesComponent implements OnInit {
quizzes = signal<any[]>([]);
loading = signal(true);
error = signal('');
constructor(private quizService: QuizService) {}
ngOnInit(): void {
this.loadQuizzes();
}
loadQuizzes(): void {
this.quizService.getHRQuizzes().subscribe({
next: (res) => { this.quizzes.set(res.quizzes); this.loading.set(false); },
error: () => this.loading.set(false)
});
}
deleteQuiz(quizId: string): void {
if (!confirm('Are you sure you want to delete this quiz?')) return;
this.quizService.deleteHRQuiz(quizId).subscribe({
next: () => this.loadQuizzes(),
error: (err) => this.error.set(err.error?.message || 'Cannot delete quiz')
});
}
}
.page-container { padding: 32px 40px; max-width: 900px; }
.back-link { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); font-weight: 500; margin-bottom: 20px; }
.back-link:hover { color: var(--accent-primary); }
.loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px 0; gap: 16px; }
.loading-center p { color: var(--text-muted); }
.submission-header { margin-bottom: 28px; }
.header-row { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; flex-wrap: wrap; }
.header-row h1 { font-size: 22px; font-weight: 700; color: var(--text-primary); margin: 0; }
.sub-info { font-size: 14px; color: var(--text-muted); margin: 4px 0 0; }
.score-display { text-align: right; }
.score-value { font-size: 24px; font-weight: 700; color: var(--text-primary); }
.score-percent { display: block; font-size: 16px; font-weight: 600; margin-top: 2px; }
.score-percent.good { color: var(--success); }
.score-percent.avg { color: var(--warning); }
.score-percent.poor { color: var(--danger); }
.meta-row { display: flex; gap: 24px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-subtle); }
.meta-row span { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); }
.meta-row .material-symbols-rounded { font-size: 18px; color: var(--text-muted); }
.section-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin-bottom: 16px; }
.answer-card { margin-bottom: 12px; border-left: 4px solid transparent; }
.answer-card.correct { border-left-color: var(--success); }
.answer-card.wrong { border-left-color: var(--danger); }
.answer-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.q-num { font-size: 12px; font-weight: 700; color: var(--accent-primary); background: var(--accent-primary-light); padding: 3px 10px; border-radius: var(--radius-full); }
.result-icon { font-size: 22px; }
.answer-card.correct .result-icon { color: var(--success); }
.answer-card.wrong .result-icon { color: var(--danger); }
.question-text { font-size: 15px; color: var(--text-primary); font-weight: 500; margin-bottom: 12px; line-height: 1.5; }
.answer-details { display: flex; flex-direction: column; gap: 6px; }
.answer-row { display: flex; gap: 8px; font-size: 13px; }
.answer-label { color: var(--text-muted); min-width: 130px; }
.answer-value { color: var(--text-primary); font-weight: 500; }
.wrong-text { color: var(--danger); }
.correct-text { color: var(--success); }
@media (max-width: 768px) { .page-container { padding: 20px 16px; } }
<div class="page-container animate-fade-in">
<a routerLink="/hr/candidates" class="back-link">
<span class="material-symbols-rounded">arrow_back</span> Back to Candidates
</a>
@if (loading()) {
<div class="loading-center"><div class="spinner spinner-lg"></div><p>Loading submission...</p></div>
} @else if (submission()) {
<div class="submission-header card card-padding">
<div class="header-row">
<div>
<h1>{{ submission().quizId?.title || 'Quiz' }}</h1>
<p class="sub-info">{{ submission().studentId?.name }} • {{ submission().studentId?.email }}</p>
</div>
<div class="score-display">
<span class="score-value">{{ submission().score }}/{{ submission().totalMarks }}</span>
<span class="score-percent" [class.good]="submission().percentage >= 70"
[class.avg]="submission().percentage >= 40 && submission().percentage < 70"
[class.poor]="submission().percentage < 40">{{ submission().percentage }}%</span>
</div>
</div>
<div class="meta-row">
<span><span class="material-symbols-rounded">timer</span> {{ formatTime(submission().timeTaken) }}</span>
<span><span class="material-symbols-rounded">calendar_today</span> {{ submission().submittedAt | date:'medium' }}</span>
</div>
</div>
<h2 class="section-title">Answers</h2>
@for (ans of detailedAnswers(); track $index; let i = $index) {
<div class="card card-padding answer-card" [class.correct]="ans.isCorrect" [class.wrong]="!ans.isCorrect">
<div class="answer-header">
<span class="q-num">Q{{ i + 1 }}</span>
<span class="material-symbols-rounded result-icon">{{ ans.isCorrect ? 'check_circle' : 'cancel' }}</span>
</div>
<p class="question-text">{{ ans.question }}</p>
<div class="answer-details">
<div class="answer-row">
<span class="answer-label">Student's Answer:</span>
<span class="answer-value" [class.wrong-text]="!ans.isCorrect">{{ ans.studentAnswer || '—' }}</span>
</div>
<div class="answer-row">
<span class="answer-label">Correct Answer:</span>
<span class="answer-value correct-text">{{ ans.correctAnswer }}</span>
</div>
</div>
</div>
}
}
</div>
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-hr-submission-detail',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './submission-detail.html',
styleUrl: './submission-detail.css'
})
export class HRSubmissionDetailComponent implements OnInit {
submission = signal<any>(null);
detailedAnswers = signal<any[]>([]);
loading = signal(true);
constructor(private route: ActivatedRoute, private quizService: QuizService) {}
ngOnInit(): void {
const id = this.route.snapshot.params['submissionId'];
this.quizService.getHRSubmissionDetails(id).subscribe({
next: (res) => {
this.submission.set(res.submission);
this.detailedAnswers.set(res.detailedAnswers);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}m ${s}s`;
}
}
.auth-container {
/* ============================================================
Login Page — Professional White Split Layout
============================================================ */
.auth-page {
display: flex;
min-height: 100vh;
}
/* ---- LEFT BRANDING PANEL ---- */
.auth-brand {
flex: 0 0 44%;
background: linear-gradient(160deg, #4f6ef7 0%, #7c5cfc 50%, #a855f7 100%);
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
padding: 60px;
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%);
}
.auth-bg {
.auth-brand::before {
content: '';
position: absolute;
inset: 0;
overflow: hidden;
top: -120px;
right: -120px;
width: 400px;
height: 400px;
background: rgba(255, 255, 255, 0.06);
border-radius: 50%;
}
.bg-shape {
.auth-brand::after {
content: '';
position: absolute;
bottom: -80px;
left: -80px;
width: 300px;
height: 300px;
background: rgba(255, 255, 255, 0.04);
border-radius: 50%;
filter: blur(80px);
opacity: 0.4;
animation: float 8s ease-in-out infinite;
}
.shape-1 {
width: 400px;
height: 400px;
background: linear-gradient(135deg, #667eea, #764ba2);
top: -100px;
right: -100px;
animation-delay: 0s;
.brand-content {
position: relative;
z-index: 1;
}
.shape-2 {
width: 350px;
height: 350px;
background: linear-gradient(135deg, #f093fb, #f5576c);
bottom: -80px;
left: -80px;
animation-delay: 2s;
.brand-logo {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 48px;
}
.shape-3 {
width: 250px;
height: 250px;
background: linear-gradient(135deg, #4facfe, #00f2fe);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation-delay: 4s;
.brand-logo .material-symbols-rounded {
font-size: 32px;
color: rgba(255, 255, 255, 0.95);
}
@keyframes float {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-30px) scale(1.05); }
.brand-name {
font-size: 22px;
font-weight: 700;
color: #fff;
letter-spacing: -0.3px;
}
.auth-card {
.brand-headline {
font-size: 40px;
font-weight: 800;
color: #fff;
line-height: 1.15;
margin: 0 0 20px;
letter-spacing: -0.8px;
}
.brand-desc {
font-size: 16px;
color: rgba(255, 255, 255, 0.75);
line-height: 1.6;
margin: 0 0 40px;
max-width: 380px;
}
.brand-features {
display: flex;
flex-direction: column;
gap: 16px;
}
.feature-item {
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
color: rgba(255, 255, 255, 0.85);
font-weight: 500;
}
.feature-item .material-symbols-rounded {
font-size: 20px;
background: rgba(255, 255, 255, 0.15);
border-radius: 8px;
padding: 6px;
}
.brand-footer {
position: relative;
z-index: 1;
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 24px;
padding: 48px 40px;
margin-top: auto;
padding-top: 40px;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
}
/* ---- RIGHT FORM PANEL ---- */
.auth-form-panel {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
background: #ffffff;
}
.auth-card {
width: 100%;
max-width: 440px;
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.3);
animation: slideUp 0.6s ease-out;
max-width: 400px;
animation: fadeUp 0.5s ease-out;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(30px); }
@keyframes fadeUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.auth-header {
text-align: center;
margin-bottom: 32px;
.mobile-logo {
display: none;
align-items: center;
gap: 8px;
margin-bottom: 24px;
font-size: 18px;
font-weight: 700;
color: #1a1d26;
}
.logo {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
.mobile-logo .material-symbols-rounded {
font-size: 28px;
color: #4f6ef7;
}
.logo-icon {
font-size: 36px;
.auth-header {
margin-bottom: 32px;
}
.logo h1 {
.auth-header h1 {
font-size: 28px;
font-weight: 700;
color: #fff;
margin: 0;
color: #1a1d26;
margin: 0 0 6px;
letter-spacing: -0.5px;
}
.subtitle {
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
.auth-header p {
font-size: 15px;
color: #8492a6;
margin: 0;
}
.alert {
padding: 12px 16px;
border-radius: 12px;
margin-bottom: 20px;
/* Alert */
.auth-alert {
display: flex;
align-items: center;
gap: 8px;
gap: 10px;
padding: 12px 16px;
border-radius: 10px;
font-size: 14px;
animation: shake 0.4s ease;
font-weight: 500;
margin-bottom: 24px;
animation: shake 0.35s ease;
}
.alert-error {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #fca5a5;
.auth-alert.error {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
.auth-alert .material-symbols-rounded {
font-size: 20px;
flex-shrink: 0;
}
.alert-icon {
font-size: 16px;
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-4px); }
60% { transform: translateX(4px); }
}
/* Form */
.auth-form {
display: flex;
flex-direction: column;
gap: 20px;
gap: 22px;
}
.form-group label {
.field label {
display: block;
color: rgba(255, 255, 255, 0.8);
font-size: 13px;
font-weight: 500;
font-weight: 600;
color: #4a5568;
margin-bottom: 8px;
letter-spacing: 0.3px;
letter-spacing: 0.2px;
}
.input-wrapper {
.input-group {
position: relative;
display: flex;
align-items: center;
}
.input-icon {
position: absolute;
left: 14px;
font-size: 16px;
z-index: 1;
top: 50%;
transform: translateY(-50%);
font-size: 20px;
color: #c1c9d4;
pointer-events: none;
transition: color 0.2s;
}
.input-wrapper input {
.input-group input {
width: 100%;
padding: 14px 16px 14px 44px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
color: #fff;
padding: 13px 16px 13px 48px;
background: #f8f9fb;
border: 1.5px solid #e2e8f0;
border-radius: 10px;
color: #1a1d26;
font-size: 15px;
transition: all 0.3s ease;
outline: none;
font-family: inherit;
outline: none;
transition: all 0.2s ease;
}
.input-group input::placeholder {
color: #b0b8c4;
}
.input-wrapper input::placeholder {
color: rgba(255, 255, 255, 0.3);
.input-group input:focus {
border-color: #4f6ef7;
background: #fff;
box-shadow: 0 0 0 3px rgba(79, 110, 247, 0.08);
}
.input-wrapper input:focus {
border-color: #667eea;
background: rgba(255, 255, 255, 0.1);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15);
.input-group input:focus + .input-icon,
.input-group:focus-within .input-icon {
color: #4f6ef7;
}
.btn {
/* Submit Button */
.submit-btn {
width: 100%;
padding: 14px 24px;
background: #1a1d26;
color: #fff;
border: none;
border-radius: 12px;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-family: inherit;
}
.btn-primary {
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
transition: all 0.2s ease;
margin-top: 4px;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 25px rgba(102, 126, 234, 0.4);
.submit-btn:hover:not(:disabled) {
background: #2d3142;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(26, 29, 38, 0.2);
}
.btn-primary:active:not(:disabled) {
.submit-btn:active:not(:disabled) {
transform: translateY(0);
}
.btn:disabled {
.submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.spinner {
.submit-btn .material-symbols-rounded {
font-size: 20px;
}
.btn-spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.3);
......@@ -228,37 +304,65 @@
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes spin { to { transform: rotate(360deg); } }
.auth-footer {
/* Divider */
.auth-divider {
text-align: center;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
margin: 28px 0;
position: relative;
}
.auth-footer p {
color: rgba(255, 255, 255, 0.5);
font-size: 14px;
margin: 0;
.auth-divider::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 1px;
background: #e2e8f0;
}
.auth-footer a {
color: #667eea;
text-decoration: none;
.auth-divider span {
position: relative;
padding: 0 16px;
background: #fff;
font-size: 13px;
color: #8492a6;
font-weight: 500;
}
/* Secondary Button */
.secondary-btn {
display: block;
width: 100%;
padding: 13px 24px;
text-align: center;
border: 1.5px solid #e2e8f0;
border-radius: 10px;
background: transparent;
color: #4a5568;
font-size: 14px;
font-weight: 600;
transition: color 0.3s;
font-family: inherit;
text-decoration: none;
transition: all 0.2s ease;
}
.secondary-btn:hover {
border-color: #4f6ef7;
color: #4f6ef7;
background: #f5f7ff;
}
.auth-footer a:hover {
color: #8b9cf7;
/* ---- RESPONSIVE ---- */
@media (max-width: 900px) {
.auth-brand { display: none; }
.auth-form-panel { padding: 24px; }
.mobile-logo { display: flex; }
}
@media (max-width: 480px) {
.auth-card {
margin: 16px;
padding: 32px 24px;
}
.auth-card { max-width: 100%; }
.auth-header h1 { font-size: 24px; }
}
<div class="auth-container">
<div class="auth-bg">
<div class="bg-shape shape-1"></div>
<div class="bg-shape shape-2"></div>
<div class="bg-shape shape-3"></div>
</div>
<div class="auth-card">
<div class="auth-header">
<div class="logo">
<span class="logo-icon">📝</span>
<h1>QuizMaster</h1>
<div class="auth-page">
<!-- Left: Branding Panel -->
<div class="auth-brand">
<div class="brand-content">
<div class="brand-logo">
<span class="material-symbols-rounded filled">quiz</span>
<span class="brand-name">QuizMaster</span>
</div>
<h2 class="brand-headline">Assess. Evaluate.<br>Excel.</h2>
<p class="brand-desc">The professional evaluation platform for organizations that demand precision and insight.</p>
<div class="brand-features">
<div class="feature-item">
<span class="material-symbols-rounded">verified</span>
<span>Role-based access control</span>
</div>
<div class="feature-item">
<span class="material-symbols-rounded">analytics</span>
<span>Detailed performance analytics</span>
</div>
<div class="feature-item">
<span class="material-symbols-rounded">auto_awesome</span>
<span>AI-powered quiz generation</span>
</div>
</div>
<p class="subtitle">Welcome back! Sign in to continue</p>
</div>
<div class="brand-footer">© 2026 QuizMaster Pro. All rights reserved.</div>
</div>
@if (error()) {
<div class="alert alert-error">
<span class="alert-icon">⚠️</span>
{{ error() }}
<!-- Right: Login Form -->
<div class="auth-form-panel">
<div class="auth-card">
<div class="auth-header">
<div class="mobile-logo">
<span class="material-symbols-rounded filled">quiz</span>
<span>QuizMaster</span>
</div>
<h1>Sign in</h1>
<p>Enter your credentials to access your account</p>
</div>
}
<form (ngSubmit)="onSubmit()" class="auth-form">
<div class="form-group">
<label for="email">Email Address</label>
<div class="input-wrapper">
<span class="input-icon">✉️</span>
<input
type="email"
id="email"
[(ngModel)]="email"
name="email"
placeholder="Enter your email"
autocomplete="email"
/>
@if (error()) {
<div class="auth-alert error">
<span class="material-symbols-rounded">error</span>
<span>{{ error() }}</span>
</div>
</div>
}
<div class="form-group">
<label for="password">Password</label>
<div class="input-wrapper">
<span class="input-icon">🔒</span>
<input
type="password"
id="password"
[(ngModel)]="password"
name="password"
placeholder="Enter your password"
autocomplete="current-password"
/>
<form (ngSubmit)="onSubmit()" class="auth-form">
<div class="field">
<label for="email">Email</label>
<div class="input-group">
<span class="material-symbols-rounded input-icon">mail</span>
<input
type="email"
id="email"
[(ngModel)]="email"
name="email"
placeholder="you&#64;company.com"
autocomplete="email"
/>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary" [disabled]="loading()">
@if (loading()) {
<span class="spinner"></span> Signing in...
} @else {
Sign In
}
</button>
</form>
<div class="field">
<label for="password">Password</label>
<div class="input-group">
<span class="material-symbols-rounded input-icon">lock</span>
<input
type="password"
id="password"
[(ngModel)]="password"
name="password"
placeholder="Enter your password"
autocomplete="current-password"
/>
</div>
</div>
<button type="submit" class="submit-btn" [disabled]="loading()">
@if (loading()) {
<div class="btn-spinner"></div> Signing in...
} @else {
Sign in <span class="material-symbols-rounded">arrow_forward</span>
}
</button>
</form>
<div class="auth-divider">
<span>New here?</span>
</div>
<div class="auth-footer">
<p>Don't have an account? <a routerLink="/register">Register here</a></p>
<a routerLink="/register" class="secondary-btn">Create an account</a>
</div>
</div>
</div>
......@@ -29,13 +29,9 @@ export class LoginComponent {
this.error.set('');
this.authService.login(this.email, this.password).subscribe({
next: (res) => {
next: () => {
this.loading.set(false);
if (res.user.role === 'admin') {
this.router.navigate(['/admin/dashboard']);
} else {
this.router.navigate(['/student/dashboard']);
}
this.router.navigate([this.authService.getDashboardRoute()]);
},
error: (err) => {
this.loading.set(false);
......
.auth-container {
/* ============================================================
Register Page — Professional White Split Layout
============================================================ */
.auth-page {
display: flex;
min-height: 100vh;
}
/* ---- LEFT BRANDING PANEL ---- */
.auth-brand {
flex: 0 0 44%;
background: linear-gradient(160deg, #4f6ef7 0%, #7c5cfc 50%, #a855f7 100%);
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
padding: 60px;
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%);
}
.auth-bg {
.auth-brand::before {
content: '';
position: absolute;
inset: 0;
overflow: hidden;
top: -120px;
right: -120px;
width: 400px;
height: 400px;
background: rgba(255, 255, 255, 0.06);
border-radius: 50%;
}
.bg-shape {
.auth-brand::after {
content: '';
position: absolute;
bottom: -80px;
left: -80px;
width: 300px;
height: 300px;
background: rgba(255, 255, 255, 0.04);
border-radius: 50%;
filter: blur(80px);
opacity: 0.4;
animation: float 8s ease-in-out infinite;
}
.shape-1 {
width: 400px;
height: 400px;
background: linear-gradient(135deg, #667eea, #764ba2);
top: -100px;
right: -100px;
.brand-content {
position: relative;
z-index: 1;
}
.shape-2 {
width: 350px;
height: 350px;
background: linear-gradient(135deg, #f093fb, #f5576c);
bottom: -80px;
left: -80px;
animation-delay: 2s;
.brand-logo {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 48px;
}
.shape-3 {
width: 250px;
height: 250px;
background: linear-gradient(135deg, #4facfe, #00f2fe);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation-delay: 4s;
.brand-logo .material-symbols-rounded {
font-size: 32px;
color: rgba(255, 255, 255, 0.95);
}
@keyframes float {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-30px) scale(1.05); }
.brand-name {
font-size: 22px;
font-weight: 700;
color: #fff;
letter-spacing: -0.3px;
}
.auth-card {
.brand-headline {
font-size: 40px;
font-weight: 800;
color: #fff;
line-height: 1.15;
margin: 0 0 20px;
letter-spacing: -0.8px;
}
.brand-desc {
font-size: 16px;
color: rgba(255, 255, 255, 0.75);
line-height: 1.6;
margin: 0 0 40px;
max-width: 380px;
}
.brand-features {
display: flex;
flex-direction: column;
gap: 16px;
}
.feature-item {
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
color: rgba(255, 255, 255, 0.85);
font-weight: 500;
}
.feature-item .material-symbols-rounded {
font-size: 20px;
background: rgba(255, 255, 255, 0.15);
border-radius: 8px;
padding: 6px;
}
.brand-footer {
position: relative;
z-index: 1;
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 24px;
margin-top: auto;
padding-top: 40px;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
}
/* ---- RIGHT FORM PANEL ---- */
.auth-form-panel {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
background: #ffffff;
}
.auth-card {
width: 100%;
max-width: 440px;
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.3);
animation: slideUp 0.6s ease-out;
max-width: 420px;
animation: fadeUp 0.5s ease-out;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(30px); }
@keyframes fadeUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.auth-header {
text-align: center;
margin-bottom: 28px;
.mobile-logo {
display: none;
align-items: center;
gap: 8px;
margin-bottom: 24px;
font-size: 18px;
font-weight: 700;
color: #1a1d26;
}
.logo {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
.mobile-logo .material-symbols-rounded {
font-size: 28px;
color: #4f6ef7;
}
.logo-icon { font-size: 36px; }
.auth-header {
margin-bottom: 28px;
}
.logo h1 {
.auth-header h1 {
font-size: 28px;
font-weight: 700;
color: #fff;
margin: 0;
color: #1a1d26;
margin: 0 0 6px;
letter-spacing: -0.5px;
}
.subtitle {
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
.auth-header p {
font-size: 15px;
color: #8492a6;
margin: 0;
}
.alert {
padding: 12px 16px;
border-radius: 12px;
margin-bottom: 20px;
/* Alert */
.auth-alert {
display: flex;
align-items: center;
gap: 8px;
gap: 10px;
padding: 12px 16px;
border-radius: 10px;
font-size: 14px;
animation: shake 0.4s ease;
font-weight: 500;
margin-bottom: 24px;
animation: shake 0.35s ease;
}
.auth-alert.error {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
.alert-error {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #fca5a5;
.auth-alert .material-symbols-rounded {
font-size: 20px;
flex-shrink: 0;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
20% { transform: translateX(-4px); }
60% { transform: translateX(4px); }
}
.alert-icon { font-size: 16px; }
/* Form */
.auth-form {
display: flex;
flex-direction: column;
gap: 18px;
gap: 20px;
}
.form-group label {
.field label {
display: block;
color: rgba(255, 255, 255, 0.8);
font-size: 13px;
font-weight: 500;
font-weight: 600;
color: #4a5568;
margin-bottom: 8px;
letter-spacing: 0.2px;
}
.input-wrapper {
position: relative;
.fields-row {
display: flex;
align-items: center;
gap: 14px;
}
.fields-row .field {
flex: 1;
}
.input-group {
position: relative;
}
.input-icon {
position: absolute;
left: 14px;
font-size: 16px;
z-index: 1;
top: 50%;
transform: translateY(-50%);
font-size: 20px;
color: #c1c9d4;
pointer-events: none;
transition: color 0.2s;
}
.input-wrapper input {
.input-group input {
width: 100%;
padding: 14px 16px 14px 44px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
color: #fff;
padding: 13px 16px 13px 48px;
background: #f8f9fb;
border: 1.5px solid #e2e8f0;
border-radius: 10px;
color: #1a1d26;
font-size: 15px;
transition: all 0.3s ease;
outline: none;
font-family: inherit;
outline: none;
transition: all 0.2s ease;
}
.input-group input::placeholder {
color: #b0b8c4;
}
.input-wrapper input::placeholder {
color: rgba(255, 255, 255, 0.3);
.input-group input:focus {
border-color: #4f6ef7;
background: #fff;
box-shadow: 0 0 0 3px rgba(79, 110, 247, 0.08);
}
.input-wrapper input:focus {
border-color: #667eea;
background: rgba(255, 255, 255, 0.1);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15);
.input-group:focus-within .input-icon {
color: #4f6ef7;
}
.btn {
/* Submit Button */
.submit-btn {
width: 100%;
padding: 14px 24px;
background: #1a1d26;
color: #fff;
border: none;
border-radius: 12px;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-family: inherit;
transition: all 0.2s ease;
margin-top: 4px;
}
.btn-primary {
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
.submit-btn:hover:not(:disabled) {
background: #2d3142;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(26, 29, 38, 0.2);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 25px rgba(102, 126, 234, 0.4);
.submit-btn:active:not(:disabled) {
transform: translateY(0);
}
.btn:disabled {
.submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.spinner {
.submit-btn .material-symbols-rounded {
font-size: 20px;
}
.btn-spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.3);
......@@ -219,28 +314,64 @@
@keyframes spin { to { transform: rotate(360deg); } }
.auth-footer {
/* Divider */
.auth-divider {
text-align: center;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
margin: 24px 0;
position: relative;
}
.auth-footer p {
color: rgba(255, 255, 255, 0.5);
font-size: 14px;
margin: 0;
.auth-divider::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 1px;
background: #e2e8f0;
}
.auth-footer a {
color: #667eea;
text-decoration: none;
.auth-divider span {
position: relative;
padding: 0 16px;
background: #fff;
font-size: 13px;
color: #8492a6;
font-weight: 500;
}
/* Secondary Button */
.secondary-btn {
display: block;
width: 100%;
padding: 13px 24px;
text-align: center;
border: 1.5px solid #e2e8f0;
border-radius: 10px;
background: transparent;
color: #4a5568;
font-size: 14px;
font-weight: 600;
transition: color 0.3s;
font-family: inherit;
text-decoration: none;
transition: all 0.2s ease;
}
.auth-footer a:hover { color: #8b9cf7; }
.secondary-btn:hover {
border-color: #4f6ef7;
color: #4f6ef7;
background: #f5f7ff;
}
/* ---- RESPONSIVE ---- */
@media (max-width: 900px) {
.auth-brand { display: none; }
.auth-form-panel { padding: 24px; }
.mobile-logo { display: flex; }
}
@media (max-width: 480px) {
.auth-card { margin: 16px; padding: 32px 24px; }
.auth-card { max-width: 100%; }
.auth-header h1 { font-size: 24px; }
.fields-row { flex-direction: column; gap: 20px; }
}
<div class="auth-container">
<div class="auth-bg">
<div class="bg-shape shape-1"></div>
<div class="bg-shape shape-2"></div>
<div class="bg-shape shape-3"></div>
</div>
<div class="auth-card">
<div class="auth-header">
<div class="logo">
<span class="logo-icon">📝</span>
<h1>QuizMaster</h1>
<div class="auth-page">
<!-- Left: Branding Panel -->
<div class="auth-brand">
<div class="brand-content">
<div class="brand-logo">
<span class="material-symbols-rounded filled">quiz</span>
<span class="brand-name">QuizMaster</span>
</div>
<h2 class="brand-headline">Join the<br>Platform.</h2>
<p class="brand-desc">Create your candidate account and start taking quizzes assigned to you by your organization.</p>
<div class="brand-features">
<div class="feature-item">
<span class="material-symbols-rounded">speed</span>
<span>Timed assessments with instant results</span>
</div>
<div class="feature-item">
<span class="material-symbols-rounded">bar_chart</span>
<span>Track your progress over time</span>
</div>
<div class="feature-item">
<span class="material-symbols-rounded">devices</span>
<span>Take quizzes from any device</span>
</div>
</div>
<p class="subtitle">Create your student account</p>
</div>
<div class="brand-footer">© 2026 QuizMaster Pro. All rights reserved.</div>
</div>
@if (error()) {
<div class="alert alert-error">
<span class="alert-icon">⚠️</span>
{{ error() }}
<!-- Right: Register Form -->
<div class="auth-form-panel">
<div class="auth-card">
<div class="mobile-logo">
<span class="material-symbols-rounded filled">quiz</span>
<span>QuizMaster</span>
</div>
}
<form (ngSubmit)="onSubmit()" class="auth-form">
<div class="form-group">
<label for="name">Full Name</label>
<div class="input-wrapper">
<span class="input-icon">👤</span>
<input
type="text"
id="name"
[(ngModel)]="name"
name="name"
placeholder="Enter your full name"
/>
</div>
<div class="auth-header">
<h1>Create account</h1>
<p>Fill in your details to get started</p>
</div>
<div class="form-group">
<label for="email">Email Address</label>
<div class="input-wrapper">
<span class="input-icon">✉️</span>
<input
type="email"
id="email"
[(ngModel)]="email"
name="email"
placeholder="Enter your email"
autocomplete="email"
/>
@if (error()) {
<div class="auth-alert error">
<span class="material-symbols-rounded">error</span>
<span>{{ error() }}</span>
</div>
</div>
}
<div class="form-group">
<label for="password">Password</label>
<div class="input-wrapper">
<span class="input-icon">🔒</span>
<input
type="password"
id="password"
[(ngModel)]="password"
name="password"
placeholder="Min. 6 characters"
autocomplete="new-password"
/>
<form (ngSubmit)="onSubmit()" class="auth-form">
<div class="field">
<label for="name">Full Name</label>
<div class="input-group">
<span class="material-symbols-rounded input-icon">person</span>
<input
type="text"
id="name"
[(ngModel)]="name"
name="name"
placeholder="John Doe"
/>
</div>
</div>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password</label>
<div class="input-wrapper">
<span class="input-icon">🔒</span>
<input
type="password"
id="confirmPassword"
[(ngModel)]="confirmPassword"
name="confirmPassword"
placeholder="Re-enter your password"
autocomplete="new-password"
/>
<div class="field">
<label for="email">Email</label>
<div class="input-group">
<span class="material-symbols-rounded input-icon">mail</span>
<input
type="email"
id="email"
[(ngModel)]="email"
name="email"
placeholder="you&#64;company.com"
autocomplete="email"
/>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary" [disabled]="loading()">
@if (loading()) {
<span class="spinner"></span> Creating Account...
} @else {
Create Account
}
</button>
</form>
<div class="fields-row">
<div class="field">
<label for="password">Password</label>
<div class="input-group">
<span class="material-symbols-rounded input-icon">lock</span>
<input
type="password"
id="password"
[(ngModel)]="password"
name="password"
placeholder="Min. 6 chars"
autocomplete="new-password"
/>
</div>
</div>
<div class="field">
<label for="confirmPassword">Confirm</label>
<div class="input-group">
<span class="material-symbols-rounded input-icon">lock</span>
<input
type="password"
id="confirmPassword"
[(ngModel)]="confirmPassword"
name="confirmPassword"
placeholder="Re-enter"
autocomplete="new-password"
/>
</div>
</div>
</div>
<button type="submit" class="submit-btn" [disabled]="loading()">
@if (loading()) {
<div class="btn-spinner"></div> Creating account...
} @else {
Create account <span class="material-symbols-rounded">arrow_forward</span>
}
</button>
</form>
<div class="auth-divider">
<span>Already registered?</span>
</div>
<div class="auth-footer">
<p>Already have an account? <a routerLink="/login">Sign in</a></p>
<a routerLink="/login" class="secondary-btn">Sign in instead</a>
</div>
</div>
</div>
......@@ -23,8 +23,8 @@ export class StudentProfileComponent implements OnInit {
}
loadProfile(): void {
this.quizService.getStudentProfile().subscribe({
next: (res) => {
this.quizService.getCandidateProfile().subscribe({
next: (res: any) => {
this.user.set(res.user);
this.submissions.set(res.submissions);
this.loading.set(false);
......
......@@ -7,7 +7,8 @@ export interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'student';
role: 'admin' | 'hr' | 'candidate';
group?: string;
}
export interface AuthResponse {
......@@ -83,4 +84,14 @@ export class AuthService {
getUserRole(): string | null {
return this.currentUser()?.role || null;
}
getDashboardRoute(): string {
const role = this.getUserRole();
switch (role) {
case 'admin': return '/admin/dashboard';
case 'hr': return '/hr/dashboard';
case 'candidate': return '/candidate/dashboard';
default: return '/login';
}
}
}
......@@ -7,20 +7,34 @@ import { Observable } from 'rxjs';
})
export class QuizService {
private adminUrl = 'http://localhost:5000/api/admin';
private studentUrl = 'http://localhost:5000/api/student';
private hrUrl = 'http://localhost:5000/api/hr';
private candidateUrl = 'http://localhost:5000/api/candidate';
constructor(private http: HttpClient) {}
// ========== ADMIN ENDPOINTS ==========
getUsers(): Observable<any> {
return this.http.get(`${this.adminUrl}/users`);
getAdminStats(): Observable<any> {
return this.http.get(`${this.adminUrl}/stats`);
}
getUsers(role?: string): Observable<any> {
const url = role ? `${this.adminUrl}/users?role=${role}` : `${this.adminUrl}/users`;
return this.http.get(url);
}
getLoggedInUsers(): Observable<any> {
return this.http.get(`${this.adminUrl}/users/logged-in`);
}
createHRUser(data: { name: string; email: string; password: string }): Observable<any> {
return this.http.post(`${this.adminUrl}/users/create-hr`, data);
}
deleteUser(userId: string): Observable<any> {
return this.http.delete(`${this.adminUrl}/users/${userId}`);
}
getUserHistory(userId: string): Observable<any> {
return this.http.get(`${this.adminUrl}/users/${userId}/history`);
}
......@@ -33,33 +47,107 @@ export class QuizService {
return this.http.post(`${this.adminUrl}/quiz/create`, formData);
}
createQuizManual(data: any): Observable<any> {
return this.http.post(`${this.adminUrl}/quiz/create-manual`, data);
}
getAdminQuizzes(): Observable<any> {
return this.http.get(`${this.adminUrl}/quizzes`);
}
getAdminQuiz(quizId: string): Observable<any> {
return this.http.get(`${this.adminUrl}/quiz/${quizId}`);
}
updateQuiz(quizId: string, data: any): Observable<any> {
return this.http.put(`${this.adminUrl}/quiz/${quizId}`, data);
}
assignQuiz(quizId: string, data: any): Observable<any> {
return this.http.put(`${this.adminUrl}/quiz/${quizId}/assign`, data);
}
deleteQuiz(quizId: string): Observable<any> {
return this.http.delete(`${this.adminUrl}/quiz/${quizId}`);
}
// ========== STUDENT ENDPOINTS ==========
getAdminCategories(): Observable<any> {
return this.http.get(`${this.adminUrl}/categories`);
}
getAdminGroups(): Observable<any> {
return this.http.get(`${this.adminUrl}/groups`);
}
// ========== HR ENDPOINTS ==========
getHRStats(): Observable<any> {
return this.http.get(`${this.hrUrl}/stats`);
}
getHRQuizzes(): Observable<any> {
return this.http.get(`${this.hrUrl}/quizzes`);
}
getHRQuiz(quizId: string): Observable<any> {
return this.http.get(`${this.hrUrl}/quiz/${quizId}`);
}
createHRQuiz(formData: FormData): Observable<any> {
return this.http.post(`${this.hrUrl}/quiz/create`, formData);
}
createHRQuizManual(data: any): Observable<any> {
return this.http.post(`${this.hrUrl}/quiz/create-manual`, data);
}
updateHRQuiz(quizId: string, data: any): Observable<any> {
return this.http.put(`${this.hrUrl}/quiz/${quizId}`, data);
}
deleteHRQuiz(quizId: string): Observable<any> {
return this.http.delete(`${this.hrUrl}/quiz/${quizId}`);
}
getHRCandidates(): Observable<any> {
return this.http.get(`${this.hrUrl}/candidates`);
}
getHRCandidateHistory(userId: string): Observable<any> {
return this.http.get(`${this.hrUrl}/candidates/${userId}/history`);
}
getHRSubmissionDetails(submissionId: string): Observable<any> {
return this.http.get(`${this.hrUrl}/submissions/${submissionId}`);
}
getHRCategories(): Observable<any> {
return this.http.get(`${this.hrUrl}/categories`);
}
getHRGroups(): Observable<any> {
return this.http.get(`${this.hrUrl}/groups`);
}
// ========== CANDIDATE ENDPOINTS ==========
getAvailableQuizzes(): Observable<any> {
return this.http.get(`${this.studentUrl}/quizzes`);
return this.http.get(`${this.candidateUrl}/quizzes`);
}
getQuizForTaking(quizId: string): Observable<any> {
return this.http.get(`${this.studentUrl}/quiz/${quizId}`);
return this.http.get(`${this.candidateUrl}/quiz/${quizId}`);
}
submitQuiz(quizId: string, answers: any[], timeTaken: number): Observable<any> {
return this.http.post(`${this.studentUrl}/quiz/${quizId}/submit`, { answers, timeTaken });
return this.http.post(`${this.candidateUrl}/quiz/${quizId}/submit`, { answers, timeTaken });
}
getStudentProfile(): Observable<any> {
return this.http.get(`${this.studentUrl}/profile`);
getCandidateProfile(): Observable<any> {
return this.http.get(`${this.candidateUrl}/profile`);
}
getResultDetails(submissionId: string): Observable<any> {
return this.http.get(`${this.studentUrl}/results/${submissionId}`);
return this.http.get(`${this.candidateUrl}/results/${submissionId}`);
}
}
import { Injectable, signal, effect } from '@angular/core';
export type ThemeMode = 'light' | 'dark' | 'blue';
@Injectable({
providedIn: 'root'
})
export class ThemeService {
currentTheme = signal<ThemeMode>('light');
constructor() {
const saved = localStorage.getItem('theme') as ThemeMode;
if (saved && ['light', 'dark', 'blue'].includes(saved)) {
this.currentTheme.set(saved);
}
this.applyTheme(this.currentTheme());
effect(() => {
this.applyTheme(this.currentTheme());
});
}
setTheme(theme: ThemeMode): void {
this.currentTheme.set(theme);
localStorage.setItem('theme', theme);
}
toggleTheme(): void {
const themes: ThemeMode[] = ['light', 'dark', 'blue'];
const currentIdx = themes.indexOf(this.currentTheme());
const nextIdx = (currentIdx + 1) % themes.length;
this.setTheme(themes[nextIdx]);
}
private applyTheme(theme: ThemeMode): void {
document.documentElement.setAttribute('data-theme', theme);
}
}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>QuizMaster - Online Quiz Platform</title>
<meta name="description" content="QuizMaster - Take quizzes, track your progress, and improve your knowledge">
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8" />
<title>QuizMaster Pro - Online Assessment Platform</title>
<meta
name="description"
content="QuizMaster Pro - Professional online quiz and assessment platform for organizations"
/>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<!-- Google Material Symbols -->
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap"
/>
<!-- Inter Font -->
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap"
rel="stylesheet"
/>
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
</head>
<body class="azure-theme">
<app-root></app-root>
</body>
</html>
@use '@angular/material' as mat;
@include mat.core();
// 🔵 Azure Theme (default)
$azure-theme: mat.define-theme((
color: (
theme-type: light,
primary: mat.$azure-palette,
)
));
// 🟣 Magenta Theme
$magenta-theme: mat.define-theme((
color: (
theme-type: light,
primary: mat.$magenta-palette,
)
));
// 🟢 Cyan Theme
$cyan-theme: mat.define-theme((
color: (
theme-type: light,
primary: mat.$cyan-palette,
)
));
// 🔴 Rose Theme
$rose-theme: mat.define-theme((
color: (
theme-type: light,
primary: mat.$rose-palette,
)
));
// Apply themes via classes
.azure-theme {
@include mat.all-component-themes($azure-theme);
}
.magenta-theme {
@include mat.all-component-themes($magenta-theme);
}
.cyan-theme {
@include mat.all-component-themes($cyan-theme);
}
.rose-theme {
@include mat.all-component-themes($rose-theme);
}
\ No newline at end of file
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
/* ============================================================
QuizMaster Pro — Global Theme System
CSS Custom Properties with Light (default), Dark, Blue themes
============================================================ */
*, *::before, *::after {
box-sizing: border-box;
/* ---- Material Symbols Configuration ---- */
.material-symbols-rounded {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
font-size: 20px;
vertical-align: middle;
user-select: none;
}
body {
.material-symbols-rounded.filled {
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
/* ============================================================
LIGHT THEME (Default) — Professional, LeetCode-inspired
============================================================ */
:root,
[data-theme="light"] {
/* Backgrounds */
--bg-primary: #ffffff;
--bg-secondary: #f8f9fb;
--bg-tertiary: #f1f3f5;
--bg-card: #ffffff;
--bg-sidebar: #ffffff;
--bg-input: #f8f9fb;
--bg-hover: #f1f3f5;
--bg-active: #eff2ff;
--bg-overlay: rgba(0, 0, 0, 0.5);
--bg-code: #f6f8fa;
--bg-badge: #eef1ff;
/* Text */
--text-primary: #1a1d26;
--text-secondary: #4a5568;
--text-muted: #8492a6;
--text-inverse: #ffffff;
--text-link: #4f6ef7;
/* Borders */
--border-color: #e2e8f0;
--border-subtle: #edf0f4;
--border-strong: #cbd5e0;
--border-focus: #4f6ef7;
/* Accent / Brand */
--accent-primary: #4f6ef7;
--accent-primary-hover: #3b5bdb;
--accent-primary-light: #eff2ff;
--accent-primary-rgb: 79, 110, 247;
--accent-secondary: #7c5cfc;
--accent-gradient: linear-gradient(135deg, #4f6ef7, #7c5cfc);
/* Status */
--success: #22c55e;
--success-light: #f0fdf4;
--success-border: #bbf7d0;
--warning: #f59e0b;
--warning-light: #fffbeb;
--warning-border: #fde68a;
--danger: #ef4444;
--danger-light: #fef2f2;
--danger-border: #fecaca;
--info: #3b82f6;
--info-light: #eff6ff;
/* Shadows */
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.05), 0 2px 4px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.06), 0 4px 10px rgba(0, 0, 0, 0.04);
--shadow-xl: 0 20px 40px rgba(0, 0, 0, 0.08);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04), 0 0 0 1px rgba(0, 0, 0, 0.02);
--shadow-card-hover: 0 8px 24px rgba(0, 0, 0, 0.08);
--shadow-sidebar: 1px 0 0 var(--border-subtle);
/* Misc */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 20px;
--radius-full: 9999px;
--transition-fast: 0.15s ease;
--transition-normal: 0.25s ease;
--transition-slow: 0.4s ease;
/* Sidebar */
--sidebar-width: 260px;
--sidebar-bg: var(--bg-sidebar);
--sidebar-text: var(--text-secondary);
--sidebar-active-bg: var(--bg-active);
--sidebar-active-text: var(--accent-primary);
--sidebar-hover-bg: var(--bg-hover);
/* Scrollbar */
--scrollbar-track: transparent;
--scrollbar-thumb: #d1d5db;
--scrollbar-thumb-hover: #9ca3af;
}
/* ============================================================
DARK THEME — Sleek, modern dark UI
============================================================ */
[data-theme="dark"] {
--bg-primary: #0f1117;
--bg-secondary: #161922;
--bg-tertiary: #1c2030;
--bg-card: #1a1e2e;
--bg-sidebar: #12141d;
--bg-input: #1c2030;
--bg-hover: #232838;
--bg-active: rgba(79, 110, 247, 0.12);
--bg-overlay: rgba(0, 0, 0, 0.7);
--bg-code: #1c2030;
--bg-badge: rgba(79, 110, 247, 0.15);
--text-primary: #e8ecf4;
--text-secondary: #9ca3b8;
--text-muted: #636b83;
--text-inverse: #0f1117;
--text-link: #7c9cff;
--border-color: rgba(255, 255, 255, 0.08);
--border-subtle: rgba(255, 255, 255, 0.05);
--border-strong: rgba(255, 255, 255, 0.15);
--border-focus: #7c9cff;
--accent-primary: #6c8cff;
--accent-primary-hover: #8ba3ff;
--accent-primary-light: rgba(108, 140, 255, 0.12);
--accent-primary-rgb: 108, 140, 255;
--accent-secondary: #a78bfa;
--accent-gradient: linear-gradient(135deg, #6c8cff, #a78bfa);
--success: #4ade80;
--success-light: rgba(74, 222, 128, 0.1);
--success-border: rgba(74, 222, 128, 0.25);
--warning: #fbbf24;
--warning-light: rgba(251, 191, 36, 0.1);
--warning-border: rgba(251, 191, 36, 0.25);
--danger: #f87171;
--danger-light: rgba(248, 113, 113, 0.1);
--danger-border: rgba(248, 113, 113, 0.25);
--info: #60a5fa;
--info-light: rgba(96, 165, 250, 0.1);
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.5);
--shadow-xl: 0 20px 40px rgba(0, 0, 0, 0.6);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.04);
--shadow-card-hover: 0 8px 24px rgba(0, 0, 0, 0.4);
--shadow-sidebar: 1px 0 0 rgba(255, 255, 255, 0.06);
--sidebar-bg: var(--bg-sidebar);
--scrollbar-track: rgba(255, 255, 255, 0.02);
--scrollbar-thumb: rgba(255, 255, 255, 0.1);
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.18);
}
/* ============================================================
BLUE THEME — Corporate navy/steel blue
============================================================ */
[data-theme="blue"] {
--bg-primary: #0c1929;
--bg-secondary: #112240;
--bg-tertiary: #172a4a;
--bg-card: #15294a;
--bg-sidebar: #0a1628;
--bg-input: #172a4a;
--bg-hover: #1d3461;
--bg-active: rgba(100, 180, 255, 0.12);
--bg-overlay: rgba(0, 0, 0, 0.7);
--bg-code: #172a4a;
--bg-badge: rgba(100, 180, 255, 0.15);
--text-primary: #ccd6f6;
--text-secondary: #8892b0;
--text-muted: #5a6583;
--text-inverse: #0c1929;
--text-link: #64b4ff;
--border-color: rgba(255, 255, 255, 0.08);
--border-subtle: rgba(255, 255, 255, 0.04);
--border-strong: rgba(255, 255, 255, 0.12);
--border-focus: #64b4ff;
--accent-primary: #64b4ff;
--accent-primary-hover: #89c8ff;
--accent-primary-light: rgba(100, 180, 255, 0.12);
--accent-primary-rgb: 100, 180, 255;
--accent-secondary: #00d4aa;
--accent-gradient: linear-gradient(135deg, #64b4ff, #00d4aa);
--success: #00d4aa;
--success-light: rgba(0, 212, 170, 0.1);
--success-border: rgba(0, 212, 170, 0.25);
--warning: #ffcc57;
--warning-light: rgba(255, 204, 87, 0.1);
--warning-border: rgba(255, 204, 87, 0.25);
--danger: #ff6b6b;
--danger-light: rgba(255, 107, 107, 0.1);
--danger-border: rgba(255, 107, 107, 0.25);
--info: #64b4ff;
--info-light: rgba(100, 180, 255, 0.1);
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.5);
--shadow-xl: 0 20px 40px rgba(0, 0, 0, 0.6);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.03);
--shadow-card-hover: 0 8px 24px rgba(0, 0, 0, 0.5);
--shadow-sidebar: 1px 0 0 rgba(255, 255, 255, 0.05);
--sidebar-bg: var(--bg-sidebar);
--scrollbar-track: rgba(255, 255, 255, 0.02);
--scrollbar-thumb: rgba(255, 255, 255, 0.08);
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.15);
}
/* ============================================================
GLOBAL RESETS & BASE STYLES
============================================================ */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f1117;
color: #fff;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html, body {
html {
height: 100%;
overflow-x: hidden;
scroll-behavior: smooth;
}
body {
min-height: 100%;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg-secondary);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.6;
font-size: 14px;
transition: background var(--transition-normal), color var(--transition-normal);
}
a {
......@@ -24,28 +257,418 @@ a {
text-decoration: none;
}
a:hover {
color: var(--accent-primary);
}
img, svg {
display: block;
max-width: 100%;
}
input, button, textarea, select {
font-family: inherit;
font-size: inherit;
}
button {
cursor: pointer;
border: none;
background: none;
}
ul, ol {
list-style: none;
}
/* ============================================================
SCROLLBAR STYLES
============================================================ */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.02);
background: var(--scrollbar-track);
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
background: var(--scrollbar-thumb);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.15);
background: var(--scrollbar-thumb-hover);
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
::selection {
background: rgba(102, 126, 234, 0.3);
background: rgba(var(--accent-primary-rgb), 0.2);
color: var(--text-primary);
}
/* ============================================================
UTILITY CLASSES
============================================================ */
/* Status badges */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: var(--radius-full);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.3px;
text-transform: uppercase;
}
.badge-primary {
background: var(--accent-primary-light);
color: var(--accent-primary);
}
.badge-success {
background: var(--success-light);
color: var(--success);
}
.badge-warning {
background: var(--warning-light);
color: var(--warning);
}
.badge-danger {
background: var(--danger-light);
color: var(--danger);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 20px;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 600;
transition: all var(--transition-fast);
border: 1px solid transparent;
cursor: pointer;
white-space: nowrap;
font-family: inherit;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent-gradient);
color: #fff;
box-shadow: 0 2px 8px rgba(var(--accent-primary-rgb), 0.25);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(var(--accent-primary-rgb), 0.35);
}
.btn-outline {
background: transparent;
color: var(--text-secondary);
border-color: var(--border-color);
}
.btn-outline:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--border-strong);
color: var(--text-primary);
}
.btn-danger {
background: var(--danger);
color: #fff;
}
.btn-danger:hover:not(:disabled) {
background: #dc2626;
transform: translateY(-1px);
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
padding: 8px 12px;
}
.btn-ghost:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.btn-sm {
padding: 6px 14px;
font-size: 13px;
border-radius: var(--radius-sm);
}
.btn-lg {
padding: 14px 28px;
font-size: 15px;
border-radius: var(--radius-lg);
}
/* Cards */
.card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
transition: box-shadow var(--transition-normal), border-color var(--transition-normal), transform var(--transition-normal);
}
.card-hover:hover {
box-shadow: var(--shadow-card-hover);
border-color: var(--border-strong);
transform: translateY(-2px);
}
.card-padding {
padding: 24px;
}
/* Form inputs */
.form-input {
width: 100%;
padding: 10px 14px;
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 14px;
outline: none;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background var(--transition-fast);
}
.form-input::placeholder {
color: var(--text-muted);
}
.form-input:focus {
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(var(--accent-primary-rgb), 0.1);
background: var(--bg-primary);
}
.form-label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 6px;
}
.form-select {
width: 100%;
padding: 10px 14px;
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 14px;
outline: none;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right 10px center;
background-repeat: no-repeat;
background-size: 20px;
padding-right: 36px;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.form-select:focus {
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(var(--accent-primary-rgb), 0.1);
}
/* Alerts */
.alert {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 500;
}
.alert-error {
background: var(--danger-light);
color: var(--danger);
border: 1px solid var(--danger-border);
}
.alert-success {
background: var(--success-light);
color: var(--success);
border: 1px solid var(--success-border);
}
.alert-warning {
background: var(--warning-light);
color: var(--warning);
border: 1px solid var(--warning-border);
}
/* Loading */
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-color);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
.spinner-lg {
width: 40px;
height: 40px;
border-width: 3px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Tables */
.table-container {
overflow-x: auto;
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
}
table {
width: 100%;
border-collapse: collapse;
}
thead {
background: var(--bg-tertiary);
}
th {
padding: 12px 16px;
text-align: left;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--border-color);
}
td {
padding: 14px 16px;
font-size: 14px;
color: var(--text-primary);
border-bottom: 1px solid var(--border-subtle);
}
tr:last-child td {
border-bottom: none;
}
tbody tr {
transition: background var(--transition-fast);
}
tbody tr:hover {
background: var(--bg-hover);
}
/* Empty states */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
.empty-state .material-symbols-rounded {
font-size: 48px;
color: var(--text-muted);
margin-bottom: 12px;
}
.empty-state h3 {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
}
.empty-state p {
font-size: 14px;
color: var(--text-muted);
}
/* ============================================================
ANIMATIONS
============================================================ */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideInRight {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.animate-fade-in { animation: fadeIn 0.3s ease forwards; }
.animate-slide-up { animation: slideUp 0.4s ease forwards; }
.animate-scale-in { animation: scaleIn 0.25s ease forwards; }
/* Staggered children animation */
.stagger-children > * {
opacity: 0;
animation: fadeIn 0.3s ease forwards;
}
.stagger-children > *:nth-child(1) { animation-delay: 0.05s; }
.stagger-children > *:nth-child(2) { animation-delay: 0.10s; }
.stagger-children > *:nth-child(3) { animation-delay: 0.15s; }
.stagger-children > *:nth-child(4) { animation-delay: 0.20s; }
.stagger-children > *:nth-child(5) { animation-delay: 0.25s; }
.stagger-children > *:nth-child(6) { animation-delay: 0.30s; }
/* ============================================================
RESPONSIVE UTILITIES
============================================================ */
@media (max-width: 768px) {
:root {
--sidebar-width: 0px;
}
}
# QuizMaster Pro - Implementation Plan
## Current State Analysis
- **Backend**: Express + MongoDB (Mongoose) with JWT auth, 2 roles (admin/student), Excel-based quiz creation
- **Frontend**: Angular 21 standalone components, dark theme, emoji icons, sidebar navigation
- **Issues**: Guards mostly commented out, dark-only theme, no quiz editing, no assignment system, no AI generation, no HR role
## Changes Required
### Phase 1: Backend - Models & Roles
1. **User Model** - Add `hr` role to enum: `['admin', 'hr', 'candidate']` (rename student → candidate)
2. **Quiz Model** - Add `assignToAll: false` default, ensure `category`, `assignees`, `difficulty`, `topic` fields
3. **Submission Model** - No changes needed (already tracks studentId/quizId)
4. **Middleware** - Update `authorize()` to support new roles
### Phase 2: Backend - Routes
1. **Auth routes** - Register creates 'candidate' by default, admin can create HR users
2. **Admin routes** - Full CRUD for quizzes, users, assignments; create HR users
3. **New HR routes** - Quiz CRUD, assign quizzes, view candidate results
4. **Quiz edit/delete guards** - Check `Submission.countDocuments({ quizId })` before allowing edit/delete
5. **Quiz assignment** - Assign to specific users; filter student quiz list by assignment
6. **AI quiz generation** - New endpoint using Google Gemini API
### Phase 3: Frontend - Theme System
1. **Professional white/light theme** as default (like LeetCode/Codeforces)
2. **CSS custom properties** for theming with dark mode toggle
3. **Material-inspired design** with clean typography, subtle shadows, accent colors
4. **Angular Material** integration for polished UI components
### Phase 4: Frontend - Components
1. **Quiz edit modal/page** - Edit title, timer, questions for unattempted quizzes
2. **Quiz assignment UI** - Multi-select users/groups for assignment
3. **AI generation form** - Topic, difficulty, count inputs
4. **Category/group management** - Quiz categorization
5. **Role-based navigation** - Different sidebars for Admin/HR/Candidate
6. **Guard updates** - Enable all guards with HR role support
### Phase 5: Polish
1. Professional LeetCode-like UI with clean white theme
2. Smooth transitions and micro-animations
3. Responsive design
4. Google Material icons instead of emojis
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment