Added Project

This commit is contained in:
Ninjaws
2024-11-06 22:13:00 +01:00
commit 10c4aa8831
107 changed files with 18082 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
export interface Answers {
sessionId: string;
answers: string[];
}
+4
View File
@@ -0,0 +1,4 @@
export interface Category {
id: number;
name: string;
}
+11
View File
@@ -0,0 +1,11 @@
export interface Question {
question: string;
answers: string[];
category: string;
type: string;
difficulty: string;
/** To make special characters visible */
question_decoded?: string;
answers_decoded?: string[];
}
@@ -0,0 +1,8 @@
import { Category } from "./category";
export interface QuizParams {
category: number;
type: string;
amount: number;
difficulty: string;
}
+4
View File
@@ -0,0 +1,4 @@
export interface Score {
amount_questions: number;
amount_correct: number;
}
+7
View File
@@ -0,0 +1,7 @@
import { Question } from "./question";
export interface Session {
id: string;
statusCode: number;
questions: Question[];
}
@@ -0,0 +1,18 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { SetupComponent } from './setup/setup.component';
import { QuizComponent } from './quiz/quiz.component';
import { ScoreComponent } from './score/score.component';
const routes: Routes = [
{ path: '', component: SetupComponent },
{ path: 'quiz', component: QuizComponent },
{ path: 'score', component: ScoreComponent },
{ path: '**', redirectTo: '' }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class QuizRoutingModule { }
+15
View File
@@ -0,0 +1,15 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { QuizRoutingModule } from './quiz-routing.module';
@NgModule({
declarations: [],
imports: [
CommonModule,
QuizRoutingModule
],
providers: []
})
export class QuizModule { }
@@ -0,0 +1,19 @@
<div class="flex items-center justify-center min-h-screen bg-gray-100">
<div class="bg-white p-8 rounded-lg shadow-md w-80 overflow-y-scroll max-h-3/4 no-scrollbar">
<div *ngFor="let question of questions; let i = index" class="mb-6">
<h2 class="text-lg font-semibold mb-2">{{ question.question_decoded }}</h2>
<div *ngFor="let answer of question.answers_decoded; let j = index">
<label class="block mb-1">
<input type="radio" name="question{{ i }}" [value]="answer" (change)="selectAnswer(i, question.answers[j])"
[checked]="selectedAnswers[i] === answer" class="mr-2" />
{{ answer }}
</label>
</div>
</div>
<button (click)="getScore()" [disabled]="!allQuestionsAnswered()"
class="w-full bg-blue-500 text-white py-2 rounded hover:bg-blue-600 transition disabled:opacity-50 ">
Get Score
</button>
</div>
</div>
@@ -0,0 +1,3 @@
.no-scrollbar {
scrollbar-width: none;
}
@@ -0,0 +1,61 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { Score } from '../models/score';
import { Question } from '../models/question';
import { QuizService } from '../services/quiz.service';
@Component({
selector: 'app-quiz',
standalone: true,
imports: [CommonModule],
templateUrl: './quiz.component.html',
styleUrl: './quiz.component.scss'
})
export class QuizComponent {
questions: Question[] = [];
selectedAnswers: { [key: number]: string } = {};
constructor(private router: Router, private quizService: QuizService) {
const navigation = this.router.getCurrentNavigation();
if (navigation?.extras?.state) {
this.questions = navigation.extras.state['questions'];
}else {
// You didn't come here through the proper channels, byebye
this.router.navigate(['']);
}
}
selectAnswer(questionIndex: number, answer: string) {
this.selectedAnswers[questionIndex] = answer;
}
allQuestionsAnswered(): boolean {
return Object.keys(this.selectedAnswers).length === this.questions.length;
}
getScore() {
const answers: string[] = []
for(let i = 0; i < Object.keys(this.selectedAnswers).length; i++) {
answers.push(this.selectedAnswers[i]);
}
this.quizService.getScore(answers)
.then((result:number) => {
const score: Score = {
amount_correct:result,
amount_questions: Object.keys(this.selectedAnswers).length
}
this.router.navigate(['/score'], { state: { score: score },skipLocationChange:true });
})
.catch((e) => {
alert("Something went wrong");
});
// const sampleScore: Score = {
// amount_questions: 10,
// amount_correct: 7
// }
}
}
@@ -0,0 +1,11 @@
<div class="flex flex-col items-center justify-center h-screen bg-gray-100">
<h1 class="text-4xl font-bold mb-4">Your Score</h1>
<p class="text-2xl">
{{ score.amount_correct }} / {{ score.amount_questions }}
</p>
<button
(click)="playAgain()"
class="mt-6 bg-blue-500 text-white p-2 rounded hover:bg-blue-600">
Play Again
</button>
</div>
@@ -0,0 +1,29 @@
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { Score } from '../models/score';
@Component({
selector: 'app-score',
standalone: true,
imports: [],
templateUrl: './score.component.html',
styleUrl: './score.component.scss'
})
export class ScoreComponent {
score !: Score;
constructor(private router: Router) {
const navigation = this.router.getCurrentNavigation();
if (navigation?.extras?.state) {
this.score = navigation.extras.state['score'];
}else {
// You didn't come here through the proper channels, byebye
this.router.navigate(['']);
}
}
playAgain() {
this.router.navigate(['']);
}
}
@@ -0,0 +1,33 @@
import { Injectable } from '@angular/core';
import { Question } from '../models/question';
@Injectable({
providedIn: 'root'
})
export class CleanerService {
constructor() { }
/**
* Sets decoded strings for the questions and the answers, so the special characters will be shown instead of their codes
*/
public cleanQuestionStrings(questions: Question[]): Question[] {
for(let i = 0; i < questions.length; i++) {
questions[i].question_decoded = this.decodeString(questions[i].question);
questions[i].answers_decoded = [];
for(let j = 0; j < questions[i].answers.length; j++){
questions[i].answers_decoded!.push(this.decodeString(questions[i].answers[j]));
}
}
return questions;
}
/** Puts special characters back into the string, like ' and " */
private decodeString(text: string) {
const txt = document.createElement("textarea");
txt.innerHTML = text;
const decodedValue = txt.value;
txt.remove(); // Clean up the temporary element
return decodedValue;
}
}
@@ -0,0 +1,100 @@
import { Injectable } from '@angular/core';
import { QuizParams } from '../models/quiz-params';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import {
catchError,
filter,
firstValueFrom,
interval,
lastValueFrom,
of,
startWith,
switchMap,
take,
takeWhile,
} from 'rxjs';
import { Question } from '../models/question';
import { Answers } from '../models/answers';
import { StorageService } from './storage.service';
import { CleanerService } from './cleaner.service';
import { ResponseHandlerService } from './response-handler.service';
import { environment } from '../../../environments/environment';
import { Category } from '../models/category';
@Injectable({
providedIn: 'root',
})
export class QuizService {
private apiUrl = environment.apiUrl + '/quiz';
constructor(
private http: HttpClient,
private storageService: StorageService,
private cleanerService: CleanerService,
private reponseHandler: ResponseHandlerService) {}
public async startSession(quizParams: QuizParams): Promise<Question[] | null> {
const sessionIdObj = await firstValueFrom(this.http.get<any>(this.apiUrl + '/questions' + this.convertParamsToUrl(quizParams)));
/** No need to poll if the session is not created. Likely means the queue is full */
if(sessionIdObj.statusCode !== 0) {
return this.reponseHandler.handleResponse(sessionIdObj.statusCode);
}
this.storageService.setSession(sessionIdObj.sessionId);
const sessionObj = await lastValueFrom(this.pollQuestions(sessionIdObj.sessionId));
return this.reponseHandler.handleResponse(sessionObj.statusCode, sessionObj);
}
public pollQuestions(sessionId: string) {
return interval(2000).pipe(
startWith(0),
switchMap(() => this.http.get<any>(this.apiUrl + `/status/${sessionId}`)),
// takeWhile((response) => response === null),
filter((response) => response !== null),
take(1)
);
}
public async getScore(answers: string[]) {
const answersJson: Answers = {
sessionId: this.storageService.getSession()!,
answers: answers
}
const headers = new HttpHeaders({'Content-Type': 'application/json'});
const scoreObj = await firstValueFrom(this.http.post<any>(this.apiUrl+'/score', answersJson, { headers }));
return scoreObj.score;
}
private convertParamsToUrl(quizParams: QuizParams) {
let url = `?amount=${quizParams.amount}`;
const catIndex = quizParams.category;
url += catIndex !== 0 ? `&category=${catIndex}` : '';
const diff = quizParams.difficulty.toLowerCase();
url += diff !== 'any difficulty' ? `&difficulty=${diff}` : '';
const typeIndex = this.getTypes().indexOf(quizParams.type);
url +=
typeIndex !== 0
? typeIndex === 1
? '&type=multiple'
: '&type=boolean'
: '';
return url;
}
public async getCategories(): Promise<Category[]> {
let categories: Category[] = await firstValueFrom(this.http.get<any>(this.apiUrl + '/categories'));
categories.push({id:0,name:"Any Category"});
categories.sort((a,b) => a.id - b.id);
return categories;
}
public getDifficulties(): string[] {
return ['Any Difficulty', 'Easy', 'Medium', 'Hard'];
}
public getTypes(): string[] {
return ['Any Type', 'Multiple choice', 'True/False'];
}
}
@@ -0,0 +1,36 @@
import { Injectable } from '@angular/core';
import { Session } from '../models/session';
import { CleanerService } from './cleaner.service';
import { Question } from '../models/question';
@Injectable({
providedIn: 'root',
})
export class ResponseHandlerService {
constructor(private cleanerService: CleanerService) {}
/**
* Session is optional, except for the success state (0)
*/
public handleResponse(statusCode: number, session?: Session): Question[] | null {
switch (statusCode) {
case 0:
// Success
session!.questions = this.cleanerService.cleanQuestionStrings(session!.questions);
return session!.questions;
case 1:
// No Results (requests too much data for this combination of parameters)
alert('Not enough data for this request, please try a different combination');
return null;
case 2:
// Invalid parameter (should be handled by the backend)
alert('Invalid input');
return null;
case 5:
// Queue is full, try again later
alert('The server is experiencing a lot of traffic, please try again later');
return null;
}
return null;
}
}
@@ -0,0 +1,17 @@
import { Injectable } from "@angular/core";
@Injectable({
providedIn: 'root',
})
export class StorageService {
constructor() {
}
public setSession(sessionId: string): void {
localStorage.setItem("sessionId", sessionId);
}
public getSession(): string | null {
return localStorage.getItem("sessionId");
}
}
@@ -0,0 +1,35 @@
<div class="flex items-center justify-center min-h-screen bg-gray-100">
<div class="bg-white p-8 rounded-lg shadow-md w-80">
<h1 class="text-2xl font-bold mb-6 text-center bg-red">Quiz App</h1>
<div class="mb-4">
<label for="category" class="block mb-1">Category</label>
<select id="category" class="w-full p-2 border rounded" [(ngModel)]="category">
<option *ngFor="let category of categories" [value]="category.id">{{ category.name }}</option>
</select>
</div>
<div class="mb-4">
<label for="difficulty" class="block mb-1">Difficulty</label>
<select id="difficulty" class="w-full p-2 border rounded" [(ngModel)]="difficulty">
<option *ngFor="let difficulty of quizService.getDifficulties()" class="focus:outline-none" [value]="difficulty">{{ difficulty }}</option>
</select>
</div>
<div class="mb-4">
<label for="type" class="block mb-1">Type</label>
<select id="type" class="w-full p-2 border rounded" [(ngModel)]="type">
<option *ngFor="let type of quizService.getTypes()" [value]="type">{{ type }}</option>
</select>
</div>
<div class="mb-4">
<label for="amount" class="block mb-1">Amount</label>
<input type="number" id="amount" [(ngModel)]="amount" min="1" max="50" class="w-full p-2 border rounded" />
</div>
<button class="w-full bg-blue-500 text-white py-2 rounded hover:bg-blue-600 transition" (click)="startQuiz()">
Start Quiz
</button>
</div>
</div>
@@ -0,0 +1,66 @@
import { HttpClient } from '@angular/common/http';
import { Component } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { QuizService } from '../services/quiz.service';
import { QuizParams } from '../models/quiz-params';
import { SpinnerService } from '../../Spinner/services/spinner.service';
import { CommonModule } from '@angular/common';
import { Question } from '../models/question';
import { Category } from '../models/category';
@Component({
selector: 'app-setup',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './setup.component.html',
styleUrl: './setup.component.scss',
})
export class SetupComponent {
categories!: Category[];
category!: number;
difficulty: string = "";
type: string = "";
amount: number = 10;
constructor(
private router: Router,
public quizService: QuizService,
private spinnerService: SpinnerService
) {
quizService.getCategories().then((categories: Category[]) => {
/** Setting default values */
this.categories = categories;
this.category = this.categories.find(item => item.id == 0)!.id;
this.difficulty = this.quizService.getDifficulties()[0];
this.type = this.quizService.getTypes()[0];
});
}
startQuiz() {
const params: QuizParams = {
category: this.category,
type: this.type,
amount: this.amount,
difficulty: this.difficulty,
};
this.spinnerService.setActive(true);
this.quizService.startSession(params).then((questions: Question[] | null) => {
if(questions !== null) {
this.gotoQuiz(questions);
}
})
.catch((e) => {alert("Something went wrong, please try again");})
.finally(() => {this.spinnerService.setActive(false);});
}
gotoQuiz(questions: Question[]) {
this.router.navigate(['/quiz'], {
state: { questions: questions },
skipLocationChange: true,
});
}
}
@@ -0,0 +1,25 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class SpinnerService {
private isActive = false;
constructor() { }
toggleSpinner() {
this.isActive = !this.isActive;
}
setActive(active: boolean) {
new Promise(
(resolve) => setTimeout(resolve, 0)).then(
_ =>this.isActive = active
)
}
isSpinnerActive() {
return this.isActive;
}
}
@@ -0,0 +1,11 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
@NgModule({
declarations: [],
imports: []
})
export class SpinnerModule { }
@@ -0,0 +1,5 @@
<div class="spinner" *ngIf="spinnerService.isSpinnerActive()">
<div class="spinner-content">
<div class="lds-spinner"><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div></div>
</div>
</div>
@@ -0,0 +1,98 @@
.lds-spinner {
color: green;
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.lds-spinner div {
transform-origin: 40px 40px;
animation: lds-spinner 1.2s linear infinite;
}
.lds-spinner div:after {
content: " ";
display: block;
position: absolute;
top: 3px;
left: 37px;
width: 6px;
height: 18px;
border-radius: 20%;
background: #fff;
}
.lds-spinner div:nth-child(1) {
transform: rotate(0deg);
animation-delay: -1.1s;
}
.lds-spinner div:nth-child(2) {
transform: rotate(30deg);
animation-delay: -1s;
}
.lds-spinner div:nth-child(3) {
transform: rotate(60deg);
animation-delay: -0.9s;
}
.lds-spinner div:nth-child(4) {
transform: rotate(90deg);
animation-delay: -0.8s;
}
.lds-spinner div:nth-child(5) {
transform: rotate(120deg);
animation-delay: -0.7s;
}
.lds-spinner div:nth-child(6) {
transform: rotate(150deg);
animation-delay: -0.6s;
}
.lds-spinner div:nth-child(7) {
transform: rotate(180deg);
animation-delay: -0.5s;
}
.lds-spinner div:nth-child(8) {
transform: rotate(210deg);
animation-delay: -0.4s;
}
.lds-spinner div:nth-child(9) {
transform: rotate(240deg);
animation-delay: -0.3s;
}
.lds-spinner div:nth-child(10) {
transform: rotate(270deg);
animation-delay: -0.2s;
}
.lds-spinner div:nth-child(11) {
transform: rotate(300deg);
animation-delay: -0.1s;
}
.lds-spinner div:nth-child(12) {
transform: rotate(330deg);
animation-delay: 0s;
}
@keyframes lds-spinner {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.spinner {
position: fixed; /* Stay in place */
z-index: var(--z-spinner);
left: 0;
top: 0;
width: 100%; /* Full width */
height: 100%; /* Full height */
overflow: auto; /* Enable scroll if needed */
background-color: rgb(0,0,0); /* Fallback color */
background-color: rgba(0,0,0,0.4); /* Black w/ opacity */
}
.spinner-content {
width:100%;
height:100%;
display:flex;
align-items: center;
justify-content: center;
}
@@ -0,0 +1,22 @@
import { animate, style, transition, trigger } from '@angular/animations';
import { Component } from '@angular/core';
import { SpinnerService } from '../services/spinner.service';
import { CommonModule } from '@angular/common';
import { BrowserAnimationsModule, NoopAnimationsModule } from '@angular/platform-browser/animations';
@Component({
selector: 'app-spinner',
standalone: true,
imports: [CommonModule],
templateUrl: './spinner.component.html',
styleUrl: './spinner.component.scss',
styles: [],
animations: [
trigger('fadeInOut', [
transition(':enter', [style({ opacity:0}), animate("300ms ease-in-out")]),
transition(':leave', [animate("300ms ease-in-out", style({ opacity: 0}))]),
])]
})
export class SpinnerComponent {
constructor(public spinnerService: SpinnerService) { }
}
+2
View File
@@ -0,0 +1,2 @@
<router-outlet></router-outlet>
<app-spinner></app-spinner>
View File
+29
View File
@@ -0,0 +1,29 @@
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have the 'quiz' title`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('quiz');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, quiz');
});
});
+14
View File
@@ -0,0 +1,14 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { SpinnerComponent } from './Spinner/spinner/spinner.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, SpinnerComponent],
templateUrl: './app.component.html',
styleUrl: './app.component.scss'
})
export class AppComponent {
title = 'quiz';
}
+12
View File
@@ -0,0 +1,12 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient()]
};
+7
View File
@@ -0,0 +1,7 @@
import { Routes } from '@angular/router';
export const routes: Routes = [
{ path: '', loadChildren: () => import('./Quiz/quiz.module').then(m => m.QuizModule) }, // Lazy load QuizModule
// { path: '**', redirectTo: '/quiz' } // Handle unmatched routes
];
@@ -0,0 +1,7 @@
export const environment = {
production: true,
/** Connecting through the docker compose network is not working for now, so I'll have to use the localhost */
// apiUrl: 'http://backend:9090',
apiUrl: '/api' //'https://api.nosotros.ianvink.nl',
// apiUrl: 'http://localhost:9090',
};
+4
View File
@@ -0,0 +1,4 @@
export const environment = {
production: false,
apiUrl: 'http://localhost:9090'
};
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Quiz</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="question.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));
+27
View File
@@ -0,0 +1,27 @@
/* You can add global styles to this file, and also import other style files */
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
html, body {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
display: flex;
}
app-root {
flex: 1; /* Makes the AppComponent fill the available space */
// display:flex;
// justify-content: center;
// align-items: center;
}
:root {
// --z-base: 0;
// --z-dropdown: 100;
// --z-modal: 200;
// --z-tooltip: 300;
--z-spinner: 1800;
}