@@ -36,3 +36,14 @@ sudo reboot
|
||||
docker compose up
|
||||
```
|
||||
- Visit localhost:4200 to play the quiz!
|
||||
|
||||
|
||||
## Benchmarking
|
||||
- Go to the /scripts folder and run the LoadTestMultiple.sh script
|
||||
- Inside you can change how many sessions should be created (set to 6 by default)
|
||||
- In the /scripts/benchmarks folder you can find some benchmarks I've done
|
||||
|
||||
## Notes
|
||||
- If the data is not present, it will try to query it from the external API, which takes significantly longer (only 1 request per 5 seconds)
|
||||
- When running it locally, give the backend a few minutes to gather data in bulk (should be completely done in ~7 minutes)
|
||||
- It refreshes this data every night in the background, so it stays up to date
|
||||
@@ -0,0 +1,38 @@
|
||||
# Issues
|
||||
|
||||
## Issues during testing
|
||||
### Scheduler
|
||||
I don't know if it was the scheduler specifically, but whenever I try to test it, both the cache and the queue seem to become empty after the first function is completed.
|
||||
I don't know how to solve this yet
|
||||
|
||||
|
||||
## Caching solution
|
||||
### Lack of data
|
||||
Not being able to gather blocks of 50 is a huge issue. The idea of reusing blocks becomes less viable if I can just get a big chunk every time for others to use
|
||||
I can't force a big chunk, because it might just return a status 1, and then the user gets nothing.
|
||||
|
||||
Perhaps I can separate the questions myself and make a collection, that i can build the requests from. However, for every quest tracking if someone has already seen is a huge pain.
|
||||
|
||||
|
||||
## Possible solution
|
||||
Cache all data, 50 at a time
|
||||
Store it in a database
|
||||
When its done, let the db make blocks for quick cache access
|
||||
|
||||
Then randomize when someone asks for an item from the cache
|
||||
Or return a failure if there are not enough items (a la the api itself)
|
||||
|
||||
Every night, create a new db in the background and start filling it (should take ~34 minutes for all 20k items the external API has)
|
||||
When it's full, replace the old database with the new one, and delete the old one.
|
||||
Then start replacing the cache items one at a time.
|
||||
|
||||
Users shouldn't feel this at all, because the active sessions are stored in their own cache items
|
||||
|
||||
# Progress so far
|
||||
There only seem to be ~4100 items, making the collecting phase only last around 7 minutes.
|
||||
|
||||
Swapping dbs is replaced with swapping tables, seems to work just as fine, yet much simpler to set up
|
||||
|
||||
TODO: Add a caching state, and start filling up each unique combination with the items from the db
|
||||
This should lead to performance gains
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# Use an official OpenJDK as a parent image
|
||||
FROM openjdk:17-jdk-slim
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install Maven
|
||||
RUN apt-get update && apt-get install -y \
|
||||
maven=3.3.9-4 \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN mvn clean package
|
||||
|
||||
CMD ["tail","-f","/dev/null"]
|
||||
@@ -2,13 +2,19 @@
|
||||
"name": "Quiz Backend",
|
||||
|
||||
"build": {
|
||||
"context": ".",
|
||||
"dockerfile": "Dockerfile",
|
||||
"context": "..",
|
||||
"dockerfile": "../Dockerfile",
|
||||
"args": {
|
||||
"WORKDIR": "/app"
|
||||
}
|
||||
// "UID": "${localEnv:USER_UID}",
|
||||
// "GID": "${localEnv:USER_GID}"
|
||||
},
|
||||
"runArgs": ["--name=quiz-backend-dev-cont"],
|
||||
"target": "development"
|
||||
},
|
||||
"runArgs": [
|
||||
"--name=quiz-backend-dev-cont",
|
||||
"--net=host" // Allows it to be opened on the host network
|
||||
],
|
||||
"workspaceFolder": "/app",
|
||||
"mounts": [
|
||||
"source=.,target=/app,type=bind"
|
||||
@@ -17,6 +23,9 @@
|
||||
"vscode": {
|
||||
"settings": {},
|
||||
"extensions": [
|
||||
"vscjava.vscode-java-pack",
|
||||
"Oracle.oracle-java",
|
||||
"scala-lang.scala"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
FROM openjdk:17-jdk-slim AS base
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt install -y \
|
||||
maven \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
#---Development
|
||||
FROM base AS development
|
||||
# Switching user so it doesn't use root
|
||||
# If root adds files, it takes ownership, making them unremovable on the host except by using sudo
|
||||
# node tranfers ownership to the host user
|
||||
ENV UID=1000
|
||||
ENV GID=1000
|
||||
ENV USER=dev
|
||||
RUN groupadd -g ${GID} ${USER} \
|
||||
&& useradd -m -u ${UID} -g ${USER} ${USER}
|
||||
USER ${USER}
|
||||
EXPOSE 9090
|
||||
CMD ["tail","-f","/dev/null"]
|
||||
|
||||
#---Building
|
||||
FROM base AS build
|
||||
COPY . .
|
||||
RUN mvn clean package
|
||||
# Add -DskipTests for slightly faster build, but no testing
|
||||
|
||||
#---Production
|
||||
# Pulled again to get rid of maven, to decrease the size
|
||||
FROM openjdk:17-jdk-slim AS production
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/target/*.jar app.jar
|
||||
EXPOSE 9090
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
@@ -28,6 +28,8 @@
|
||||
</scm>
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<lombok.version>1.18.34</lombok.version>
|
||||
<caffeine.version>3.1.8</caffeine.version>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
@@ -55,6 +57,26 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
<version>${caffeine.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>${lombok.version}</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-text</artifactId>
|
||||
<version>1.10.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.ninjaws.quiz;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
public class QuizApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.ninjaws.quiz.components;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.ninjaws.quiz.services.ApiService;
|
||||
import com.ninjaws.quiz.services.StateManagementService;
|
||||
|
||||
|
||||
@Component
|
||||
public class StartupRunner implements CommandLineRunner {
|
||||
|
||||
@Autowired
|
||||
private StateManagementService stateManagementService;
|
||||
@Autowired
|
||||
private ApiService apiService;
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
|
||||
/**
|
||||
* On startup:
|
||||
* - Set ProcessingState to Startup
|
||||
* - Store the categories
|
||||
* - Get a sessiontoken
|
||||
*
|
||||
* * Inside the SchedulerService
|
||||
* - Start filling the question table
|
||||
*
|
||||
* * Inside the Response Handling Service
|
||||
* - No more questions? Stop
|
||||
* - Set ProcessingState to Normal
|
||||
*/
|
||||
|
||||
stateManagementService.setCurrentProcessingState(StateManagementService.ProcessingState.STARTUP);
|
||||
apiService.getCategories();
|
||||
apiService.getSessionToken();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.ninjaws.quiz.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
@Configuration
|
||||
public class AppConfig {
|
||||
|
||||
@Bean
|
||||
public RestTemplate restTemplate() {
|
||||
return new RestTemplate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.ninjaws.quiz.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
@EnableWebMvc
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/**") // Allow all endpoints
|
||||
.allowedOrigins("http://localhost:4200") // Allow specific origin
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") // Allow specific methods
|
||||
.allowedHeaders("*") // Allow all headers
|
||||
.allowCredentials(true); // Allow credentials
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.ninjaws.quiz.controllers;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.ninjaws.quiz.entities.Category;
|
||||
import com.ninjaws.quiz.models.Answers;
|
||||
import com.ninjaws.quiz.models.QuizSettings;
|
||||
import com.ninjaws.quiz.models.Session;
|
||||
import com.ninjaws.quiz.services.DataService;
|
||||
import com.ninjaws.quiz.services.QuizService;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/quiz")
|
||||
public class QuizController {
|
||||
|
||||
@Autowired
|
||||
private QuizService quizService;
|
||||
@Autowired
|
||||
private DataService dataService;
|
||||
|
||||
|
||||
/**
|
||||
* Start a session
|
||||
* @param amount The amount of questsions
|
||||
* @param category The desired category (or all)
|
||||
* @param difficulty The desired difficulty (or all)
|
||||
* @param type The desired type (or both)
|
||||
* @return The sessionId, or statusCode 5 when the queue is full
|
||||
*/
|
||||
@GetMapping("/questions")
|
||||
public String startSession(
|
||||
@RequestParam(required = true) Integer amount,
|
||||
@RequestParam(required = false) Integer category,
|
||||
@RequestParam(required = false) String difficulty,
|
||||
@RequestParam(required = false) String type
|
||||
) {
|
||||
QuizSettings quizSettings = new QuizSettings(amount, category, difficulty, type);
|
||||
Optional<String> result = quizService.createSession(quizSettings);
|
||||
if (result.isPresent()) {
|
||||
String jsonString = String.format("{\"statusCode\":0,\"sessionId\":\"%s\"}", result.get());
|
||||
return jsonString;
|
||||
}
|
||||
String jsonString = String.format("{\"statusCode\":5}");
|
||||
return jsonString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the questions have been retrieved
|
||||
* @param sessionId The session that holds the questions
|
||||
* @return The Session, or nothing if they are not ready yet
|
||||
*/
|
||||
@GetMapping("/status/{sessionId}")
|
||||
public Optional<Session> checkStatus(@PathVariable String sessionId) {
|
||||
return quizService.checkSession(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive the chosen answers, calculate the score, and return it
|
||||
* @param answers The chosen answers
|
||||
* @return The score
|
||||
*/
|
||||
@PostMapping("/score")
|
||||
public String checkAnswers(@RequestBody Answers answers) {
|
||||
String jsonString = String.format("{\"score\":\"%s\"}", quizService.getScore(answers));
|
||||
return jsonString;
|
||||
}
|
||||
|
||||
@GetMapping("/categories")
|
||||
public List<Category> getCategories(){
|
||||
return dataService.getCategories();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.ninjaws.quiz.entities;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.MappedSuperclass;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@MappedSuperclass
|
||||
@Getter
|
||||
@Setter
|
||||
public class BaseQuestionEntity {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "category", nullable = false)
|
||||
private Category category;
|
||||
|
||||
private String difficulty;
|
||||
private String type;
|
||||
private String question;
|
||||
|
||||
private String correctAnswer;
|
||||
// @ElementCollection // This stores it in a separate table, it's just a set of strings so not required right now
|
||||
private List<String> incorrectAnswers;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ninjaws.quiz.entities;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@Table(name = "category")
|
||||
@Getter
|
||||
@Setter
|
||||
public class Category {
|
||||
@Id
|
||||
private Integer id;
|
||||
private String name;
|
||||
|
||||
public Category() {}
|
||||
|
||||
public Category(int id, String name) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ninjaws.quiz.entities;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
@Entity
|
||||
@Table(name = "question_collecting")
|
||||
public class QuestionCollectingEntity extends BaseQuestionEntity{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ninjaws.quiz.entities;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
@Entity
|
||||
@Table(name = "question")
|
||||
public class QuestionEntity extends BaseQuestionEntity {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.ninjaws.quiz.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
|
||||
/**
|
||||
* Determines behaviour when certain types of Exceptions are encountered
|
||||
*/
|
||||
@ControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<String> handleIllegalArgumentException(IllegalArgumentException e) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.body(e.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.ninjaws.quiz.models;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class Answers {
|
||||
private String sessionId;
|
||||
private List<String> answers;
|
||||
|
||||
public Answers() {}
|
||||
|
||||
public Answers(String sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ninjaws.quiz.models;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
|
||||
/**
|
||||
* The response sent by the external API
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public class ApiResponse {
|
||||
@JsonProperty("response_code")
|
||||
private int responseCode;
|
||||
@JsonProperty("results")
|
||||
private List<QuestionDTO> results;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.ninjaws.quiz.models;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.ninjaws.quiz.entities.Category;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class CategoryResponse {
|
||||
List<Category> trivia_categories;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ninjaws.quiz.models;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class QuestionDTO {
|
||||
|
||||
private String category;
|
||||
private String difficulty;
|
||||
private String type;
|
||||
private String question;
|
||||
|
||||
//* Hidden from the user */
|
||||
private String correctAnswer;
|
||||
private List<String> incorrectAnswers;
|
||||
|
||||
@JsonProperty("answers")
|
||||
public List<String> getAnswers() {
|
||||
List<String> possibleAnswers = new ArrayList<>(incorrectAnswers);
|
||||
possibleAnswers.add(correctAnswer);
|
||||
Collections.shuffle(possibleAnswers);
|
||||
return possibleAnswers;
|
||||
}
|
||||
|
||||
@JsonProperty("correct_answer")
|
||||
public void setCorrectAnswer(String correctAnswer) {
|
||||
this.correctAnswer = correctAnswer;
|
||||
}
|
||||
|
||||
@JsonProperty("incorrect_answers")
|
||||
public void setIncorrectAnswer(List<String> incorrectAnswers) {
|
||||
this.incorrectAnswers = incorrectAnswers;
|
||||
}
|
||||
|
||||
@JsonProperty("correct_answer")
|
||||
@JsonIgnore
|
||||
public String getCorrectAnswer() {
|
||||
return correctAnswer;
|
||||
}
|
||||
|
||||
@JsonProperty("incorrect_answers")
|
||||
@JsonIgnore
|
||||
public List<String> getIncorrectAnswers() {
|
||||
return incorrectAnswers;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.ninjaws.quiz.models;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class QuizSettings {
|
||||
|
||||
@Min(1)
|
||||
@Max(50)
|
||||
private int amount = 10;
|
||||
private Integer category;
|
||||
private String difficulty;
|
||||
private String type;
|
||||
|
||||
public QuizSettings(){}
|
||||
|
||||
/** Inverse of toApiQuery */
|
||||
public QuizSettings(int amount, Integer category, String difficulty, String type) {
|
||||
this.amount = amount;
|
||||
this.category = category;
|
||||
this.difficulty = difficulty;
|
||||
this.type = type;
|
||||
// this.category = category == null ? Category.ANY : Category.values()[category];
|
||||
// this.difficulty = difficulty == null ? Difficulty.ANY : Difficulty.valueOf(difficulty.toUpperCase());
|
||||
// this.type = type == null ? Type.ANY : type.equals("boolean") ? Type.BOOLEAN : Type.MULTIPLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the value is between 1 and 50
|
||||
* @Valid doesn't seem to be enforcing it, so this ensures the value is legal
|
||||
*/
|
||||
public void setAmount(@Valid int amount) {
|
||||
this.amount = Math.max(1, Math.min(amount, 50));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the class to a format that can be provided as arguments for the api request
|
||||
* @return The resulting request arguments as String
|
||||
*/
|
||||
public String toApiQuery() {
|
||||
StringBuilder query = new StringBuilder("?amount=" + amount);
|
||||
|
||||
if (category != null) {
|
||||
query.append("&category=").append(category);
|
||||
}
|
||||
|
||||
if (difficulty != null ) {
|
||||
query.append("&difficulty=").append(difficulty);
|
||||
}
|
||||
|
||||
if (type != null ) {
|
||||
query.append("&type=").append(type);
|
||||
}
|
||||
|
||||
return query.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.ninjaws.quiz.models;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public class Request {
|
||||
private final String sessionId;
|
||||
private final QuizSettings quizSettings;
|
||||
|
||||
public Request(String sessionId, QuizSettings quizSettings) {
|
||||
this.sessionId = sessionId;
|
||||
this.quizSettings = quizSettings;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.ninjaws.quiz.models;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class Session {
|
||||
private final String id;
|
||||
private int statusCode = 0;
|
||||
private List<QuestionDTO> questions;
|
||||
|
||||
public Session() {
|
||||
this.id = UUID.randomUUID().toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.ninjaws.quiz.models;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class SessionTokenDTO {
|
||||
@JsonProperty("response_code")
|
||||
private int responseCode;
|
||||
@JsonProperty("token")
|
||||
private String token;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.ninjaws.quiz.repositories;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import com.ninjaws.quiz.entities.Category;
|
||||
|
||||
public interface CategoryRepository extends JpaRepository<Category, Long>{
|
||||
Optional<Category> findByName(String name);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.ninjaws.quiz.repositories;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import com.ninjaws.quiz.entities.QuestionCollectingEntity;
|
||||
|
||||
/**
|
||||
* This table is used for collecting questions from the external API
|
||||
* When it finishes, the data from this repository will replace the data in the QuestionRepository, to ensure it stays fresh
|
||||
* This should be scheduled once every day
|
||||
*/
|
||||
public interface QuestionCollectingRepository extends JpaRepository<QuestionCollectingEntity, Long>{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.ninjaws.quiz.repositories;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import com.ninjaws.quiz.entities.QuestionEntity;
|
||||
|
||||
import jakarta.transaction.Transactional;
|
||||
|
||||
public interface QuestionRepository extends JpaRepository<QuestionEntity, Long>{//, JpaSpecificationExecutor<Question> {
|
||||
|
||||
// @Query(value = "SELECT * FROM question AS q " +
|
||||
// "WHERE (?1 IS NULL OR q.category = ?1) " +
|
||||
// "AND (?2 IS NULL OR q.difficulty = ?2) " +
|
||||
// "AND (?3 IS NULL OR q.type = ?3) " +
|
||||
// "ORDER BY RAND() LIMIT ?4", nativeQuery = true)
|
||||
@Query(value = "SELECT * FROM question AS q " +
|
||||
"WHERE (:categoryId IS NULL OR q.category = :categoryId) " +
|
||||
"AND (:difficulty IS NULL OR q.difficulty = :difficulty) " +
|
||||
"AND (:type IS NULL OR q.type = :type) " +
|
||||
"ORDER BY RAND() LIMIT :limit;", nativeQuery = true)
|
||||
List<QuestionEntity> lookupQuestions(
|
||||
@Param("categoryId") Integer categoryId,
|
||||
@Param("difficulty") String difficulty,
|
||||
@Param("type") String type,
|
||||
@Param("limit") int limit);
|
||||
|
||||
/**
|
||||
* Moves all data from one table to another
|
||||
*/
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query(value="INSERT INTO question SELECT * FROM question_collecting;", nativeQuery=true)
|
||||
void moveCollection();
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.ninjaws.quiz.services;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import com.ninjaws.quiz.models.ApiResponse;
|
||||
import com.ninjaws.quiz.models.CategoryResponse;
|
||||
import com.ninjaws.quiz.models.QuizSettings;
|
||||
import com.ninjaws.quiz.models.Request;
|
||||
import com.ninjaws.quiz.models.SessionTokenDTO;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
@Service
|
||||
public class ApiService {
|
||||
|
||||
@Autowired
|
||||
private CleanerService cleanerService;
|
||||
@Autowired
|
||||
private ResponseHandlingService responseHandler;
|
||||
@Autowired
|
||||
private RestTemplate restTemplate;
|
||||
|
||||
private static final System.Logger logger = System.getLogger(ApiService.class.getName());
|
||||
|
||||
private final String apiUrl = "https://opentdb.com";
|
||||
private String sessionToken;
|
||||
|
||||
public ApiService() {
|
||||
}
|
||||
|
||||
private String buildRequestString(QuizSettings quizSettings) {
|
||||
return apiUrl + "/api.php" + quizSettings.toApiQuery();
|
||||
}
|
||||
|
||||
public void handleRequest(@Valid Request request) {
|
||||
String url = buildRequestString(request.getQuizSettings());
|
||||
|
||||
try {
|
||||
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
|
||||
String json = response.getBody();
|
||||
ApiResponse apiResponse = cleanerService.extractApiResponse(json);
|
||||
responseHandler.handleResponse(request.getSessionId(), apiResponse);
|
||||
|
||||
} catch (RestClientException | IOException | IllegalStateException e) {
|
||||
logger.log(System.Logger.Level.ERROR, "Critical failure", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect a chuck of 50 items
|
||||
* Store them in the DataService
|
||||
*/
|
||||
public void collectBulk() {
|
||||
String tokenStr = sessionToken == null ? "" : "&token="+sessionToken;
|
||||
String url = apiUrl + "/api.php?amount=50" + tokenStr;
|
||||
try {
|
||||
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
|
||||
String json = response.getBody();
|
||||
ApiResponse apiResponse = cleanerService.extractApiResponse(json);
|
||||
responseHandler.handleResponse(apiResponse);
|
||||
|
||||
/**
|
||||
* A bit dirty, but otherwise it causes circular dependencies
|
||||
* Makes sure the categories are refreshed after finishing to replace the questions, to make sure the ids match
|
||||
* And resets the sessionToken, it will be remade during the next REFRESH
|
||||
*/
|
||||
if(apiResponse.getResponseCode() == 4) {
|
||||
getCategories();
|
||||
sessionToken = null;
|
||||
}
|
||||
|
||||
} catch (RestClientException | IOException e) {
|
||||
logger.log(System.Logger.Level.ERROR, "Critical failure", e);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public void getSessionToken() {
|
||||
String url = apiUrl + "/api_token.php?command=request";
|
||||
try {
|
||||
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
|
||||
String json = response.getBody();
|
||||
SessionTokenDTO tokenDto = cleanerService.extractSessionToken(json);
|
||||
sessionToken = tokenDto.getToken();
|
||||
|
||||
} catch (RestClientException | IOException e) {
|
||||
logger.log(System.Logger.Level.ERROR, "Critical failure", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void getCategories() {
|
||||
String url = apiUrl + "/api_category.php";
|
||||
try {
|
||||
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
|
||||
String json = response.getBody();
|
||||
CategoryResponse categoryResponse = cleanerService.extractCategoryResponse(json);
|
||||
responseHandler.handleCategoryResponse(categoryResponse);
|
||||
|
||||
} catch (RestClientException | IOException e) {
|
||||
logger.log(System.Logger.Level.ERROR, "Critical failure", e);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.ninjaws.quiz.services;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import com.ninjaws.quiz.models.Session;
|
||||
|
||||
@Service
|
||||
public class CacheService {
|
||||
|
||||
/**
|
||||
* Stores all active sessions
|
||||
* Gets added to when a user requests data
|
||||
* Gets removed from when a user requests their score (by sending their answers)
|
||||
*/
|
||||
private final Cache<String,Session> sessions;
|
||||
|
||||
public CacheService() {
|
||||
sessions = (Caffeine.newBuilder()
|
||||
.expireAfterWrite(1,TimeUnit.HOURS)
|
||||
.build());
|
||||
}
|
||||
|
||||
public void addToCache(String key, Session session) {
|
||||
sessions.put(key, session);
|
||||
}
|
||||
|
||||
public Session getFromCache(String sessionId) {
|
||||
return sessions.getIfPresent(sessionId);
|
||||
}
|
||||
|
||||
public void removeFromCache(String sessionId) {
|
||||
sessions.invalidate(sessionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.ninjaws.quiz.services;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.commons.lang3.StringEscapeUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ninjaws.quiz.entities.Category;
|
||||
import com.ninjaws.quiz.entities.QuestionCollectingEntity;
|
||||
import com.ninjaws.quiz.entities.QuestionEntity;
|
||||
import com.ninjaws.quiz.models.ApiResponse;
|
||||
import com.ninjaws.quiz.models.CategoryResponse;
|
||||
import com.ninjaws.quiz.models.QuestionDTO;
|
||||
import com.ninjaws.quiz.models.SessionTokenDTO;
|
||||
import com.ninjaws.quiz.repositories.CategoryRepository;
|
||||
|
||||
/**
|
||||
* Deals with conversions from one form of data to another
|
||||
* Json to Object
|
||||
* Entity to DTO
|
||||
* etc
|
||||
*/
|
||||
@Service
|
||||
public class CleanerService {
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
@Autowired
|
||||
private CategoryRepository categoryRepository;
|
||||
|
||||
public CleanerService() {
|
||||
}
|
||||
|
||||
public ApiResponse extractApiResponse(String json) throws IOException {
|
||||
return objectMapper.readValue(json, ApiResponse.class);
|
||||
}
|
||||
|
||||
public CategoryResponse extractCategoryResponse(String json) throws IOException {
|
||||
return objectMapper.readValue(json, CategoryResponse.class);
|
||||
}
|
||||
|
||||
public List<QuestionDTO> questionListEntitytoDTO(List<QuestionEntity> entities) {
|
||||
List<QuestionDTO> dtos = new ArrayList<>();
|
||||
for (int i = 0; i < entities.size(); i++) {
|
||||
QuestionEntity entity = entities.get(i);
|
||||
dtos.add(questionEntityToDTO(entity));
|
||||
}
|
||||
return dtos;
|
||||
}
|
||||
|
||||
public SessionTokenDTO extractSessionToken(String json) throws IOException {
|
||||
return objectMapper.readValue(json, SessionTokenDTO.class);
|
||||
}
|
||||
|
||||
public QuestionDTO questionEntityToDTO(QuestionEntity entity) {
|
||||
QuestionDTO dto = new QuestionDTO();
|
||||
dto.setQuestion(entity.getQuestion());
|
||||
dto.setCorrectAnswer(entity.getCorrectAnswer());
|
||||
dto.setIncorrectAnswer(entity.getIncorrectAnswers());
|
||||
dto.setCategory(entity.getCategory().getName());
|
||||
dto.setType(entity.getType());
|
||||
dto.setDifficulty(entity.getDifficulty());
|
||||
return dto;
|
||||
}
|
||||
|
||||
public List<QuestionEntity> questionListDTOtoEntity(List<QuestionDTO> dtos) {
|
||||
List<QuestionEntity> entities = new ArrayList<>();
|
||||
for (int i = 0; i < dtos.size(); i++) {
|
||||
QuestionDTO dto = dtos.get(i);
|
||||
entities.add(questionDTOtoEntity(dto));
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
public QuestionEntity questionDTOtoEntity(QuestionDTO dto) {
|
||||
QuestionEntity entity = new QuestionEntity();
|
||||
Category category = categoryRepository.findByName(decodeString(dto.getCategory())).orElseThrow(() -> new IllegalStateException("Category not in DB!"));
|
||||
entity.setCategory(category);
|
||||
entity.setDifficulty(dto.getDifficulty());
|
||||
entity.setType(dto.getType());
|
||||
entity.setQuestion(dto.getQuestion());
|
||||
entity.setCorrectAnswer(dto.getCorrectAnswer());
|
||||
entity.setIncorrectAnswers(dto.getIncorrectAnswers());
|
||||
return entity;
|
||||
}
|
||||
|
||||
|
||||
// TODO: Find a simpler way to convert from one table to another if the data is identical so this nonsense isn't necessary
|
||||
public List<QuestionCollectingEntity> questionListDTOtoCollectingEntity(List<QuestionDTO> dtos) {
|
||||
List<QuestionCollectingEntity> entities = new ArrayList<>();
|
||||
for (int i = 0; i < dtos.size(); i++) {
|
||||
QuestionDTO dto = dtos.get(i);
|
||||
entities.add(questionDTOtoCollectingEntity(dto));
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
public QuestionCollectingEntity questionDTOtoCollectingEntity(QuestionDTO dto) {
|
||||
QuestionCollectingEntity entity = new QuestionCollectingEntity();
|
||||
Category category = categoryRepository.findByName(decodeString(dto.getCategory())).orElseThrow(() -> new IllegalStateException("Category not in DB!"));
|
||||
entity.setCategory(category);
|
||||
entity.setDifficulty(dto.getDifficulty());
|
||||
entity.setType(dto.getType());
|
||||
entity.setQuestion(dto.getQuestion());
|
||||
entity.setCorrectAnswer(dto.getCorrectAnswer());
|
||||
entity.setIncorrectAnswers(dto.getIncorrectAnswers());
|
||||
return entity;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* For some reason, the categories come with special characters,
|
||||
* But the categories from the questions dont
|
||||
* So this has to be manually fixed if they are to be stored in the db (otherwise namematching doesnt work)
|
||||
*/
|
||||
private String decodeString(String text){
|
||||
return StringEscapeUtils.unescapeHtml4(text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.ninjaws.quiz.services;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.ninjaws.quiz.entities.Category;
|
||||
import com.ninjaws.quiz.entities.QuestionCollectingEntity;
|
||||
import com.ninjaws.quiz.entities.QuestionEntity;
|
||||
import com.ninjaws.quiz.models.QuizSettings;
|
||||
import com.ninjaws.quiz.repositories.CategoryRepository;
|
||||
import com.ninjaws.quiz.repositories.QuestionCollectingRepository;
|
||||
import com.ninjaws.quiz.repositories.QuestionRepository;
|
||||
|
||||
import jakarta.transaction.Transactional;
|
||||
|
||||
@Service
|
||||
public class DataService {
|
||||
|
||||
@Autowired
|
||||
private QuestionRepository questionRepository;
|
||||
@Autowired
|
||||
private QuestionCollectingRepository collectingRepository;
|
||||
@Autowired
|
||||
private CategoryRepository categoryRepository;
|
||||
|
||||
public List<QuestionEntity> getAllQuestions() {
|
||||
return questionRepository.findAll();
|
||||
}
|
||||
|
||||
public QuestionEntity saveQuestion(QuestionEntity question) {
|
||||
return questionRepository.save(question);
|
||||
}
|
||||
|
||||
public List<QuestionEntity> saveQuestions(List<QuestionEntity> questions) {
|
||||
return questionRepository.saveAll(questions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to get questions that match the specified QuizSettings
|
||||
*
|
||||
* @return The questions if the query was possible, and an empty array if not
|
||||
*/
|
||||
public List<QuestionEntity> lookupQuestions(QuizSettings quizSettings) {
|
||||
return questionRepository.lookupQuestions(quizSettings.getCategory(), quizSettings.getDifficulty(), quizSettings.getType(), quizSettings.getAmount());
|
||||
}
|
||||
|
||||
public List<Category> saveCategories(List<Category> categories) {
|
||||
return categoryRepository.saveAll(categories);
|
||||
}
|
||||
|
||||
public List<Category> getCategories(){
|
||||
return categoryRepository.findAll();
|
||||
}
|
||||
/**
|
||||
* Moving the collected questions to the questions list
|
||||
*
|
||||
*/
|
||||
@Transactional
|
||||
public void migrateQuestions() {
|
||||
questionRepository.deleteAll();
|
||||
questionRepository.moveCollection();
|
||||
collectingRepository.deleteAll();
|
||||
}
|
||||
|
||||
public List<QuestionCollectingEntity> collectQuestions(List<QuestionCollectingEntity> questions) {
|
||||
return collectingRepository.saveAll(questions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.ninjaws.quiz.services;
|
||||
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.ninjaws.quiz.models.Request;
|
||||
|
||||
@Service
|
||||
public class QueueService {
|
||||
|
||||
/**
|
||||
* Stores all user requests for data
|
||||
* Executes them in order, one every 5 seconds (the limit of the external API)
|
||||
* Removes them from the Queue when the request was succesful
|
||||
*/
|
||||
private final BlockingQueue<Request> requests = new LinkedBlockingQueue<>(5);
|
||||
|
||||
/**
|
||||
* Offer automatically doesn't add it to the queue if it is full, so no Exceptions thrown
|
||||
* @return Whether it's possible to add the request to the queue
|
||||
*/
|
||||
public boolean addToQueue(Request request) {
|
||||
return requests.offer(request);
|
||||
}
|
||||
|
||||
public Request popFromQueue() {
|
||||
return requests.poll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.ninjaws.quiz.services;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.ninjaws.quiz.entities.QuestionEntity;
|
||||
import com.ninjaws.quiz.models.Answers;
|
||||
import com.ninjaws.quiz.models.QuizSettings;
|
||||
import com.ninjaws.quiz.models.Request;
|
||||
import com.ninjaws.quiz.models.Session;
|
||||
|
||||
|
||||
@Service
|
||||
public class QuizService {
|
||||
|
||||
@Autowired
|
||||
private CacheService cacheService;
|
||||
@Autowired
|
||||
private QueueService queueService;
|
||||
@Autowired
|
||||
private DataService dataService;
|
||||
@Autowired
|
||||
private CleanerService cleanerService;
|
||||
@Autowired
|
||||
private StateManagementService stateManager;
|
||||
|
||||
/**
|
||||
* Actions:
|
||||
* - Creates a session with sessionId
|
||||
* - Adds session to the cache
|
||||
* - Creates a request and adds it to the queue
|
||||
* @param quizSettings The values set by the user, determining the kind of quiz they want
|
||||
* @return The sessionId
|
||||
*/
|
||||
public Optional<String> createSession(QuizSettings quizSettings) {
|
||||
Session session = new Session();
|
||||
|
||||
/** First, we check if there are already enough items in our db */
|
||||
List<QuestionEntity> questionsRetrieved = dataService.lookupQuestions(quizSettings);
|
||||
/** If the items are not matching the required amount, and the db is not currently being filled up, then they simply don't exist */
|
||||
if (questionsRetrieved.size() < quizSettings.getAmount() && stateManager.getCurrentProcessingState() != StateManagementService.ProcessingState.STARTUP) {
|
||||
session.setStatusCode(1);
|
||||
cacheService.addToCache(session.getId(), session);
|
||||
return Optional.of(session.getId());
|
||||
}
|
||||
/** All items are collected in from the database, it doesn't need to be added to the queue */
|
||||
if(!questionsRetrieved.isEmpty() && questionsRetrieved.size() == quizSettings.getAmount()) {
|
||||
session.setQuestions(cleanerService.questionListEntitytoDTO(questionsRetrieved));
|
||||
cacheService.addToCache(session.getId(), session);
|
||||
return Optional.of(session.getId());
|
||||
}
|
||||
|
||||
cacheService.addToCache(session.getId(), session);
|
||||
boolean addedToQueue = queueService.addToQueue(new Request(session.getId(), quizSettings));
|
||||
/** In this order, because doing the queue before the cache could lead to race conditions (request completion immediately calls cache, but then the item doesn't exist) */
|
||||
if(!addedToQueue) {
|
||||
cacheService.removeFromCache(session.getId());
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.of(session.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions:
|
||||
* - Looks up the session in the cache
|
||||
* - Checks if the data from the request is stored
|
||||
* @param sessionId
|
||||
* @return The data if it's available, Optional.empty() if not, and an Exception if the session is invalid
|
||||
*/
|
||||
public Optional<Session> checkSession(String sessionId) throws IllegalArgumentException {
|
||||
Session session = cacheService.getFromCache(sessionId);
|
||||
|
||||
if(session == null) {
|
||||
throw new IllegalArgumentException("Invalid session ID: " + sessionId);
|
||||
}
|
||||
|
||||
/** Anything other than statuscode 0 doesn't result in active sessions, so remove them from the cache after reporting to the frontend */
|
||||
if(session.getStatusCode() != 0) {
|
||||
cacheService.removeFromCache(session.getId());
|
||||
return Optional.of(session);
|
||||
}
|
||||
|
||||
/** Still waiting for a response */
|
||||
if (session.getQuestions() == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
|
||||
return Optional.of(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives: sessionId and answers
|
||||
*
|
||||
* Actions:
|
||||
* - Retrieves session from cache
|
||||
* - Deletes session from cache
|
||||
* - Matches answers answers to correctAnswers
|
||||
* - Calculates score
|
||||
* Returns: calculated score
|
||||
* */
|
||||
public int getScore(Answers answers) {
|
||||
Session session = cacheService.getFromCache(answers.getSessionId());
|
||||
cacheService.removeFromCache(session.getId());
|
||||
return calculateScore(session, answers);
|
||||
}
|
||||
|
||||
private int calculateScore(Session session, Answers answers) {
|
||||
int amountCorrect = 0;
|
||||
for(int i = 0; i < session.getQuestions().size(); i++) {
|
||||
String correctAnswer = session.getQuestions().get(i).getCorrectAnswer();
|
||||
String userAnswer = answers.getAnswers().get(i);
|
||||
if (correctAnswer.equalsIgnoreCase(userAnswer)) {
|
||||
amountCorrect++;
|
||||
}
|
||||
}
|
||||
return amountCorrect;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.ninjaws.quiz.services;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.ninjaws.quiz.models.ApiResponse;
|
||||
import com.ninjaws.quiz.models.CategoryResponse;
|
||||
import com.ninjaws.quiz.models.Session;
|
||||
|
||||
@Service
|
||||
public class ResponseHandlingService {
|
||||
|
||||
@Autowired
|
||||
private StateManagementService stateManager;
|
||||
@Autowired
|
||||
private CacheService cacheService;
|
||||
@Autowired
|
||||
private DataService dataService;
|
||||
@Autowired
|
||||
private CleanerService cleanerService;
|
||||
|
||||
/**
|
||||
* If there is no sessionId, then this is a request from the scheduler
|
||||
* purely to stock in the database No Cache call is required
|
||||
*/
|
||||
public void handleResponse(String sessionId, ApiResponse response) throws IllegalStateException {
|
||||
|
||||
Session session = cacheService.getFromCache(sessionId);
|
||||
if (session == null) {
|
||||
// Critical system failure: Should never occur
|
||||
throw new IllegalStateException("Session should be defined!");
|
||||
}
|
||||
|
||||
switch (response.getResponseCode()) {
|
||||
case 0:
|
||||
// Success
|
||||
updateSessionInCache(session, response);
|
||||
/** Managing the token gets difficult since there is a timelimit, so simply dont add these, let the bulk collector do it */
|
||||
// dataService.saveQuestions(cleanerService.questionListDTOtoEntity(response.getResults()));
|
||||
break;
|
||||
case 1:
|
||||
// Not enough questions to satisfy the request.
|
||||
updateSessionInCache(session, response);
|
||||
break;
|
||||
case 2:
|
||||
// Invalid parameters
|
||||
updateSessionInCache(session, response);
|
||||
break;
|
||||
case 3:
|
||||
// If it expired. Shouldn't happen since the backup database is full within 40 minutes, and the code lasts for 6 hours
|
||||
// Return a statuscode 1 to signal an issue, just in case a user hits this situation (should not happen)
|
||||
session.setStatusCode(1);
|
||||
updateSessionInCache(session, response);
|
||||
case 4:
|
||||
// It returns nothing, the previous batch was the last one
|
||||
// Return a statuscode 1 to the user
|
||||
session.setStatusCode(1);
|
||||
updateSessionInCache(session, response);
|
||||
break;
|
||||
case 5:
|
||||
// Should be prevented by the scheduler
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the request was made by the scheduler during queue-downtime, there is no session
|
||||
*/
|
||||
public void handleResponse(ApiResponse response) {
|
||||
switch (response.getResponseCode()) {
|
||||
case 0:
|
||||
if(stateManager.getCurrentProcessingState() == StateManagementService.ProcessingState.STARTUP) {
|
||||
/** Initial saving during the startup */
|
||||
dataService.saveQuestions(cleanerService.questionListDTOtoEntity(response.getResults()));
|
||||
} else if (stateManager.getCurrentProcessingState() == StateManagementService.ProcessingState.REFRESH) {
|
||||
/** Background collecting during the night */
|
||||
dataService.collectQuestions(cleanerService.questionListDTOtoCollectingEntity(response.getResults()));
|
||||
}
|
||||
break;
|
||||
case 4:
|
||||
// Signal DataService that we have extracted all the data. Stop gathering,
|
||||
// TODO: Switch to caching-mode
|
||||
|
||||
/**
|
||||
* If it was in the REFRESH state, move the data from collecting to the used questions
|
||||
* Otherwise simply switch to the NORMAL state
|
||||
*/
|
||||
if (stateManager.getCurrentProcessingState() == StateManagementService.ProcessingState.REFRESH) {
|
||||
dataService.migrateQuestions();
|
||||
}
|
||||
stateManager.setCurrentProcessingState(StateManagementService.ProcessingState.NORMAL);
|
||||
break;
|
||||
case 5:
|
||||
// Should be prevented by the scheduler
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void updateSessionInCache(Session session, ApiResponse response) {
|
||||
session.setStatusCode(response.getResponseCode());
|
||||
session.setQuestions(response.getResults());
|
||||
cacheService.addToCache(session.getId(), session);
|
||||
}
|
||||
|
||||
public void handleCategoryResponse(CategoryResponse categoryResponse) {
|
||||
dataService.saveCategories(categoryResponse.getTrivia_categories());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.ninjaws.quiz.services;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.ninjaws.quiz.models.Request;
|
||||
|
||||
@Service
|
||||
public class SchedulerService {
|
||||
|
||||
@Autowired
|
||||
private QueueService queueService;
|
||||
@Autowired
|
||||
private ApiService apiService;
|
||||
@Autowired
|
||||
private StateManagementService stateService;
|
||||
|
||||
public SchedulerService() {}
|
||||
|
||||
/**
|
||||
* Every 5 seconds:
|
||||
* - Check if there is a request in queue
|
||||
* - Yes: Send it to the apiService
|
||||
* - No: Have the apiService do bulk gathering for the DB
|
||||
*/
|
||||
@Scheduled(fixedDelay = 5000)
|
||||
protected void processQueue() {
|
||||
Request request = queueService.popFromQueue();
|
||||
|
||||
if (request != null) {
|
||||
apiService.handleRequest(request);
|
||||
} else {
|
||||
if (stateService.getCurrentProcessingState() == StateManagementService.ProcessingState.STARTUP || stateService.getCurrentProcessingState() == StateManagementService.ProcessingState.REFRESH) {
|
||||
apiService.collectBulk();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the renewing of the questions, to ensure they stay fresh
|
||||
*/
|
||||
@Scheduled(cron = "0 0 1 * * ?")
|
||||
protected void StartDbRefresh() {
|
||||
apiService.getSessionToken();
|
||||
stateService.setCurrentProcessingState(StateManagementService.ProcessingState.REFRESH);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ninjaws.quiz.services;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class StateManagementService {
|
||||
|
||||
/**
|
||||
* STARTUP: When starting the app
|
||||
* REFRESH: Scheduled at night, so the database stays fresh
|
||||
* NORMAL: No special activities
|
||||
*/
|
||||
public enum ProcessingState {
|
||||
STARTUP,
|
||||
REFRESH,
|
||||
NORMAL
|
||||
}
|
||||
|
||||
private ProcessingState currentProcessingState;
|
||||
|
||||
public ProcessingState getCurrentProcessingState() {
|
||||
return currentProcessingState;
|
||||
}
|
||||
|
||||
public void setCurrentProcessingState(ProcessingState currentProcessingState) {
|
||||
this.currentProcessingState = currentProcessingState;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1 +1,34 @@
|
||||
spring.application.name=quiz
|
||||
server.port=9090
|
||||
|
||||
# H2 Database configuration
|
||||
spring.h2.console.enabled=true
|
||||
# spring.h2.console.path=/console
|
||||
|
||||
|
||||
spring.datasource.url=jdbc:h2:mem:testdb
|
||||
spring.datasource.driverClassName=org.h2.Driver
|
||||
spring.datasource.username=sa
|
||||
spring.datasource.password=
|
||||
|
||||
spring.jpa.hibernate.ddl-auto=create-drop
|
||||
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
|
||||
|
||||
|
||||
# # Primary database configuration
|
||||
# spring.datasource.primary.url=jdbc:h2:mem:testdb
|
||||
# # ;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
|
||||
# spring.datasource.primary.driver-class-name=org.h2.Driver
|
||||
# spring.datasource.primary.username=sa
|
||||
# spring.datasource.primary.password=
|
||||
# spring.jpa.primary.hibernate.ddl-auto=create-drop
|
||||
# spring.jpa.primary.database-platform=org.hibernate.dialect.H2Dialect
|
||||
|
||||
# # Secondary database configuration
|
||||
# spring.datasource.secondary.url=jdbc:h2:mem:secondarydb
|
||||
# # ;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
|
||||
# spring.datasource.secondary.driver-class-name=org.h2.Driver
|
||||
# spring.datasource.secondary.username=sa
|
||||
# spring.datasource.secondary.password=
|
||||
# spring.jpa.secondary.hibernate.ddl-auto=create-drop
|
||||
# spring.jpa.secondary.database-platform=org.hibernate.dialect.H2Dialect
|
||||
@@ -0,0 +1 @@
|
||||
<h1>Quiz backend</h1>
|
||||
@@ -1,13 +1,12 @@
|
||||
package com.ninjaws.quiz;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest
|
||||
class QuizApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
// @Test
|
||||
// void contextLoads() {
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.ninjaws.quiz.scala;
|
||||
import io.gatling.core.Predef._
|
||||
import io.gatling.http.Predef._
|
||||
import scala.concurrent.duration._
|
||||
|
||||
/** Does not work, use the LoadTestMultiple.sh in the /scripts folder in the root instead */
|
||||
class QuestionStatusSimulation extends Simulation {
|
||||
val httpProtocol = http.baseUrl("http://localhost:9090").acceptHeader("application/json")
|
||||
|
||||
val requestQuestions = exec(http("Request Questions").get("/questions/bla").check(status.is(200)).check(jsonPath("$.id").saveAs("sessionId")))
|
||||
|
||||
val checkStatus = exec(http("Check Status").get("/status/${sessionId}").check(status.is(200),jsonPath("$.status").notNull))
|
||||
|
||||
val scn = scenario("Check Questions Status Completion").exec(requestQuestions).pause(1).asLongAsDuring(session => session("completed").asOption[Boolean].getOrElse(false) == false, 30.seconds) {
|
||||
exec(checkStatus).pause(1).exec{ session => val status = session("status").asOption[String]
|
||||
session.set("completed", status.isDefined && status.get != "null")
|
||||
}
|
||||
}
|
||||
setUp(
|
||||
scn.inject(atOnceUsers(10))
|
||||
).protocols(httpProtocol);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.ninjaws.quiz.services;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.ninjaws.quiz.models.Session;
|
||||
|
||||
public class CacheServiceTest {
|
||||
|
||||
private CacheService cacheService;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
cacheService = new CacheService();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAddToCache() {
|
||||
String key = "session1";
|
||||
Session session = new Session();
|
||||
|
||||
cacheService.addToCache(key, session);
|
||||
|
||||
assertNotNull(cacheService.getFromCache(key));
|
||||
assertEquals(session, cacheService.getFromCache(key));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetFromCache() {
|
||||
String key = "session1";
|
||||
Session session = new Session();
|
||||
|
||||
cacheService.addToCache(key, session);
|
||||
|
||||
Session retrievedSession = cacheService.getFromCache(key);
|
||||
assertNotNull(retrievedSession);
|
||||
assertEquals(session, retrievedSession);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRemoveFromCache() {
|
||||
String key = "session1";
|
||||
Session session = new Session();
|
||||
|
||||
cacheService.addToCache(key, session);
|
||||
cacheService.removeFromCache(key);
|
||||
|
||||
assertNull(cacheService.getFromCache(key));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetFromCacheWithNonExistentKey() {
|
||||
assertNull(cacheService.getFromCache("nonExistentKey"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.ninjaws.quiz.services;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.ninjaws.quiz.models.QuizSettings;
|
||||
import com.ninjaws.quiz.models.Request;
|
||||
|
||||
public class QueueServiceTest {
|
||||
|
||||
private QueueService queueService;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
queueService = new QueueService();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAddToQueue() {
|
||||
QuizSettings quizSettings = new QuizSettings();
|
||||
Request request = new Request("session1", quizSettings);
|
||||
|
||||
boolean added = queueService.addToQueue(request);
|
||||
assertTrue(added);
|
||||
|
||||
for (int i = 0; i < 4; i++) {
|
||||
added = queueService.addToQueue(new Request("session" + (i + 2), new QuizSettings()));
|
||||
assertTrue(added);
|
||||
}
|
||||
|
||||
added = queueService.addToQueue(new Request("session6", new QuizSettings()));
|
||||
assertFalse(added);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPopFromQueue() {
|
||||
QuizSettings quizSettings1 = new QuizSettings();
|
||||
QuizSettings quizSettings2 = new QuizSettings();
|
||||
|
||||
Request request1 = new Request("session1", quizSettings1);
|
||||
Request request2 = new Request("session2", quizSettings2);
|
||||
|
||||
queueService.addToQueue(request1);
|
||||
queueService.addToQueue(request2);
|
||||
|
||||
Request poppedRequest = queueService.popFromQueue();
|
||||
assertEquals(request1, poppedRequest);
|
||||
|
||||
poppedRequest = queueService.popFromQueue();
|
||||
assertEquals(request2, poppedRequest);
|
||||
|
||||
assertNull(queueService.popFromQueue());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPopFromEmptyQueue() {
|
||||
assertNull(queueService.popFromQueue());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
services:
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
target: production
|
||||
image: quiz-frontend-prod
|
||||
container_name: quiz-frontend-prod-cont
|
||||
ports:
|
||||
- "4200:80"
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
target: production
|
||||
image: quiz-backend-prod
|
||||
container_name: quiz-backend-prod-cont
|
||||
ports:
|
||||
- "9090:9090"
|
||||
@@ -1,21 +0,0 @@
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
ARG WORKDIR
|
||||
WORKDIR ${WORKDIR}
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y git && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Angular CLI globally
|
||||
RUN npm install -g @angular/cli
|
||||
|
||||
# Switching user so it doesn't use root
|
||||
# If root adds files, it takes ownership, making them unremovable on the host except by using sudo
|
||||
# node tranfers ownership to the host user
|
||||
USER node
|
||||
|
||||
EXPOSE 4200
|
||||
|
||||
CMD ["tail","-f","/dev/null"]
|
||||
@@ -2,11 +2,12 @@
|
||||
"name": "Quiz Frontend",
|
||||
|
||||
"build": {
|
||||
"context": ".",
|
||||
"dockerfile": "Dockerfile",
|
||||
"context": "..",
|
||||
"dockerfile": "../Dockerfile",
|
||||
"args": {
|
||||
"WORKDIR": "/app"
|
||||
}
|
||||
// "WORKDIR": "/app"
|
||||
},
|
||||
"target": "development"
|
||||
},
|
||||
"runArgs": [
|
||||
"--name=quiz-frontend-dev-cont",
|
||||
@@ -24,7 +25,10 @@
|
||||
"Angular.ng-template",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"Zignd.html-css-class-completion",
|
||||
"esbenp.prettier-vscode"
|
||||
"esbenp.prettier-vscode",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"ecmel.vscode-html-css",
|
||||
"pranaygp.vscode-css-peek"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
.git/
|
||||
.gitignore
|
||||
README.md
|
||||
LICENSE
|
||||
.vscode/
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
npm-debug.log
|
||||
@@ -0,0 +1,31 @@
|
||||
FROM node:22-bookworm-slim AS base
|
||||
WORKDIR /app
|
||||
ENV NG_CLI_ANALYTICS=ci
|
||||
RUN npm install -g @angular/cli
|
||||
|
||||
#--- Development
|
||||
FROM base AS development
|
||||
RUN apt-get update && \
|
||||
apt-get install -y git && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
# Switching user so it doesn't use root
|
||||
# If root adds files, it takes ownership, making them unremovable on the host except by using sudo
|
||||
# node tranfers ownership to the host user
|
||||
USER node
|
||||
EXPOSE 4200
|
||||
CMD ["tail","-f","/dev/null"]
|
||||
|
||||
#--- Building
|
||||
FROM base AS build
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
# Copied here, since the the Development setup uses a bind volume instead
|
||||
COPY . .
|
||||
RUN ng build --configuration production
|
||||
|
||||
#--- Production
|
||||
FROM nginx:alpine AS production
|
||||
COPY --from=build /app/dist/quiz/browser /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -50,6 +50,12 @@
|
||||
"maximumError": "4kB"
|
||||
}
|
||||
],
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
@@ -98,5 +104,8 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cli": {
|
||||
"analytics": false
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+499
-237
@@ -25,15 +25,31 @@
|
||||
"@angular/cli": "^18.2.10",
|
||||
"@angular/compiler-cli": "^18.2.0",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"jasmine-core": "~5.2.0",
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "~5.5.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||
@@ -193,6 +209,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular/node_modules/postcss": {
|
||||
"version": "8.4.41",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz",
|
||||
"integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.7",
|
||||
"picocolors": "^1.0.1",
|
||||
"source-map-js": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular/node_modules/tslib": {
|
||||
"version": "2.6.3",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz",
|
||||
@@ -450,6 +495,36 @@
|
||||
"typescript": ">=5.4 <5.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/compiler-cli/node_modules/chokidar": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz",
|
||||
"integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/compiler-cli/node_modules/readdirp": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz",
|
||||
"integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/core": {
|
||||
"version": "18.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-18.2.9.tgz",
|
||||
@@ -4962,6 +5037,13 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/any-promise": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
@@ -4989,6 +5071,13 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
@@ -5474,6 +5563,16 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase-css": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001673",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001673.tgz",
|
||||
@@ -5520,19 +5619,41 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz",
|
||||
"integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==",
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
"anymatch": "~3.1.2",
|
||||
"braces": "~3.0.2",
|
||||
"glob-parent": "~5.1.2",
|
||||
"is-binary-path": "~2.1.0",
|
||||
"is-glob": "~4.0.1",
|
||||
"normalize-path": "~3.0.0",
|
||||
"readdirp": "~3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
"node": ">= 8.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar/node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
@@ -6322,6 +6443,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/didyoumean": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dlv": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dns-packet": {
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
|
||||
@@ -8605,31 +8740,6 @@
|
||||
"source-map-support": "^0.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/karma/node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
"braces": "~3.0.2",
|
||||
"glob-parent": "~5.1.2",
|
||||
"is-binary-path": "~2.1.0",
|
||||
"is-glob": "~4.0.1",
|
||||
"normalize-path": "~3.0.0",
|
||||
"readdirp": "~3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/karma/node_modules/cliui": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
|
||||
@@ -8649,19 +8759,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/karma/node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/karma/node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
@@ -8672,32 +8769,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/karma/node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/karma/node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/karma/node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
@@ -8924,6 +8995,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
|
||||
"integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
@@ -9780,6 +9861,18 @@
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mz": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"any-promise": "^1.0.0",
|
||||
"object-assign": "^4.0.1",
|
||||
"thenify-all": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.7",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
||||
@@ -10210,6 +10303,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-hash": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz",
|
||||
@@ -10716,6 +10819,16 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pirates": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
|
||||
"integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/piscina": {
|
||||
"version": "4.6.1",
|
||||
"resolved": "https://registry.npmjs.org/piscina/-/piscina-4.6.1.tgz",
|
||||
@@ -10743,9 +10856,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.41",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz",
|
||||
"integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==",
|
||||
"version": "8.4.47",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",
|
||||
"integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -10764,13 +10877,100 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.7",
|
||||
"picocolors": "^1.0.1",
|
||||
"source-map-js": "^1.2.0"
|
||||
"picocolors": "^1.1.0",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-import": {
|
||||
"version": "15.1.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
|
||||
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postcss-value-parser": "^4.0.0",
|
||||
"read-cache": "^1.0.0",
|
||||
"resolve": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"postcss": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-js": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz",
|
||||
"integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"camelcase-css": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12 || ^14 || >= 16"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"postcss": "^8.4.21"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-load-config": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz",
|
||||
"integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lilconfig": "^3.0.0",
|
||||
"yaml": "^2.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"postcss": ">=8.0.9",
|
||||
"ts-node": ">=9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"postcss": {
|
||||
"optional": true
|
||||
},
|
||||
"ts-node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-load-config/node_modules/lilconfig": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz",
|
||||
"integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antonk52"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-loader": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-8.1.1.tgz",
|
||||
@@ -10873,6 +11073,32 @@
|
||||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-nested": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
|
||||
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postcss-selector-parser": "^6.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"postcss": "^8.2.14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-selector-parser": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
||||
@@ -11054,6 +11280,26 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pify": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache/node_modules/pify": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
|
||||
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
@@ -11070,17 +11316,29 @@
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz",
|
||||
"integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==",
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp/node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect-metadata": {
|
||||
@@ -11513,70 +11771,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/sass/node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
"braces": "~3.0.2",
|
||||
"glob-parent": "~5.1.2",
|
||||
"is-binary-path": "~2.1.0",
|
||||
"is-glob": "~4.0.1",
|
||||
"normalize-path": "~3.0.0",
|
||||
"readdirp": "~3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/sass/node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/sass/node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/sass/node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
|
||||
@@ -12410,6 +12604,86 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.35.0",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
|
||||
"integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.2",
|
||||
"commander": "^4.0.0",
|
||||
"glob": "^10.3.10",
|
||||
"lines-and-columns": "^1.1.6",
|
||||
"mz": "^2.7.0",
|
||||
"pirates": "^4.0.1",
|
||||
"ts-interface-checker": "^0.1.9"
|
||||
},
|
||||
"bin": {
|
||||
"sucrase": "bin/sucrase",
|
||||
"sucrase-node": "bin/sucrase-node"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/sucrase/node_modules/brace-expansion": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sucrase/node_modules/commander": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/sucrase/node_modules/glob": {
|
||||
"version": "10.4.5",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.1.0",
|
||||
"jackspeak": "^3.1.2",
|
||||
"minimatch": "^9.0.4",
|
||||
"minipass": "^7.1.2",
|
||||
"package-json-from-dist": "^1.0.0",
|
||||
"path-scurry": "^1.11.1"
|
||||
},
|
||||
"bin": {
|
||||
"glob": "dist/esm/bin.mjs"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/sucrase/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
@@ -12446,6 +12720,44 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "3.4.14",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.14.tgz",
|
||||
"integrity": "sha512-IcSvOcTRcUtQQ7ILQL5quRDg7Xs93PdJEk1ZLbhhvJc7uj/OAhYOnruEiwnGgBvUtaUAJ8/mhSw1o8L2jCiENA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
"chokidar": "^3.5.3",
|
||||
"didyoumean": "^1.2.2",
|
||||
"dlv": "^1.1.3",
|
||||
"fast-glob": "^3.3.0",
|
||||
"glob-parent": "^6.0.2",
|
||||
"is-glob": "^4.0.3",
|
||||
"jiti": "^1.21.0",
|
||||
"lilconfig": "^2.1.0",
|
||||
"micromatch": "^4.0.5",
|
||||
"normalize-path": "^3.0.0",
|
||||
"object-hash": "^3.0.0",
|
||||
"picocolors": "^1.0.0",
|
||||
"postcss": "^8.4.23",
|
||||
"postcss-import": "^15.1.0",
|
||||
"postcss-js": "^4.0.1",
|
||||
"postcss-load-config": "^4.0.1",
|
||||
"postcss-nested": "^6.0.1",
|
||||
"postcss-selector-parser": "^6.0.11",
|
||||
"resolve": "^1.22.2",
|
||||
"sucrase": "^3.32.0"
|
||||
},
|
||||
"bin": {
|
||||
"tailwind": "lib/cli.js",
|
||||
"tailwindcss": "lib/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
|
||||
@@ -12637,6 +12949,29 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/thenify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"any-promise": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/thenify-all": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
|
||||
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"thenify": ">= 3.1.0 < 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/thingies": {
|
||||
"version": "1.21.0",
|
||||
"resolved": "https://registry.npmjs.org/thingies/-/thingies-1.21.0.tgz",
|
||||
@@ -12720,6 +13055,13 @@
|
||||
"tree-kill": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-interface-checker": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz",
|
||||
@@ -13525,35 +13867,6 @@
|
||||
"@esbuild/win32-x64": "0.21.5"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/postcss": {
|
||||
"version": "8.4.47",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",
|
||||
"integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.7",
|
||||
"picocolors": "^1.1.0",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz",
|
||||
@@ -13752,31 +14065,6 @@
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-server/node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
"braces": "~3.0.2",
|
||||
"glob-parent": "~5.1.2",
|
||||
"is-binary-path": "~2.1.0",
|
||||
"is-glob": "~4.0.1",
|
||||
"normalize-path": "~3.0.0",
|
||||
"readdirp": "~3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-server/node_modules/glob": {
|
||||
"version": "10.4.5",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||
@@ -13798,19 +14086,6 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-server/node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-server/node_modules/http-proxy-middleware": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz",
|
||||
@@ -13852,32 +14127,6 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-server/node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-server/node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-server/node_modules/rimraf": {
|
||||
"version": "5.0.10",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz",
|
||||
@@ -14190,6 +14439,19 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz",
|
||||
"integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
|
||||
@@ -27,12 +27,15 @@
|
||||
"@angular/cli": "^18.2.10",
|
||||
"@angular/compiler-cli": "^18.2.0",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"jasmine-core": "~5.2.0",
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "~5.5.2"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,4 @@
|
||||
export interface Answers {
|
||||
sessionId: string;
|
||||
answers: string[];
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface Score {
|
||||
amount_questions: number;
|
||||
amount_correct: number;
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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) { }
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
<h1>Quiz App</h1>
|
||||
<router-outlet></router-outlet>
|
||||
<app-spinner></app-spinner>
|
||||
@@ -1,10 +1,11 @@
|
||||
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],
|
||||
imports: [RouterOutlet, SpinnerComponent],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss'
|
||||
})
|
||||
|
||||
@@ -2,7 +2,11 @@ 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)]
|
||||
providers: [
|
||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||
provideRouter(routes),
|
||||
provideHttpClient()]
|
||||
};
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [];
|
||||
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,6 @@
|
||||
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: 'http://localhost:9090',
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiUrl: 'http://localhost:9090'
|
||||
};
|
||||
@@ -5,7 +5,7 @@
|
||||
<title>Quiz</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link rel="icon" type="image/x-icon" href="question.ico">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
|
||||
@@ -1 +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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/**/*.{html,ts}"
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
|
||||
start_time=$(date +%s)
|
||||
|
||||
for i in {1..6}; do
|
||||
./LoadTestOne-Silent.sh &
|
||||
# sleep 0.1
|
||||
done
|
||||
|
||||
wait
|
||||
|
||||
end_time=$(date +%s)
|
||||
|
||||
elapsed_time=$((end_time - start_time))
|
||||
echo "Done!"
|
||||
echo "Total time taken: $elapsed_time seconds"
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
# Step 1: Get the sessionId from the questions endpoint
|
||||
sessionIdJson=$(curl -s "http://localhost:9090/quiz/questions?amount=10&category=23&difficulty=medium&type=multiple")
|
||||
|
||||
# Extract the status code
|
||||
statusCode=$(echo "$sessionIdJson" | grep -o '"statusCode":[0-9]*' | sed 's/"statusCode"://')
|
||||
|
||||
if [ "$statusCode" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract the session ID
|
||||
sessionId=$(echo "$sessionIdJson" | grep -o '"sessionId":"[^"]*"' | sed 's/"sessionId":"//;s/"//')
|
||||
|
||||
if [ -z "$sessionId" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 2: Poll the status endpoint every 2 seconds
|
||||
while true; do
|
||||
status_response=$(curl -s "http://localhost:9090/quiz/status/$sessionId")
|
||||
|
||||
if [ "$status_response" != "null" ]; then
|
||||
# Extract the status code from the response
|
||||
statusCode=$(echo "$status_response" | grep -o '"statusCode":[0-9]*' | sed 's/"statusCode"://')
|
||||
|
||||
if [ "$statusCode" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if the response is JSON (starts with '{')
|
||||
if [[ $status_response == \{* ]]; then
|
||||
break
|
||||
fi
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
done
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/bin/bash
|
||||
# Step 1: Get the sessionId from the questions endpoint
|
||||
sessionIdJson=$(curl -s "http://localhost:9090/quiz/questions?amount=20&category=27&difficulty=medium&type=multiple")
|
||||
|
||||
statusCode=$(echo "$sessionIdJson" | grep -o '"statusCode":[0-9]*' | sed 's/"statusCode"://')
|
||||
|
||||
if [ "$statusCode" -ne 0 ]; then
|
||||
echo "Exiting: statusCode is not 0."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sessionId=$(echo "$sessionIdJson" | grep -o '"sessionId":"[^"]*"' | sed 's/"sessionId":"//;s/"//')
|
||||
|
||||
if [ -z "$sessionId" ]; then
|
||||
echo "Failed to get sessionId"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Received sessionId: $sessionId"
|
||||
|
||||
# Step 2: Poll the status endpoint every 2 seconds
|
||||
while true; do
|
||||
status_response=$(curl -s http://localhost:9090/quiz/status/"$sessionId")
|
||||
if [ "$status_response" != "null" ] && [ $? -eq 0 ]; then
|
||||
|
||||
statusCode=$(echo "$status_response" | grep -o '"statusCode":[0-9]*' | sed 's/"statusCode"://')
|
||||
|
||||
if [ "$statusCode" -ne 0 ]; then
|
||||
echo "Exiting: statusCode is $statusCode"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if the response is JSON (starts with '{')
|
||||
if [[ $status_response == \{* ]]; then
|
||||
echo "$sessionId: done! status: $statusCode"
|
||||
break
|
||||
fi
|
||||
else
|
||||
echo "Status check failed, retrying..."
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
done
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/bin/bash
|
||||
# Step 1: Get the sessionId from the questions endpoint
|
||||
sessionIdJson=$(curl -s http://localhost:9090/quiz/questions?amount=5)
|
||||
|
||||
statusCode=$(echo "$sessionIdJson" | grep -o '"statusCode":[0-9]*' | sed 's/"statusCode"://')
|
||||
|
||||
if [ "$statusCode" -ne 0 ]; then
|
||||
echo "Exiting: statusCode is not 0."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sessionId=$(echo "$sessionIdJson" | grep -o '"sessionId":"[^"]*"' | sed 's/"sessionId":"//;s/"//')
|
||||
|
||||
if [ -z "$sessionId" ]; then
|
||||
echo "Failed to get sessionId"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Received sessionId: $sessionId"
|
||||
|
||||
# Step 2: Poll the status endpoint every 2 seconds
|
||||
while true; do
|
||||
status_response=$(curl -s http://localhost:9090/quiz/status/"$sessionId")
|
||||
if [ "$status_response" != "null" ] && [ $? -eq 0 ]; then
|
||||
|
||||
statusCode=$(echo "$status_response" | grep -o '"statusCode":[0-9]*' | sed 's/"statusCode"://')
|
||||
|
||||
if [ "$statusCode" -ne 0 ]; then
|
||||
echo "Exiting: statusCode is $statusCode"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if the response is JSON (starts with '{')
|
||||
if [[ $status_response == \{* ]]; then
|
||||
echo "$sessionId: done! status: $statusCode"
|
||||
break
|
||||
fi
|
||||
else
|
||||
echo "Status check failed, retrying..."
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
done
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Reference in New Issue
Block a user