first working version

This commit is contained in:
howard
2025-10-22 20:14:31 +08:00
parent c9767b830b
commit 8dc869634e
118 changed files with 22518 additions and 0 deletions

View 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"]

View 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"
}
}
}
}
}

View 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"
}
}

View 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 { }

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

View 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 { }

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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']);
}
}
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
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--;
}
}
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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(['/']);
}
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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 = '';
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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']);
}
}

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

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

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

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

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

View File

@@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts"
],
"include": [
"src/**/*.d.ts"
]
}

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