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

feat: changed the layout of the application

parent 324b5c4d
const mongoose = require('mongoose'); const mongoose = require('mongoose');
const User = require('../models/User'); const User = require('../models/User');
const createAdmin = async () => { const createDefaultUsers = async () => {
// Create default admin
const adminExists = await User.findOne({ email: 'admin@quizapp.com' }); const adminExists = await User.findOne({ email: 'admin@quizapp.com' });
if (!adminExists) { if (!adminExists) {
// Pass plain password - User model's pre-save hook will hash it
await User.create({ await User.create({
name: 'Admin', name: 'Administrator',
email: 'admin@quizapp.com', email: 'admin@quizapp.com',
password: 'admin123', password: 'admin123',
role: 'admin' role: 'admin'
}); });
console.log('Default admin created: admin@quizapp.com / admin123'); 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 () => { const connectDB = async () => {
try { try {
const conn = await mongoose.connect(process.env.MONGODB_URI); const conn = await mongoose.connect(process.env.MONGODB_URI);
console.log(`MongoDB Connected: ${conn.connection.host}`); console.log(`MongoDB Connected: ${conn.connection.host}`);
await createAdmin(); await createDefaultUsers();
} catch (error) { } catch (error) {
console.error(`Error: ${error.message}`); console.error(`Error: ${error.message}`);
process.exit(1); process.exit(1);
......
...@@ -18,9 +18,7 @@ const protect = async (req, res, next) => { ...@@ -18,9 +18,7 @@ const protect = async (req, res, next) => {
try { try {
const decoded = jwt.verify(token, process.env.JWT_SECRET); const decoded = jwt.verify(token, process.env.JWT_SECRET);
console.log("DECODED:", decoded);
req.user = await User.findById(decoded.id).select('-password'); req.user = await User.findById(decoded.id).select('-password');
console.log("USER ROLE:", req.user.role);
if (!req.user) { if (!req.user) {
return res.status(401).json({ message: 'User not found' }); return res.status(401).json({ message: 'User not found' });
} }
......
...@@ -24,28 +24,36 @@ const quizSchema = new mongoose.Schema({ ...@@ -24,28 +24,36 @@ const quizSchema = new mongoose.Schema({
type: Boolean, type: Boolean,
default: true default: true
}, },
// 🔥 ADD THESE (important)
category: { category: {
type: String, type: String,
default: 'General' default: 'General',
trim: true
}, },
assignToAll: { assignToAll: {
type: Boolean, type: Boolean,
default: true default: false
}, },
assignees: [{ assignees: [{
type: mongoose.Schema.Types.ObjectId, type: mongoose.Schema.Types.ObjectId,
ref: 'User' ref: 'User'
}], }],
assignedGroups: [{
type: String,
trim: true
}],
difficulty: { difficulty: {
type: String, type: String,
enum: ['Beginner', 'Intermediate', 'Hard'] enum: ['Beginner', 'Intermediate', 'Advanced'],
default: 'Intermediate'
}, },
topic: { topic: {
type: String type: String,
trim: true
},
generatedByAI: {
type: Boolean,
default: false
} }
}, { }, {
timestamps: true timestamps: true
}); });
......
...@@ -21,8 +21,13 @@ const userSchema = new mongoose.Schema({ ...@@ -21,8 +21,13 @@ const userSchema = new mongoose.Schema({
}, },
role: { role: {
type: String, type: String,
enum: ['admin', 'student'], enum: ['admin', 'hr', 'candidate'],
default: 'student' default: 'candidate'
},
group: {
type: String,
default: 'General',
trim: true
}, },
isLoggedIn: { isLoggedIn: {
type: Boolean, type: Boolean,
......
This diff is collapsed.
...@@ -12,7 +12,7 @@ const generateToken = (id) => { ...@@ -12,7 +12,7 @@ const generateToken = (id) => {
}; };
// @route POST /api/auth/register // @route POST /api/auth/register
// @desc Register a new student // @desc Register a new candidate
// @access Public // @access Public
router.post('/register', async (req, res) => { router.post('/register', async (req, res) => {
try { try {
...@@ -29,8 +29,8 @@ router.post('/register', async (req, res) => { ...@@ -29,8 +29,8 @@ router.post('/register', async (req, res) => {
return res.status(400).json({ message: 'User with this email already exists' }); return res.status(400).json({ message: 'User with this email already exists' });
} }
// Create user (role defaults to 'student') // Create user (role defaults to 'candidate')
const user = await User.create({ name, email, password, role: 'student' }); const user = await User.create({ name, email, password, role: 'candidate' });
res.status(201).json({ res.status(201).json({
message: 'Registration successful', message: 'Registration successful',
...@@ -47,7 +47,7 @@ router.post('/register', async (req, res) => { ...@@ -47,7 +47,7 @@ router.post('/register', async (req, res) => {
}); });
// @route POST /api/auth/login // @route POST /api/auth/login
// @desc Login user (admin or student) // @desc Login user (admin, hr, or candidate)
// @access Public // @access Public
router.post('/login', async (req, res) => { router.post('/login', async (req, res) => {
try { try {
...@@ -82,7 +82,8 @@ router.post('/login', async (req, res) => { ...@@ -82,7 +82,8 @@ router.post('/login', async (req, res) => {
id: user._id, id: user._id,
name: user.name, name: user.name,
email: user.email, email: user.email,
role: user.role role: user.role,
group: user.group
} }
}); });
} catch (error) { } catch (error) {
...@@ -95,8 +96,7 @@ router.post('/login', async (req, res) => { ...@@ -95,8 +96,7 @@ router.post('/login', async (req, res) => {
// @access Private // @access Private
router.post('/logout', protect, async (req, res) => { router.post('/logout', protect, async (req, res) => {
try { try {
req.user.isLoggedIn = false; await User.updateOne({ _id: req.user._id }, { isLoggedIn: false });
await req.user.save();
res.json({ message: 'Logout successful' }); res.json({ message: 'Logout successful' });
} catch (error) { } catch (error) {
res.status(500).json({ message: 'Server error', error: error.message }); 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;
This diff is collapsed.
...@@ -50,11 +50,14 @@ app.use(cookieParser()); ...@@ -50,11 +50,14 @@ app.use(cookieParser());
// Routes // Routes
app.use('/api/auth', require('./routes/auth')); app.use('/api/auth', require('./routes/auth'));
app.use('/api/admin', require('./routes/admin')); 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 // Health check
app.get('/api/health', (req, res) => { 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 // Error handling middleware
......
...@@ -51,7 +51,7 @@ ...@@ -51,7 +51,7 @@
"input": "public" "input": "public"
} }
], ],
"styles": ["src/styles.css"] "styles": ["src/material-theme.scss", "src/styles.css"]
}, },
"configurations": { "configurations": {
"production": { "production": {
......
...@@ -8,10 +8,12 @@ ...@@ -8,10 +8,12 @@
"name": "quiz-app", "name": "quiz-app",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@angular/cdk": "^21.2.6",
"@angular/common": "^21.2.0", "@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0", "@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0", "@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0", "@angular/forms": "^21.2.0",
"@angular/material": "^21.2.6",
"@angular/platform-browser": "^21.2.0", "@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0", "@angular/router": "^21.2.0",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
...@@ -417,6 +419,22 @@ ...@@ -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": { "node_modules/@angular/cli": {
"version": "21.2.6", "version": "21.2.6",
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.6.tgz", "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.6.tgz",
...@@ -557,6 +575,23 @@ ...@@ -557,6 +575,23 @@
"rxjs": "^6.5.3 || ^7.4.0" "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": { "node_modules/@angular/platform-browser": {
"version": "21.2.7", "version": "21.2.7",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.7.tgz", "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.7.tgz",
...@@ -4554,7 +4589,6 @@ ...@@ -4554,7 +4589,6 @@
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
"integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"entities": "^6.0.0" "entities": "^6.0.0"
...@@ -4608,7 +4642,6 @@ ...@@ -4608,7 +4642,6 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=0.12" "node": ">=0.12"
......
...@@ -13,10 +13,12 @@ ...@@ -13,10 +13,12 @@
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
"dependencies": { "dependencies": {
"@angular/cdk": "^21.2.6",
"@angular/common": "^21.2.0", "@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0", "@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0", "@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0", "@angular/forms": "^21.2.0",
"@angular/material": "^21.2.6",
"@angular/platform-browser": "^21.2.0", "@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0", "@angular/router": "^21.2.0",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
......
import { Routes } from '@angular/router'; 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 = [ export const routes: Routes = [
// Default redirect // Default redirect
{ path: '', redirectTo: '/login', pathMatch: 'full' }, { path: '', redirectTo: '/login', pathMatch: 'full' },
// Auth routes (guest only) // Auth routes (guest only — no sidebar)
{ {
path: 'login', path: 'login',
// canActivate: [guestGuard], canActivate: [guestGuard],
loadComponent: () => import('./pages/login/login').then(m => m.LoginComponent) loadComponent: () => import('./pages/login/login').then(m => m.LoginComponent)
}, },
{ {
path: 'register', path: 'register',
// canActivate: [guestGuard], canActivate: [guestGuard],
loadComponent: () => import('./pages/register/register').then(m => m.RegisterComponent) 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], canActivate: [adminGuard],
children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{
path: 'dashboard',
loadComponent: () => import('./pages/admin/dashboard/dashboard').then(m => m.AdminDashboardComponent) loadComponent: () => import('./pages/admin/dashboard/dashboard').then(m => m.AdminDashboardComponent)
}, },
{ {
path: 'admin/users', path: 'users',
// canActivate: [adminGuard],
loadComponent: () => import('./pages/admin/users/users').then(m => m.AdminUsersComponent) loadComponent: () => import('./pages/admin/users/users').then(m => m.AdminUsersComponent)
}, },
{ {
path: 'admin/users/:userId/history', path: 'users/:userId/history',
// canActivate: [adminGuard],
loadComponent: () => import('./pages/admin/user-history/user-history').then(m => m.UserHistoryComponent) loadComponent: () => import('./pages/admin/user-history/user-history').then(m => m.UserHistoryComponent)
}, },
{ {
path: 'admin/submissions/:submissionId', path: 'submissions/:submissionId',
// canActivate: [adminGuard],
loadComponent: () => import('./pages/admin/submission-detail/submission-detail').then(m => m.SubmissionDetailComponent) loadComponent: () => import('./pages/admin/submission-detail/submission-detail').then(m => m.SubmissionDetailComponent)
}, },
{ {
path: 'admin/generate-quiz', path: 'quizzes',
// canActivate: [adminGuard], loadComponent: () => import('./pages/admin/quizzes/quizzes').then(m => m.AdminQuizzesComponent)
loadComponent: () => import('./pages/admin/generate-quiz/generate-quiz').then(m => m.GenerateQuizComponent) },
{
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)
},
]
},
// ========== HR ROUTES (with shared layout) ==========
{
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)
},
]
}, },
// Student routes // ========== CANDIDATE ROUTES (with shared layout) ==========
{ {
path: 'student/dashboard', path: 'candidate',
// canActivate: [studentGuard], component: LayoutComponent,
loadComponent: () => import('./pages/student/dashboard/dashboard').then(m => m.StudentDashboardComponent) canActivate: [candidateGuard],
children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{
path: 'dashboard',
loadComponent: () => import('./pages/candidate/dashboard/dashboard').then(m => m.CandidateDashboardComponent)
}, },
{ {
path: 'student/quiz/:quizId', path: 'quiz/:quizId',
// canActivate: [studentGuard], loadComponent: () => import('./pages/candidate/take-quiz/take-quiz').then(m => m.TakeQuizComponent)
loadComponent: () => import('./pages/student/take-quiz/take-quiz').then(m => m.TakeQuizComponent)
}, },
{ {
path: 'student/profile', path: 'profile',
// canActivate: [studentGuard], loadComponent: () => import('./pages/candidate/profile/profile').then(m => m.CandidateProfileComponent)
loadComponent: () => import('./pages/student/profile/profile').then(m => m.StudentProfileComponent)
}, },
{ {
path: 'student/results/:submissionId', path: 'results/:submissionId',
// canActivate: [studentGuard], loadComponent: () => import('./pages/candidate/results/results').then(m => m.CandidateResultsComponent)
loadComponent: () => import('./pages/student/results/results').then(m => m.StudentResultsComponent) },
]
}, },
// 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 // Catch-all
{ path: '**', redirectTo: '/login' } { 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'; ...@@ -2,7 +2,8 @@ import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router'; import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service'; 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 authService = inject(AuthService);
const router = inject(Router); const router = inject(Router);
...@@ -13,7 +14,8 @@ export const authGuard: CanActivateFn = (route, state) => { ...@@ -13,7 +14,8 @@ export const authGuard: CanActivateFn = (route, state) => {
return true; return true;
}; };
export const adminGuard: CanActivateFn = (route, state) => { // Admin only
export const adminGuard: CanActivateFn = () => {
const authService = inject(AuthService); const authService = inject(AuthService);
const router = inject(Router); const router = inject(Router);
...@@ -23,14 +25,15 @@ export const adminGuard: CanActivateFn = (route, state) => { ...@@ -23,14 +25,15 @@ export const adminGuard: CanActivateFn = (route, state) => {
} }
if (authService.getUserRole() !== 'admin') { if (authService.getUserRole() !== 'admin') {
router.navigate(['/student/dashboard']); router.navigate([authService.getDashboardRoute()]);
return false; return false;
} }
return true; return true;
}; };
export const studentGuard: CanActivateFn = (route, state) => { // HR only
export const hrGuard: CanActivateFn = () => {
const authService = inject(AuthService); const authService = inject(AuthService);
const router = inject(Router); const router = inject(Router);
...@@ -39,25 +42,58 @@ export const studentGuard: CanActivateFn = (route, state) => { ...@@ -39,25 +42,58 @@ export const studentGuard: CanActivateFn = (route, state) => {
return false; return false;
} }
if (authService.getUserRole() !== 'student') { if (authService.getUserRole() !== 'hr') {
router.navigate(['/admin/dashboard']); router.navigate([authService.getDashboardRoute()]);
return false; return false;
} }
return true; return true;
}; };
export const guestGuard: CanActivateFn = (route, state) => { // Candidate only
export const candidateGuard: CanActivateFn = () => {
const authService = inject(AuthService); const authService = inject(AuthService);
const router = inject(Router); const router = inject(Router);
if (authService.isLoggedIn()) { 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(); const role = authService.getUserRole();
if (role === 'admin') { if (role !== 'admin' && role !== 'hr') {
router.navigate(['/admin/dashboard']); router.navigate([authService.getDashboardRoute()]);
} else { return false;
router.navigate(['/student/dashboard']);
} }
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()) {
router.navigate([authService.getDashboardRoute()]);
return false; 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 { /* Admin Dashboard */
display: flex; .page {
min-height: 100vh; padding: 32px;
background: #0f1117; max-width: 1200px;
} margin: 0 auto;
/* 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;
} }
.sidebar-header { .page-header {
display: flex;
align-items: center;
gap: 10px;
padding: 0 8px;
margin-bottom: 32px; margin-bottom: 32px;
flex-wrap: wrap;
} }
.sidebar-header .logo-icon { font-size: 28px; } .page-header h1 {
font-size: 24px;
.sidebar-header h2 {
font-size: 20px;
font-weight: 700; font-weight: 700;
color: #fff; color: var(--text-primary);
margin: 0; margin: 0 0 4px;
} }
.role-badge { .page-subtitle {
background: linear-gradient(135deg, #667eea, #764ba2); font-size: 14px;
color: #fff; color: var(--text-muted);
font-size: 11px; margin: 0;
font-weight: 600;
padding: 3px 10px;
border-radius: 20px;
letter-spacing: 0.5px;
text-transform: uppercase;
} }
.sidebar-nav { /* Loading */
.loading-center {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px;
flex: 1;
}
.nav-item {
display: flex;
align-items: center; align-items: center;
gap: 12px; justify-content: center;
padding: 12px 16px; padding: 80px 20px;
border-radius: 12px; gap: 16px;
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;
} }
.nav-item.active { .loading-text {
background: rgba(102, 126, 234, 0.15); font-size: 14px;
color: #667eea; color: var(--text-muted);
} }
.nav-icon { font-size: 18px; } /* Stats Grid */
.stats-grid {
.sidebar-footer { display: grid;
border-top: 1px solid rgba(255, 255, 255, 0.06); grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
padding-top: 16px; gap: 16px;
margin-bottom: 32px;
} }
.user-info { .stat-card {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 16px;
padding: 8px; padding: 20px;
margin-bottom: 12px; background: var(--bg-card);
} border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
.user-avatar { box-shadow: var(--shadow-card);
width: 38px; }
height: 38px;
border-radius: 10px; .stat-icon-wrap {
background: linear-gradient(135deg, #667eea, #764ba2); width: 48px;
height: 48px;
border-radius: var(--radius-md);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #fff; flex-shrink: 0;
font-weight: 700;
font-size: 16px;
}
.user-details {
display: flex;
flex-direction: column;
} }
.user-name { .stat-icon-wrap .material-symbols-rounded {
font-size: 24px;
color: #fff; color: #fff;
font-size: 13px;
font-weight: 600;
} }
.user-email { .stat-icon-wrap.blue { background: linear-gradient(135deg, #4f6ef7, #3b5bdb); }
color: rgba(255, 255, 255, 0.4); .stat-icon-wrap.purple { background: linear-gradient(135deg, #7c5cfc, #6033e0); }
font-size: 11px; .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 { .stat-body {
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; display: flex;
align-items: center; flex-direction: column;
justify-content: center;
gap: 8px;
font-family: inherit;
}
.logout-btn:hover {
background: rgba(239, 68, 68, 0.2);
} }
/* Main Content */ .stat-value {
.main-content { font-size: 24px;
flex: 1; font-weight: 700;
margin-left: 260px; color: var(--text-primary);
padding: 40px; line-height: 1.2;
} }
.page-header { .stat-label {
margin-bottom: 40px; font-size: 13px;
color: var(--text-muted);
font-weight: 500;
} }
.page-header h1 { /* Sections */
font-size: 28px; .section {
font-weight: 700; margin-bottom: 32px;
color: #fff;
margin: 0 0 8px;
} }
.page-header p { .section-title {
color: rgba(255, 255, 255, 0.5); font-size: 16px;
font-size: 15px; font-weight: 600;
margin: 0; color: var(--text-primary);
margin: 0 0 16px;
} }
.dashboard-cards { /* Quick Actions */
.actions-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 24px; gap: 16px;
} }
.dash-card { .action-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;
display: flex; display: flex;
align-items: flex-start; align-items: center;
gap: 20px; gap: 16px;
transition: all 0.3s ease; text-decoration: none;
cursor: pointer; color: inherit;
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));
} }
.card-quiz::before { .action-icon {
background: linear-gradient(135deg, rgba(79, 172, 254, 0.08), rgba(0, 242, 254, 0.05)); font-size: 28px;
color: var(--accent-primary);
flex-shrink: 0;
} }
.dash-card:hover { .action-info {
border-color: rgba(255, 255, 255, 0.15); flex: 1;
transform: translateY(-4px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
} }
.dash-card:hover::before { opacity: 1; } .action-info h3 {
font-size: 15px;
.card-icon-wrap { font-weight: 600;
width: 56px; color: var(--text-primary);
height: 56px; margin: 0 0 4px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
z-index: 1;
} }
.card-users .card-icon-wrap { .action-info p {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.2), rgba(118, 75, 162, 0.15)); font-size: 13px;
color: var(--text-muted);
margin: 0;
} }
.card-quiz .card-icon-wrap { .action-arrow {
background: linear-gradient(135deg, rgba(79, 172, 254, 0.2), rgba(0, 242, 254, 0.15)); color: var(--text-muted);
font-size: 20px;
transition: transform 0.2s;
} }
.card-icon { font-size: 28px; } .action-card:hover .action-arrow {
transform: translateX(4px);
.card-content { color: var(--accent-primary);
flex: 1;
position: relative;
z-index: 1;
} }
.card-content h3 { /* Table helpers */
font-size: 18px; .user-cell {
font-weight: 700; display: flex;
color: #fff; flex-direction: column;
margin: 0 0 8px;
} }
.card-content p { .user-cell-name {
color: rgba(255, 255, 255, 0.5); font-weight: 600;
font-size: 14px; font-size: 14px;
margin: 0;
line-height: 1.5;
} }
.card-arrow { .user-cell-email {
font-size: 24px; font-size: 12px;
color: rgba(255, 255, 255, 0.2); color: var(--text-muted);
transition: all 0.3s;
position: relative;
z-index: 1;
align-self: center;
} }
.dash-card:hover .card-arrow { .text-muted {
color: rgba(255, 255, 255, 0.6); color: var(--text-muted);
transform: translateX(5px); font-size: 13px;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.sidebar { .page { padding: 20px; }
width: 100%; .stats-grid { grid-template-columns: 1fr 1fr; }
position: relative; .actions-grid { grid-template-columns: 1fr; }
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;
}
} }
<div class="dashboard-layout"> <!-- Admin Dashboard -->
<!-- Sidebar --> <div class="page animate-fade-in">
<aside class="sidebar"> <div class="page-header">
<div class="sidebar-header"> <div>
<span class="logo-icon">📝</span> <h1>Dashboard</h1>
<h2>QuizMaster</h2> <p class="page-subtitle">Overview of your assessment platform</p>
<span class="role-badge">Admin</span> </div>
</div> </div>
<nav class="sidebar-nav">
<a routerLink="/admin/dashboard" class="nav-item active"> @if (loading()) {
<span class="nav-icon">🏠</span> <div class="loading-center">
<span>Dashboard</span> <div class="spinner spinner-lg"></div>
</a> <p class="loading-text">Loading statistics...</p>
<a routerLink="/admin/users" class="nav-item"> </div>
<span class="nav-icon">👥</span> } @else if (stats()) {
<span>Users</span> <!-- Stats Cards -->
</a> <div class="stats-grid stagger-children">
<a routerLink="/admin/generate-quiz" class="nav-item"> <div class="stat-card">
<span class="nav-icon"></span> <div class="stat-icon-wrap blue">
<span>Generate Quiz</span> <span class="material-symbols-rounded">group</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>
<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> </div>
</aside>
<!-- Main Content --> <div class="stat-card">
<main class="main-content"> <div class="stat-icon-wrap purple">
<div class="page-header"> <span class="material-symbols-rounded">badge</span>
<h1>Admin Dashboard</h1> </div>
<p>Manage quizzes and monitor student activity</p> <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>
<div class="dashboard-cards"> <div class="stat-card">
<a routerLink="/admin/users" class="dash-card card-users"> <div class="stat-icon-wrap orange">
<div class="card-icon-wrap"> <span class="material-symbols-rounded">assignment_turned_in</span>
<span class="card-icon">👥</span>
</div> </div>
<div class="card-content"> <div class="stat-body">
<h3>Users</h3> <span class="stat-value">{{ stats().totalSubmissions }}</span>
<p>View registered students, check who's logged in, and review their test history</p> <span class="stat-label">Submissions</span>
</div> </div>
<div class="card-arrow"></div> </div>
<div class="stat-card">
<div class="stat-icon-wrap teal">
<span class="material-symbols-rounded">circle</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().onlineUsers }}</span>
<span class="stat-label">Online Now</span>
</div>
</div>
</div>
<!-- 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>
<a routerLink="/admin/generate-quiz" class="dash-card card-quiz"> <a routerLink="/admin/quizzes" class="action-card card card-hover card-padding">
<div class="card-icon-wrap"> <span class="material-symbols-rounded action-icon">quiz</span>
<span class="card-icon">📋</span> <div class="action-info">
<h3>Manage Quizzes</h3>
<p>View, edit, assign, and delete quizzes</p>
</div> </div>
<div class="card-content"> <span class="material-symbols-rounded action-arrow">arrow_forward</span>
<h3>Generate Quiz</h3> </a>
<p>Create a new quiz by uploading questions from an Excel file with timer settings</p>
<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> </div>
<div class="card-arrow"></div> <span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a> </a>
</div> </div>
</main> </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> </div>
import { Component } from '@angular/core'; import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { AuthService } from '../../../services/auth.service'; import { QuizService } from '../../../services/quiz.service';
@Component({ @Component({
selector: 'app-admin-dashboard', selector: 'app-admin-dashboard',
...@@ -10,10 +10,21 @@ import { AuthService } from '../../../services/auth.service'; ...@@ -10,10 +10,21 @@ import { AuthService } from '../../../services/auth.service';
templateUrl: './dashboard.html', templateUrl: './dashboard.html',
styleUrl: './dashboard.css' styleUrl: './dashboard.css'
}) })
export class AdminDashboardComponent { export class AdminDashboardComponent implements OnInit {
constructor(public authService: AuthService) {} stats = signal<any>(null);
recentSubmissions = signal<any[]>([]);
loading = signal(true);
logout(): void { constructor(private quizService: QuizService) {}
this.authService.logout();
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';
}
}
}
<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"> <main class="main-content">
<div class="content-wrapper">
<div class="breadcrumb"> <div class="breadcrumb">
<a routerLink="/admin/users">← Back to Users</a> <a routerLink="/admin/users">← Back to Users</a>
</div> </div>
...@@ -90,5 +70,6 @@ ...@@ -90,5 +70,6 @@
</div> </div>
} }
} }
</div>
</main> </main>
\ No newline at end of file
</div>
/* 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');
}
});
}
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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