Commit 566dca04 authored by Aravind RK's avatar Aravind RK

feat : added categorized groups for the quizzes

parent c5f72512
...@@ -272,6 +272,8 @@ ...@@ -272,6 +272,8 @@
flex: 1; flex: 1;
margin-left: var(--sidebar-width); margin-left: var(--sidebar-width);
min-height: 100vh; min-height: 100vh;
min-width: 0;
overflow-x: hidden;
transition: margin-left 0.3s ease; transition: margin-left 0.3s ease;
} }
...@@ -390,9 +392,12 @@ ...@@ -390,9 +392,12 @@
padding: 32px; padding: 32px;
box-shadow: 0 15px 35px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.1); box-shadow: 0 15px 35px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.1);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
transform: translateZ(0); /* Hardware accelerate backdrop-filter blending layer */
backface-visibility: hidden;
} }
.modal-header { .modal-header {
...@@ -416,7 +421,7 @@ ...@@ -416,7 +421,7 @@
cursor: pointer; cursor: pointer;
padding: 4px; padding: 4px;
border-radius: 50%; border-radius: 50%;
transition: all 0.2s; transition: background-color 0.2s ease, color 0.2s ease;
display: flex; display: flex;
} }
.close-btn:hover { .close-btn:hover {
...@@ -429,8 +434,8 @@ ...@@ -429,8 +434,8 @@
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
overflow-y: auto; overflow-y: auto;
padding-right: 8px; /* Space for scrollbar */ padding: 8px 12px 12px 8px; /* Buffer space for shadows and transforms */
padding-bottom: 8px; margin: -8px -12px -12px -8px; /* Compensate padding to maintain perfect visual alignment */
} }
.glassy-option { .glassy-option {
...@@ -442,12 +447,20 @@ ...@@ -442,12 +447,20 @@
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 16px; border-radius: 16px;
text-decoration: none; text-decoration: none;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); /* GPU Hardware Acceleration */
transform: translateY(0) translateZ(0);
will-change: transform, box-shadow, background-color, border-color;
backface-visibility: hidden;
/* Specific optimized transitions (avoiding 'transition: all') */
transition: transform 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94),
background-color 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease;
position: relative; position: relative;
} }
.glassy-option:hover { .glassy-option:hover {
transform: translateY(-4px); transform: translateY(-4px) translateZ(0);
background: var(--bg-hover); background: var(--bg-hover);
border-color: var(--accent-primary); border-color: var(--accent-primary);
box-shadow: 0 10px 20px rgba(0,0,0,0.1); box-shadow: 0 10px 20px rgba(0,0,0,0.1);
......
...@@ -151,8 +151,40 @@ ...@@ -151,8 +151,40 @@
<span class="detail-value">{{ selectedInterview().source || '—' }}</span> <span class="detail-value">{{ selectedInterview().source || '—' }}</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">Interviewer</span> <span class="detail-label">Interviewer(s)</span>
<span class="detail-value">{{ selectedInterview().interviewerId?.name }}</span> <span class="detail-value">
@if (selectedInterview().assignedInterviewers?.length) {
@for (i of selectedInterview().assignedInterviewers; track i._id; let last = $last) {
{{ i.name }}{{ !last ? ', ' : '' }}
}
} @else {
{{ selectedInterview().interviewerId?.name || '—' }}
}
</span>
</div>
<div class="detail-item">
<span class="detail-label">HR(s)</span>
<span class="detail-value">
@if (selectedInterview().assignedHRs?.length) {
@for (h of selectedInterview().assignedHRs; track h._id; let last = $last) {
{{ h.name }}{{ !last ? ', ' : '' }}
}
} @else {
}
</span>
</div>
<div class="detail-item">
<span class="detail-label">Project Manager(s)</span>
<span class="detail-value">
@if (selectedInterview().assignedPMs?.length) {
@for (p of selectedInterview().assignedPMs; track p._id; let last = $last) {
{{ p.name }}{{ !last ? ', ' : '' }}
}
} @else {
}
</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">Date of Interview</span> <span class="detail-label">Date of Interview</span>
......
...@@ -151,8 +151,40 @@ ...@@ -151,8 +151,40 @@
<span class="detail-value">{{ selectedInterview().source || '—' }}</span> <span class="detail-value">{{ selectedInterview().source || '—' }}</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">Interviewer</span> <span class="detail-label">Interviewer(s)</span>
<span class="detail-value">{{ selectedInterview().interviewerId?.name }}</span> <span class="detail-value">
@if (selectedInterview().assignedInterviewers?.length) {
@for (i of selectedInterview().assignedInterviewers; track i._id; let last = $last) {
{{ i.name }}{{ !last ? ', ' : '' }}
}
} @else {
{{ selectedInterview().interviewerId?.name || '—' }}
}
</span>
</div>
<div class="detail-item">
<span class="detail-label">HR(s)</span>
<span class="detail-value">
@if (selectedInterview().assignedHRs?.length) {
@for (h of selectedInterview().assignedHRs; track h._id; let last = $last) {
{{ h.name }}{{ !last ? ', ' : '' }}
}
} @else {
}
</span>
</div>
<div class="detail-item">
<span class="detail-label">Project Manager(s)</span>
<span class="detail-value">
@if (selectedInterview().assignedPMs?.length) {
@for (p of selectedInterview().assignedPMs; track p._id; let last = $last) {
{{ p.name }}{{ !last ? ', ' : '' }}
}
} @else {
}
</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">Date of Interview</span> <span class="detail-label">Date of Interview</span>
......
...@@ -256,8 +256,40 @@ ...@@ -256,8 +256,40 @@
<span class="detail-value">{{ selectedInterview().source || '—' }}</span> <span class="detail-value">{{ selectedInterview().source || '—' }}</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">Interviewer</span> <span class="detail-label">Interviewer(s)</span>
<span class="detail-value">{{ selectedInterview().interviewerId?.name }}</span> <span class="detail-value">
@if (selectedInterview().assignedInterviewers?.length) {
@for (i of selectedInterview().assignedInterviewers; track i._id; let last = $last) {
{{ i.name }}{{ !last ? ', ' : '' }}
}
} @else {
{{ selectedInterview().interviewerId?.name || '—' }}
}
</span>
</div>
<div class="detail-item">
<span class="detail-label">HR(s)</span>
<span class="detail-value">
@if (selectedInterview().assignedHRs?.length) {
@for (h of selectedInterview().assignedHRs; track h._id; let last = $last) {
{{ h.name }}{{ !last ? ', ' : '' }}
}
} @else {
}
</span>
</div>
<div class="detail-item">
<span class="detail-label">Project Manager(s)</span>
<span class="detail-value">
@if (selectedInterview().assignedPMs?.length) {
@for (p of selectedInterview().assignedPMs; track p._id; let last = $last) {
{{ p.name }}{{ !last ? ', ' : '' }}
}
} @else {
}
</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">Date of Interview</span> <span class="detail-label">Date of Interview</span>
......
.page-container { padding: 32px 40px; max-width: 1200px; } .page-container { padding: 32px 40px; max-width: 1200px; }
/* ─── Page Header ─────────────────────────────────────── */
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 28px; flex-wrap: wrap; gap: 16px; } .page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 28px; flex-wrap: wrap; gap: 16px; }
.page-header-left { display: flex; align-items: center; gap: 14px; }
.page-header h1 { font-size: 26px; font-weight: 700; color: var(--text-primary); margin: 0; } .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; } .page-subtitle { color: var(--text-muted); font-size: 14px; margin: 4px 0 0; }
.back-btn {
display: flex; align-items: center; justify-content: center;
width: 40px; height: 40px; border-radius: var(--radius-md);
border: 1px solid var(--border-color); background: var(--bg-card);
color: var(--text-secondary); cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
flex-shrink: 0;
}
.back-btn:hover { background: var(--bg-hover); border-color: var(--border-strong); color: var(--text-primary); }
.back-btn .material-symbols-rounded { font-size: 20px; }
/* ─── Loading / Empty ─────────────────────────────────── */
.loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px 0; gap: 16px; } .loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px 0; gap: 16px; }
.loading-center p { color: var(--text-muted); } .loading-center p { color: var(--text-muted); }
.empty-state { display: flex; flex-direction: column; align-items: center; padding: 80px 0; gap: 12px; text-align: center; color: var(--text-muted); }
.empty-state .material-symbols-rounded { font-size: 48px; opacity: 0.4; }
.empty-state h3 { color: var(--text-primary); margin: 0; font-size: 18px; }
.empty-state p { margin: 0; font-size: 14px; }
/* ─── Category Grid ───────────────────────────────────── */
.category-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.category-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px 24px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
cursor: pointer;
text-align: left;
font-family: inherit;
width: 100%;
/* GPU accelerated hover */
transform: translateY(0) translateZ(0);
will-change: transform, box-shadow, border-color;
transition: transform 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94),
box-shadow 0.2s ease,
border-color 0.2s ease;
}
.category-card:hover {
transform: translateY(-3px) translateZ(0);
box-shadow: var(--shadow-lg);
border-color: var(--border-strong);
}
.cat-icon-wrap {
width: 52px; height: 52px; border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center;
border: 1px solid;
flex-shrink: 0;
}
.cat-icon { font-size: 26px !important; }
.cat-body { flex: 1; min-width: 0; }
.cat-name {
font-size: 16px; font-weight: 700;
color: var(--text-primary); margin: 0 0 3px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cat-count { font-size: 13px; color: var(--text-muted); margin: 0 0 8px; }
.cat-diffs { display: flex; gap: 6px; flex-wrap: wrap; }
.diff-chip { font-size: 11px !important; padding: 2px 8px !important; }
.cat-arrow-wrap { display: flex; flex-direction: column; align-items: center; gap: 6px; flex-shrink: 0; }
.cat-arrow { font-size: 22px; color: var(--text-muted); transition: transform 0.2s ease, color 0.2s ease; }
.category-card:hover .cat-arrow { transform: translateX(3px); color: var(--accent-primary); }
.cat-attempts-badge {
font-size: 10px; font-weight: 600; color: var(--text-muted);
background: var(--bg-tertiary); border: 1px solid var(--border-color);
padding: 2px 7px; border-radius: var(--radius-full);
white-space: nowrap;
}
/* ─── Quiz Cards Grid ─────────────────────────────────── */
.quiz-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 20px; } .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 { 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-card-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
...@@ -12,84 +93,42 @@ ...@@ -12,84 +93,42 @@
.quiz-meta { display: flex; flex-wrap: wrap; gap: 16px; } .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 { 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); } .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); } .quiz-card-actions { display: flex; gap: 8px; margin-top: auto; padding-top: 8px; border-top: 1px solid var(--border-subtle); }
.btn-primary:hover { .btn-primary:hover { color: #fff; }
color: #fff; /* force text to stay white */ .btn-primary .material-symbols-rounded { color: inherit; }
} .btn-primary:hover .material-symbols-rounded { color: #fff; }
.btn-primary .material-symbols-rounded {
color: inherit;
}
.btn-primary:hover .material-symbols-rounded {
color: #fff;
}
.attempted-badge { .attempted-badge {
display: inline-flex; display: inline-flex; align-items: center; gap: 6px;
align-items: center; padding: 6px 14px; background: var(--bg-tertiary);
gap: 6px; border: 1px solid var(--border-color); border-radius: var(--radius-full);
padding: 6px 14px; font-size: 12px; font-weight: 600; color: var(--text-muted);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-full);
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
}
.attempted-badge .material-symbols-rounded {
font-size: 16px;
color: var(--text-muted);
}
@media (max-width: 768px) {
.page-container { padding: 20px 16px; }
.quiz-grid { grid-template-columns: 1fr; }
} }
.attempted-badge .material-symbols-rounded { font-size: 16px; color: var(--text-muted); }
/* Modal Overlay Styles */ /* ─── Edit Modal ──────────────────────────────────────── */
.modal-overlay { .modal-overlay {
position: fixed; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
top: 0; background: rgba(0,0,0,0.4); backdrop-filter: blur(4px);
left: 0; display: flex; align-items: center; justify-content: center;
width: 100vw; z-index: 1000; animation: fadeIn 0.2s ease-out;
height: 100vh;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease-out;
} }
.modal-container { .modal-container {
background: var(--bg-card); background: var(--bg-card); border-radius: var(--radius-lg);
border-radius: var(--radius-lg); padding: 32px; width: 100%; max-width: 600px;
padding: 32px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1), 0 10px 10px -5px rgba(0,0,0,0.04);
width: 100%;
max-width: 600px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
} }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.modal-header h2 { font-size: 20px; font-weight: 700; margin: 0; color: var(--text-primary); }
.modal-header { @keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
display: flex; @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.modal-header h2 {
font-size: 20px;
font-weight: 700;
margin: 0;
color: var(--text-primary);
}
@keyframes slideUp { /* ─── Responsive ──────────────────────────────────────── */
from { opacity: 0; transform: translateY(20px); } @media (max-width: 768px) {
to { opacity: 1; transform: translateY(0); } .page-container { padding: 20px 16px; }
.category-grid { grid-template-columns: 1fr; }
.quiz-grid { grid-template-columns: 1fr; }
} }
<div class="page-container animate-fade-in"> <div class="page-container animate-fade-in">
<!-- ─── Page Header ─────────────────────────────── -->
<div class="page-header"> <div class="page-header">
<div class="page-header-left">
@if (selectedCategory()) {
<button class="back-btn" (click)="clearCategory()">
<span class="material-symbols-rounded">arrow_back</span>
</button>
}
<div> <div>
<h1>Quizzes</h1> <h1>
<p class="page-subtitle">Manage all quizzes in the system</p> @if (selectedCategory()) {
{{ selectedCategory() }}
} @else {
Quizzes
}
</h1>
<p class="page-subtitle">
@if (selectedCategory()) {
{{ filteredQuizzes().length }} quiz{{ filteredQuizzes().length !== 1 ? 'zes' : '' }} in this category
} @else {
{{ categories().length }} categor{{ categories().length !== 1 ? 'ies' : 'y' }} · {{ quizzes().length }} total quizzes
}
</p>
</div>
</div> </div>
<a routerLink="/admin/create-quiz" class="btn btn-primary"> <a routerLink="/admin/create-quiz" class="btn btn-primary">
<span class="material-symbols-rounded">add</span> Create Quiz <span class="material-symbols-rounded">add</span> Create Quiz
...@@ -20,6 +41,7 @@ ...@@ -20,6 +41,7 @@
<div class="spinner spinner-lg"></div> <div class="spinner spinner-lg"></div>
<p>Loading quizzes...</p> <p>Loading quizzes...</p>
</div> </div>
} @else if (quizzes().length === 0) { } @else if (quizzes().length === 0) {
<div class="empty-state"> <div class="empty-state">
<span class="material-symbols-rounded">quiz</span> <span class="material-symbols-rounded">quiz</span>
...@@ -27,9 +49,38 @@ ...@@ -27,9 +49,38 @@
<p>Create your first quiz to get started.</p> <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> <a routerLink="/admin/create-quiz" class="btn btn-primary" style="margin-top: 16px;">Create Quiz</a>
</div> </div>
} @else if (!selectedCategory()) {
<!-- ─── Category Grid View ──────────────────────── -->
<div class="category-grid stagger-children">
@for (group of categoryGroups(); track group.name; let i = $index) {
<button class="category-card card card-hover" (click)="selectCategory(group.name)">
<div class="cat-icon-wrap" [style.background]="getCategoryColor(i) + '18'" [style.border-color]="getCategoryColor(i) + '30'">
<span class="material-symbols-rounded cat-icon" [style.color]="getCategoryColor(i)">{{ getCategoryIcon(group.name) }}</span>
</div>
<div class="cat-body">
<h3 class="cat-name">{{ group.name }}</h3>
<p class="cat-count">{{ group.count }} quiz{{ group.count !== 1 ? 'zes' : '' }}</p>
<div class="cat-diffs">
@for (diff of group.difficulties; track diff) {
<span class="diff-chip badge" [ngClass]="getDifficultyClass(diff)">{{ getDifficultyLabel(diff) }}</span>
}
</div>
</div>
<div class="cat-arrow-wrap">
<span class="material-symbols-rounded cat-arrow">chevron_right</span>
@if (group.totalAttempts > 0) {
<span class="cat-attempts-badge">{{ group.totalAttempts }} attempt{{ group.totalAttempts !== 1 ? 's' : '' }}</span>
}
</div>
</button>
}
</div>
} @else { } @else {
<!-- ─── Quiz Drill-down View ────────────────────── -->
<div class="quiz-grid stagger-children"> <div class="quiz-grid stagger-children">
@for (quiz of quizzes(); track quiz._id) { @for (quiz of filteredQuizzes(); track quiz._id) {
<div class="card card-hover quiz-card"> <div class="card card-hover quiz-card">
<div class="quiz-card-header"> <div class="quiz-card-header">
<h3 class="quiz-title">{{ quiz.title }}</h3> <h3 class="quiz-title">{{ quiz.title }}</h3>
...@@ -75,7 +126,7 @@ ...@@ -75,7 +126,7 @@
} }
</div> </div>
<!-- Edit Quiz Modal --> <!-- ─── Edit Quiz Modal ───────────────────────────── -->
@if (showEditPopup()) { @if (showEditPopup()) {
<div class="modal-overlay" (click)="closeEditPopup()"> <div class="modal-overlay" (click)="closeEditPopup()">
<div class="modal-container" (click)="$event.stopPropagation()"> <div class="modal-container" (click)="$event.stopPropagation()">
......
import { Component, OnInit, signal } from '@angular/core'; import { Component, OnInit, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { RouterLink, Router } from '@angular/router'; import { RouterLink, Router } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
...@@ -16,6 +16,9 @@ export class AdminQuizzesComponent implements OnInit { ...@@ -16,6 +16,9 @@ export class AdminQuizzesComponent implements OnInit {
loading = signal(true); loading = signal(true);
error = signal(''); error = signal('');
/** Currently expanded category — null = showing category groups */
selectedCategory = signal<string | null>(null);
showEditPopup = signal(false); showEditPopup = signal(false);
editQuizForm = { editQuizForm = {
_id: '', _id: '',
...@@ -40,12 +43,35 @@ export class AdminQuizzesComponent implements OnInit { ...@@ -40,12 +43,35 @@ export class AdminQuizzesComponent implements OnInit {
}); });
} }
deleteQuiz(quizId: string): void { /** All unique categories sorted alphabetically */
if (!confirm('Are you sure you want to delete this quiz?')) return; categories = computed<string[]>(() => {
this.quizService.deleteQuiz(quizId).subscribe({ const cats = new Set(this.quizzes().map((q: any) => q.category || 'Uncategorized'));
next: () => this.loadQuizzes(), return Array.from(cats).sort((a, b) => a.localeCompare(b));
error: (err) => this.error.set(err.error?.message || 'Cannot delete quiz') });
/** Category summary cards: name + count + difficulties present */
categoryGroups = computed(() => {
return this.categories().map(cat => {
const quizzesInCat = this.quizzes().filter((q: any) => (q.category || 'Uncategorized') === cat);
const diffs = [...new Set(quizzesInCat.map((q: any) => (q.difficulty || 'General').toLowerCase()))];
const totalAttempts = quizzesInCat.reduce((sum: number, q: any) => sum + (q.attemptCount || 0), 0);
return { name: cat, count: quizzesInCat.length, difficulties: diffs, totalAttempts };
}); });
});
/** Quizzes filtered by currently selected category */
filteredQuizzes = computed<any[]>(() => {
const cat = this.selectedCategory();
if (!cat) return [];
return this.quizzes().filter((q: any) => (q.category || 'Uncategorized') === cat);
});
selectCategory(cat: string): void {
this.selectedCategory.set(cat);
}
clearCategory(): void {
this.selectedCategory.set(null);
} }
getDifficultyClass(d: string): string { getDifficultyClass(d: string): string {
...@@ -57,6 +83,37 @@ export class AdminQuizzesComponent implements OnInit { ...@@ -57,6 +83,37 @@ export class AdminQuizzesComponent implements OnInit {
} }
} }
getDifficultyLabel(d: string): string {
const map: Record<string, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' };
return map[d?.toLowerCase()] || d || 'General';
}
getCategoryIcon(cat: string): string {
const lower = cat.toLowerCase();
if (lower.includes('java') && !lower.includes('javascript')) return 'coffee';
if (lower.includes('javascript') || lower.includes('js')) return 'javascript';
if (lower.includes('python')) return 'terminal';
if (lower.includes('web') || lower.includes('html') || lower.includes('css')) return 'web';
if (lower.includes('mern') || lower.includes('mean') || lower.includes('stack')) return 'layers';
if (lower.includes('angular') || lower.includes('react') || lower.includes('vue')) return 'dashboard';
if (lower.includes('aptitude') || lower.includes('general')) return 'psychology';
if (lower.includes('data') || lower.includes('sql') || lower.includes('database')) return 'database';
if (lower.includes('ai') || lower.includes('ml') || lower.includes('artificial')) return 'smart_toy';
if (lower.includes('operating') || lower.includes('os')) return 'memory';
if (lower.includes('network')) return 'lan';
if (lower.includes('cloud')) return 'cloud';
return 'quiz';
}
getCategoryColor(index: number): string {
const colors = [
'#4f6ef7', '#7c5cfc', '#06b6d4', '#22c55e',
'#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6',
'#f97316', '#ec4899', '#64748b', '#0ea5e9'
];
return colors[index % colors.length];
}
openEditPopup(quiz: any): void { openEditPopup(quiz: any): void {
this.editQuizForm = { this.editQuizForm = {
_id: quiz._id, _id: quiz._id,
...@@ -74,7 +131,6 @@ export class AdminQuizzesComponent implements OnInit { ...@@ -74,7 +131,6 @@ export class AdminQuizzesComponent implements OnInit {
saveBasicChanges(): void { saveBasicChanges(): void {
this.savingPopup.set(true); this.savingPopup.set(true);
// Don't send questions, only update basic details
this.quizService.updateQuiz(this.editQuizForm._id, this.editQuizForm).subscribe({ this.quizService.updateQuiz(this.editQuizForm._id, this.editQuizForm).subscribe({
next: () => { next: () => {
this.savingPopup.set(false); this.savingPopup.set(false);
...@@ -89,12 +145,17 @@ export class AdminQuizzesComponent implements OnInit { ...@@ -89,12 +145,17 @@ export class AdminQuizzesComponent implements OnInit {
}); });
} }
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')
});
}
editQuestions(): void { editQuestions(): void {
// Navigate to edit-quiz page with the pending changes in history.state
this.router.navigate(['/admin/quiz', this.editQuizForm._id, 'edit'], { this.router.navigate(['/admin/quiz', this.editQuizForm._id, 'edit'], {
state: { state: { quizOverrides: { ...this.editQuizForm } }
quizOverrides: { ...this.editQuizForm }
}
}); });
this.closeEditPopup(); this.closeEditPopup();
} }
......
...@@ -9,6 +9,35 @@ ...@@ -9,6 +9,35 @@
.quiz-title-area h1 { color: var(--text-primary); font-size: 20px; font-weight: 700; margin: 0 0 4px; } .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; } .q-counter { color: var(--text-muted); font-size: 13px; }
/* Right cluster groups violation badge + fullscreen indicator + timer */
.header-right-cluster { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
/* Violation Badge */
.violation-badge {
display: flex; align-items: center; gap: 6px;
background: var(--warning-light); border: 1px solid var(--warning-border);
color: var(--warning); padding: 7px 14px; border-radius: var(--radius-md);
font-size: 13px; font-weight: 700; transition: all 0.3s;
}
.violation-badge .material-symbols-rounded { font-size: 18px; }
.violation-badge.critical {
background: var(--danger-light); border-color: var(--danger-border);
color: var(--danger); animation: pulse 1s infinite;
}
/* Fullscreen indicator */
.fullscreen-indicator {
display: flex; align-items: center; justify-content: center;
width: 38px; height: 38px; border-radius: var(--radius-md);
border: 1px solid var(--border-color); background: var(--bg-card);
color: var(--text-muted); transition: all 0.3s; cursor: default;
}
.fullscreen-indicator.active {
border-color: var(--success-border); background: var(--success-light);
color: var(--success);
}
.fullscreen-indicator .material-symbols-rounded { font-size: 20px; }
.timer { .timer {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
background: var(--bg-card); border: 1px solid var(--border-color); background: var(--bg-card); border: 1px solid var(--border-color);
...@@ -59,7 +88,7 @@ ...@@ -59,7 +88,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
flex: 0 1 350px; /* max width for the dots wrapper */ flex: 0 1 350px;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
} }
...@@ -79,11 +108,11 @@ ...@@ -79,11 +108,11 @@
display: flex; gap: 6px; display: flex; gap: 6px;
overflow-x: auto; flex: 1; overflow-x: auto; flex: 1;
scroll-behavior: smooth; scroll-behavior: smooth;
scrollbar-width: none; /* Firefox */ scrollbar-width: none;
-ms-overflow-style: none; /* IE */ -ms-overflow-style: none;
padding: 4px 2px; padding: 4px 2px;
} }
.question-dots::-webkit-scrollbar { display: none; /* Chrome/Safari */ } .question-dots::-webkit-scrollbar { display: none; }
.dot { .dot {
width: 32px; height: 32px; border-radius: var(--radius-sm); border: 1px solid var(--border-color); 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; background: transparent; color: var(--text-muted); font-size: 12px; font-weight: 600;
...@@ -94,10 +123,100 @@ ...@@ -94,10 +123,100 @@
.dot.answered { background: var(--success-light); border-color: var(--success-border); color: var(--success); } .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); } .dot.active.answered { background: var(--accent-primary-light); border-color: var(--accent-primary); color: var(--accent-primary); }
/* ════════════════════════════════════════════════════
Anti-Cheat Warning Modal
════════════════════════════════════════════════════ */
.ac-overlay {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
display: flex; align-items: center; justify-content: center;
z-index: 9999; padding: 24px;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.ac-modal {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
padding: 40px 36px;
max-width: 460px; width: 100%;
text-align: center;
display: flex; flex-direction: column; align-items: center; gap: 16px;
}
.ac-icon-wrap {
width: 72px; height: 72px; border-radius: 50%;
background: var(--warning-light); border: 2px solid var(--warning-border);
display: flex; align-items: center; justify-content: center;
transition: all 0.3s;
}
.ac-icon-wrap.danger {
background: var(--danger-light); border-color: var(--danger-border);
}
.ac-icon {
font-size: 36px !important;
color: var(--warning);
}
.ac-icon-wrap.danger .ac-icon { color: var(--danger); }
.ac-title {
color: var(--text-primary);
font-size: 20px; font-weight: 700;
margin: 0; line-height: 1.3;
}
.ac-message {
color: var(--text-secondary);
font-size: 14px; line-height: 1.7;
margin: 0;
}
/* Violation progress dots */
.ac-violation-dots {
display: flex; gap: 10px; align-items: center; justify-content: center;
}
.ac-dot {
width: 14px; height: 14px; border-radius: 50%;
background: var(--bg-tertiary); border: 2px solid var(--border-color);
transition: all 0.3s;
}
.ac-dot.filled {
background: var(--danger); border-color: var(--danger);
box-shadow: 0 0 8px rgba(239, 68, 68, 0.4);
}
.ac-dot-label {
color: var(--text-muted); font-size: 12px; margin: -4px 0 0;
}
.ac-dismiss-btn {
width: 100%; justify-content: center; gap: 8px; font-size: 14px;
padding: 12px 24px; border-radius: var(--radius-md);
}
.ac-note {
color: var(--text-muted); font-size: 12px; margin: 0; line-height: 1.5;
}
.ac-submitting {
display: flex; flex-direction: column; align-items: center; gap: 12px;
color: var(--text-muted); font-size: 14px;
}
/* ════════════════════════════════════════════════════
Responsive
════════════════════════════════════════════════════ */
@media (max-width: 640px) { @media (max-width: 640px) {
.quiz-page { padding: 16px; } .quiz-page { padding: 16px; }
.question-card { padding: 20px; } .question-card { padding: 20px; }
.quiz-nav { flex-direction: column; position: static; } .quiz-nav { flex-direction: column; position: static; }
.quiz-nav .btn { position: static; width: 100%; } .quiz-nav .btn { position: static; width: 100%; }
.dots-wrapper { order: -1; width: 100%; flex: 1; max-width: none; } .dots-wrapper { order: -1; width: 100%; flex: 1; max-width: none; }
.header-right-cluster { width: 100%; justify-content: flex-end; }
.ac-modal { padding: 28px 20px; }
} }
<!-- Take Quiz --> <!-- Take Quiz -->
<div class="quiz-page"> <div class="quiz-page" #quizContainer>
<!-- ── Loading ─────────────────────────────── -->
@if (loading()) { @if (loading()) {
<div class="loading-center"><div class="spinner spinner-lg"></div><p class="loading-text">Loading quiz...</p></div> <div class="loading-center">
<div class="spinner spinner-lg"></div>
<p class="loading-text">Loading quiz...</p>
</div>
<!-- ── Error (pre-load) ─────────────────────── -->
} @else if (error() && !quiz()) { } @else if (error() && !quiz()) {
<div class="error-state"> <div class="error-state">
<span class="material-symbols-rounded" style="font-size:48px;color:var(--danger)">error</span> <span class="material-symbols-rounded" style="font-size:48px;color:var(--danger)">error</span>
<h2>{{ error() }}</h2> <h2>{{ error() }}</h2>
<a routerLink="/candidate/dashboard" class="btn btn-outline">Back to Dashboard</a> <a routerLink="/candidate/dashboard" class="btn btn-outline">Back to Dashboard</a>
</div> </div>
<!-- ── Success (submitted) ─────────────────── -->
} @else if (submitted()) { } @else if (submitted()) {
<div class="success-state animate-scale-in"> <div class="success-state animate-scale-in">
<span class="material-symbols-rounded" style="font-size:64px;color:var(--success)">check_circle</span> <span class="material-symbols-rounded" style="font-size:64px;color:var(--success)">check_circle</span>
<h2>Quiz Submitted!</h2> <h2>Quiz Submitted!</h2>
<p>Redirecting to dashboard...</p> <p>Redirecting to dashboard...</p>
</div> </div>
<!-- ── Active Quiz ────────────────────────── -->
} @else if (quiz() && currentQuestion()) { } @else if (quiz() && currentQuestion()) {
<!-- Quiz Header --> <!-- Quiz Header -->
<div class="quiz-header"> <div class="quiz-header">
<div class="quiz-title-area"> <div class="quiz-title-area">
<h1>{{ quiz().title }}</h1> <h1>{{ quiz().title }}</h1>
<span class="q-counter">Question {{ currentIndex() + 1 }} of {{ questions().length }}</span> <span class="q-counter">Question {{ currentIndex() + 1 }} of {{ questions().length }}</span>
</div> </div>
<div class="header-right-cluster">
<!-- Violation badge (shows only when there's at least 1 violation) -->
@if (violationCount() > 0) {
<div class="violation-badge" [class.critical]="violationCount() >= getMaxViolations() - 1"
title="Violations detected. {{ getMaxViolations() - violationCount() }} warning(s) remaining.">
<span class="material-symbols-rounded">warning</span>
<span>{{ violationCount() }}/{{ getMaxViolations() }}</span>
</div>
}
<!-- Fullscreen indicator -->
<div class="fullscreen-indicator" [class.active]="isFullscreen()" title="{{ isFullscreen() ? 'Fullscreen active' : 'Not in fullscreen' }}">
<span class="material-symbols-rounded">{{ isFullscreen() ? 'fullscreen' : 'fullscreen_exit' }}</span>
</div>
<!-- Timer -->
<div class="timer" [class.warning]="timeLeft() < 300 && timeLeft() >= 60" [class.danger]="timeLeft() < 60"> <div class="timer" [class.warning]="timeLeft() < 300 && timeLeft() >= 60" [class.danger]="timeLeft() < 60">
<span class="material-symbols-rounded">timer</span> <span class="material-symbols-rounded">timer</span>
<span class="timer-value">{{ formattedTime() }}</span> <span class="timer-value">{{ formattedTime() }}</span>
</div> </div>
</div> </div>
</div>
<!-- Progress --> <!-- Progress -->
<div class="progress-track"><div class="progress-fill" [style.width.%]="progress()"></div></div> <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> } @if (error()) {
<div class="alert alert-error" style="margin-bottom:16px">
<span class="material-symbols-rounded">error</span>{{ error() }}
</div>
}
<!-- Question Card --> <!-- Question Card -->
<div class="question-card card card-padding animate-fade-in"> <div class="question-card card card-padding animate-fade-in">
...@@ -84,3 +118,48 @@ ...@@ -84,3 +118,48 @@
</div> </div>
} }
</div> </div>
<!-- ══════════════════════════════════════════════════════════
Anti-Cheat Warning Modal (rendered at page root level)
══════════════════════════════════════════════════════════ -->
@if (showWarningModal() && currentWarning()) {
<div class="ac-overlay" (click)="!currentWarning()!.isAutoSubmit && dismissWarning()">
<div class="ac-modal animate-scale-in" (click)="$event.stopPropagation()">
<!-- Icon -->
<div class="ac-icon-wrap" [class.danger]="currentWarning()!.isAutoSubmit">
<span class="material-symbols-rounded filled ac-icon">
{{ currentWarning()!.isAutoSubmit ? 'block' : 'warning' }}
</span>
</div>
<!-- Content -->
<h2 class="ac-title">{{ currentWarning()!.title }}</h2>
<p class="ac-message">{{ currentWarning()!.message }}</p>
<!-- Violation progress dots -->
@if (!currentWarning()!.isAutoSubmit) {
<div class="ac-violation-dots">
@for (i of [1,2,3]; track i) {
<div class="ac-dot" [class.filled]="i <= currentWarning()!.violationCount"></div>
}
</div>
<p class="ac-dot-label">{{ currentWarning()!.violationCount }}/{{ getMaxViolations() }} violations</p>
}
<!-- Actions -->
@if (!currentWarning()!.isAutoSubmit) {
<button class="btn btn-primary ac-dismiss-btn" (click)="dismissWarning()">
<span class="material-symbols-rounded">fullscreen</span>
Return to Fullscreen
</button>
<p class="ac-note">⚠️ Exiting fullscreen or switching tabs again will count as another violation.</p>
} @else {
<div class="ac-submitting">
<div class="spinner spinner-lg"></div>
<p>Submitting your quiz...</p>
</div>
}
</div>
</div>
}
import { Component, OnInit, OnDestroy, signal, computed, ViewChild, ElementRef } from '@angular/core'; import {
Component, OnInit, OnDestroy, signal, computed,
ViewChild, ElementRef, HostListener
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Subscription } from 'rxjs';
import { QuizService } from '../../../services/quiz.service'; import { QuizService } from '../../../services/quiz.service';
import { AntiCheatService, AntiCheatWarning } from '../../../services/anti-cheat.service';
@Component({ @Component({
selector: 'app-take-quiz', selector: 'app-take-quiz',
...@@ -17,6 +22,7 @@ export class TakeQuizComponent implements OnInit, OnDestroy { ...@@ -17,6 +22,7 @@ export class TakeQuizComponent implements OnInit, OnDestroy {
answers = signal<Map<string, string[]>>(new Map()); answers = signal<Map<string, string[]>>(new Map());
@ViewChild('dotsContainer') dotsContainer!: ElementRef<HTMLDivElement>; @ViewChild('dotsContainer') dotsContainer!: ElementRef<HTMLDivElement>;
@ViewChild('quizContainer') quizContainer!: ElementRef<HTMLDivElement>;
timeLeft = signal<number>(0); timeLeft = signal<number>(0);
timerInterval: any; timerInterval: any;
...@@ -27,6 +33,16 @@ export class TakeQuizComponent implements OnInit, OnDestroy { ...@@ -27,6 +33,16 @@ export class TakeQuizComponent implements OnInit, OnDestroy {
error = signal(''); error = signal('');
submitted = signal(false); submitted = signal(false);
// ── Anti-cheat state ─────────────────────────────────
showWarningModal = signal(false);
currentWarning = signal<AntiCheatWarning | null>(null);
violationCount = signal(0);
isFullscreen = signal(false);
private antiCheatSubs: Subscription[] = [];
// ─────────────────────────────────────────────────────
currentQuestion = computed(() => this.questions()[this.currentIndex()]); currentQuestion = computed(() => this.questions()[this.currentIndex()]);
progress = computed(() => { progress = computed(() => {
const total = this.questions().length; const total = this.questions().length;
...@@ -43,17 +59,79 @@ export class TakeQuizComponent implements OnInit, OnDestroy { ...@@ -43,17 +59,79 @@ export class TakeQuizComponent implements OnInit, OnDestroy {
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private quizService: QuizService private quizService: QuizService,
public antiCheat: AntiCheatService
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
const quizId = this.route.snapshot.params['quizId']; const quizId = this.route.snapshot.params['quizId'];
this.loadQuiz(quizId); this.loadQuiz(quizId);
this.setupAntiCheat();
} }
ngOnDestroy(): void { ngOnDestroy(): void {
if (this.timerInterval) clearInterval(this.timerInterval); if (this.timerInterval) clearInterval(this.timerInterval);
this.antiCheatSubs.forEach(s => s.unsubscribe());
this.antiCheat.stop();
}
// ─────────────────────────────────────────────────────
// Anti-cheat setup
// ─────────────────────────────────────────────────────
private setupAntiCheat(): void {
// Subscribe to warnings
const warnSub = this.antiCheat.warning$.subscribe((warning: AntiCheatWarning) => {
this.currentWarning.set(warning);
this.showWarningModal.set(true);
});
// Subscribe to auto-submit trigger
const submitSub = this.antiCheat.autoSubmit$.subscribe(() => {
this.submitQuiz(true);
});
// Keep violation count in sync
const countSub = this.antiCheat.violationCount$.subscribe(count => {
this.violationCount.set(count);
});
this.antiCheatSubs.push(warnSub, submitSub, countSub);
}
private startAntiCheat(): void {
this.antiCheat.reset();
this.antiCheat.start();
// Enter fullscreen, track state
this.antiCheat.requestFullscreen().then(() => {
this.isFullscreen.set(true);
}).catch(() => {
// Silently fail if browser denies fullscreen (e.g. Safari without user gesture)
this.isFullscreen.set(false);
});
}
/** Dismiss warning, re-enter fullscreen if it was a fullscreen exit */
dismissWarning(): void {
const warning = this.currentWarning();
this.showWarningModal.set(false);
if (warning?.isAutoSubmit) return; // Already submitting
// Re-request fullscreen after dismissal
if (!this.antiCheat.isCurrentlyFullscreen) {
this.antiCheat.requestFullscreen().then(() => {
this.isFullscreen.set(true);
}).catch(() => {
this.isFullscreen.set(false);
});
} }
}
// ─────────────────────────────────────────────────────
// Quiz lifecycle
// ─────────────────────────────────────────────────────
loadQuiz(quizId: string): void { loadQuiz(quizId: string): void {
this.quizService.getQuizForTaking(quizId).subscribe({ this.quizService.getQuizForTaking(quizId).subscribe({
...@@ -64,6 +142,8 @@ export class TakeQuizComponent implements OnInit, OnDestroy { ...@@ -64,6 +142,8 @@ export class TakeQuizComponent implements OnInit, OnDestroy {
this.startTime = Date.now(); this.startTime = Date.now();
this.loading.set(false); this.loading.set(false);
this.startTimer(); this.startTimer();
// Start anti-cheat after quiz loads
this.startAntiCheat();
}, },
error: (err) => { error: (err) => {
this.error.set(err.error?.message || 'Failed to load quiz'); this.error.set(err.error?.message || 'Failed to load quiz');
...@@ -85,6 +165,10 @@ export class TakeQuizComponent implements OnInit, OnDestroy { ...@@ -85,6 +165,10 @@ export class TakeQuizComponent implements OnInit, OnDestroy {
}, 1000); }, 1000);
} }
// ─────────────────────────────────────────────────────
// Answer management
// ─────────────────────────────────────────────────────
selectOption(questionId: string, option: string, type: string): void { selectOption(questionId: string, option: string, type: string): void {
const currentAnswers = new Map(this.answers()); const currentAnswers = new Map(this.answers());
if (type === 'mcq') { if (type === 'mcq') {
...@@ -112,6 +196,10 @@ export class TakeQuizComponent implements OnInit, OnDestroy { ...@@ -112,6 +196,10 @@ export class TakeQuizComponent implements OnInit, OnDestroy {
return !!ans && ans.length > 0; return !!ans && ans.length > 0;
} }
// ─────────────────────────────────────────────────────
// Navigation
// ─────────────────────────────────────────────────────
goTo(index: number): void { goTo(index: number): void {
if (index >= 0 && index < this.questions().length) this.currentIndex.set(index); if (index >= 0 && index < this.questions().length) this.currentIndex.set(index);
} }
...@@ -124,11 +212,17 @@ export class TakeQuizComponent implements OnInit, OnDestroy { ...@@ -124,11 +212,17 @@ export class TakeQuizComponent implements OnInit, OnDestroy {
if (el) el.scrollBy({ left: direction * 120, behavior: 'smooth' }); if (el) el.scrollBy({ left: direction * 120, behavior: 'smooth' });
} }
submitQuiz(): void { // ─────────────────────────────────────────────────────
// Submission
// ─────────────────────────────────────────────────────
submitQuiz(isAutoSubmit = false): void {
if (this.submitting() || this.submitted()) return; if (this.submitting() || this.submitted()) return;
this.submitting.set(true); this.submitting.set(true);
this.error.set(''); this.error.set('');
clearInterval(this.timerInterval); clearInterval(this.timerInterval);
this.antiCheat.markSubmitted();
const timeTaken = Math.round((Date.now() - this.startTime) / 1000); const timeTaken = Math.round((Date.now() - this.startTime) / 1000);
const answersArray = Array.from(this.answers().entries()).map(([questionId, selectedAnswers]) => ({ const answersArray = Array.from(this.answers().entries()).map(([questionId, selectedAnswers]) => ({
...@@ -139,6 +233,10 @@ export class TakeQuizComponent implements OnInit, OnDestroy { ...@@ -139,6 +233,10 @@ export class TakeQuizComponent implements OnInit, OnDestroy {
next: () => { next: () => {
this.submitted.set(true); this.submitted.set(true);
this.submitting.set(false); this.submitting.set(false);
// Exit fullscreen cleanly
if (this.antiCheat.isCurrentlyFullscreen) {
document.exitFullscreen?.().catch(() => {});
}
setTimeout(() => this.router.navigate(['/candidate/dashboard']), 2000); setTimeout(() => this.router.navigate(['/candidate/dashboard']), 2000);
}, },
error: (err) => { error: (err) => {
...@@ -147,4 +245,8 @@ export class TakeQuizComponent implements OnInit, OnDestroy { ...@@ -147,4 +245,8 @@ export class TakeQuizComponent implements OnInit, OnDestroy {
} }
}); });
} }
getMaxViolations(): number {
return this.antiCheat.getMaxViolations();
}
} }
...@@ -256,8 +256,40 @@ ...@@ -256,8 +256,40 @@
<span class="detail-value">{{ selectedInterview().source || '—' }}</span> <span class="detail-value">{{ selectedInterview().source || '—' }}</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">Interviewer</span> <span class="detail-label">Interviewer(s)</span>
<span class="detail-value">{{ selectedInterview().interviewerId?.name }}</span> <span class="detail-value">
@if (selectedInterview().assignedInterviewers?.length) {
@for (i of selectedInterview().assignedInterviewers; track i._id; let last = $last) {
{{ i.name }}{{ !last ? ', ' : '' }}
}
} @else {
{{ selectedInterview().interviewerId?.name || '—' }}
}
</span>
</div>
<div class="detail-item">
<span class="detail-label">HR(s)</span>
<span class="detail-value">
@if (selectedInterview().assignedHRs?.length) {
@for (h of selectedInterview().assignedHRs; track h._id; let last = $last) {
{{ h.name }}{{ !last ? ', ' : '' }}
}
} @else {
}
</span>
</div>
<div class="detail-item">
<span class="detail-label">Project Manager(s)</span>
<span class="detail-value">
@if (selectedInterview().assignedPMs?.length) {
@for (p of selectedInterview().assignedPMs; track p._id; let last = $last) {
{{ p.name }}{{ !last ? ', ' : '' }}
}
} @else {
}
</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">Date of Interview</span> <span class="detail-label">Date of Interview</span>
......
.page-container { padding: 32px 40px; max-width: 1200px; } .page-container { padding: 32px 40px; max-width: 1200px; }
/* ─── Page Header ─────────────────────────────────────── */
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 28px; flex-wrap: wrap; gap: 16px; } .page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 28px; flex-wrap: wrap; gap: 16px; }
.page-header-left { display: flex; align-items: center; gap: 14px; }
.page-header h1 { font-size: 26px; font-weight: 700; color: var(--text-primary); margin: 0; } .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; } .page-subtitle { color: var(--text-muted); font-size: 14px; margin: 4px 0 0; }
.back-btn {
display: flex; align-items: center; justify-content: center;
width: 40px; height: 40px; border-radius: var(--radius-md);
border: 1px solid var(--border-color); background: var(--bg-card);
color: var(--text-secondary); cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
flex-shrink: 0;
}
.back-btn:hover { background: var(--bg-hover); border-color: var(--border-strong); color: var(--text-primary); }
.back-btn .material-symbols-rounded { font-size: 20px; }
/* ─── Loading / Empty ─────────────────────────────────── */
.loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px 0; gap: 16px; } .loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px 0; gap: 16px; }
.loading-center p { color: var(--text-muted); } .loading-center p { color: var(--text-muted); }
.empty-state { display: flex; flex-direction: column; align-items: center; padding: 80px 0; gap: 12px; text-align: center; color: var(--text-muted); }
.empty-state .material-symbols-rounded { font-size: 48px; opacity: 0.4; }
.empty-state h3 { color: var(--text-primary); margin: 0; font-size: 18px; }
.empty-state p { margin: 0; font-size: 14px; }
/* ─── Category Grid ───────────────────────────────────── */
.category-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.category-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px 24px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
cursor: pointer;
text-align: left;
font-family: inherit;
width: 100%;
/* GPU accelerated hover */
transform: translateY(0) translateZ(0);
will-change: transform, box-shadow, border-color;
transition: transform 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94),
box-shadow 0.2s ease,
border-color 0.2s ease;
}
.category-card:hover {
transform: translateY(-3px) translateZ(0);
box-shadow: var(--shadow-lg);
border-color: var(--border-strong);
}
.cat-icon-wrap {
width: 52px; height: 52px; border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center;
border: 1px solid;
flex-shrink: 0;
}
.cat-icon { font-size: 26px !important; }
.cat-body { flex: 1; min-width: 0; }
.cat-name {
font-size: 16px; font-weight: 700;
color: var(--text-primary); margin: 0 0 3px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cat-count { font-size: 13px; color: var(--text-muted); margin: 0 0 8px; }
.cat-diffs { display: flex; gap: 6px; flex-wrap: wrap; }
.diff-chip { font-size: 11px !important; padding: 2px 8px !important; }
.cat-arrow-wrap { display: flex; flex-direction: column; align-items: center; gap: 6px; flex-shrink: 0; }
.cat-arrow { font-size: 22px; color: var(--text-muted); transition: transform 0.2s ease, color 0.2s ease; }
.category-card:hover .cat-arrow { transform: translateX(3px); color: var(--accent-primary); }
.cat-attempts-badge {
font-size: 10px; font-weight: 600; color: var(--text-muted);
background: var(--bg-tertiary); border: 1px solid var(--border-color);
padding: 2px 7px; border-radius: var(--radius-full);
white-space: nowrap;
}
/* ─── Quiz Cards Grid ─────────────────────────────────── */
.quiz-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 20px; } .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 { 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-card-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
...@@ -12,84 +93,42 @@ ...@@ -12,84 +93,42 @@
.quiz-meta { display: flex; flex-wrap: wrap; gap: 16px; } .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 { 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); } .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); } .quiz-card-actions { display: flex; gap: 8px; margin-top: auto; padding-top: 8px; border-top: 1px solid var(--border-subtle); }
.btn-primary:hover { .btn-primary:hover { color: #fff; }
color: #fff; /* force text to stay white */ .btn-primary .material-symbols-rounded { color: inherit; }
} .btn-primary:hover .material-symbols-rounded { color: #fff; }
.btn-primary .material-symbols-rounded {
color: inherit;
}
.btn-primary:hover .material-symbols-rounded {
color: #fff;
}
.attempted-badge { .attempted-badge {
display: inline-flex; display: inline-flex; align-items: center; gap: 6px;
align-items: center; padding: 6px 14px; background: var(--bg-tertiary);
gap: 6px; border: 1px solid var(--border-color); border-radius: var(--radius-full);
padding: 6px 14px; font-size: 12px; font-weight: 600; color: var(--text-muted);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-full);
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
}
.attempted-badge .material-symbols-rounded {
font-size: 16px;
color: var(--text-muted);
}
@media (max-width: 768px) {
.page-container { padding: 20px 16px; }
.quiz-grid { grid-template-columns: 1fr; }
} }
.attempted-badge .material-symbols-rounded { font-size: 16px; color: var(--text-muted); }
/* Modal Overlay Styles */ /* ─── Edit Modal ──────────────────────────────────────── */
.modal-overlay { .modal-overlay {
position: fixed; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
top: 0; background: rgba(0,0,0,0.4); backdrop-filter: blur(4px);
left: 0; display: flex; align-items: center; justify-content: center;
width: 100vw; z-index: 1000; animation: fadeIn 0.2s ease-out;
height: 100vh;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease-out;
} }
.modal-container { .modal-container {
background: var(--bg-card); background: var(--bg-card); border-radius: var(--radius-lg);
border-radius: var(--radius-lg); padding: 32px; width: 100%; max-width: 600px;
padding: 32px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1), 0 10px 10px -5px rgba(0,0,0,0.04);
width: 100%;
max-width: 600px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
} }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.modal-header h2 { font-size: 20px; font-weight: 700; margin: 0; color: var(--text-primary); }
.modal-header { @keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
display: flex; @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.modal-header h2 {
font-size: 20px;
font-weight: 700;
margin: 0;
color: var(--text-primary);
}
@keyframes slideUp { /* ─── Responsive ──────────────────────────────────────── */
from { opacity: 0; transform: translateY(20px); } @media (max-width: 768px) {
to { opacity: 1; transform: translateY(0); } .page-container { padding: 20px 16px; }
.category-grid { grid-template-columns: 1fr; }
.quiz-grid { grid-template-columns: 1fr; }
} }
<div class="page-container animate-fade-in"> <div class="page-container animate-fade-in">
<!-- ─── Page Header ─────────────────────────────── -->
<div class="page-header"> <div class="page-header">
<div class="page-header-left">
@if (selectedCategory()) {
<button class="back-btn" (click)="clearCategory()">
<span class="material-symbols-rounded">arrow_back</span>
</button>
}
<div> <div>
<h1>Quizzes</h1> <h1>
<p class="page-subtitle">Manage all quizzes in the system</p> @if (selectedCategory()) {
{{ selectedCategory() }}
} @else {
Quizzes
}
</h1>
<p class="page-subtitle">
@if (selectedCategory()) {
{{ filteredQuizzes().length }} quiz{{ filteredQuizzes().length !== 1 ? 'zes' : '' }} in this category
} @else {
{{ categories().length }} categor{{ categories().length !== 1 ? 'ies' : 'y' }} · {{ quizzes().length }} total quizzes
}
</p>
</div>
</div> </div>
<a routerLink="/admin/create-quiz" class="btn btn-primary"> <a routerLink="/hr/create-quiz" class="btn btn-primary">
<span class="material-symbols-rounded">add</span> Create Quiz <span class="material-symbols-rounded">add</span> Create Quiz
</a> </a>
</div> </div>
...@@ -20,16 +41,46 @@ ...@@ -20,16 +41,46 @@
<div class="spinner spinner-lg"></div> <div class="spinner spinner-lg"></div>
<p>Loading quizzes...</p> <p>Loading quizzes...</p>
</div> </div>
} @else if (quizzes().length === 0) { } @else if (quizzes().length === 0) {
<div class="empty-state"> <div class="empty-state">
<span class="material-symbols-rounded">quiz</span> <span class="material-symbols-rounded">quiz</span>
<h3>No quizzes yet</h3> <h3>No quizzes yet</h3>
<p>Create your first quiz to get started.</p> <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> <a routerLink="/hr/create-quiz" class="btn btn-primary" style="margin-top: 16px;">Create Quiz</a>
</div> </div>
} @else if (!selectedCategory()) {
<!-- ─── Category Grid View ──────────────────────── -->
<div class="category-grid stagger-children">
@for (group of categoryGroups(); track group.name; let i = $index) {
<button class="category-card card card-hover" (click)="selectCategory(group.name)">
<div class="cat-icon-wrap" [style.background]="getCategoryColor(i) + '18'" [style.border-color]="getCategoryColor(i) + '30'">
<span class="material-symbols-rounded cat-icon" [style.color]="getCategoryColor(i)">{{ getCategoryIcon(group.name) }}</span>
</div>
<div class="cat-body">
<h3 class="cat-name">{{ group.name }}</h3>
<p class="cat-count">{{ group.count }} quiz{{ group.count !== 1 ? 'zes' : '' }}</p>
<div class="cat-diffs">
@for (diff of group.difficulties; track diff) {
<span class="diff-chip badge" [ngClass]="getDifficultyClass(diff)">{{ getDifficultyLabel(diff) }}</span>
}
</div>
</div>
<div class="cat-arrow-wrap">
<span class="material-symbols-rounded cat-arrow">chevron_right</span>
@if (group.totalAttempts > 0) {
<span class="cat-attempts-badge">{{ group.totalAttempts }} attempt{{ group.totalAttempts !== 1 ? 's' : '' }}</span>
}
</div>
</button>
}
</div>
} @else { } @else {
<!-- ─── Quiz Drill-down View ────────────────────── -->
<div class="quiz-grid stagger-children"> <div class="quiz-grid stagger-children">
@for (quiz of quizzes(); track quiz._id) { @for (quiz of filteredQuizzes(); track quiz._id) {
<div class="card card-hover quiz-card"> <div class="card card-hover quiz-card">
<div class="quiz-card-header"> <div class="quiz-card-header">
<h3 class="quiz-title">{{ quiz.title }}</h3> <h3 class="quiz-title">{{ quiz.title }}</h3>
...@@ -75,7 +126,7 @@ ...@@ -75,7 +126,7 @@
} }
</div> </div>
<!-- Edit Quiz Modal --> <!-- ─── Edit Quiz Modal ───────────────────────────── -->
@if (showEditPopup()) { @if (showEditPopup()) {
<div class="modal-overlay" (click)="closeEditPopup()"> <div class="modal-overlay" (click)="closeEditPopup()">
<div class="modal-container" (click)="$event.stopPropagation()"> <div class="modal-container" (click)="$event.stopPropagation()">
......
import { Component, OnInit, signal } from '@angular/core'; import { Component, OnInit, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { RouterLink, Router } from '@angular/router'; import { RouterLink, Router } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
...@@ -12,10 +12,13 @@ import { QuizService } from '../../../services/quiz.service'; ...@@ -12,10 +12,13 @@ import { QuizService } from '../../../services/quiz.service';
styleUrl: './quizzes.css' styleUrl: './quizzes.css'
}) })
export class HRQuizzesComponent implements OnInit { export class HRQuizzesComponent implements OnInit {
quizzes = signal<any[]>([]); quizzes = signal<any[]>([]);
loading = signal(true); loading = signal(true);
error = signal(''); error = signal('');
/** Currently expanded category — null = showing category groups */
selectedCategory = signal<string | null>(null);
showEditPopup = signal(false); showEditPopup = signal(false);
editQuizForm = { editQuizForm = {
_id: '', _id: '',
...@@ -40,12 +43,35 @@ quizzes = signal<any[]>([]); ...@@ -40,12 +43,35 @@ quizzes = signal<any[]>([]);
}); });
} }
deleteQuiz(quizId: string): void { /** All unique categories sorted alphabetically */
if (!confirm('Are you sure you want to delete this quiz?')) return; categories = computed<string[]>(() => {
this.quizService.deleteHRQuiz(quizId).subscribe({ const cats = new Set(this.quizzes().map((q: any) => q.category || 'Uncategorized'));
next: () => this.loadQuizzes(), return Array.from(cats).sort((a, b) => a.localeCompare(b));
error: (err) => this.error.set(err.error?.message || 'Cannot delete quiz') });
/** Category summary cards: name + count + difficulties present */
categoryGroups = computed(() => {
return this.categories().map(cat => {
const quizzesInCat = this.quizzes().filter((q: any) => (q.category || 'Uncategorized') === cat);
const diffs = [...new Set(quizzesInCat.map((q: any) => (q.difficulty || 'General').toLowerCase()))];
const totalAttempts = quizzesInCat.reduce((sum: number, q: any) => sum + (q.attemptCount || 0), 0);
return { name: cat, count: quizzesInCat.length, difficulties: diffs, totalAttempts };
}); });
});
/** Quizzes filtered by currently selected category */
filteredQuizzes = computed<any[]>(() => {
const cat = this.selectedCategory();
if (!cat) return [];
return this.quizzes().filter((q: any) => (q.category || 'Uncategorized') === cat);
});
selectCategory(cat: string): void {
this.selectedCategory.set(cat);
}
clearCategory(): void {
this.selectedCategory.set(null);
} }
getDifficultyClass(d: string): string { getDifficultyClass(d: string): string {
...@@ -57,6 +83,37 @@ quizzes = signal<any[]>([]); ...@@ -57,6 +83,37 @@ quizzes = signal<any[]>([]);
} }
} }
getDifficultyLabel(d: string): string {
const map: Record<string, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' };
return map[d?.toLowerCase()] || d || 'General';
}
getCategoryIcon(cat: string): string {
const lower = cat.toLowerCase();
if (lower.includes('java') && !lower.includes('javascript')) return 'coffee';
if (lower.includes('javascript') || lower.includes('js')) return 'javascript';
if (lower.includes('python')) return 'terminal';
if (lower.includes('web') || lower.includes('html') || lower.includes('css')) return 'web';
if (lower.includes('mern') || lower.includes('mean') || lower.includes('stack')) return 'layers';
if (lower.includes('angular') || lower.includes('react') || lower.includes('vue')) return 'dashboard';
if (lower.includes('aptitude') || lower.includes('general')) return 'psychology';
if (lower.includes('data') || lower.includes('sql') || lower.includes('database')) return 'database';
if (lower.includes('ai') || lower.includes('ml') || lower.includes('artificial')) return 'smart_toy';
if (lower.includes('operating') || lower.includes('os')) return 'memory';
if (lower.includes('network')) return 'lan';
if (lower.includes('cloud')) return 'cloud';
return 'quiz';
}
getCategoryColor(index: number): string {
const colors = [
'#4f6ef7', '#7c5cfc', '#06b6d4', '#22c55e',
'#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6',
'#f97316', '#ec4899', '#64748b', '#0ea5e9'
];
return colors[index % colors.length];
}
openEditPopup(quiz: any): void { openEditPopup(quiz: any): void {
this.editQuizForm = { this.editQuizForm = {
_id: quiz._id, _id: quiz._id,
...@@ -74,7 +131,6 @@ quizzes = signal<any[]>([]); ...@@ -74,7 +131,6 @@ quizzes = signal<any[]>([]);
saveBasicChanges(): void { saveBasicChanges(): void {
this.savingPopup.set(true); this.savingPopup.set(true);
// Don't send questions, only update basic details
this.quizService.updateHRQuiz(this.editQuizForm._id, this.editQuizForm).subscribe({ this.quizService.updateHRQuiz(this.editQuizForm._id, this.editQuizForm).subscribe({
next: () => { next: () => {
this.savingPopup.set(false); this.savingPopup.set(false);
...@@ -89,12 +145,17 @@ quizzes = signal<any[]>([]); ...@@ -89,12 +145,17 @@ quizzes = signal<any[]>([]);
}); });
} }
deleteQuiz(quizId: string): void {
if (!confirm('Are you sure you want to delete this quiz?')) return;
this.quizService.deleteHRQuiz(quizId).subscribe({
next: () => this.loadQuizzes(),
error: (err) => this.error.set(err.error?.message || 'Cannot delete quiz')
});
}
editQuestions(): void { editQuestions(): void {
// Navigate to edit-quiz page with the pending changes in history.state
this.router.navigate(['/hr/quiz', this.editQuizForm._id, 'edit'], { this.router.navigate(['/hr/quiz', this.editQuizForm._id, 'edit'], {
state: { state: { quizOverrides: { ...this.editQuizForm } }
quizOverrides: { ...this.editQuizForm }
}
}); });
this.closeEditPopup(); this.closeEditPopup();
} }
......
import { Injectable, OnDestroy } from '@angular/core';
import { BehaviorSubject, Subject } from 'rxjs';
export interface ViolationLog {
type: 'fullscreen_exit' | 'tab_switch' | 'window_blur' | 'visibility_hidden';
timestamp: Date;
message: string;
}
export interface AntiCheatWarning {
title: string;
message: string;
violationCount: number;
isAutoSubmit: boolean;
}
@Injectable({ providedIn: 'root' })
export class AntiCheatService implements OnDestroy {
private readonly MAX_VIOLATIONS = 3;
private violationCount = 0;
private isActive = false;
private isFullscreen = false;
private hasSubmitted = false;
/** Debounce guard — prevents burst events (e.g., ESC + fullscreenchange firing twice) */
private lastViolationTime = 0;
private readonly DEBOUNCE_MS = 1500;
readonly violationLogs: ViolationLog[] = [];
/** Emits warning data for the component to show a modal */
readonly warning$ = new Subject<AntiCheatWarning>();
/** Emits true when the quiz should be auto-submitted */
readonly autoSubmit$ = new Subject<void>();
/** Emits live violation count */
readonly violationCount$ = new BehaviorSubject<number>(0);
// ---- Bound listener refs for cleanup ----
private onFullscreenChange = this.handleFullscreenChange.bind(this);
private onVisibilityChange = this.handleVisibilityChange.bind(this);
private onWindowBlur = this.handleWindowBlur.bind(this);
// ─────────────────────────────────────────────
// Lifecycle
// ─────────────────────────────────────────────
ngOnDestroy(): void {
this.stop();
}
// ─────────────────────────────────────────────
// Start / Stop
// ─────────────────────────────────────────────
start(): void {
if (this.isActive) return;
this.isActive = true;
this.violationCount = 0;
this.hasSubmitted = false;
this.violationLogs.length = 0;
this.violationCount$.next(0);
document.addEventListener('fullscreenchange', this.onFullscreenChange);
document.addEventListener('webkitfullscreenchange', this.onFullscreenChange);
document.addEventListener('mozfullscreenchange', this.onFullscreenChange);
document.addEventListener('msfullscreenchange', this.onFullscreenChange);
document.addEventListener('visibilitychange', this.onVisibilityChange);
window.addEventListener('blur', this.onWindowBlur);
}
stop(): void {
this.isActive = false;
document.removeEventListener('fullscreenchange', this.onFullscreenChange);
document.removeEventListener('webkitfullscreenchange', this.onFullscreenChange);
document.removeEventListener('mozfullscreenchange', this.onFullscreenChange);
document.removeEventListener('msfullscreenchange', this.onFullscreenChange);
document.removeEventListener('visibilitychange', this.onVisibilityChange);
window.removeEventListener('blur', this.onWindowBlur);
}
reset(): void {
this.stop();
this.violationCount = 0;
this.hasSubmitted = false;
this.violationLogs.length = 0;
this.isFullscreen = false;
this.violationCount$.next(0);
}
// ─────────────────────────────────────────────
// Fullscreen API
// ─────────────────────────────────────────────
requestFullscreen(element: HTMLElement = document.documentElement): Promise<void> {
const el: any = element;
if (el.requestFullscreen) return el.requestFullscreen();
if (el.webkitRequestFullscreen) return el.webkitRequestFullscreen();
if (el.mozRequestFullScreen) return el.mozRequestFullScreen();
if (el.msRequestFullscreen) return el.msRequestFullscreen();
return Promise.reject('Fullscreen API not supported');
}
get isCurrentlyFullscreen(): boolean {
return !!(
document.fullscreenElement ||
(document as any).webkitFullscreenElement ||
(document as any).mozFullScreenElement ||
(document as any).msFullscreenElement
);
}
// ─────────────────────────────────────────────
// Event Handlers
// ─────────────────────────────────────────────
private handleFullscreenChange(): void {
if (!this.isActive) return;
const nowFullscreen = this.isCurrentlyFullscreen;
// Transition: was fullscreen → no longer fullscreen = exit
if (this.isFullscreen && !nowFullscreen) {
this.recordViolation('fullscreen_exit', 'Exited fullscreen mode');
}
this.isFullscreen = nowFullscreen;
}
private handleVisibilityChange(): void {
if (!this.isActive) return;
if (document.visibilityState === 'hidden') {
this.recordViolation('tab_switch', 'Switched to another tab or minimized the browser');
}
}
private handleWindowBlur(): void {
if (!this.isActive) return;
// Only fire if document is still visible (tab switch already caught it)
if (document.visibilityState === 'visible') {
this.recordViolation('window_blur', 'Switched to another application or window');
}
}
// ─────────────────────────────────────────────
// Core violation logic
// ─────────────────────────────────────────────
private recordViolation(type: ViolationLog['type'], message: string): void {
if (this.hasSubmitted) return;
// Debounce burst events
const now = Date.now();
if (now - this.lastViolationTime < this.DEBOUNCE_MS) return;
this.lastViolationTime = now;
this.violationCount++;
this.violationCount$.next(this.violationCount);
const log: ViolationLog = { type, timestamp: new Date(), message };
this.violationLogs.push(log);
const remaining = this.MAX_VIOLATIONS - this.violationCount;
const isAutoSubmit = this.violationCount >= this.MAX_VIOLATIONS;
const warning: AntiCheatWarning = {
title: isAutoSubmit ? '🚫 Quiz Auto-Submitted' : '⚠️ Warning — Suspicious Activity Detected',
message: isAutoSubmit
? `You have exceeded the maximum number of violations (${this.MAX_VIOLATIONS}). Your quiz has been automatically submitted.`
: `${message}. This is violation ${this.violationCount} of ${this.MAX_VIOLATIONS}. ${remaining} warning${remaining === 1 ? '' : 's'} remaining before automatic submission.`,
violationCount: this.violationCount,
isAutoSubmit
};
this.warning$.next(warning);
if (isAutoSubmit) {
this.hasSubmitted = true;
this.stop();
// Slight delay so the modal renders before submit fires
setTimeout(() => this.autoSubmit$.next(), 800);
}
}
markSubmitted(): void {
this.hasSubmitted = true;
this.stop();
}
getViolationCount(): number {
return this.violationCount;
}
getMaxViolations(): number {
return this.MAX_VIOLATIONS;
}
}
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