first working version
This commit is contained in:
16
exam_system/exam_web/Dockerfile
Normal file
16
exam_system/exam_web/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
EXPOSE 4200
|
||||
|
||||
# Development server
|
||||
CMD ["npm", "start", "--", "--host", "0.0.0.0", "--port", "4200"]
|
||||
|
||||
68
exam_system/exam_web/angular.json
Normal file
68
exam_system/exam_web/angular.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"exam-app": {
|
||||
"projectType": "application",
|
||||
"schematics": {},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"outputPath": "dist/exam-app",
|
||||
"index": "src/index.html",
|
||||
"main": "src/main.ts",
|
||||
"polyfills": ["zone.js"],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"assets": ["src/favicon.ico", "src/assets"],
|
||||
"styles": ["src/styles.css"],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kb",
|
||||
"maximumError": "1mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "2kb",
|
||||
"maximumError": "4kb"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"buildOptimizer": false,
|
||||
"optimization": false,
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true,
|
||||
"namedChunks": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"browserTarget": "exam-app:build:production"
|
||||
},
|
||||
"development": {
|
||||
"browserTarget": "exam-app:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
31
exam_system/exam_web/package.json
Normal file
31
exam_system/exam_web/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "exam-web",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"test": "ng test"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^16.0.0",
|
||||
"@angular/common": "^16.0.0",
|
||||
"@angular/compiler": "^16.0.0",
|
||||
"@angular/core": "^16.0.0",
|
||||
"@angular/forms": "^16.0.0",
|
||||
"@angular/platform-browser": "^16.0.0",
|
||||
"@angular/platform-browser-dynamic": "^16.0.0",
|
||||
"@angular/router": "^16.0.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^16.0.0",
|
||||
"@angular/cli": "^16.0.0",
|
||||
"@angular/compiler-cli": "^16.0.0",
|
||||
"typescript": "~5.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
25
exam_system/exam_web/src/app/app-routing.module.ts
Normal file
25
exam_system/exam_web/src/app/app-routing.module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { ExamListComponent } from './components/exam-list/exam-list.component';
|
||||
import { ExamPlayerComponent } from './components/exam-player/exam-player.component';
|
||||
import { ExamDoneComponent } from './components/exam-done/exam-done.component';
|
||||
import { LoginComponent } from './components/login/login.component';
|
||||
import { HistoryComponent } from './components/history/history.component';
|
||||
import { ResultComponent } from './components/result/result.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{ path: '', component: ExamListComponent },
|
||||
{ path: 'login', component: LoginComponent },
|
||||
{ path: 'history', component: HistoryComponent },
|
||||
{ path: 'exam/:examId', component: ExamPlayerComponent },
|
||||
{ path: 'done/:attemptId', component: ExamDoneComponent },
|
||||
{ path: 'result/:attemptId', component: ResultComponent },
|
||||
{ path: '**', redirectTo: '' }
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forRoot(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class AppRoutingModule { }
|
||||
|
||||
101
exam_system/exam_web/src/app/app.component.ts
Normal file
101
exam_system/exam_web/src/app/app.component.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { AuthService, User } from './services/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
template: `
|
||||
<div class="app">
|
||||
<header>
|
||||
<div class="container header-content">
|
||||
<h1 (click)="goHome()" style="cursor: pointer;">Exam System</h1>
|
||||
<nav>
|
||||
<a *ngIf="!currentUser" (click)="goToLogin()">Login</a>
|
||||
<span *ngIf="currentUser">
|
||||
<span class="username">{{ currentUser.username }}</span>
|
||||
<a (click)="goToHistory()">My History</a>
|
||||
<a (click)="logout()">Logout</a>
|
||||
</span>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
header {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
padding: 20px 0;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
nav a {
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
nav a:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
.username {
|
||||
margin-right: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
main {
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
title = 'Exam System';
|
||||
currentUser: User | null = null;
|
||||
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private router: Router
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.authService.currentUser$.subscribe(user => {
|
||||
this.currentUser = user;
|
||||
});
|
||||
}
|
||||
|
||||
goHome() {
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
|
||||
goToLogin() {
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
goToHistory() {
|
||||
this.router.navigate(['/history']);
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.authService.logout().subscribe(() => {
|
||||
this.router.navigate(['/login']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
39
exam_system/exam_web/src/app/app.module.ts
Normal file
39
exam_system/exam_web/src/app/app.module.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { HttpClientModule, HttpClientXsrfModule } from '@angular/common/http';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
import { AppComponent } from './app.component';
|
||||
import { ExamListComponent } from './components/exam-list/exam-list.component';
|
||||
import { ExamPlayerComponent } from './components/exam-player/exam-player.component';
|
||||
import { ExamDoneComponent } from './components/exam-done/exam-done.component';
|
||||
import { LoginComponent } from './components/login/login.component';
|
||||
import { HistoryComponent } from './components/history/history.component';
|
||||
import { ResultComponent } from './components/result/result.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppComponent,
|
||||
ExamListComponent,
|
||||
ExamPlayerComponent,
|
||||
ExamDoneComponent,
|
||||
LoginComponent,
|
||||
HistoryComponent,
|
||||
ResultComponent
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
AppRoutingModule,
|
||||
HttpClientModule,
|
||||
HttpClientXsrfModule.withOptions({
|
||||
cookieName: 'csrftoken',
|
||||
headerName: 'X-CSRFToken'
|
||||
}),
|
||||
FormsModule,
|
||||
ReactiveFormsModule
|
||||
],
|
||||
providers: [],
|
||||
bootstrap: [AppComponent]
|
||||
})
|
||||
export class AppModule { }
|
||||
@@ -0,0 +1,120 @@
|
||||
.success-card {
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
margin: 50px auto;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: #28a745;
|
||||
color: white;
|
||||
font-size: 48px;
|
||||
line-height: 80px;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
.success-card h2 {
|
||||
color: #28a745;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.success-card p {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.success-card .info {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.success-card button {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.score-section {
|
||||
margin: 30px 0;
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.score-section h3 {
|
||||
text-align: center;
|
||||
color: #007bff;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.score-display {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.score-circle {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: 50%;
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.score-circle.passed {
|
||||
background: #28a745;
|
||||
}
|
||||
|
||||
.score-circle .percentage {
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.score-circle .points {
|
||||
font-size: 16px;
|
||||
margin-top: 10px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.result-text {
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin: 20px 0;
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.result-text.passed {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.score-details {
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.no-score {
|
||||
margin: 20px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
flex: 1;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<div class="container">
|
||||
<div class="card success-card">
|
||||
<div class="success-icon">✓</div>
|
||||
<h2>Exam Submitted Successfully!</h2>
|
||||
|
||||
<!-- Show score if auto-scored -->
|
||||
<div *ngIf="score" class="score-section">
|
||||
<h3>Your Score</h3>
|
||||
<div class="score-display">
|
||||
<div class="score-circle" [class.passed]="score.passed">
|
||||
<span class="percentage">{{ score.percentage }}%</span>
|
||||
<span class="points">{{ score.totalScore }} / {{ score.maxScore }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="result-text" [class.passed]="score.passed">
|
||||
{{ score.passed ? '✓ PASSED' : '✗ NOT PASSED' }}
|
||||
</p>
|
||||
<div class="score-details">
|
||||
<p><strong>Correct:</strong> {{ getCorrectCount() }} / {{ score.byQuestion.length }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No score message -->
|
||||
<div *ngIf="!score" class="no-score">
|
||||
<p class="info">Your answers have been saved and will be reviewed.</p>
|
||||
<p class="info">This exam contains questions that require manual grading.</p>
|
||||
</div>
|
||||
|
||||
<p><strong>Attempt ID:</strong> {{ attemptId }}</p>
|
||||
|
||||
<div class="actions">
|
||||
<button class="primary" (click)="goToHistory()">View History</button>
|
||||
<button (click)="goHome()">Back to Exams</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-exam-done',
|
||||
templateUrl: './exam-done.component.html',
|
||||
styleUrls: ['./exam-done.component.css']
|
||||
})
|
||||
export class ExamDoneComponent implements OnInit {
|
||||
attemptId = '';
|
||||
score: any = null;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router
|
||||
) {
|
||||
// Get score from navigation state
|
||||
const navigation = this.router.getCurrentNavigation();
|
||||
if (navigation?.extras?.state) {
|
||||
this.score = navigation.extras.state['score'];
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.attemptId = this.route.snapshot.paramMap.get('attemptId') || '';
|
||||
}
|
||||
|
||||
goHome() {
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
|
||||
goToHistory() {
|
||||
this.router.navigate(['/history']);
|
||||
}
|
||||
|
||||
getCorrectCount(): number {
|
||||
if (!this.score || !this.score.byQuestion) return 0;
|
||||
return this.score.byQuestion.filter((q: any) => q.correct).length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
/* Header and Summary Stats */
|
||||
.exam-header {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.exam-header h2 {
|
||||
margin-bottom: 20px;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.summary-stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* Filters Section */
|
||||
.filters-section {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.filter-group select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.view-controls {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.view-toggle.active {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.view-toggle:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.view-toggle.active:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
/* Exam Container */
|
||||
.exams-container.grid-view {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||||
gap: 28px; /* increased spacing between cards */
|
||||
align-items: stretch; /* ensure cards stretch to same height */
|
||||
}
|
||||
|
||||
.exams-container.list-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px; /* increased vertical spacing between rows */
|
||||
}
|
||||
|
||||
.exams-container.list-view .exam-card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.exams-container.list-view .exam-header {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.exams-container.list-view .exam-details {
|
||||
flex: 2;
|
||||
margin: 0 20px;
|
||||
}
|
||||
|
||||
.exams-container.list-view .exam-score {
|
||||
flex: 1;
|
||||
margin: 0 20px 0 0;
|
||||
}
|
||||
|
||||
.exams-container.list-view .exam-actions {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Exam Cards */
|
||||
.exam-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: box-shadow 0.2s, transform 0.1s;
|
||||
padding: 22px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e9ecef;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||
height: 100%; /* ensure cards fill grid cell height */
|
||||
}
|
||||
|
||||
.exam-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 16px rgba(0,0,0,0.10);
|
||||
}
|
||||
|
||||
.exam-card .exam-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.exam-card .exam-header h3 {
|
||||
color: #0d6efd;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
font-size: 18px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.exam-badges {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 6px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-badge.available {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.in_progress {
|
||||
background: #ffc107;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.status-badge.finished {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.exam-details {
|
||||
margin: 18px 0 8px 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
column-gap: 20px;
|
||||
row-gap: 8px;
|
||||
flex: 0 0 auto; /* fixed size, don't grow */
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: grid;
|
||||
grid-template-columns: 110px 1fr;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.detail-row .label {
|
||||
font-weight: 600;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.detail-row .value {
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.difficulty-easy {
|
||||
color: #28a745;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.difficulty-medium,
|
||||
.difficulty-intermediate {
|
||||
color: #ffc107;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.difficulty-hard,
|
||||
.difficulty-advanced {
|
||||
color: #dc3545;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Subject-specific styling */
|
||||
.subject-python {
|
||||
color: #3776ab;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subject-cpp {
|
||||
color: #00599c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subject-linear_algebra {
|
||||
color: #8e44ad;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subject-javascript {
|
||||
color: #f7df1e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subject-java {
|
||||
color: #ed8b00;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subject-sql {
|
||||
color: #336791;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Button Styles */
|
||||
.btn-primary {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.no-results p {
|
||||
margin-bottom: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.exam-score {
|
||||
margin: 20px 0 10px 0;
|
||||
padding: 14px 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #eef1f4;
|
||||
flex: 1 1 auto; /* grow to fill space */
|
||||
display: flex;
|
||||
align-items: center; /* center score content vertically */
|
||||
}
|
||||
|
||||
/* Spacer for cards without score to maintain alignment */
|
||||
.exam-spacer {
|
||||
flex: 1 1 auto; /* grow to fill space */
|
||||
min-height: 20px; /* minimum spacing */
|
||||
}
|
||||
|
||||
.score-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.score-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.score-badge.passed {
|
||||
background: #28a745;
|
||||
}
|
||||
|
||||
.score-circle {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.score-circle.passed {
|
||||
background: #28a745;
|
||||
}
|
||||
|
||||
.score-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.score-text {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #343a40;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.pass-status {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.pass-status.passed {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.exam-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: auto; /* push to bottom */
|
||||
padding-top: 18px; /* maintain spacing */
|
||||
flex: 0 0 auto; /* don't grow */
|
||||
}
|
||||
|
||||
.exam-actions button {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Responsive tweaks */
|
||||
@media (max-width: 768px) {
|
||||
.exam-details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.detail-row {
|
||||
grid-template-columns: 120px 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.secondary:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
<div class="container">
|
||||
<div class="exam-header">
|
||||
<h2>Available Exams</h2>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div *ngIf="summary" class="summary-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-number">{{ summary.total }}</span>
|
||||
<span class="stat-label">Total</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-number">{{ summary.available }}</span>
|
||||
<span class="stat-label">Available</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-number">{{ summary.finished }}</span>
|
||||
<span class="stat-label">Finished</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-number">{{ summary.passed }}</span>
|
||||
<span class="stat-label">Passed</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-number">{{ summary.notPassed }}</span>
|
||||
<span class="stat-label">Not Passed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters and Controls -->
|
||||
<div class="filters-section">
|
||||
<div class="filter-group">
|
||||
<label for="subject-filter">Subject:</label>
|
||||
<select id="subject-filter" [(ngModel)]="selectedSubject" (change)="onSubjectChange(selectedSubject)">
|
||||
<option value="all">All Subjects</option>
|
||||
<option *ngFor="let subject of subjects" [value]="subject">
|
||||
{{ getSubjectDisplayName(subject) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label for="status-filter">Status:</label>
|
||||
<select id="status-filter" [(ngModel)]="selectedStatus" (change)="onStatusChange(selectedStatus)">
|
||||
<option value="all">All Status</option>
|
||||
<option value="available">Available</option>
|
||||
<option value="finished">Finished</option>
|
||||
<option value="passed">Passed</option>
|
||||
<option value="notPassed">Not Passed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="view-controls">
|
||||
<button (click)="toggleViewMode()" class="view-toggle" [class.active]="viewMode === 'grid'">
|
||||
<i class="icon-grid">⊞</i> Grid
|
||||
</button>
|
||||
<button (click)="toggleViewMode()" class="view-toggle" [class.active]="viewMode === 'list'">
|
||||
<i class="icon-list">☰</i> List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="loading" class="loading">Loading exams...</div>
|
||||
|
||||
<div *ngIf="error" class="error">{{ error }}</div>
|
||||
|
||||
<div *ngIf="!loading && getFilteredExams().length === 0" class="card no-results">
|
||||
<p>No exams match your current filters.</p>
|
||||
<button (click)="selectedSubject = 'all'; selectedStatus = 'all'" class="btn-secondary">
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Exam Cards -->
|
||||
<div class="exams-container" [class.grid-view]="viewMode === 'grid'" [class.list-view]="viewMode === 'list'">
|
||||
<div *ngFor="let exam of getFilteredExams()" class="card exam-card">
|
||||
<div class="exam-header">
|
||||
<h3>{{ exam.title }}</h3>
|
||||
<div class="exam-badges">
|
||||
<span class="status-badge" [class]="exam.status">
|
||||
{{ exam.status === 'in_progress' ? 'In Progress' : (exam.status === 'finished' ? 'Finished' : 'Available') }}
|
||||
</span>
|
||||
<span *ngIf="exam.lastScore" class="score-badge" [class.passed]="exam.lastScore.passed">
|
||||
{{ exam.lastScore.percentage }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="exam-details">
|
||||
<div class="detail-row">
|
||||
<span class="label">Subject:</span>
|
||||
<span class="value subject-{{ exam.subject }}">{{ getSubjectDisplayName(exam.subject) }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">Difficulty:</span>
|
||||
<span class="value difficulty-{{ exam.difficulty }}">{{ exam.difficulty }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">Duration:</span>
|
||||
<span class="value">{{ exam.durationMinutes }} minutes</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">Questions:</span>
|
||||
<span class="value">{{ getQuestionCount(exam) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Show score if exam is finished and score is available -->
|
||||
<div *ngIf="exam.status === 'finished' && exam.lastScore" class="exam-score">
|
||||
<div class="score-summary">
|
||||
<div class="score-circle" [class.passed]="exam.lastScore.passed">
|
||||
{{ exam.lastScore.percentage }}%
|
||||
</div>
|
||||
<div class="score-info">
|
||||
<div class="score-text">{{ exam.lastScore.totalScore }} / {{ exam.lastScore.maxScore }} points</div>
|
||||
<div class="pass-status" [class.passed]="exam.lastScore.passed">
|
||||
{{ exam.lastScore.passed ? 'PASSED' : 'NOT PASSED' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spacer for cards without score to maintain alignment -->
|
||||
<div *ngIf="!(exam.status === 'finished' && exam.lastScore)" class="exam-spacer"></div>
|
||||
|
||||
<div class="exam-actions">
|
||||
<button (click)="startExam(exam.examId, exam.status)" class="btn-primary">
|
||||
<span *ngIf="exam.status === 'in_progress'">Continue Exam</span>
|
||||
<span *ngIf="exam.status === 'finished'">Take Again</span>
|
||||
<span *ngIf="exam.status === 'available'">Start Exam</span>
|
||||
</button>
|
||||
<button *ngIf="exam.status === 'finished'" (click)="viewHistory(exam.examId)" class="btn-secondary">
|
||||
View History
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { ApiService } from '../../services/api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-exam-list',
|
||||
templateUrl: './exam-list.component.html',
|
||||
styleUrls: ['./exam-list.component.css']
|
||||
})
|
||||
export class ExamListComponent implements OnInit {
|
||||
exams: any[] = [];
|
||||
organizedExams: any = null;
|
||||
subjects: string[] = [];
|
||||
summary: any = null;
|
||||
loading = true;
|
||||
error = '';
|
||||
|
||||
// Filter and view state
|
||||
selectedSubject: string = 'all';
|
||||
selectedStatus: string = 'all';
|
||||
viewMode: 'grid' | 'list' = 'grid';
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private router: Router
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.loadExams();
|
||||
}
|
||||
|
||||
loadExams() {
|
||||
this.loading = true;
|
||||
this.apiService.listExams().subscribe({
|
||||
next: (response: any) => {
|
||||
this.exams = response.exams;
|
||||
this.organizedExams = response.organized;
|
||||
this.subjects = response.subjects;
|
||||
this.summary = response.summary;
|
||||
this.loading = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to load exams';
|
||||
this.loading = false;
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getFilteredExams(): any[] {
|
||||
if (!this.organizedExams) return this.exams;
|
||||
|
||||
let filteredExams: any[] = [];
|
||||
|
||||
// Filter by subject
|
||||
if (this.selectedSubject === 'all') {
|
||||
filteredExams = this.exams;
|
||||
} else {
|
||||
filteredExams = this.organizedExams.bySubject[this.selectedSubject] || [];
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if (this.selectedStatus !== 'all') {
|
||||
filteredExams = filteredExams.filter(exam => {
|
||||
switch (this.selectedStatus) {
|
||||
case 'available':
|
||||
return exam.status === 'available';
|
||||
case 'finished':
|
||||
return exam.status === 'finished';
|
||||
case 'passed':
|
||||
return exam.status === 'finished' && exam.lastScore?.passed === true;
|
||||
case 'notPassed':
|
||||
return exam.status === 'finished' && exam.lastScore?.passed === false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return filteredExams;
|
||||
}
|
||||
|
||||
getSubjectDisplayName(subject: string): string {
|
||||
const subjectMap: { [key: string]: string } = {
|
||||
'python': 'Python',
|
||||
'cpp': 'C++',
|
||||
'linear_algebra': 'Linear Algebra',
|
||||
'javascript': 'JavaScript',
|
||||
'java': 'Java',
|
||||
'sql': 'SQL',
|
||||
'other': 'Other'
|
||||
};
|
||||
return subjectMap[subject.toLowerCase()] || subject;
|
||||
}
|
||||
|
||||
getStatusDisplayName(status: string): string {
|
||||
const statusMap: { [key: string]: string } = {
|
||||
'available': 'Available',
|
||||
'finished': 'Finished',
|
||||
'passed': 'Passed',
|
||||
'notPassed': 'Not Passed'
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
}
|
||||
|
||||
onSubjectChange(subject: string) {
|
||||
this.selectedSubject = subject;
|
||||
}
|
||||
|
||||
onStatusChange(status: string) {
|
||||
this.selectedStatus = status;
|
||||
}
|
||||
|
||||
getQuestionCount(exam: any): number {
|
||||
return exam.sections.reduce((total: number, section: any) => total + section.questions.length, 0);
|
||||
}
|
||||
|
||||
toggleViewMode() {
|
||||
this.viewMode = this.viewMode === 'grid' ? 'list' : 'grid';
|
||||
}
|
||||
|
||||
|
||||
startExam(examId: string, status: string) {
|
||||
// If exam is finished, reset it first
|
||||
if (status === 'finished') {
|
||||
this.apiService.resetExam(examId).subscribe({
|
||||
next: () => {
|
||||
this.router.navigate(['/exam', examId]);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to reset exam. Please try again.';
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.router.navigate(['/exam', examId]);
|
||||
}
|
||||
}
|
||||
|
||||
viewHistory(examId?: string) {
|
||||
if (examId) {
|
||||
// Navigate to history with exam filter (will be implemented in history component)
|
||||
this.router.navigate(['/history'], { queryParams: { examId: examId } });
|
||||
} else {
|
||||
this.router.navigate(['/history']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
.exam-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.exam-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.timer {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.timer.warning {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.save-status {
|
||||
color: #28a745;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.progress {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.question-card {
|
||||
margin-top: 20px;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.question-card h3 {
|
||||
color: #007bff;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.question-prompt {
|
||||
font-size: 16px;
|
||||
margin: 15px 0;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.points {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.question-answer {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.choice {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.choice label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.choice label:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.choice input[type="radio"],
|
||||
.choice input[type="checkbox"] {
|
||||
margin-right: 10px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.choice.idk-choice {
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.choice.idk-choice label {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.instruction {
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* IDE-like code editor */
|
||||
.code-editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.language-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
background: #e7f3ff;
|
||||
color: #0d6efd;
|
||||
border: 1px solid #0d6efd;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editor-hint {
|
||||
color: #6c757d;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.code-editor-container {
|
||||
display: grid;
|
||||
grid-template-columns: 48px 1fr;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fbfbfb;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.code-line-numbers {
|
||||
background: #f5f7f9;
|
||||
color: #6c757d;
|
||||
padding: 8px 0 8px 0;
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-number {
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
padding: 0 10px 0 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Highlight overlay + textarea shell */
|
||||
.code-editor-shell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.code-highlight {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
margin: 0;
|
||||
padding: 12px; /* must match textarea padding */
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
white-space: pre; /* match textarea wrap=off */
|
||||
word-wrap: break-word;
|
||||
pointer-events: none; /* allow interactions to hit textarea */
|
||||
color: #24292e; /* base text color; tokens override */
|
||||
overflow: hidden;
|
||||
tab-size: 2;
|
||||
-moz-tab-size: 2;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.code-highlight code {
|
||||
display: block;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
line-height: inherit;
|
||||
white-space: inherit;
|
||||
tab-size: inherit;
|
||||
-moz-tab-size: inherit;
|
||||
}
|
||||
|
||||
/* Token colors (very light, basic) */
|
||||
.tok-keyword { color: #d73a49; }
|
||||
.tok-type { color: #6f42c1; }
|
||||
.tok-func { color: #005cc5; }
|
||||
.tok-number { color: #005cc5; }
|
||||
.tok-string { color: #032f62; }
|
||||
.tok-comment { color: #6a737d; font-style: italic; }
|
||||
|
||||
textarea.code-editor {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
min-height: 320px;
|
||||
padding: 12px; /* must match overlay */
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
line-height: 20px;
|
||||
font-size: 14px;
|
||||
background: #ffffff00; /* transparent to show highlight */
|
||||
color: transparent; /* hide raw text, show highlighted overlay */
|
||||
caret-color: #111; /* visible caret */
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: transparent; /* show highlight underneath */
|
||||
tab-size: 2;
|
||||
-moz-tab-size: 2;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
textarea.code-editor:focus {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 1px #0d6efd33;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.navigation button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<div class="container" *ngIf="!loading">
|
||||
<div class="exam-header card">
|
||||
<h2>{{ exam?.title }}</h2>
|
||||
<div class="exam-info">
|
||||
<div class="timer" [class.warning]="timeRemaining < 300000">
|
||||
Time Remaining: {{ getTimeRemainingString() }}
|
||||
</div>
|
||||
<div class="save-status">{{ saveStatus }}</div>
|
||||
<div class="progress">
|
||||
Question {{ currentQuestionIndex + 1 }} of {{ allQuestions.length }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="question-card card" *ngIf="allQuestions[currentQuestionIndex] as question">
|
||||
<h3>{{ question.sectionTitle }}</h3>
|
||||
<p class="question-prompt">{{ question.prompt }}</p>
|
||||
<p class="points">Points: {{ question.points }}</p>
|
||||
|
||||
<!-- Single Choice -->
|
||||
<div *ngIf="question.type === 'single_choice'" class="question-answer">
|
||||
<div *ngFor="let choice of question.choices" class="choice">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
[name]="question.id"
|
||||
[value]="choice.key"
|
||||
[checked]="answers.get(question.id) === choice.key"
|
||||
(change)="onAnswerChange(question.id, choice.key)"
|
||||
/>
|
||||
{{ choice.key }}. {{ choice.text }}
|
||||
</label>
|
||||
</div>
|
||||
<!-- I don't know option -->
|
||||
<div *ngIf="question.allowIDK !== false" class="choice idk-choice">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
[name]="question.id"
|
||||
value="IDK"
|
||||
[checked]="answers.get(question.id) === 'IDK'"
|
||||
(change)="onAnswerChange(question.id, 'IDK')"
|
||||
/>
|
||||
? I don't know
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Multiple Choices -->
|
||||
<div *ngIf="question.type === 'multiple_choices'" class="question-answer">
|
||||
<p class="instruction">Select all that apply:</p>
|
||||
<div *ngFor="let choice of question.choices" class="choice">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
[value]="choice.key"
|
||||
[checked]="isMultipleChoiceSelected(question.id, choice.key)"
|
||||
(change)="onMultipleChoiceChange(question.id, choice.key, $any($event.target).checked)"
|
||||
/>
|
||||
{{ choice.key }}. {{ choice.text }}
|
||||
</label>
|
||||
</div>
|
||||
<!-- I don't know option -->
|
||||
<div *ngIf="question.allowIDK !== false" class="choice idk-choice">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
value="IDK"
|
||||
[checked]="isMultipleChoiceIDK(question.id)"
|
||||
(change)="onMultipleChoiceIDKChange(question.id, $any($event.target).checked)"
|
||||
/>
|
||||
? I don't know
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- True/False -->
|
||||
<div *ngIf="question.type === 'true_false'" class="question-answer">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
[name]="question.id"
|
||||
[value]="true"
|
||||
[checked]="answers.get(question.id) === true"
|
||||
(change)="onAnswerChange(question.id, true)"
|
||||
/>
|
||||
True
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
[name]="question.id"
|
||||
[value]="false"
|
||||
[checked]="answers.get(question.id) === false"
|
||||
(change)="onAnswerChange(question.id, false)"
|
||||
/>
|
||||
False
|
||||
</label>
|
||||
<!-- I don't know option -->
|
||||
<div *ngIf="question.allowIDK !== false" class="choice idk-choice">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
[name]="question.id"
|
||||
value="IDK"
|
||||
[checked]="answers.get(question.id) === 'IDK'"
|
||||
(change)="onAnswerChange(question.id, 'IDK')"
|
||||
/>
|
||||
? I don't know
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Essay -->
|
||||
<div *ngIf="question.type === 'essay'" class="question-answer">
|
||||
<textarea
|
||||
[value]="answers.get(question.id) || ''"
|
||||
(input)="onAnswerChange(question.id, $any($event.target).value)"
|
||||
placeholder="Type your answer here..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Code Simple / Code Exercise -->
|
||||
<div *ngIf="question.type === 'code_simple' || question.type === 'code_exercise'" class="question-answer">
|
||||
<div class="code-editor-header">
|
||||
<span class="language-badge">{{ question.language }}</span>
|
||||
<span class="editor-hint">Press Tab for indentation</span>
|
||||
</div>
|
||||
<div class="code-editor-container">
|
||||
<div class="code-line-numbers" #lineNumbers>
|
||||
<div *ngFor="let line of getLineNumbers(question.id)" class="line-number">{{ line }}</div>
|
||||
</div>
|
||||
<div class="code-editor-shell">
|
||||
<pre class="code-highlight"><code [ngClass]="getPrismLanguageClass(question.language)" [innerHTML]="getPrismHighlighted(question.id, question.language)"></code></pre>
|
||||
<textarea
|
||||
class="code-editor"
|
||||
[value]="answers.get(question.id)?.code || ''"
|
||||
(input)="onCodeInput(question.id, $any($event.target).value, $any($event.target))"
|
||||
(keydown)="onCodeKeyDown($event, question.id)"
|
||||
(scroll)="syncScroll($event)"
|
||||
placeholder="// Write your code here..."
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
wrap="off"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="navigation card">
|
||||
<button [disabled]="currentQuestionIndex === 0" (click)="prevQuestion()">Previous</button>
|
||||
<button [disabled]="currentQuestionIndex === allQuestions.length - 1" (click)="nextQuestion()">Next</button>
|
||||
<button class="primary" (click)="submit()">Submit Exam</button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="error" class="error">{{ error }}</div>
|
||||
</div>
|
||||
|
||||
<div class="container" *ngIf="loading">
|
||||
<div class="card">Loading exam...</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ApiService, Exam, Attempt, Answer } from '../../services/api.service';
|
||||
import { interval, Subscription } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-exam-player',
|
||||
templateUrl: './exam-player.component.html',
|
||||
styleUrls: ['./exam-player.component.css']
|
||||
})
|
||||
export class ExamPlayerComponent implements OnInit, OnDestroy {
|
||||
exam: Exam | null = null;
|
||||
attempt: Attempt | null = null;
|
||||
answers: Map<string, any> = new Map();
|
||||
questionTimers: Map<string, number> = new Map();
|
||||
|
||||
loading = true;
|
||||
error = '';
|
||||
saveStatus = '';
|
||||
|
||||
timeRemaining = 0;
|
||||
timerSubscription?: Subscription;
|
||||
autosaveSubscription?: Subscription;
|
||||
|
||||
currentQuestionIndex = 0;
|
||||
allQuestions: any[] = [];
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private apiService: ApiService
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
const examId = this.route.snapshot.paramMap.get('examId');
|
||||
if (examId) {
|
||||
this.loadExamAndStartAttempt(examId);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.timerSubscription?.unsubscribe();
|
||||
this.autosaveSubscription?.unsubscribe();
|
||||
}
|
||||
|
||||
loadExamAndStartAttempt(examId: string) {
|
||||
this.loading = true;
|
||||
|
||||
// Load exam
|
||||
this.apiService.getExam(examId).subscribe({
|
||||
next: (exam) => {
|
||||
this.exam = exam;
|
||||
this.flattenQuestions();
|
||||
|
||||
// Start or resume attempt
|
||||
this.apiService.startOrResumeAttempt(examId).subscribe({
|
||||
next: (attempt) => {
|
||||
this.attempt = attempt;
|
||||
this.loadAnswers(attempt.answers);
|
||||
this.startTimer();
|
||||
this.startAutosave();
|
||||
this.loading = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to start exam';
|
||||
this.loading = false;
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to load exam';
|
||||
this.loading = false;
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
flattenQuestions() {
|
||||
if (!this.exam) return;
|
||||
this.allQuestions = [];
|
||||
for (const section of this.exam.sections) {
|
||||
for (const question of section.questions) {
|
||||
this.allQuestions.push({ ...question, sectionTitle: section.title });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadAnswers(answers: Answer[]) {
|
||||
for (const answer of answers) {
|
||||
this.answers.set(answer.questionId, answer.response);
|
||||
if (answer.timeSec) {
|
||||
this.questionTimers.set(answer.questionId, answer.timeSec);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
startTimer() {
|
||||
if (!this.exam || !this.attempt) return;
|
||||
|
||||
const startTime = new Date(this.attempt.startedAt).getTime();
|
||||
const duration = this.exam.durationMinutes * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const elapsed = now - startTime;
|
||||
|
||||
this.timeRemaining = Math.max(0, duration - elapsed);
|
||||
|
||||
this.timerSubscription = interval(1000).subscribe(() => {
|
||||
this.timeRemaining -= 1000;
|
||||
if (this.timeRemaining <= 0) {
|
||||
this.autoSubmit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startAutosave() {
|
||||
this.autosaveSubscription = interval(10000).subscribe(() => {
|
||||
this.autosave();
|
||||
});
|
||||
}
|
||||
|
||||
autosave() {
|
||||
if (!this.attempt) return;
|
||||
|
||||
const answers: Answer[] = [];
|
||||
this.answers.forEach((response, questionId) => {
|
||||
answers.push({
|
||||
questionId,
|
||||
response,
|
||||
timeSec: this.questionTimers.get(questionId) || 0
|
||||
});
|
||||
});
|
||||
|
||||
this.apiService.autosaveAttempt(this.attempt.attemptId, answers).subscribe({
|
||||
next: () => {
|
||||
this.saveStatus = 'Saved';
|
||||
setTimeout(() => this.saveStatus = '', 2000);
|
||||
},
|
||||
error: (err) => {
|
||||
this.saveStatus = 'Save failed';
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onAnswerChange(questionId: string, value: any) {
|
||||
this.answers.set(questionId, value);
|
||||
|
||||
// Track time spent on this question
|
||||
const currentTime = this.questionTimers.get(questionId) || 0;
|
||||
this.questionTimers.set(questionId, currentTime + 1);
|
||||
}
|
||||
|
||||
// Code editor helpers
|
||||
private prismUpdateTimer: any = null;
|
||||
|
||||
onCodeInput(questionId: string, code: string, textarea: HTMLTextAreaElement) {
|
||||
this.onAnswerChange(questionId, { code });
|
||||
// Debounce highlight to improve typing performance
|
||||
if (this.prismUpdateTimer) clearTimeout(this.prismUpdateTimer);
|
||||
this.prismUpdateTimer = setTimeout(() => {
|
||||
// Trigger change detection; Prism will re-highlight via binding
|
||||
}, 50);
|
||||
}
|
||||
|
||||
onCodeKeyDown(event: KeyboardEvent, questionId: string) {
|
||||
const textarea = event.target as HTMLTextAreaElement;
|
||||
|
||||
// Handle Tab key for indentation
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const value = textarea.value;
|
||||
|
||||
// Insert tab (2 spaces)
|
||||
const newValue = value.substring(0, start) + ' ' + value.substring(end);
|
||||
textarea.value = newValue;
|
||||
textarea.selectionStart = textarea.selectionEnd = start + 2;
|
||||
|
||||
// Update answer
|
||||
this.onCodeInput(questionId, newValue, textarea);
|
||||
}
|
||||
|
||||
// Auto-close brackets
|
||||
else if (event.key === '{' || event.key === '(' || event.key === '[') {
|
||||
event.preventDefault();
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const value = textarea.value;
|
||||
|
||||
const closeBracket = event.key === '{' ? '}' : event.key === '(' ? ')' : ']';
|
||||
const newValue = value.substring(0, start) + event.key + closeBracket + value.substring(end);
|
||||
textarea.value = newValue;
|
||||
textarea.selectionStart = textarea.selectionEnd = start + 1;
|
||||
|
||||
this.onCodeInput(questionId, newValue, textarea);
|
||||
}
|
||||
|
||||
// Skip over existing closing brace if it's already there
|
||||
else if (event.key === '}' || event.key === ')' || event.key === ']') {
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
if (start === end) {
|
||||
const nextChar = textarea.value.substring(start, start + 1);
|
||||
if ((event.key === '}' && nextChar === '}') ||
|
||||
(event.key === ')' && nextChar === ')') ||
|
||||
(event.key === ']' && nextChar === ']')) {
|
||||
event.preventDefault();
|
||||
textarea.selectionStart = textarea.selectionEnd = start + 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Otherwise allow default insertion
|
||||
}
|
||||
|
||||
// Auto indentation on Enter
|
||||
else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
const value = textarea.value;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
|
||||
// Find start of current line
|
||||
const lineStart = value.lastIndexOf('\n', start - 1) + 1; // 0 if not found
|
||||
const lineBeforeCursor = value.substring(lineStart, start);
|
||||
|
||||
// Base indentation: reuse leading whitespace of current line
|
||||
const baseIndentMatch = lineBeforeCursor.match(/^\s*/);
|
||||
let baseIndent = baseIndentMatch ? baseIndentMatch[0] : '';
|
||||
|
||||
// Heuristics for extra indent
|
||||
// Last non-space char before cursor on this line
|
||||
const beforeTrim = lineBeforeCursor.replace(/\s+$/,'');
|
||||
const lastChar = beforeTrim.length ? beforeTrim[beforeTrim.length - 1] : '';
|
||||
|
||||
// Next non-space char after cursor on the same line
|
||||
const lineEnd = value.indexOf('\n', start) === -1 ? value.length : value.indexOf('\n', start);
|
||||
const afterSlice = value.substring(start, lineEnd);
|
||||
const nextNonSpaceMatch = afterSlice.match(/\S/);
|
||||
const nextNonSpace = nextNonSpaceMatch ? afterSlice[nextNonSpaceMatch.index || 0] : '';
|
||||
|
||||
// Special case: between braces "{ | }" → insert interior and closing line
|
||||
const isBetweenBraces = lastChar === '{' && nextNonSpace === '}';
|
||||
if (isBetweenBraces) {
|
||||
const middleIndent = baseIndent + ' ';
|
||||
const insertText = '\n' + middleIndent + '\n' + baseIndent;
|
||||
const newValue = value.substring(0, start) + insertText + value.substring(end);
|
||||
const newCaret = start + 1 + middleIndent.length; // place caret on indented blank line
|
||||
textarea.value = newValue;
|
||||
textarea.selectionStart = textarea.selectionEnd = newCaret;
|
||||
this.onCodeInput(questionId, newValue, textarea);
|
||||
return;
|
||||
}
|
||||
|
||||
// Outdent before a closing brace only (align with brace)
|
||||
if (nextNonSpace === '}') {
|
||||
baseIndent = baseIndent.slice(0, Math.max(0, baseIndent.length - 2));
|
||||
}
|
||||
|
||||
// Extra indent after block openers: '{' for C/C++; ':' for Python
|
||||
let extra = '';
|
||||
if ((lastChar === '{' || lastChar === ':') && nextNonSpace !== '}') {
|
||||
extra = ' ';
|
||||
}
|
||||
|
||||
const insertText = '\n' + baseIndent + extra;
|
||||
const newValue = value.substring(0, start) + insertText + value.substring(end);
|
||||
const newCaret = start + insertText.length;
|
||||
|
||||
textarea.value = newValue;
|
||||
textarea.selectionStart = textarea.selectionEnd = newCaret;
|
||||
this.onCodeInput(questionId, newValue, textarea);
|
||||
}
|
||||
}
|
||||
|
||||
getLineNumbers(questionId: string): number[] {
|
||||
const code = this.answers.get(questionId)?.code || '';
|
||||
const lineCount = Math.max(code.split('\n').length, 20); // minimum 20 lines
|
||||
return Array.from({ length: lineCount }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
syncScroll(event: Event) {
|
||||
const textarea = event.target as HTMLTextAreaElement;
|
||||
const shell = textarea.parentElement as HTMLElement | null; // .code-editor-shell
|
||||
const lineNumbers = shell?.previousElementSibling as HTMLElement | null; // .code-line-numbers
|
||||
const highlight = shell?.querySelector('.code-highlight') as HTMLElement | null;
|
||||
if (lineNumbers) lineNumbers.scrollTop = textarea.scrollTop;
|
||||
if (highlight) {
|
||||
// Move highlight content with textarea scroll (both axes)
|
||||
highlight.style.transform = `translate(${-textarea.scrollLeft}px, ${-textarea.scrollTop}px)`;
|
||||
}
|
||||
}
|
||||
|
||||
// Very lightweight syntax highlighter for C++ and Python
|
||||
getHighlightedCode(questionId: string, language: string): string {
|
||||
const code: string = this.answers.get(questionId)?.code || '';
|
||||
const escaped = this.escapeHtml(code);
|
||||
const lang = (language || '').toLowerCase();
|
||||
|
||||
if (lang === 'cpp' || lang === 'c++' || lang === 'cc' || lang === 'cxx') {
|
||||
return this.highlightCpp(escaped);
|
||||
}
|
||||
if (lang === 'py' || lang === 'python') {
|
||||
return this.highlightPython(escaped);
|
||||
}
|
||||
return escaped; // fallback
|
||||
}
|
||||
|
||||
// Prism helpers
|
||||
getPrismLanguageClass(language: string): string {
|
||||
const lang = (language || '').toLowerCase();
|
||||
if (lang === 'cpp' || lang === 'c++' || lang === 'cc' || lang === 'cxx') return 'language-cpp';
|
||||
if (lang === 'c') return 'language-c';
|
||||
if (lang === 'py' || lang === 'python') return 'language-python';
|
||||
return 'language-clike';
|
||||
}
|
||||
|
||||
getPrismHighlighted(questionId: string, language: string): string {
|
||||
const code: string = this.answers.get(questionId)?.code || '';
|
||||
const escaped = this.escapeHtml(code);
|
||||
// Use Prism directly for immediate, single-node highlight (faster, no layout thrash)
|
||||
try {
|
||||
// @ts-ignore
|
||||
const Prism = (window as any).Prism;
|
||||
if (!Prism || !Prism.languages) return escaped;
|
||||
const langKey = (() => {
|
||||
const l = (language || '').toLowerCase();
|
||||
if (l === 'cpp' || l === 'c++' || l === 'cc' || l === 'cxx') return 'cpp';
|
||||
if (l === 'c') return 'c';
|
||||
if (l === 'py' || l === 'python') return 'python';
|
||||
return 'clike';
|
||||
})();
|
||||
const grammar = Prism.languages[langKey] || Prism.languages.clike;
|
||||
return Prism.highlight(code, grammar, langKey);
|
||||
} catch {
|
||||
return escaped;
|
||||
}
|
||||
}
|
||||
|
||||
private escapeHtml(input: string): string {
|
||||
return input
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
private highlightCpp(code: string): string {
|
||||
// Order matters: comments -> strings -> numbers -> keywords/types -> functions
|
||||
// Comments
|
||||
code = code
|
||||
.replace(/\/\/[^\n]*/g, (m) => `<span class=tok-comment>${m}</span>`) // line comments
|
||||
.replace(/\/\*[\s\S]*?\*\//g, (m) => `<span class=tok-comment>${m}</span>`); // block comments
|
||||
|
||||
// Strings (simple '"..."' and character literals)
|
||||
code = code
|
||||
.replace(/'(?:\\.|[^'\\])'/g, (m) => `<span class=tok-string>${m}</span>`) // char
|
||||
.replace(/"(?:\\.|[^"\\])*"/g, (m) => `<span class=tok-string>${m}</span>`); // string
|
||||
|
||||
// Numbers
|
||||
code = code.replace(/\b(0x[0-9a-fA-F]+|\d+\.\d+|\d+)\b/g, (m) => `<span class=tok-number>${m}</span>`);
|
||||
|
||||
const keywords = [
|
||||
'alignas','alignof','and','and_eq','asm','auto','bitand','bitor','bool','break','case','catch','char','char16_t','char32_t','class','compl','const','constexpr','const_cast','continue','decltype','default','delete','do','double','dynamic_cast','else','enum','explicit','export','extern','false','float','for','friend','goto','if','inline','int','long','mutable','namespace','new','noexcept','not','not_eq','nullptr','operator','or','or_eq','private','protected','public','register','reinterpret_cast','return','short','signed','sizeof','static','static_assert','static_cast','struct','switch','template','this','thread_local','throw','true','try','typedef','typeid','typename','union','unsigned','using','virtual','void','volatile','wchar_t','while','xor','xor_eq'
|
||||
];
|
||||
const types = ['std','string','vector','map','set','unordered_map','unordered_set'];
|
||||
|
||||
const kwRegex = new RegExp(`\\b(${keywords.join('|')})\\b`, 'g');
|
||||
const typeRegex = new RegExp(`\\b(${types.join('|')})\\b`, 'g');
|
||||
code = code.replace(kwRegex, '<span class=tok-keyword>$1</span>');
|
||||
code = code.replace(typeRegex, '<span class=tok-type>$1</span>');
|
||||
|
||||
// Functions: identifier followed by '('
|
||||
code = code.replace(/\b([A-Za-z_][A-Za-z0-9_]*)\s*(?=\()/g, '<span class=tok-func>$1</span>');
|
||||
return code;
|
||||
}
|
||||
|
||||
private highlightPython(code: string): string {
|
||||
// Comments
|
||||
code = code.replace(/#[^\n]*/g, (m) => `<span class=tok-comment>${m}</span>`);
|
||||
|
||||
// Strings (single, double, triple)
|
||||
code = code
|
||||
.replace(/'''[\s\S]*?'''/g, (m) => `<span class=tok-string>${m}</span>`)
|
||||
.replace(/"""[\s\S]*?"""/g, (m) => `<span class=tok-string>${m}</span>`)
|
||||
.replace(/'(?:\\.|[^'\\])*'/g, (m) => `<span class=tok-string>${m}</span>`)
|
||||
.replace(/"(?:\\.|[^"\\])*"/g, (m) => `<span class=tok-string>${m}</span>`);
|
||||
|
||||
// Numbers
|
||||
code = code.replace(/\b(0x[0-9a-fA-F]+|\d+\.\d+|\d+)\b/g, (m) => `<span class=tok-number>${m}</span>`);
|
||||
|
||||
const keywords = [
|
||||
'False','None','True','and','as','assert','async','await','break','class','continue','def','del','elif','else','except','finally','for','from','global','if','import','in','is','lambda','nonlocal','not','or','pass','raise','return','try','while','with','yield'
|
||||
];
|
||||
const kwRegex = new RegExp(`\\b(${keywords.join('|')})\\b`, 'g');
|
||||
code = code.replace(kwRegex, '<span class=tok-keyword>$1</span>');
|
||||
|
||||
// Functions: identifier followed by '('
|
||||
code = code.replace(/\b([A-Za-z_][A-Za-z0-9_]*)\s*(?=\()/g, '<span class=tok-func>$1</span>');
|
||||
return code;
|
||||
}
|
||||
|
||||
// Multiple choice helpers
|
||||
isMultipleChoiceSelected(questionId: string, choiceKey: string): boolean {
|
||||
const answer = this.answers.get(questionId);
|
||||
if (!answer) return false;
|
||||
if (Array.isArray(answer)) {
|
||||
return answer.includes(choiceKey);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isMultipleChoiceIDK(questionId: string): boolean {
|
||||
const answer = this.answers.get(questionId);
|
||||
if (!answer) return false;
|
||||
if (Array.isArray(answer)) {
|
||||
return answer.includes('IDK');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
onMultipleChoiceChange(questionId: string, choiceKey: string, checked: boolean) {
|
||||
let currentAnswer = this.answers.get(questionId);
|
||||
|
||||
// Initialize as array if not exists
|
||||
if (!currentAnswer || !Array.isArray(currentAnswer)) {
|
||||
currentAnswer = [];
|
||||
}
|
||||
|
||||
// Remove IDK if present (user is making a real selection)
|
||||
currentAnswer = currentAnswer.filter((k: any) => k !== 'IDK');
|
||||
|
||||
if (checked) {
|
||||
// Add this choice if not already present
|
||||
if (!currentAnswer.includes(choiceKey)) {
|
||||
currentAnswer.push(choiceKey);
|
||||
}
|
||||
} else {
|
||||
// Remove this choice
|
||||
currentAnswer = currentAnswer.filter((k: any) => k !== choiceKey);
|
||||
}
|
||||
|
||||
this.answers.set(questionId, currentAnswer);
|
||||
|
||||
// Track time
|
||||
const currentTime = this.questionTimers.get(questionId) || 0;
|
||||
this.questionTimers.set(questionId, currentTime + 1);
|
||||
}
|
||||
|
||||
onMultipleChoiceIDKChange(questionId: string, checked: boolean) {
|
||||
if (checked) {
|
||||
// Set to IDK only, clear all other selections
|
||||
this.answers.set(questionId, ['IDK']);
|
||||
} else {
|
||||
// Clear IDK
|
||||
this.answers.set(questionId, []);
|
||||
}
|
||||
|
||||
// Track time
|
||||
const currentTime = this.questionTimers.get(questionId) || 0;
|
||||
this.questionTimers.set(questionId, currentTime + 1);
|
||||
}
|
||||
|
||||
submit() {
|
||||
if (!confirm('Are you sure you want to submit? You cannot change answers after submission.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.attempt) return;
|
||||
|
||||
// Final autosave before submit
|
||||
this.autosave();
|
||||
|
||||
setTimeout(() => {
|
||||
this.apiService.submitAttempt(this.attempt!.attemptId).subscribe({
|
||||
next: (response) => {
|
||||
// Pass score data if available
|
||||
const navigationExtras = response.score ? { state: { score: response.score } } : {};
|
||||
this.router.navigate(['/done', this.attempt!.attemptId], navigationExtras);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to submit exam';
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
|
||||
autoSubmit() {
|
||||
if (!this.attempt) return;
|
||||
this.apiService.submitAttempt(this.attempt.attemptId).subscribe({
|
||||
next: () => {
|
||||
this.router.navigate(['/done', this.attempt!.attemptId]);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getTimeRemainingString(): string {
|
||||
const minutes = Math.floor(this.timeRemaining / 60000);
|
||||
const seconds = Math.floor((this.timeRemaining % 60000) / 1000);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
nextQuestion() {
|
||||
if (this.currentQuestionIndex < this.allQuestions.length - 1) {
|
||||
this.currentQuestionIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
prevQuestion() {
|
||||
if (this.currentQuestionIndex > 0) {
|
||||
this.currentQuestionIndex--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.filter-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
background: #e7f3ff;
|
||||
border: 1px solid #0d6efd;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
.clear-filter {
|
||||
padding: 4px 10px;
|
||||
background: #0d6efd;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.clear-filter:hover {
|
||||
background: #0a58ca;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #007bff;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.link-btn:hover {
|
||||
color: #0056b3;
|
||||
}
|
||||
|
||||
.history-card h3 {
|
||||
color: #007bff;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.attempts {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.attempts h4 {
|
||||
margin-bottom: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.attempt-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
margin: 5px 0;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.attempt-info {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background: #ffc107;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status.finished {
|
||||
background: #28a745;
|
||||
}
|
||||
|
||||
.date {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.answers-count {
|
||||
color: #007bff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
padding: 6px 16px;
|
||||
font-size: 14px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.retake-btn {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
margin-top: 20px;
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<div class="container">
|
||||
<div class="header-section">
|
||||
<h2>My Exam History</h2>
|
||||
|
||||
<!-- Filter indicator -->
|
||||
<div *ngIf="selectedExamId" class="filter-badge">
|
||||
<span>Filtered by exam</span>
|
||||
<button (click)="clearFilter()" class="clear-filter">✕ Show All</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="loading" class="loading">Loading history...</div>
|
||||
|
||||
<div *ngIf="error" class="error">{{ error }}</div>
|
||||
|
||||
<div *ngIf="!loading && filteredHistory.length === 0" class="card">
|
||||
<p *ngIf="selectedExamId">No history found for this exam. <button (click)="clearFilter()" class="link-btn">Show all history</button></p>
|
||||
<p *ngIf="!selectedExamId">No exam history yet. <a (click)="goBack()">Take an exam</a></p>
|
||||
</div>
|
||||
|
||||
<div *ngFor="let exam of getDisplayHistory()" class="card history-card">
|
||||
<h3>{{ exam.examTitle }}</h3>
|
||||
<p><strong>Exam ID:</strong> {{ exam.examId }}</p>
|
||||
<p><strong>Total Attempts:</strong> {{ exam.totalAttempts }}</p>
|
||||
|
||||
<div class="attempts">
|
||||
<h4>Attempts:</h4>
|
||||
<div *ngFor="let attempt of exam.attempts" class="attempt-item">
|
||||
<div class="attempt-info">
|
||||
<span class="status" [class.finished]="attempt.status === 'finished'">
|
||||
{{ attempt.status }}
|
||||
</span>
|
||||
<span class="date">Started: {{ attempt.startedAt | date:'short' }}</span>
|
||||
<span *ngIf="attempt.submittedAt" class="date">Submitted: {{ attempt.submittedAt | date:'short' }}</span>
|
||||
<span class="answers-count">Answers: {{ attempt.answersCount }}</span>
|
||||
</div>
|
||||
<button *ngIf="attempt.status === 'finished'"
|
||||
(click)="viewResult(attempt.attemptId)"
|
||||
class="view-btn">
|
||||
View Results
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button *ngIf="exam.canRetake"
|
||||
(click)="retakeExam(exam.examId)"
|
||||
class="primary retake-btn">
|
||||
Take Again
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button (click)="goBack()" class="back-btn">Back to Exams</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { ApiService } from '../../services/api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-history',
|
||||
templateUrl: './history.component.html',
|
||||
styleUrls: ['./history.component.css']
|
||||
})
|
||||
export class HistoryComponent implements OnInit {
|
||||
history: any[] = [];
|
||||
filteredHistory: any[] = [];
|
||||
loading = true;
|
||||
error = '';
|
||||
selectedExamId: string | null = null;
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private router: Router,
|
||||
private route: ActivatedRoute
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
// Check for examId query parameter
|
||||
this.route.queryParams.subscribe(params => {
|
||||
this.selectedExamId = params['examId'] || null;
|
||||
this.loadHistory();
|
||||
});
|
||||
}
|
||||
|
||||
loadHistory() {
|
||||
this.loading = true;
|
||||
this.apiService.getHistory().subscribe({
|
||||
next: (response) => {
|
||||
this.history = response.history;
|
||||
|
||||
// Filter by examId if provided
|
||||
if (this.selectedExamId) {
|
||||
this.filteredHistory = this.history.filter(
|
||||
exam => exam.examId === this.selectedExamId
|
||||
);
|
||||
} else {
|
||||
this.filteredHistory = this.history;
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to load history';
|
||||
this.loading = false;
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearFilter() {
|
||||
this.selectedExamId = null;
|
||||
this.router.navigate(['/history']);
|
||||
}
|
||||
|
||||
getDisplayHistory(): any[] {
|
||||
return this.filteredHistory;
|
||||
}
|
||||
|
||||
viewResult(attemptId: string) {
|
||||
this.router.navigate(['/result', attemptId]);
|
||||
}
|
||||
|
||||
retakeExam(examId: string) {
|
||||
this.apiService.resetExam(examId).subscribe({
|
||||
next: () => {
|
||||
this.router.navigate(['/exam', examId]);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to reset exam';
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
goBack() {
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
.login-card {
|
||||
max-width: 500px;
|
||||
margin: 50px auto;
|
||||
}
|
||||
|
||||
.login-card h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button[type="submit"] {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.toggle-link {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.toggle-link a {
|
||||
color: #007bff;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.toggle-link a:hover {
|
||||
color: #0056b3;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<div class="container">
|
||||
<div class="card login-card">
|
||||
<h2>{{ showRegister ? 'Register' : 'Login' }}</h2>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form *ngIf="!showRegister" (ngSubmit)="login()">
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" [(ngModel)]="username" name="username" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Password</label>
|
||||
<input type="password" [(ngModel)]="password" name="password" required>
|
||||
</div>
|
||||
|
||||
<div class="error" *ngIf="error">{{ error }}</div>
|
||||
|
||||
<button type="submit" class="primary" [disabled]="loading || !username || !password">
|
||||
{{ loading ? 'Logging in...' : 'Login' }}
|
||||
</button>
|
||||
|
||||
<p class="toggle-link">
|
||||
Don't have an account? <a (click)="toggleRegister()">Register here</a>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<!-- Registration Form -->
|
||||
<form *ngIf="showRegister" (ngSubmit)="register()">
|
||||
<div class="form-group">
|
||||
<label>Username *</label>
|
||||
<input type="text" [(ngModel)]="regUsername" name="regUsername" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Email *</label>
|
||||
<input type="email" [(ngModel)]="regEmail" name="regEmail" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>First Name</label>
|
||||
<input type="text" [(ngModel)]="regFirstName" name="regFirstName">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Last Name</label>
|
||||
<input type="text" [(ngModel)]="regLastName" name="regLastName">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Password *</label>
|
||||
<input type="password" [(ngModel)]="regPassword" name="regPassword" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Confirm Password *</label>
|
||||
<input type="password" [(ngModel)]="regPasswordConfirm" name="regPasswordConfirm" required>
|
||||
</div>
|
||||
|
||||
<div class="error" *ngIf="error">{{ error }}</div>
|
||||
|
||||
<button type="submit" class="primary" [disabled]="loading || !regUsername || !regEmail || !regPassword || !regPasswordConfirm">
|
||||
{{ loading ? 'Registering...' : 'Register' }}
|
||||
</button>
|
||||
|
||||
<p class="toggle-link">
|
||||
Already have an account? <a (click)="toggleRegister()">Login here</a>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
templateUrl: './login.component.html',
|
||||
styleUrls: ['./login.component.css']
|
||||
})
|
||||
export class LoginComponent {
|
||||
username = '';
|
||||
password = '';
|
||||
error = '';
|
||||
loading = false;
|
||||
showRegister = false;
|
||||
|
||||
// Registration fields
|
||||
regUsername = '';
|
||||
regEmail = '';
|
||||
regPassword = '';
|
||||
regPasswordConfirm = '';
|
||||
regFirstName = '';
|
||||
regLastName = '';
|
||||
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private router: Router
|
||||
) {}
|
||||
|
||||
login() {
|
||||
this.error = '';
|
||||
this.loading = true;
|
||||
|
||||
this.authService.login(this.username, this.password).subscribe({
|
||||
next: () => {
|
||||
this.router.navigate(['/']);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = err.error?.error || 'Login failed';
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
register() {
|
||||
this.error = '';
|
||||
this.loading = true;
|
||||
|
||||
this.authService.register(
|
||||
this.regUsername,
|
||||
this.regEmail,
|
||||
this.regPassword,
|
||||
this.regPasswordConfirm,
|
||||
this.regFirstName,
|
||||
this.regLastName
|
||||
).subscribe({
|
||||
next: () => {
|
||||
// Auto-login after registration
|
||||
this.authService.login(this.regUsername, this.regPassword).subscribe({
|
||||
next: () => {
|
||||
this.router.navigate(['/']);
|
||||
},
|
||||
error: () => {
|
||||
this.showRegister = false;
|
||||
this.error = 'Registration successful! Please login.';
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = err.error?.username?.[0] || err.error?.email?.[0] || 'Registration failed';
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleRegister() {
|
||||
this.showRegister = !this.showRegister;
|
||||
this.error = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/* Options list styles */
|
||||
.options-list {
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
background: white;
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.option-item.selected {
|
||||
border-color: #007bff;
|
||||
background: #e7f3ff;
|
||||
}
|
||||
|
||||
.option-item.correct {
|
||||
border-color: #28a745;
|
||||
background: #d4edda;
|
||||
}
|
||||
|
||||
.option-item.incorrect {
|
||||
border-color: #dc3545;
|
||||
background: #f8d7da;
|
||||
}
|
||||
|
||||
.option-item.selected.correct {
|
||||
border-color: #28a745;
|
||||
background: #d4edda;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.option-item.selected.incorrect {
|
||||
border-color: #dc3545;
|
||||
background: #f8d7da;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.choice-key {
|
||||
font-weight: bold;
|
||||
margin-right: 10px;
|
||||
min-width: 30px;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.choice-text {
|
||||
flex: 1;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.marker {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: 10px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.correct-marker {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.incorrect-marker {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.selected-marker {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.essay-answer, .code-answer {
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-left: 4px solid #007bff;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.code-answer {
|
||||
font-family: 'Courier New', monospace;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.idk-notice {
|
||||
margin: 15px 0;
|
||||
padding: 12px;
|
||||
background: #fff3cd;
|
||||
border: 2px solid #ffc107;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.idk-notice .marker {
|
||||
background: #ffc107;
|
||||
color: #212529;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.instruction {
|
||||
font-size: 14px;
|
||||
color: #6c757d;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
.result-header {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.result-header h2 {
|
||||
color: #007bff;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.meta p {
|
||||
margin: 5px 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.score-summary-header {
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.score-summary-header p {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pass-status {
|
||||
color: #dc3545;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.pass-status.passed {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.passing-threshold {
|
||||
color: #6c757d;
|
||||
font-size: 14px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.section-card h3 {
|
||||
color: #007bff;
|
||||
border-bottom: 2px solid #007bff;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.question-result {
|
||||
padding: 20px;
|
||||
margin: 15px 0;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid #dee2e6;
|
||||
}
|
||||
|
||||
.question-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.question-number {
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.correct-badge {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.incorrect-badge {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.question-prompt {
|
||||
font-size: 16px;
|
||||
margin: 10px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.points {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.answer-section {
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.your-answer {
|
||||
padding: 10px;
|
||||
background: white;
|
||||
border-left: 4px solid #007bff;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.your-answer pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.code-answer {
|
||||
font-family: 'Courier New', monospace;
|
||||
background: #f8f9fa;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Manual Grading Comments */
|
||||
.manual-grading {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #007bff;
|
||||
}
|
||||
|
||||
.manual-grading h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #007bff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.grading-comments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.comment-item {
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background-color: white;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.comment-type {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.comment-type.type-positive {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.comment-type.type-negative {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.comment-type.type-suggestion {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.comment-points {
|
||||
font-weight: 600;
|
||||
color: #007bff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.comment-text {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
margin-top: 30px;
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<div class="container" *ngIf="!loading && result">
|
||||
<div class="card result-header">
|
||||
<h2>{{ result.exam.title }} - Results</h2>
|
||||
<div class="meta">
|
||||
<p><strong>Attempt ID:</strong> {{ result.attempt.attemptId }}</p>
|
||||
<p><strong>Submitted:</strong> {{ result.attempt.submittedAt | date:'medium' }}</p>
|
||||
<p><strong>Status:</strong> {{ result.attempt.status }}</p>
|
||||
<div class="score-summary-header">
|
||||
<p><strong>Score:</strong> {{ getTotalScore() }} / {{ getMaxScore() }} ({{ getPercentage() | number:'1.1-1' }}%)</p>
|
||||
<p class="pass-status" [class.passed]="isPassed()">
|
||||
{{ isPassed() ? '✓ PASSED' : '✗ NOT PASSED' }}
|
||||
</p>
|
||||
<p class="passing-threshold"><strong>Passing Score:</strong> {{ result.exam.passingScore }}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngFor="let section of result.exam.sections; let sIdx = index" class="card section-card">
|
||||
<h3>{{ section.title }}</h3>
|
||||
|
||||
<div *ngFor="let question of section.questions; let qIdx = index" class="question-result">
|
||||
<div class="question-header">
|
||||
<span class="question-number">Question {{ qIdx + 1 }}</span>
|
||||
<span *ngIf="isCorrect(question)" class="correct-badge">✓ Correct</span>
|
||||
<span *ngIf="isIncorrect(question)" class="incorrect-badge">✗ Incorrect</span>
|
||||
</div>
|
||||
|
||||
<p class="question-prompt">{{ question.prompt }}</p>
|
||||
<p class="points">Points: {{ getQuestionScore(question) }} / {{ question.points }}</p>
|
||||
|
||||
<!-- Single Choice - Show all options -->
|
||||
<div *ngIf="question.type === 'single_choice'" class="options-list">
|
||||
<div *ngFor="let choice of question.choices"
|
||||
class="option-item"
|
||||
[class.selected]="getAnswerForQuestion(question.id)?.response === choice.key"
|
||||
[class.correct]="choice.key === question.answer"
|
||||
[class.incorrect]="getAnswerForQuestion(question.id)?.response === choice.key && choice.key !== question.answer">
|
||||
<span class="choice-key">{{ choice.key }}.</span>
|
||||
<span class="choice-text">{{ choice.text }}</span>
|
||||
<span *ngIf="choice.key === question.answer" class="marker correct-marker">✓ Correct Answer</span>
|
||||
<span *ngIf="getAnswerForQuestion(question.id)?.response === choice.key && choice.key !== question.answer" class="marker incorrect-marker">✗ Your Answer</span>
|
||||
<span *ngIf="getAnswerForQuestion(question.id)?.response === choice.key && choice.key === question.answer" class="marker selected-marker">✓ Your Answer (Correct!)</span>
|
||||
</div>
|
||||
<!-- Show if IDK was selected -->
|
||||
<div *ngIf="isIDKSelected(question.id)" class="idk-notice">
|
||||
<span class="marker incorrect-marker">? You selected "I don't know"</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Multiple Choices - Show all options -->
|
||||
<div *ngIf="question.type === 'multiple_choices'" class="options-list">
|
||||
<p class="instruction">Correct answers: {{ getCorrectAnswersString(question.answer) }}</p>
|
||||
<div *ngFor="let choice of question.choices"
|
||||
class="option-item"
|
||||
[class.selected]="isSelectedInMultiple(question.id, choice.key)"
|
||||
[class.correct]="isCorrectInMultiple(question.answer, choice.key)"
|
||||
[class.incorrect]="isSelectedInMultiple(question.id, choice.key) && !isCorrectInMultiple(question.answer, choice.key)">
|
||||
<span class="choice-key">{{ choice.key }}.</span>
|
||||
<span class="choice-text">{{ choice.text }}</span>
|
||||
<span *ngIf="isCorrectInMultiple(question.answer, choice.key)" class="marker correct-marker">✓ Correct</span>
|
||||
<span *ngIf="isSelectedInMultiple(question.id, choice.key) && !isCorrectInMultiple(question.answer, choice.key)" class="marker incorrect-marker">✗ Wrong Selection</span>
|
||||
<span *ngIf="isSelectedInMultiple(question.id, choice.key) && isCorrectInMultiple(question.answer, choice.key)" class="marker selected-marker">✓ Your Selection (Correct!)</span>
|
||||
</div>
|
||||
<!-- Show if IDK was selected -->
|
||||
<div *ngIf="isIDKSelected(question.id)" class="idk-notice">
|
||||
<span class="marker incorrect-marker">? You selected "I don't know"</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- True/False - Show both options -->
|
||||
<div *ngIf="question.type === 'true_false'" class="options-list">
|
||||
<div class="option-item"
|
||||
[class.selected]="getAnswerForQuestion(question.id)?.response === true"
|
||||
[class.correct]="question.answer === true"
|
||||
[class.incorrect]="getAnswerForQuestion(question.id)?.response === true && question.answer !== true">
|
||||
<span class="choice-key">True</span>
|
||||
<span *ngIf="question.answer === true" class="marker correct-marker">✓ Correct Answer</span>
|
||||
<span *ngIf="getAnswerForQuestion(question.id)?.response === true && question.answer !== true" class="marker incorrect-marker">✗ Your Answer</span>
|
||||
<span *ngIf="getAnswerForQuestion(question.id)?.response === true && question.answer === true" class="marker selected-marker">✓ Your Answer (Correct!)</span>
|
||||
</div>
|
||||
<div class="option-item"
|
||||
[class.selected]="getAnswerForQuestion(question.id)?.response === false"
|
||||
[class.correct]="question.answer === false"
|
||||
[class.incorrect]="getAnswerForQuestion(question.id)?.response === false && question.answer !== false">
|
||||
<span class="choice-key">False</span>
|
||||
<span *ngIf="question.answer === false" class="marker correct-marker">✓ Correct Answer</span>
|
||||
<span *ngIf="getAnswerForQuestion(question.id)?.response === false && question.answer !== false" class="marker incorrect-marker">✗ Your Answer</span>
|
||||
<span *ngIf="getAnswerForQuestion(question.id)?.response === false && question.answer === false" class="marker selected-marker">✓ Your Answer (Correct!)</span>
|
||||
</div>
|
||||
<!-- Show if IDK was selected -->
|
||||
<div *ngIf="isIDKSelected(question.id)" class="idk-notice">
|
||||
<span class="marker incorrect-marker">? You selected "I don't know"</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Essay -->
|
||||
<div *ngIf="question.type === 'essay'" class="answer-section">
|
||||
<p><strong>Your Answer:</strong></p>
|
||||
<pre class="essay-answer">{{ getAnswerForQuestion(question.id)?.response || 'Not answered' }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- Code -->
|
||||
<div *ngIf="question.type === 'code_simple' || question.type === 'code_exercise'" class="answer-section">
|
||||
<p><strong>Your Code:</strong></p>
|
||||
<pre class="code-answer">{{ getAnswerForQuestion(question.id)?.response?.code || 'Not answered' }}</pre>
|
||||
|
||||
<!-- Manual Grading Comments -->
|
||||
<div *ngIf="getManualGradingComments(question.id)" class="manual-grading">
|
||||
<h4>📝 Instructor Feedback:</h4>
|
||||
<div class="grading-comments">
|
||||
<div *ngFor="let comment of getManualGradingComments(question.id)" class="comment-item">
|
||||
<div class="comment-header">
|
||||
<span class="comment-type" [class]="'type-' + comment.type">{{ comment.type === 'positive' ? '✅' : comment.type === 'negative' ? '❌' : '💡' }} {{ comment.type | titlecase }}</span>
|
||||
<span class="comment-points" *ngIf="comment.points !== undefined">{{ comment.points }} points</span>
|
||||
</div>
|
||||
<p class="comment-text">{{ comment.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button (click)="goBack()" class="back-btn">Back to History</button>
|
||||
</div>
|
||||
|
||||
<div class="container" *ngIf="loading">
|
||||
<div class="card">Loading results...</div>
|
||||
</div>
|
||||
|
||||
<div class="container" *ngIf="error">
|
||||
<div class="card error">{{ error }}</div>
|
||||
<button (click)="goBack()">Back to History</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ApiService } from '../../services/api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-result',
|
||||
templateUrl: './result.component.html',
|
||||
styleUrls: ['./result.component.css', './result-options.css']
|
||||
})
|
||||
export class ResultComponent implements OnInit {
|
||||
result: any = null;
|
||||
loading = true;
|
||||
error = '';
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private apiService: ApiService
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
const attemptId = this.route.snapshot.paramMap.get('attemptId');
|
||||
if (attemptId) {
|
||||
this.loadResult(attemptId);
|
||||
}
|
||||
}
|
||||
|
||||
loadResult(attemptId: string) {
|
||||
this.apiService.getAttemptResult(attemptId).subscribe({
|
||||
next: (data) => {
|
||||
this.result = data;
|
||||
this.loading = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Failed to load results';
|
||||
this.loading = false;
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getAnswerForQuestion(questionId: string): any {
|
||||
if (!this.result) return null;
|
||||
const answer = this.result.attempt.answers.find((a: any) => a.questionId === questionId);
|
||||
return answer;
|
||||
}
|
||||
|
||||
isCorrect(question: any): boolean {
|
||||
// For coding questions, check the score
|
||||
if (question.type === 'code_simple' || question.type === 'code_exercise') {
|
||||
const scoreInfo = this.getQuestionScoreInfo(question.id);
|
||||
return scoreInfo ? scoreInfo.isCorrect : false;
|
||||
}
|
||||
// For multiple choices, compare sets (order-insensitive)
|
||||
if (question.type === 'multiple_choices') {
|
||||
return this.isMultipleFullyCorrect(question);
|
||||
}
|
||||
|
||||
// For other question types, check answer directly
|
||||
const userAnswer = this.getAnswerForQuestion(question.id);
|
||||
if (!userAnswer || !question.answer) return false;
|
||||
return userAnswer.response === question.answer;
|
||||
}
|
||||
|
||||
isIncorrect(question: any): boolean {
|
||||
// For coding questions, check the score
|
||||
if (question.type === 'code_simple' || question.type === 'code_exercise') {
|
||||
const scoreInfo = this.getQuestionScoreInfo(question.id);
|
||||
return scoreInfo ? !scoreInfo.isCorrect : true;
|
||||
}
|
||||
// For multiple choices, compare sets (order-insensitive)
|
||||
if (question.type === 'multiple_choices') {
|
||||
const isFull = this.isMultipleFullyCorrect(question);
|
||||
return !isFull;
|
||||
}
|
||||
|
||||
// For other question types, check answer directly
|
||||
const userAnswer = this.getAnswerForQuestion(question.id);
|
||||
if (!userAnswer || !question.answer) return false;
|
||||
return userAnswer.response !== question.answer;
|
||||
}
|
||||
|
||||
getQuestionScoreInfo(questionId: string): any {
|
||||
if (!this.result.attempt.score || !this.result.attempt.score.byQuestion) {
|
||||
return null;
|
||||
}
|
||||
return this.result.attempt.score.byQuestion.find((q: any) => q.questionId === questionId);
|
||||
}
|
||||
|
||||
getQuestionScore(question: any): number {
|
||||
if (!this.result.attempt.score || !this.result.attempt.score.byQuestion) {
|
||||
return 0;
|
||||
}
|
||||
const scoreInfo = this.result.attempt.score.byQuestion.find((q: any) => q.questionId === question.id);
|
||||
return scoreInfo ? scoreInfo.earned : 0;
|
||||
}
|
||||
|
||||
// Multiple choice helpers
|
||||
private getUserMultipleAnswers(questionId: string): string[] {
|
||||
const userAnswer = this.getAnswerForQuestion(questionId);
|
||||
if (!userAnswer || !userAnswer.response) return [];
|
||||
if (Array.isArray(userAnswer.response)) return userAnswer.response as string[];
|
||||
return [userAnswer.response];
|
||||
}
|
||||
|
||||
private areArraysEqualAsSets(a: string[], b: string[]): boolean {
|
||||
if (!a || !b) return false;
|
||||
const sa = new Set(a);
|
||||
const sb = new Set(b);
|
||||
if (sa.size !== sb.size) return false;
|
||||
for (const v of sa) {
|
||||
if (!sb.has(v)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private isMultipleFullyCorrect(question: any): boolean {
|
||||
const user = this.getUserMultipleAnswers(question.id).filter((k: any) => k !== 'IDK');
|
||||
const correct = Array.isArray(question.answer) ? question.answer : [question.answer];
|
||||
return this.areArraysEqualAsSets(user, correct);
|
||||
}
|
||||
|
||||
isSelectedInMultiple(questionId: string, choiceKey: string): boolean {
|
||||
const userAnswer = this.getAnswerForQuestion(questionId);
|
||||
if (!userAnswer || !userAnswer.response) return false;
|
||||
if (Array.isArray(userAnswer.response)) {
|
||||
return userAnswer.response.includes(choiceKey);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isCorrectInMultiple(correctAnswer: any, choiceKey: string): boolean {
|
||||
if (!correctAnswer) return false;
|
||||
if (Array.isArray(correctAnswer)) {
|
||||
return correctAnswer.includes(choiceKey);
|
||||
}
|
||||
return correctAnswer === choiceKey;
|
||||
}
|
||||
|
||||
getCorrectAnswersString(answer: any): string {
|
||||
if (Array.isArray(answer)) {
|
||||
return answer.join(', ');
|
||||
}
|
||||
return String(answer);
|
||||
}
|
||||
|
||||
isIDKSelected(questionId: string): boolean {
|
||||
const userAnswer = this.getAnswerForQuestion(questionId);
|
||||
if (!userAnswer || !userAnswer.response) return false;
|
||||
|
||||
// Check for single "IDK" response
|
||||
if (userAnswer.response === 'IDK') return true;
|
||||
|
||||
// Check for "IDK" in array (multiple_choices)
|
||||
if (Array.isArray(userAnswer.response)) {
|
||||
return userAnswer.response.includes('IDK');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
getManualGradingComments(questionId: string): any[] {
|
||||
if (!this.result || !this.result.attempt.manualGrading) return [];
|
||||
return this.result.attempt.manualGrading[questionId] || [];
|
||||
}
|
||||
|
||||
getTotalScore(): number {
|
||||
if (!this.result || !this.result.attempt.score) return 0;
|
||||
return this.result.attempt.score.totalScore;
|
||||
}
|
||||
|
||||
getMaxScore(): number {
|
||||
if (!this.result || !this.result.attempt.score) return 0;
|
||||
return this.result.attempt.score.maxScore;
|
||||
}
|
||||
|
||||
getPercentage(): number {
|
||||
if (!this.result || !this.result.attempt.score) return 0;
|
||||
return this.result.attempt.score.percentage;
|
||||
}
|
||||
|
||||
isPassed(): boolean {
|
||||
if (!this.result || !this.result.attempt.score) return false;
|
||||
return this.result.attempt.score.passed;
|
||||
}
|
||||
|
||||
goBack() {
|
||||
this.router.navigate(['/history']);
|
||||
}
|
||||
}
|
||||
|
||||
101
exam_system/exam_web/src/app/services/api.service.ts
Normal file
101
exam_system/exam_web/src/app/services/api.service.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface Exam {
|
||||
examId: string;
|
||||
subject: string;
|
||||
title: string;
|
||||
difficulty: string;
|
||||
durationMinutes: number;
|
||||
sections: ExamSection[];
|
||||
metadata?: any;
|
||||
}
|
||||
|
||||
export interface ExamSection {
|
||||
id: string;
|
||||
title: string;
|
||||
questions: Question[];
|
||||
}
|
||||
|
||||
export interface Question {
|
||||
id: string;
|
||||
type: 'single_choice' | 'true_false' | 'essay' | 'code_simple' | 'code_exercise';
|
||||
prompt: string;
|
||||
points: number;
|
||||
choices?: { key: string; text: string }[];
|
||||
answer?: any;
|
||||
rubric?: any;
|
||||
language?: string;
|
||||
tests?: any[];
|
||||
}
|
||||
|
||||
export interface Attempt {
|
||||
attemptId: string;
|
||||
userId: string;
|
||||
examId: string;
|
||||
status: string;
|
||||
startedAt: string;
|
||||
updatedAt: string;
|
||||
submittedAt?: string;
|
||||
answers: Answer[];
|
||||
}
|
||||
|
||||
export interface Answer {
|
||||
questionId: string;
|
||||
response: any;
|
||||
timeSec?: number;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ApiService {
|
||||
private apiUrl = '/api';
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
listExams(): Observable<{ exams: any[] }> {
|
||||
return this.http.get<{ exams: any[] }>(`${this.apiUrl}/exams/`);
|
||||
}
|
||||
|
||||
getExam(examId: string): Observable<Exam> {
|
||||
return this.http.get<Exam>(`${this.apiUrl}/exams/${examId}/`);
|
||||
}
|
||||
|
||||
startOrResumeAttempt(examId: string): Observable<Attempt> {
|
||||
return this.http.post<Attempt>(`${this.apiUrl}/exams/${examId}/attempt/`, {});
|
||||
}
|
||||
|
||||
getAttempt(attemptId: string): Observable<Attempt> {
|
||||
return this.http.get<Attempt>(`${this.apiUrl}/attempts/${attemptId}/`);
|
||||
}
|
||||
|
||||
autosaveAttempt(attemptId: string, answers: Answer[]): Observable<{ updatedAt: string }> {
|
||||
return this.http.put<{ updatedAt: string }>(
|
||||
`${this.apiUrl}/attempts/${attemptId}/autosave/`,
|
||||
{ answers }
|
||||
);
|
||||
}
|
||||
|
||||
submitAttempt(attemptId: string): Observable<any> {
|
||||
return this.http.post(`${this.apiUrl}/attempts/${attemptId}/submit/`, {});
|
||||
}
|
||||
|
||||
getProgress(): Observable<any> {
|
||||
return this.http.get(`${this.apiUrl}/progress/me/`);
|
||||
}
|
||||
|
||||
getHistory(): Observable<{ history: any[] }> {
|
||||
return this.http.get<{ history: any[] }>(`${this.apiUrl}/history/me/`);
|
||||
}
|
||||
|
||||
resetExam(examId: string): Observable<any> {
|
||||
return this.http.post(`${this.apiUrl}/exams/${examId}/reset/`, {});
|
||||
}
|
||||
|
||||
getAttemptResult(attemptId: string): Observable<any> {
|
||||
return this.http.get(`${this.apiUrl}/attempts/${attemptId}/result/`);
|
||||
}
|
||||
}
|
||||
|
||||
75
exam_system/exam_web/src/app/services/auth.service.ts
Normal file
75
exam_system/exam_web/src/app/services/auth.service.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, BehaviorSubject } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AuthService {
|
||||
private apiUrl = '/api';
|
||||
private currentUserSubject = new BehaviorSubject<User | null>(null);
|
||||
public currentUser$ = this.currentUserSubject.asObservable();
|
||||
|
||||
constructor(private http: HttpClient) {
|
||||
this.loadCurrentUser();
|
||||
}
|
||||
|
||||
loadCurrentUser() {
|
||||
this.http.get<User>(`${this.apiUrl}/auth/me/`).subscribe({
|
||||
next: (user) => this.currentUserSubject.next(user),
|
||||
error: () => this.currentUserSubject.next(null)
|
||||
});
|
||||
}
|
||||
|
||||
register(username: string, email: string, password: string, password_confirm: string, first_name?: string, last_name?: string): Observable<any> {
|
||||
return this.http.post(`${this.apiUrl}/auth/register/`, {
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
password_confirm,
|
||||
first_name,
|
||||
last_name
|
||||
}).pipe(
|
||||
tap((response: any) => {
|
||||
if (response.user) {
|
||||
this.currentUserSubject.next(response.user);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
login(username: string, password: string): Observable<any> {
|
||||
return this.http.post(`${this.apiUrl}/auth/login/`, { username, password }).pipe(
|
||||
tap((response: any) => {
|
||||
if (response.user) {
|
||||
this.currentUserSubject.next(response.user);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
logout(): Observable<any> {
|
||||
return this.http.post(`${this.apiUrl}/auth/logout/`, {}).pipe(
|
||||
tap(() => this.currentUserSubject.next(null))
|
||||
);
|
||||
}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
return this.currentUserSubject.value !== null;
|
||||
}
|
||||
|
||||
getCurrentUser(): User | null {
|
||||
return this.currentUserSubject.value;
|
||||
}
|
||||
}
|
||||
|
||||
21
exam_system/exam_web/src/index.html
Normal file
21
exam_system/exam_web/src/index.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Exam System</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<!-- PrismJS for reliable syntax highlighting (C/C++/Python) -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-clike.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-c.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-cpp.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
6
exam_system/exam_web/src/main.ts
Normal file
6
exam_system/exam_web/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
|
||||
import { AppModule } from './app/app.module';
|
||||
|
||||
platformBrowserDynamic().bootstrapModule(AppModule)
|
||||
.catch(err => console.error(err));
|
||||
|
||||
77
exam_system/exam_web/src/styles.css
Normal file
77
exam_system/exam_web/src/styles.css
Normal file
@@ -0,0 +1,77 @@
|
||||
/* Global styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc3545;
|
||||
font-size: 14px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #28a745;
|
||||
font-size: 14px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
14
exam_system/exam_web/tsconfig.app.json
Normal file
14
exam_system/exam_web/tsconfig.app.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"files": [
|
||||
"src/main.ts"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
33
exam_system/exam_web/tsconfig.json
Normal file
33
exam_system/exam_web/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"outDir": "./dist/out-tsc",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"sourceMap": true,
|
||||
"declaration": false,
|
||||
"downlevelIteration": true,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"useDefineForClassFields": false,
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"dom"
|
||||
]
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user