Compare commits

...

2 Commits

Author SHA1 Message Date
Baer afd2345cac updated npm versions; Refactored Question/Answer Types 2026-03-21 22:56:02 +01:00
Baer ae18ac1ccd old changes, idk (>1 year) 2026-03-15 17:38:23 +01:00
121 changed files with 7300 additions and 4648 deletions

View File

@ -5,6 +5,7 @@ plugins {
kotlin("jvm") version "1.9.24"
kotlin("plugin.spring") version "1.9.24"
kotlin("plugin.allopen") version "1.9.22"
kotlin("kapt") version "1.9.10"
}
group = "at.eisibaer"
@ -26,7 +27,8 @@ repositories {
mavenCentral()
}
val bcVersion: String = "1.78.1";
val bcVersion: String = "1.78.1"
val mapstructVersion: String = "1.6.0"
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
@ -39,10 +41,12 @@ dependencies {
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.bouncycastle:bcprov-jdk18on:$bcVersion")
implementation("org.mapstruct:mapstruct:$mapstructVersion")
compileOnly("org.projectlombok:lombok")
developmentOnly("org.springframework.boot:spring-boot-devtools")
runtimeOnly("org.postgresql:postgresql")
annotationProcessor("org.projectlombok:lombok")
kapt("org.mapstruct:mapstruct-processor:$mapstructVersion")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.springframework.security:spring-security-test")
@ -55,6 +59,12 @@ kotlin {
}
}
kapt {
arguments {
arg("mapstruct.defaultComponentModel", "spring")
}
}
tasks.withType<Test> {
useJUnitPlatform()
}

View File

@ -7,4 +7,18 @@ data class ApplicationProperties(
val test: Boolean,
val corsAllowedOrigins: List<String>,
val corsAllowedMethods: List<String>,
)
val storage: StorageProperties,
){
data class StorageProperties (
val fs: FileSystemStorageProperties,
) {
data class FileSystemStorageProperties(
val location: String,
)
}
}

View File

@ -47,5 +47,9 @@ class SpringConfiguration(
.addMapping("/api/**")
.allowedOrigins(*applicationProperties.corsAllowedOrigins.map { it }.toTypedArray())
.allowedMethods(*applicationProperties.corsAllowedMethods.map { it }.toTypedArray())
// registry
// .addMapping("/gs-guide-websocket")
// .allowedOrigins(*applicationProperties.corsAllowedOrigins.map { it }.toTypedArray())
// .allowedMethods(*applicationProperties.corsAllowedMethods.map { it }.toTypedArray())
}
}

View File

@ -2,20 +2,28 @@ package at.eisibaer.jbear2.config
import org.springframework.context.annotation.Configuration
import org.springframework.messaging.simp.config.MessageBrokerRegistry
import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.session.Session
import org.springframework.session.web.socket.config.annotation.AbstractSessionWebSocketMessageBrokerConfigurer
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker
import org.springframework.web.socket.config.annotation.StompEndpointRegistry
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer
@Configuration
@EnableScheduling
@EnableWebSocketMessageBroker
class WebSocketConfig : WebSocketMessageBrokerConfigurer {
class WebSocketConfig(
val applicationProperties: ApplicationProperties,
) : AbstractSessionWebSocketMessageBrokerConfigurer<Session>() {
override fun configureMessageBroker(registry: MessageBrokerRegistry) {
registry.enableSimpleBroker("/game");
registry.setApplicationDestinationPrefixes("/app");
registry.enableSimpleBroker("/topic");
}
override fun registerStompEndpoints(registry: StompEndpointRegistry) {
registry.addEndpoint("/websocket")
override fun configureStompEndpoints(registry: StompEndpointRegistry) {
registry
.addEndpoint("/ws")
.setAllowedOrigins(applicationProperties.corsAllowedOrigins.joinToString(","))
}
}

View File

@ -0,0 +1,10 @@
package at.eisibaer.jbear2.dto.board
import org.springframework.data.geo.Point
data class AnswerDto(
val text: String,
val answerType: Int,
val image: String?,
val location: Point?,
)

View File

@ -0,0 +1,9 @@
package at.eisibaer.jbear2.dto.board
data class BoardDto(
val id: Long?,
val boardName: String,
val owner: String?,
val categories: List<CategoryDto>,
val pointsAreTitle: Boolean,
)

View File

@ -0,0 +1,9 @@
package at.eisibaer.jbear2.dto.board
data class BoardEntryDto(
val name: String,
val points: Long,
val answer: AnswerDto,
val questions: List<QuestionDto>,
// val category: CategoryDto,
)

View File

@ -0,0 +1,7 @@
package at.eisibaer.jbear2.dto.board
data class CategoryDto(
val name: String,
val description: String,
val boardEntries: List<BoardEntryDto>,
)

View File

@ -0,0 +1,12 @@
package at.eisibaer.jbear2.dto.board
import org.springframework.data.geo.Point
data class QuestionDto(
val text: String,
val questionType: Int,
val fontScaling: Int,
val image: String?,
val location: Point?,
// val boardEntryDto: BoardEntryDto,
)

View File

@ -0,0 +1,14 @@
package at.eisibaer.jbear2.dto.game
import java.util.*
data class GameDto(
val inviteCode: String,
val uuid: UUID,
val boardId: Long,
// val host: String,
val players: List<PlayerDto>,
// val alreadyAnsweredEntries: List<Long>,
val acceptingAnswers: Boolean = false,
val currentlyChoosingPlayer: PlayerDto? = null,
)

View File

@ -0,0 +1,9 @@
package at.eisibaer.jbear2.dto.game
import java.util.UUID
data class PlayerDto(
val name: String,
val id: Long,
// val uuid: UUID,
)

View File

@ -0,0 +1,5 @@
package at.eisibaer.jbear2.dto.message
import java.util.*
data class GameMessage(val uuid: UUID, val message: String);

View File

@ -0,0 +1,12 @@
package at.eisibaer.jbear2.dto.message
import at.eisibaer.jbear2.model.BoardEntry
import java.util.UUID
data class GameStateMessage(
val uuid: UUID,
val categoryIndex: Int?,
val boardEntryIndex: Int?,
val questionIndex: Int?,
val questionLayerIndex: Int?,
)

View File

@ -4,8 +4,5 @@ import lombok.AllArgsConstructor
@AllArgsConstructor
data class GenericMessage (
var name: String? = "",
var content: String? = "",
val content: String,
)

View File

@ -0,0 +1,5 @@
package at.eisibaer.jbear2.dto.message
data class JoinGameMessage(
val playerName: String,
)

View File

@ -0,0 +1,8 @@
package at.eisibaer.jbear2.dto.message
import at.eisibaer.jbear2.dto.game.PlayerDto
data class PlayerMessage(
val player: PlayerDto,
val content: String,
)

View File

@ -0,0 +1,9 @@
package at.eisibaer.jbear2.dto.user
import at.eisibaer.jbear2.dto.board.BoardDto
data class UserDto(
val username: String,
val profilePicture: String,
val boards: List<BoardDto>,
)

View File

@ -1,9 +0,0 @@
package at.eisibaer.jbear2.endpoint
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.RequestMapping
@Controller
@RequestMapping("/api/board")
class BoardEndpoint {
}

View File

@ -0,0 +1,31 @@
package at.eisibaer.jbear2.endpoint
import at.eisibaer.jbear2.exception.StorageFileNotFoundException
import at.eisibaer.jbear2.service.FileService
import at.eisibaer.jbear2.service.StorageService
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.multipart.MultipartFile
@Controller
class FileEndpoint(
val storageService: StorageService,
val fileService: FileService,
) {
@PostMapping("/")
fun handleFileUpload( @RequestParam("files") files: List<MultipartFile> ): ResponseEntity<*> {
fileService.saveFiles(files);
return ResponseEntity.ok().build<Any>()
}
@ExceptionHandler(StorageFileNotFoundException::class)
fun handleStorageFileNotFound(exc: StorageFileNotFoundException?): ResponseEntity<*> {
return ResponseEntity.notFound().build<Any>()
}
}

View File

@ -1,19 +0,0 @@
package at.eisibaer.jbear2.endpoint
import org.springframework.messaging.handler.annotation.MessageMapping
import org.springframework.messaging.handler.annotation.SendTo
import at.eisibaer.jbear2.dto.message.GenericMessage
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/game")
class GameEndpoint {
@MessageMapping("/player/join")
@SendTo("/player/joined")
fun playerJoining(playerJoiningMessage: GenericMessage): GenericMessage{
return playerJoiningMessage.copy(playerJoiningMessage.name, "Joined");
}
}

View File

@ -1,17 +1,25 @@
package at.eisibaer.jbear2.endpoint
import at.eisibaer.jbear2.dto.board.BoardDto
import at.eisibaer.jbear2.model.User
import at.eisibaer.jbear2.repository.FileRepository
import at.eisibaer.jbear2.repository.UserRepository
import at.eisibaer.jbear2.security.UserDetailsImpl
import at.eisibaer.jbear2.service.BoardService
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/user")
class UserEndpoint {
class UserEndpoint(
val boardService: BoardService,
val userRepository: UserRepository,
val fileRepository: FileRepository,
) {
val log: Logger = LoggerFactory.getLogger(UserEndpoint::class.java);
@ -22,7 +30,56 @@ class UserEndpoint {
}
@GetMapping("/boards")
fun getBoards(){
TODO();
fun getBoards(): ResponseEntity<List<BoardDto>?>{
log.info("Get all Board Endpoint");
val userDetails = SecurityContextHolder.getContext().authentication.principal as UserDetailsImpl;
val user: User = userRepository.findUserByUsername(userDetails.username) ?: throw UsernameNotFoundException("Username ${userDetails.username} not found in DB");
return boardService.getBoardsByUser( user );
}
@GetMapping("/boards/{boardId}")
fun getBoardById(@PathVariable boardId: Long): ResponseEntity<BoardDto?>{
log.info("Get Board Endpoint with id {}", boardId);
val userDetails: UserDetailsImpl = SecurityContextHolder.getContext().authentication.principal as UserDetailsImpl
val user: User = userRepository.findUserByUsername(userDetails.username) ?: throw UsernameNotFoundException("Username ${userDetails.username} not found in DB");
return boardService.getBoardByUserAndId( user, boardId)
}
@PostMapping("/boards")
fun saveNewBoard(@RequestBody boardDto: BoardDto): ResponseEntity<*> {
log.info("Post Board Endpoint");
val userDetails: UserDetailsImpl = SecurityContextHolder.getContext().authentication.principal as UserDetailsImpl
val user: User = userRepository.findUserByUsername(userDetails.username) ?: throw UsernameNotFoundException("Username ${userDetails.username} not found in DB");
if( boardDto.id != null ){
return ResponseEntity.badRequest().body(Unit);
}
return boardService.saveBoardToUser( user, boardDto );
}
@PutMapping("/boards/{boardId}")
fun saveExistingBoard(@RequestBody boardDto: BoardDto, @PathVariable boardId: Long): ResponseEntity<*> {
log.info("Put Board Endpoint with id {}", boardId);
val userDetails: UserDetailsImpl = SecurityContextHolder.getContext().authentication.principal as UserDetailsImpl
val user: User = userRepository.findUserByUsername(userDetails.username) ?: throw UsernameNotFoundException("Username ${userDetails.username} not found in DB");
if( boardDto.id == null || boardDto.id != boardId ){
return ResponseEntity.badRequest().body("ID is either null or does not match")
}
return boardService.saveBoardToUser( user, boardDto );
}
@DeleteMapping("/boards/{boardId}")
fun saveNewBoard(@PathVariable boardId: Long): ResponseEntity<*> {
log.info("Delete Board Endpoint with id {}", boardId);
val userDetails: UserDetailsImpl = SecurityContextHolder.getContext().authentication.principal as UserDetailsImpl
val user: User = userRepository.findUserByUsername(userDetails.username) ?: throw UsernameNotFoundException("Username ${userDetails.username} not found in DB");
return boardService.deleteBoardOfUser( user, boardId );
}
}

View File

@ -0,0 +1,231 @@
package at.eisibaer.jbear2.endpoint.ws
import at.eisibaer.jbear2.dto.board.QuestionDto
import at.eisibaer.jbear2.dto.message.*
import at.eisibaer.jbear2.exception.NoMessageException
import at.eisibaer.jbear2.model.BoardEntry
import at.eisibaer.jbear2.model.Game
import at.eisibaer.jbear2.model.Player
import at.eisibaer.jbear2.model.User
import at.eisibaer.jbear2.repository.BoardRepository
import at.eisibaer.jbear2.repository.GameRepository
import at.eisibaer.jbear2.repository.PlayerRepository
import at.eisibaer.jbear2.repository.UserRepository
import at.eisibaer.jbear2.security.UserDetailsImpl
import at.eisibaer.jbear2.service.mapper.GameMapper
import at.eisibaer.jbear2.service.mapper.PlayerMapper
import at.eisibaer.jbear2.service.mapper.QuestionMapper
import at.eisibaer.jbear2.util.Constants.INVITE_CODE_LENGTH
import at.eisibaer.jbear2.util.RandomString
import jakarta.transaction.Transactional
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.http.ResponseEntity
import org.springframework.messaging.handler.annotation.DestinationVariable
import org.springframework.messaging.handler.annotation.Header
import org.springframework.messaging.handler.annotation.Headers
import org.springframework.messaging.handler.annotation.MessageExceptionHandler
import org.springframework.messaging.handler.annotation.MessageMapping
import org.springframework.messaging.handler.annotation.Payload
import org.springframework.messaging.handler.annotation.SendTo
import org.springframework.messaging.simp.SimpMessagingTemplate
import org.springframework.messaging.simp.annotation.SendToUser
import org.springframework.messaging.simp.annotation.SubscribeMapping
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.stereotype.Controller
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 java.lang.Exception
import java.security.Principal
import java.util.*
import kotlin.NoSuchElementException
import kotlin.collections.ArrayList
@Controller
class GameEndpoint(
val playerRepository: PlayerRepository,
val userRepository: UserRepository,
val boardRepository: BoardRepository,
val gameRepository: GameRepository,
val playerMapper: PlayerMapper,
val gameMapper: GameMapper,
val questionMapper: QuestionMapper,
val messagingTemplate: SimpMessagingTemplate,
) {
private val log: Logger = LoggerFactory.getLogger(GameEndpoint::class.java);
@Transactional
@GetMapping("/api/games/{id}")
fun getGameByInviteCode(@PathVariable id: String): ResponseEntity<*>{
val game: Game;
if( id.length == INVITE_CODE_LENGTH){
game = gameRepository.findByInviteCode(id) ?: return ResponseEntity.status(404).body("Game with invide code $id not found")
} else {
var uuid: UUID;
try{
uuid = UUID.fromString(id)
}catch (exception: IllegalArgumentException){
return ResponseEntity.status(400).body("Invalid UUID given")
}
game = gameRepository.findByUuid(uuid) ?: return ResponseEntity.status(404).body("Game with UUID $uuid not found")
}
return ResponseEntity.ok().body(gameMapper.toDto(game));
}
@Transactional
@PostMapping("/api/games")
fun hostNewGame(@RequestBody body: String?): ResponseEntity<*>{
log.info("Start new game");
val userDetails = SecurityContextHolder.getContext().authentication.principal as UserDetailsImpl;
val user: User = userRepository.findUserByUsername(userDetails.username) ?: throw UsernameNotFoundException("Username ${userDetails.username} not found in DB");
val firstAvailableBoard = boardRepository.findFirstByOwnerOrderByBoardName(user) ?: return ResponseEntity.status(404).body("No board found for user")
gameRepository.deleteAll();
gameRepository.flush()
val game = Game(RandomString.ofLength(8), UUID.randomUUID(), firstAvailableBoard, user, emptyList(), emptyList(), null )
val savedGame = gameRepository.save(game);
return ResponseEntity.ok().body(gameMapper.toDto(savedGame));
}
// @MessageMapping("/join")
// fun test(joinMessage: GenericMessage, principal: Principal){
// if( joinMessage.content == null ){
// messagingTemplate.convertAndSendToUser(principal.name, "/game/joined", GenericMessage("error", "Received empty message"));
// return
// }
// val game = gameRepository.findByInviteCode(joinMessage.content)
// if( game == null) {
// messagingTemplate.convertAndSendToUser(principal.name, "/game/joined", GenericMessage("error", "No game found"));
// return
// }
// val player = Player("test", game);
// val payload = GenericMessage(player.name, "${player.name} joined");
// messagingTemplate.convertAndSendToUser("TODO ALL", "/game/joined", payload);
// }
// @MessageMapping("/host")
// fun startGame(message: GenericMessage, principal: Principal ){
// log.info("starting to ", principal.name);
// val user = userRepository.findUserByUsername(principal.name) ?: throw UsernameNotFoundException("User not found");
// val board = boardRepository.findFirstByOwnerOrderByBoardName(user)
// if( board == null ){
// messagingTemplate.convertAndSendToUser(principal.name, "/game/created", GenericMessage("error", "No Board available for playing!"));
// return
// }
// val newGame = Game(RandomString.ofLength(8), UUID.randomUUID(), board, user, ArrayList(), ArrayList(), null )
// var savedGame: Game;
// try{
// savedGame = gameRepository.save(newGame);
// } catch (dataIntegrityViolationException: DataIntegrityViolationException){
// log.error("Could not create game", dataIntegrityViolationException)
// messagingTemplate.convertAndSendToUser(principal.name, "/game/created", GenericMessage("error", "Game could not be created!"));
// return
// }
//
// val payload = GenericMessage("created", savedGame.inviteCode);
// messagingTemplate.convertAndSendToUser(principal.name, "/game/created", payload);
// }
@MessageMapping("/join/{id}")
fun playerJoining(@Payload joinMessage: GenericMessage, @DestinationVariable id: String){
val game: Game;
if( id.length == INVITE_CODE_LENGTH){
game = gameRepository.findByInviteCode(id) ?: throw NoSuchElementException("Game with invide code $id not found")
} else {
var uuid: UUID;
try{
uuid = UUID.fromString(id)
}catch (exception: IllegalArgumentException){
throw IllegalArgumentException("Invalid UUID given")
}
game = gameRepository.findByUuid(uuid) ?: throw NoSuchElementException("Game with UUID $uuid not found")
}
val player = Player(joinMessage.content, game);
val savedPlayer = playerRepository.save(player)
val payload = playerMapper.toDto(savedPlayer);
messagingTemplate.convertAndSend("/topic/game/${game.uuid}/joined", payload);
}
@MessageMapping("/host/game/{uuid}/start")
fun startGame(@Payload startMessage: GenericMessage, @DestinationVariable uuid: UUID){
// val game = gameRepository.findByUuid(uuid) ?: throw NoSuchElementException("Game with UUID $uuid not found")
messagingTemplate.convertAndSend("/topic/game/$uuid/started", GameMessage(uuid, "The game has been started"));
}
@MessageMapping("/host/game/{uuid}/board/select")
fun selectBoard(@Payload boardMessage: GenericMessage, @DestinationVariable uuid: UUID){
messagingTemplate.convertAndSend("/topic/game/$uuid/board/selected", GameMessage(uuid, "The board is being shown"));
}
@MessageMapping("/host/game/{uuid}/boardentry/select")
fun selectBoardEntry(@Payload targetGameState: GameStateMessage, @DestinationVariable uuid: UUID){
val gameState = GameStateMessage(
uuid,
targetGameState.categoryIndex,
targetGameState.boardEntryIndex,
null,
null,
);
messagingTemplate.convertAndSend("/topic/game/$uuid/boardentry/selected", gameState);
}
@MessageMapping("/host/game/{uuid}/question/layer/select")
fun selectQuestionLayer(@Payload targetGameState: GameStateMessage, @DestinationVariable uuid: UUID){
val gameState = GameStateMessage(
uuid,
targetGameState.categoryIndex,
targetGameState.boardEntryIndex,
targetGameState.questionIndex,
targetGameState.questionLayerIndex,
);
messagingTemplate.convertAndSend("/topic/game/$uuid/questionlayer/selected", gameState);
}
@MessageMapping("/host/game/{uuid}/question/audio/play")
fun playAudio(@Payload audioMessage: GenericMessage, @DestinationVariable uuid: UUID){
messagingTemplate.convertAndSend("/topic/game/$uuid/question/audio/playing", GenericMessage("Playing audio"));
}
@MessageMapping("/host/game/{uuid}/question/audio/stop")
fun stopAudio(@Payload audioMessage: GenericMessage, @DestinationVariable uuid: UUID){
messagingTemplate.convertAndSend("/topic/game/$uuid/question/audio/stopped", GenericMessage("Playing audio"));
}
@MessageMapping("/host/game/{uuid}/question/reveal")
fun revealQuestion(@Payload gameState: GameStateMessage, @DestinationVariable uuid: UUID){
if(
gameState.categoryIndex == null ||
gameState.boardEntryIndex == null ||
gameState.questionIndex == null
){
throw InvalidPropertiesFormatException("Invalid gameSate to reveal question");
}
val game = gameRepository.findByUuid(uuid) ?: throw NoSuchElementException("Game with UUID $uuid not found")
val board = game.board;
val question = board.categories[gameState.categoryIndex].boardEntries[gameState.boardEntryIndex].questions[gameState.questionIndex];
val questionDto = questionMapper.toDto(question);
messagingTemplate.convertAndSend("/topic/game/$uuid/question/revealed", questionDto);
}
@MessageExceptionHandler
@SendToUser("/error")
fun handleException(exception: Exception): GenericMessage{
log.error("Exception in GameEndpoint", exception)
return GenericMessage(exception.message ?: "Unknown error");
}
}

View File

@ -0,0 +1,6 @@
package at.eisibaer.jbear2.exception
open class NoMessageException(
override val message: String?,
override val cause: Throwable? = null,
) : RuntimeException(message, cause)

View File

@ -0,0 +1,6 @@
package at.eisibaer.jbear2.exception
open class StorageException(
override val message: String?,
override val cause: Throwable?
) : RuntimeException(message, cause)

View File

@ -0,0 +1,6 @@
package at.eisibaer.jbear2.exception
class StorageFileNotFoundException(
override val message: String?,
override val cause: Throwable?
) : StorageException(message, cause)

View File

@ -1,5 +1,6 @@
package at.eisibaer.jbear2.model
import at.eisibaer.jbear2.model.enums.AnswerType
import jakarta.persistence.*
@Entity
@ -11,9 +12,13 @@ data class Answer(
@Column(name = "text", nullable = false, unique = false)
val text: String,
@Enumerated
@Column(name = "answer_type", nullable = false, unique = false)
val answerType: AnswerType = AnswerType.TEXT,
@OneToOne
@JoinColumn(name = "fk_image_file", referencedColumnName = "id")
val image: ImageFile?,
val image: File?,
@OneToOne
@JoinColumn(name="fk_board_entry", referencedColumnName = "id")

View File

@ -8,7 +8,7 @@ import jakarta.persistence.*
])
data class Board(
@OneToMany(mappedBy = "board")
@OneToMany(mappedBy = "board", cascade = [CascadeType.ALL], orphanRemoval = true)
val categories: List<Category>,
@Column(name = "board_name", nullable = false, unique = false)
@ -18,7 +18,11 @@ data class Board(
@JoinColumn(name="fk_owned_by", referencedColumnName = "id")
val owner: User,
@Column(name = "points_are_title", nullable = false, unique = false)
val pointsAreTitle: Boolean = false,
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator")
val id: Long? = null,
)

View File

@ -11,15 +11,18 @@ data class BoardEntry(
@Column(name = "name", nullable = false, unique = false)
val name: String,
@OneToMany(mappedBy = "boardEntry")
@Column(name = "points", nullable = false, unique = false)
val points: Long,
@OneToMany(mappedBy = "boardEntry", cascade = [CascadeType.ALL], orphanRemoval = true)
val questions: List<Question>,
@OneToOne
@OneToOne(cascade = [CascadeType.ALL], orphanRemoval = true)
val answer: Answer,
@ManyToOne
@JoinColumn(name = "fk_category", referencedColumnName = "id")
val category: Category?,
var category: Category?,
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator")

View File

@ -14,12 +14,12 @@ data class Category (
@Column(name = "description", nullable = false, unique = false)
val description: String,
@OneToMany(mappedBy = "category")
@OneToMany(mappedBy = "category", cascade = [CascadeType.ALL], orphanRemoval = true)
val boardEntries: List<BoardEntry>,
@ManyToOne
@JoinColumn(name = "fk_board", referencedColumnName = "id")
val board: Board,
var board: Board?,
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator")

View File

@ -7,7 +7,7 @@ import java.util.UUID
@Table(name = "image_files", indexes = [
Index(name = "fk_owner_to_image_file", columnList = "fk_owned_by")
])
data class ImageFile (
data class File (
@Column(name = "uuid", nullable = false, unique = true)
val uuid: UUID,
@ -22,6 +22,9 @@ data class ImageFile (
@JoinColumn(name="fk_owned_by", referencedColumnName = "id")
val owner: User,
@Column(name = "file_type", nullable = false, unique = false)
val fileType: FileType,
@OneToOne
val question: Question?,
@ -32,3 +35,9 @@ data class ImageFile (
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator")
val id: Long? = null,
)
enum class FileType{
IMAGE,
AUDIO,
PROFILE_PICTURE,
}

View File

@ -3,6 +3,7 @@ package at.eisibaer.jbear2.model
import jakarta.persistence.*
import org.springframework.data.annotation.CreatedDate
import java.time.Instant
import java.util.*
@Entity
@Table(name = "games", indexes = [
@ -15,6 +16,9 @@ data class Game(
@Column(name = "invite_code", nullable = false, unique = true)
val inviteCode: String,
@Column(name = "uuid", nullable = false, unique = true)
val uuid: UUID,
@ManyToOne
@JoinColumn(name = "fk_board", referencedColumnName = "id")
val board: Board,
@ -23,13 +27,9 @@ data class Game(
@JoinColumn(name="fk_host_user", referencedColumnName = "id")
val host: User,
@OneToMany(mappedBy = "currentGame")
@OneToMany(mappedBy = "currentGame", cascade = [CascadeType.ALL], orphanRemoval = true)
val players: List<Player>,
@OneToOne
@JoinColumn(name="fk_player_currently_choosing", referencedColumnName = "id")
val currentlyChoosingPlayer: Player,
@ManyToMany
@JoinTable(
name = "answered_board_entries_in_games",
@ -38,12 +38,16 @@ data class Game(
)
val alreadyAnsweredEntries: List<BoardEntry>,
@Column(name = "accepting_answers", nullable = false, unique = false)
val acceptingAnswers: Boolean? = false,
@OneToOne
@JoinColumn(name="fk_player_currently_choosing", referencedColumnName = "id")
val currentlyChoosingPlayer: Player?,
@CreatedDate
@Column(name = "created_timestamp", nullable = false, unique = false)
val createdTimestamp: Instant,
val createdTimestamp: Instant? = Instant.now(),
@Column(name = "accepting_answers", nullable = false, unique = false)
val acceptingAnswers: Boolean? = false,
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator")

View File

@ -1,5 +1,6 @@
package at.eisibaer.jbear2.model
import at.eisibaer.jbear2.model.enums.QuestionType
import jakarta.persistence.*
@Entity
@ -13,17 +14,20 @@ data class Question(
@Column(name = "text", nullable = false, unique = false)
val text: String,
@ManyToOne
@JoinColumn(name = "fk_question_type", referencedColumnName = "id")
val questionType: QuestionType,
@Enumerated
@Column(name = "question_type", nullable = false, unique = false)
val questionType: QuestionType = QuestionType.TEXT,
@Column(name = "font_scaling", nullable = false, unique = false)
val fontScaling: Int,
@OneToOne
@JoinColumn(name = "fk_image", referencedColumnName = "id")
val image: ImageFile?,
val image: File?,
@ManyToOne
@JoinColumn(name = "fk_board_entry", referencedColumnName = "id")
val boardEntry: BoardEntry?,
var boardEntry: BoardEntry?,
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator")

View File

@ -1,21 +0,0 @@
package at.eisibaer.jbear2.model
import jakarta.persistence.*
@Entity
@Table(name = "question_types")
data class QuestionType(
@Column(name = "title", nullable = false, unique = true)
val title: String,
@Column(name = "description", nullable = false, unique = true)
val description: String,
@Column(name = "active", nullable = false, unique = false)
val active: Boolean,
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator")
val id: Long? = null,
)

View File

@ -20,7 +20,7 @@ data class User(
@OneToOne
@JoinColumn(name = "fk_profile_picture", referencedColumnName = "id")
var profilePicture: ImageFile?,
var profilePicture: File?,
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator")

View File

@ -0,0 +1,8 @@
package at.eisibaer.jbear2.model.enums
enum class AnswerType {
TEXT,
IMAGE,
AUDIO,
LOCATION;
}

View File

@ -0,0 +1,8 @@
package at.eisibaer.jbear2.model.enums
enum class QuestionType {
TEXT,
IMAGE,
AUDIO,
LOCATION;
}

View File

@ -0,0 +1,16 @@
package at.eisibaer.jbear2.repository
import at.eisibaer.jbear2.model.Board
import at.eisibaer.jbear2.model.User
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface BoardRepository : JpaRepository<Board, Long> {
fun findByIdAndOwner(id: Long, user: User): Board?
fun findAllByOwner(owner: User): List<Board>
fun findFirstByOwnerOrderByBoardName(owner: User): Board?
}

View File

@ -0,0 +1,12 @@
package at.eisibaer.jbear2.repository
import at.eisibaer.jbear2.model.File
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.util.UUID
@Repository
interface FileRepository : JpaRepository<File, Long> {
fun findByUuid(uuid: UUID): File?;
}

View File

@ -0,0 +1,12 @@
package at.eisibaer.jbear2.repository
import at.eisibaer.jbear2.model.Game
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface GameRepository : JpaRepository<Game, Long> {
fun findByInviteCode(inviteCode: String): Game?
fun findByUuid(uuid: UUID): Game?
}

View File

@ -0,0 +1,8 @@
package at.eisibaer.jbear2.repository
import at.eisibaer.jbear2.model.Player
import org.springframework.data.jpa.repository.JpaRepository
interface PlayerRepository : JpaRepository<Player, Long> {
}

View File

@ -8,7 +8,7 @@ import java.util.*
@Repository
interface UserRepository : JpaRepository<User, Long> {
fun findUserByUsername(username: String): Optional<User>;
fun findUserByUsername(username: String): User?;
fun existsByUsername(username: String): Boolean;
}

View File

@ -41,7 +41,6 @@ class SecurityConfiguration(
return addCsrfConfig(httpSecurity)
.authorizeHttpRequests { config ->
config
.requestMatchers("/api/auth/*").permitAll()
.requestMatchers("/api/user/**").authenticated()
.requestMatchers("/**").permitAll()
}

View File

@ -16,7 +16,11 @@ class UserDetailServiceImpl(
): UserDetailsService {
override fun loadUserByUsername(username: String?): UserDetailsImpl {
val user: User = userRepository.findUserByUsername( username ?: "" ).orElseThrow { UsernameNotFoundException("User not found by username \"$username\"") }
val user: User? = userRepository.findUserByUsername( username ?: "" )
if( user == null ){
throw UsernameNotFoundException("User not found by username \"$username\"");
}
return UserDetailsImpl(
user.id!!,

View File

@ -0,0 +1,15 @@
package at.eisibaer.jbear2.service
import at.eisibaer.jbear2.dto.board.BoardDto
import at.eisibaer.jbear2.model.User
import org.springframework.http.ResponseEntity
interface BoardService {
fun getBoardsByUser( user: User ): ResponseEntity<List<BoardDto>?>
fun getBoardByUserAndId( user: User, boardId: Long ): ResponseEntity<BoardDto?>
fun saveBoardToUser( user: User, boardDto: BoardDto ): ResponseEntity<BoardDto>
fun deleteBoardOfUser( user: User, boardId: Long ): ResponseEntity<Unit>
}

View File

@ -0,0 +1,62 @@
package at.eisibaer.jbear2.service
import at.eisibaer.jbear2.dto.board.BoardDto
import at.eisibaer.jbear2.model.Board
import at.eisibaer.jbear2.model.User
import at.eisibaer.jbear2.repository.BoardRepository
import at.eisibaer.jbear2.repository.FileRepository
import at.eisibaer.jbear2.repository.UserRepository
import at.eisibaer.jbear2.service.mapper.BoardMapper
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class BoardServiceImpl (
private val boardRepository: BoardRepository,
private val userRepository: UserRepository,
private val fileRepository: FileRepository,
private val boardMapper: BoardMapper,
) : BoardService {
val log: Logger = LoggerFactory.getLogger(BoardServiceImpl::class.java);
@Transactional
override fun getBoardsByUser(user: User): ResponseEntity<List<BoardDto>?> {
val boards = boardRepository.findAllByOwner(user)
return ResponseEntity.ok(boardMapper.toDto(boards));
}
@Transactional
override fun getBoardByUserAndId(user: User, boardId: Long): ResponseEntity<BoardDto?> {
val board = boardRepository.findByIdAndOwner(boardId, user) ?: return ResponseEntity.status(404).build();
return ResponseEntity.ok(boardMapper.toDto(board))
}
@Transactional
override fun saveBoardToUser( user: User, boardDto: BoardDto): ResponseEntity<BoardDto> {
val board: Board = boardMapper.toEntity(boardDto, user, userRepository, fileRepository);
val savedBoard: Board;
try {
savedBoard = boardRepository.save(board);
} catch (ex: Exception) {
log.error(ex.message, ex);
return ResponseEntity.badRequest().build();
}
return ResponseEntity.ok(boardMapper.toDto(savedBoard))
}
override fun deleteBoardOfUser(user: User, boardId: Long): ResponseEntity<Unit> {
boardRepository.deleteById( boardId );
//TODO Delete Images
return ResponseEntity.ok().build();
}
}

View File

@ -0,0 +1,18 @@
package at.eisibaer.jbear2.service
import at.eisibaer.jbear2.model.File
import org.springframework.core.io.Resource
import org.springframework.web.multipart.MultipartFile
import java.util.UUID
interface FileService {
fun saveFiles(files: List<MultipartFile>): List<File>;
fun saveFile(file: MultipartFile): File;
fun getFile(file: File): Resource;
fun getFile(fileUUID: UUID): Resource;
fun deleteFile(file: File): Resource;
fun deleteFile(fileUUID: UUID): Resource;
}

View File

@ -0,0 +1,51 @@
package at.eisibaer.jbear2.service
import at.eisibaer.jbear2.model.File
import at.eisibaer.jbear2.repository.FileRepository
import org.springframework.core.io.Resource
import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile
import java.nio.file.Files
import java.util.*
@Service
class FileServiceImpl(
val fileRepository: FileRepository,
) : FileService {
override fun saveFiles(files: List<MultipartFile>): List<File> {
TODO("Not yet implemented")
}
override fun saveFile(file: MultipartFile): File {
TODO("Not yet implemented")
}
private fun saveMultipartFile(file: MultipartFile): File{
val uuid = UUID.randomUUID();
val filename = file.originalFilename;
var hash: String;
// val file = File(
// UUID.randomUUID(),
// uuidextension,
// hash,
// )
TODO();
}
override fun getFile(file: File): Resource {
TODO("Not yet implemented")
}
override fun getFile(fileUUID: UUID): Resource {
TODO("Not yet implemented")
}
override fun deleteFile(file: File): Resource {
TODO("Not yet implemented")
}
override fun deleteFile(fileUUID: UUID): Resource {
TODO("Not yet implemented")
}
}

View File

@ -0,0 +1,90 @@
package at.eisibaer.jbear2.service
import at.eisibaer.jbear2.config.ApplicationProperties
import at.eisibaer.jbear2.exception.StorageException
import at.eisibaer.jbear2.exception.StorageFileNotFoundException
import jakarta.annotation.PostConstruct
import org.springframework.core.io.Resource
import org.springframework.core.io.UrlResource
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import org.springframework.util.FileSystemUtils
import org.springframework.web.multipart.MultipartFile
import java.io.IOException
import java.net.MalformedURLException
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardCopyOption
@Service
@Transactional(propagation = Propagation.NEVER)
class FileSystemStorageService(
val applicationProperties: ApplicationProperties,
) : StorageService {
lateinit var rootLocation: Path
@PostConstruct
fun init(){
val location = applicationProperties.storage.fs.location;
if( location.trim().isEmpty() ){
throw StorageException("File upload location can not be Empty", null)
}
rootLocation = Paths.get(location);
}
override fun storeFile(file: MultipartFile) {
storeMultipartFile(file);
}
override fun storeFiles(files: List<MultipartFile>) {
for( file in files ){
storeMultipartFile(file)
}
}
override fun getFile(filename: String): Resource {
try {
val file: Path = rootLocation.resolve(filename)
val resource: Resource = UrlResource(file.toUri())
if (resource.exists() || resource.isReadable) {
return resource
} else {
throw StorageFileNotFoundException("Could not read file: $filename", null)
}
} catch (e: MalformedURLException) {
throw StorageFileNotFoundException("Could not read file: $filename", e)
}
}
override fun deleteFile(filename: String) {
FileSystemUtils.deleteRecursively(rootLocation.resolve(filename))
}
private fun storeMultipartFile(file: MultipartFile){
try {
if (file.isEmpty) {
throw StorageException("Failed to store empty file.", null)
}
val destinationFile: Path = rootLocation
.resolve(Paths.get(file.originalFilename))
.normalize()
.toAbsolutePath()
if (destinationFile.getParent() != rootLocation.toAbsolutePath()) {
// This is a security check
throw StorageException("Cannot store file outside current directory.", null)
}
file.inputStream.use { inputStream ->
Files.copy(
inputStream, destinationFile,
StandardCopyOption.REPLACE_EXISTING
)
}
} catch (e: IOException) {
throw StorageException("Failed to store file.", e)
}
}
}

View File

@ -0,0 +1,15 @@
package at.eisibaer.jbear2.service
import org.springframework.core.io.Resource
import org.springframework.web.multipart.MultipartFile
interface StorageService {
fun storeFile(file: MultipartFile)
fun storeFiles(files: List<MultipartFile>)
fun getFile(filename: String): Resource
fun deleteFile(filename: String)
}

View File

@ -0,0 +1,43 @@
package at.eisibaer.jbear2.service.mapper
import at.eisibaer.jbear2.dto.board.AnswerDto
import at.eisibaer.jbear2.model.Answer
import at.eisibaer.jbear2.model.File
import at.eisibaer.jbear2.model.enums.AnswerType
import at.eisibaer.jbear2.repository.FileRepository
import org.mapstruct.Context
import org.mapstruct.Mapper
import java.util.*
@Mapper
abstract class AnswerMapper {
abstract fun toDto(e: Answer): AnswerDto
abstract fun toDto(e: List<Answer>): List<AnswerDto>
abstract fun toEntity(d: AnswerDto, @Context fileRepository: FileRepository): Answer
abstract fun toEntity(d: List<AnswerDto>, @Context fileRepository: FileRepository): List<Answer>
fun map(file: File?): String{
return file?.uuid.toString()
}
fun map(imageUuid: String?, @Context fileRepository: FileRepository): File? {
return if( imageUuid == null ) {
null
} else {
try{
fileRepository.findByUuid(UUID.fromString(imageUuid))
} catch ( illegalArgumentException: IllegalArgumentException ){
null
}
}
}
fun map(type: AnswerType): Int {
return type.ordinal;
}
fun map(value: Int): AnswerType {
return AnswerType.entries[value];
}
}

View File

@ -0,0 +1,40 @@
package at.eisibaer.jbear2.service.mapper
import at.eisibaer.jbear2.dto.board.BoardEntryDto
import at.eisibaer.jbear2.model.BoardEntry
import at.eisibaer.jbear2.model.File
import at.eisibaer.jbear2.repository.FileRepository
import org.mapstruct.AfterMapping
import org.mapstruct.Context
import org.mapstruct.Mapper
import org.mapstruct.MappingTarget
import java.util.*
@Mapper(uses = [QuestionMapper::class,AnswerMapper::class])
abstract class BoardEntryMapper {
abstract fun toDto(e: BoardEntry): BoardEntryDto;
abstract fun toDto(e: List<BoardEntry>): List<BoardEntryDto>;
abstract fun toEntity(d: BoardEntryDto, @Context fileRepository: FileRepository): BoardEntry;
abstract fun toEntity(d: List<BoardEntryDto>, @Context fileRepository: FileRepository): List<BoardEntry>;
fun map(file: File): String{
return file.uuid.toString()
}
fun map(imageUuid: String?, @Context fileRepository: FileRepository): File? {
return if( imageUuid == null ) {
null
} else {
fileRepository.findByUuid(UUID.fromString(imageUuid))
}
}
@AfterMapping
fun addBoardEntryToQuestion(source: BoardEntryDto, @MappingTarget target: BoardEntry): BoardEntry {
for( question in target.questions ){
question.boardEntry = target
}
return target
}
}

View File

@ -0,0 +1,28 @@
package at.eisibaer.jbear2.service.mapper
import at.eisibaer.jbear2.dto.board.BoardDto
import at.eisibaer.jbear2.model.Board
import at.eisibaer.jbear2.model.User
import at.eisibaer.jbear2.repository.FileRepository
import at.eisibaer.jbear2.repository.UserRepository
import org.mapstruct.*
@Mapper(uses = [UserMapper::class,QuestionMapper::class,AnswerMapper::class,BoardEntryMapper::class,CategoryMapper::class])
abstract class BoardMapper {
abstract fun toDto(e: Board): BoardDto;
abstract fun toDto(e: List<Board>): List<BoardDto>;
@Mapping(target = "owner", source = "owner")
@Mapping(target = "id", source = "d.id")
abstract fun toEntity(d: BoardDto, owner: User, @Context userRepository: UserRepository, @Context fileRepository: FileRepository): Board;
abstract fun toEntity(d: List<BoardDto>, @Context owner: User, @Context userRepository: UserRepository, @Context fileRepository: FileRepository): List<Board>;
@AfterMapping
fun addBoardToCategory(source: BoardDto, @MappingTarget target: Board ): Board{
for( category in target.categories ){
category.board = target
}
return target
}
}

View File

@ -0,0 +1,40 @@
package at.eisibaer.jbear2.service.mapper
import at.eisibaer.jbear2.dto.board.CategoryDto
import at.eisibaer.jbear2.model.Category
import at.eisibaer.jbear2.model.File
import at.eisibaer.jbear2.repository.FileRepository
import org.mapstruct.AfterMapping
import org.mapstruct.Context
import org.mapstruct.Mapper
import org.mapstruct.MappingTarget
import java.util.*
@Mapper(uses = [QuestionMapper::class,AnswerMapper::class,BoardEntryMapper::class])
abstract class CategoryMapper {
abstract fun toDto(e: Category): CategoryDto;
abstract fun toDto(e: List<Category>): List<CategoryDto>;
abstract fun toEntity(d: CategoryDto, @Context fileRepository: FileRepository): Category;
abstract fun toEntity(d: List<CategoryDto>, @Context fileRepository: FileRepository): List<Category>;
fun map(file: File): String{
return file.uuid.toString()
}
fun map(imageUuid: String?, @Context fileRepository: FileRepository): File? {
return if( imageUuid == null ) {
null
} else {
fileRepository.findByUuid(UUID.fromString(imageUuid))
}
}
@AfterMapping
fun addCategoryToBoardEntry(source: CategoryDto, @MappingTarget target: Category): Category {
for( boardEntry in target.boardEntries ){
boardEntry.category = target
}
return target
}
}

View File

@ -0,0 +1,9 @@
package at.eisibaer.jbear2.service.mapper
interface EntityMapper<D, E> {
fun toDto(e: E): D;
fun toDto(e: List<E>): List<D>;
fun toEntity(d: D): E;
fun toEntity(d: List<D>): List<E>;
}

View File

@ -0,0 +1,20 @@
package at.eisibaer.jbear2.service.mapper
import at.eisibaer.jbear2.dto.game.GameDto
import at.eisibaer.jbear2.model.BoardEntry
import at.eisibaer.jbear2.model.Game
import org.mapstruct.Mapper
@Mapper
interface GameMapper : EntityMapper<GameDto, Game> {
//
// fun map(boardEntry: BoardEntry): Long{
// return boardEntry.id!!
// }
// fun map(boardEntries: List<BoardEntry>): List<Long>{
// return boardEntries.map { it.id!! }
// }
// fun map(ids: List<Long>): List<BoardEntry>{
// return emptyList()
// }
}

View File

@ -0,0 +1,26 @@
package at.eisibaer.jbear2.service.mapper
import at.eisibaer.jbear2.model.File
import at.eisibaer.jbear2.repository.FileRepository
import org.mapstruct.Context
import org.mapstruct.Mapper
import java.util.*
@Mapper
abstract class ImageMapper {
fun toEntity(d: String, @Context fileRepository: FileRepository): File? {
return fileRepository.findByUuid(UUID.fromString(d));
}
fun toDto(e: File): String {
return e.uuid.toString();
}
fun toDto(e: List<File>): List<String>{
return e.map { toDto(it) }
}
fun toEntity(d: List<String>, @Context fileRepository: FileRepository): List<File?>{
return d.map { toEntity(it, fileRepository) }
}
}

View File

@ -0,0 +1,10 @@
package at.eisibaer.jbear2.service.mapper
import at.eisibaer.jbear2.dto.game.PlayerDto
import at.eisibaer.jbear2.model.Player
import org.mapstruct.Mapper
@Mapper
interface PlayerMapper : EntityMapper<PlayerDto, Player> {
}

View File

@ -0,0 +1,43 @@
package at.eisibaer.jbear2.service.mapper
import at.eisibaer.jbear2.dto.board.QuestionDto
import at.eisibaer.jbear2.model.File
import at.eisibaer.jbear2.model.Question
import at.eisibaer.jbear2.model.enums.QuestionType
import at.eisibaer.jbear2.repository.FileRepository
import org.mapstruct.Context
import org.mapstruct.Mapper
import java.util.UUID
@Mapper
abstract class QuestionMapper {
abstract fun toDto(e: Question): QuestionDto;
abstract fun toDto(e: List<Question>): List<QuestionDto>;
abstract fun toEntity(d: QuestionDto, @Context fileRepository: FileRepository): Question;
abstract fun toEntity(d: List<QuestionDto>, @Context fileRepository: FileRepository): List<Question>;
fun map(file: File?): String?{
return file?.uuid.toString();
}
fun map(imageUuid: String?, @Context fileRepository: FileRepository): File? {
return if( imageUuid == null ) {
null
} else {
try{
fileRepository.findByUuid(UUID.fromString(imageUuid))
} catch ( illegalArgumentException: IllegalArgumentException ){
null
}
}
}
fun map(type: QuestionType): Int {
return type.ordinal;
}
fun map(value: Int): QuestionType {
return QuestionType.entries[value];
}
}

View File

@ -0,0 +1,29 @@
package at.eisibaer.jbear2.service.mapper
import at.eisibaer.jbear2.dto.user.UserDto
import at.eisibaer.jbear2.model.User
import at.eisibaer.jbear2.repository.FileRepository
import at.eisibaer.jbear2.repository.UserRepository
import org.mapstruct.Context
import org.mapstruct.Mapper
import org.springframework.security.core.userdetails.UsernameNotFoundException
@Mapper(uses = [ImageMapper::class, UserMapper::class])
interface UserMapper{
fun toDto(e: User): UserDto;
fun toDto(e: List<User>): List<UserDto>;
fun toEntity(d: UserDto, @Context userRepository: UserRepository, @Context fileRepository: FileRepository): User;
fun toEntity(d: List<UserDto>, @Context userRepository: UserRepository, @Context fileRepository: FileRepository): List<User>;
fun map(e: User): String{
return e.username;
}
fun map(d: String, @Context userRepository: UserRepository): User{
val user = userRepository.findUserByUsername(d)
?: throw UsernameNotFoundException("User not found by username \"$d\"")
return user;
}
}

View File

@ -2,4 +2,5 @@ package at.eisibaer.jbear2.util
object Constants {
const val STR_SESSION_USER_KEY = "user"
const val INVITE_CODE_LENGTH = 8
}

View File

@ -0,0 +1,17 @@
package at.eisibaer.jbear2.util
import java.security.SecureRandom
class RandomString{
companion object{
val AB: String = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
val rnd: SecureRandom = SecureRandom()
fun ofLength(len: Int): String {
val sb = StringBuilder(len)
for (i in 0 until len) sb.append(AB[rnd.nextInt(AB.length)])
return sb.toString()
}
}
}

View File

@ -3,6 +3,9 @@ logging:
at:
eisibaer:
jbear2: "DEBUG"
# org:
# springframework:
# security: "TRACE"
spring:
datasource:
url: jdbc:postgresql://${PG_HOST}:${PG_PORT}/jeobeardy?currentSchema=jeobeardy-app

View File

@ -14,10 +14,29 @@ spring:
docker:
compose:
lifecycle-management: start-only
servlet:
multipart:
max-file-size: 5MB
max-request-size: 50MB
# session:
# redis:
# flush-mode: on_save
# namespace: spring:session
# data:
# redis:
# host: localhost
# password:
# port: 6379
server:
address: localhost
port: 8008
# servlet:
# session:
# timeout:
application:
cors-allowed-methods: ["GET", "POST", "DELETE", "OPTIONS"]
cors-allowed-methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
storage:
fs:
location: "files"

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
<script type="module" crossorigin src="/assets/index-VvMfePyX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-21nzev1V.css">
<script type="module" crossorigin src="/assets/index-B_AXNEWQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B5vQMdQB.css">
</head>
<body>
<div id="app"></div>

View File

@ -1,14 +0,0 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
'extends': [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript'
],
parserOptions: {
ecmaVersion: 'latest'
}
}

View File

@ -0,0 +1,3 @@
{
"singleQuote": true
}

View File

@ -0,0 +1,24 @@
import { defineConfig } from "eslint/config";
import { js } from "@eslint/js";
import { FlagCompat } from "eslint/eslintrc";
import pluginVue from "eslint-plugin-vue";
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all
});
export default defineConfig([{
...pluginVue.configs['flat/essential'],
extends: compat.extends(
"plugin:vue/vue3-essential",
"eslint:recommended",
"@vue/eslint-config-typescript",
),
languageOptions: {
ecmaVersion: "latest",
parserOptions: {},
},
}]);

File diff suppressed because it is too large Load Diff

View File

@ -13,39 +13,37 @@
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2",
"@fortawesome/vue-fontawesome": "^3.0.8",
"@popperjs/core": "^2.11.8",
"@vuelidate/core": "^2.0.3",
"@vuelidate/validators": "^2.0.4",
"axios": "^1.7.2",
"bootstrap": "^5.3.3",
"pinia": "^2.1.7",
"vue": "^3.4.21",
"vue-draggable-plus": "^0.5.3",
"vue-i18n": "^9.13.1",
"vue-router": "^4.3.0"
"@fortawesome/fontawesome-svg-core": "~7.2.0",
"@fortawesome/free-solid-svg-icons": "~7.2.0",
"@fortawesome/vue-fontawesome": "~3.1.3",
"@stomp/stompjs": "~7.3.0",
"@vuelidate/core": "~2.0.3",
"@vuelidate/validators": "~2.0.4",
"@vueuse/core": "~14.2.1",
"axios": "~1.13.6",
"bootstrap": "~5.3.8",
"pinia": "~3.0.4",
"vue": "~3.5.30",
"vue-draggable-plus": "~0.6.1",
"vue-i18n": "~11.3.0",
"vue-router": "~5.0.3"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.8.0",
"@tsconfig/node20": "^20.1.4",
"@types/bootstrap": "^5.2.10",
"@types/jsdom": "^21.1.6",
"@types/node": "^20.12.5",
"@vitejs/plugin-vue": "^5.0.4",
"@vue/eslint-config-typescript": "^13.0.0",
"@vue/test-utils": "^2.4.5",
"@vue/tsconfig": "^0.5.1",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.23.0",
"jsdom": "^24.0.0",
"npm-run-all2": "^6.1.2",
"sass": "^1.77.6",
"typescript": "~5.4.0",
"vite": "^5.2.8",
"vite-plugin-vue-devtools": "^7.0.25",
"vitest": "^1.4.0",
"vue-tsc": "^2.0.11"
"@eslint/js": "~10.0.1",
"@intlify/eslint-plugin-vue-i18n": "~4.3.0",
"@tsconfig/node24": "~24.0.4",
"@types/bootstrap": "~5.2.10",
"@types/node": "~25.5.0",
"@vitejs/plugin-vue": "~6.0.5",
"@vue/eslint-config-typescript": "~14.7.0",
"@vue/test-utils": "~2.4.6",
"@vue/tsconfig": "~0.9.0",
"eslint": "~10.0.3",
"eslint-plugin-vue": "~10.8.0",
"sass": "~1.98.0",
"typescript": "~5.9.3",
"vite": "~8.0.0",
"vite-plugin-vue-devtools": "~8.1.0",
"vitest": "~4.1.0"
}
}

View File

@ -10,28 +10,29 @@ import { useUserStore } from './stores/UserStore';
const userStore = useUserStore();
const userLoading = ref( true );
userStore.userCheckPromise
.finally( () => {
const userLoading = ref(true);
userStore.userCheckPromise.finally(() => {
userLoading.value = false;
} );
});
const infoModal = ref<InstanceType<typeof GenericInfoModal> | null>( null );
const infoModal = ref<InstanceType<typeof GenericInfoModal> | null>(null);
function showInfoModal( title: string, text: string ): void {
if( infoModal.value ) {
function showInfoModal(title: string, text: string): void {
if (infoModal.value) {
infoModal.value.modalTitle = title;
infoModal.value.modalText = text;
infoModal.value.show();
} else {
console.error( 'Modal not yet available' );
console.error('Modal not yet available');
}
}
provide( infoModalShowFnKey, showInfoModal );
provide(infoModalShowFnKey, showInfoModal);
const navbar = ref<InstanceType<typeof NavBar> | undefined>(undefined);
provide( navbarKey, navbar);
provide(navbarKey, navbar);
userStore.initialHistoryLen = window.history.length;
</script>
<template>

View File

@ -9,3 +9,18 @@
.cursor-move{
cursor: move;
}
.spinny-spin{
animation-name: spinny;
animation-duration: 1.5s;
animation-iteration-count: infinite;
animation-timing-function: linear;
}
@keyframes spinny {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

@ -55,6 +55,8 @@ $breadcrumb-divider: quote(">");
$form-file-button-bg: var(--#{$prefix}secondary-bg);
$form-file-button-hover-bg: var(--#{$prefix}tertiary-bg);
// $btn-border-radius: 0;
// $card-border-radius: 0;
/* Bootstrap Color Map adjustments */
$custom-colors: (
@ -134,3 +136,24 @@ $utilities: map-merge(
@import "./exotic_theme.scss";
@import "../css/main.css";
@mixin animate($animation,$duration,$function,$times,$direction){
animation-name: $animation;
animation-duration: $duration;
animation-iteration-count: $times;
animation-timing-function: $function;
animation-direction: $direction;
}
@mixin keyframes($name){
@keyframes #{$name}{
@content;
}
}
.bg-pulse-primary{
@include keyframes(pulse){
to {
background-color: $primary;
}
}
@include animate(pulse, .75s, ease, infinite, alternate-reverse);
}

View File

@ -1,18 +1,26 @@
<script setup lang="ts">
import type { BoardEntry } from '@/models/board/BoardEntry';
import { computed } from 'vue';
import { computed, ref } from 'vue';
const QUESTION_TYPE_SIMPLE_TEXT_ID = 1;
const QUESTION_TYPE_IMAGE_ID = 2;
const QUESTION_TYPE_AUDIO_ID = 3;
const props = defineProps<{
boardEntry: BoardEntry,
selectedQuestionIndex: number,
isAnswerShown: boolean,
isQuestionShown: boolean,
categoryName: string,
}>();
const emit = defineEmits<{
questionSelected: [questionIndex: number],
showQuestion: [],
hideQuestion: [],
showAnswer: [],
hideAnswer: [],
showBoard: [],
}>();
const question = computed( () => {
@ -23,6 +31,37 @@ function selectQuestionIndex( qIndex: number ) {
emit( "questionSelected", qIndex );
}
function showQuestion(){
emit("showQuestion");
}
function hideQuestion(){
emit("hideQuestion");
}
function showAnswer(){
emit("showAnswer");
}
function hideAnswer(){
emit("hideAnswer");
}
function backToBoard(){
emit("showBoard");
}
const audio = ref<HTMLAudioElement | null>(null);
function startAudio(){
if( audio.value ){
audio.value.play();
} else {
audio.value = new Audio(question.value.audio);
audio.value.play();
}
}
function stopAudio(){
if( audio.value ){
audio.value.pause();
}
}
</script>
<template>
@ -31,6 +70,7 @@ function selectQuestionIndex( qIndex: number ) {
<div class="col h-100 mx-3 overflow-y-auto">
<div class="ratio ratio-16x9">
<div class="w-100 h-100 d-flex justify-content-center align-items-center">
<template v-if="isQuestionShown">
<span v-if="boardEntry.questions.length === 0" class="fs-1">
No Question to show
</span>
@ -52,18 +92,65 @@ function selectQuestionIndex( qIndex: number ) {
</div>
</div>
</template>
<template v-else-if="question.questionType.id === QUESTION_TYPE_AUDIO_ID">
<div class="d-flex flex-column justify-content-center align-items-center h-100 w-100">
<span class="text-center preserve-breaks" :style="`font-size: ${question.fontScaling}em`">
{{ question.text }}
</span>
<div class="h-75 w-100 d-flex justify-content-center align-items-center">
<img
v-if="question.audio"
src="/src/assets/images/SoundWave.png"
alt="User uploaded - No caption available"
class="h-100 w-100 object-contain"
>
<button class="btn btn-primary" @click="startAudio">
Play Audio
</button>
<button class="btn btn-primary" @click="stopAudio">
Stop Audio
</button>
</div>
</div>
</template>
</template>
<!-- Category Name -->
<div class="position-absolute top-0 start-0 mt-2">
<div class="position-absolute top-0 start-0">
<span class="fs-2">
{{ boardEntry.category.name }}
{{ }}
</span>
</div>
<!-- Points -->
<div class="position-absolute bottom-0 end-0">
<div class="position-absolute top-0 end-0">
<span class="fs-2">
{{ boardEntry.points }}
</span>
</div>
<!-- Back to Board -->
<div class="position-absolute bottom-0 end-0">
<div>
<button class="btn btn-outline-primary" @click="backToBoard">
Back to Board
</button>
</div>
</div>
<!-- Back to Board -->
<div class="position-absolute top-0 start-50 translate-middle-x">
<div class="mt-2">
<button v-if="props.isQuestionShown" class="btn btn-outline-primary me-3" @click="hideQuestion">
Hide Question
</button>
<button v-else class="btn btn-outline-primary me-3" @click="showQuestion">
Show Question
</button>
<button v-if="props.isAnswerShown" class="btn btn-outline-primary" @click="hideAnswer">
Hide Answer
</button>
<button v-else class="btn btn-outline-primary" @click="showAnswer">
Show Answer
</button>
</div>
</div>
<!-- Answer -->
<div v-if="isAnswerShown" class="position-absolute bottom-0 start-50 translate-middle-x mb-2">
<div class="bg-primary p-2 rounded bg-opacity-50 fs-4 text-center">

View File

@ -1,32 +1,55 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import type { Board } from '@/models/board/Board';
import { userService } from '@/services/UserService';
import IconJeobeardy from '../icons/IconJeobeardy.vue';
const props = defineProps<{
withNewOption: boolean;
}>();
const router = useRouter();
const boards = ref([{
id: 1,
boardName: "mockBoard",
},{
id: 2,
boardName: "test Mock",
},{
id: 3,
boardName: "Mocka Board",
},{
id: 4,
boardName: "Mocka Board 2",
}]);
const boards = ref<Array<Board>>([]);
function createNewBoard(){
router.push( { name: 'create' } );
function editBoard(board: Board){
router.push({
name: 'create',
params: {
boardId: board.id,
}
})
}
function createNewBoard() {
if (props.withNewOption) {
router.push({ name: 'create' });
}
}
const boardsLoading = ref(true);
userService
.getBoardsForUser()
.then((loadedBoards) => {
boards.value = loadedBoards;
})
.catch((error) => {
console.error(error);
})
.finally(() => {
boardsLoading.value = false;
});
</script>
<template>
<div class="row">
<template v-if="boardsLoading">
<div class="col-12 d-flex justify-content-center py-3">
<IconJeobeardy class="spinny-spin spinny-size" />
</div>
</template>
<template v-else>
<template v-for="board in boards" :key="board.id">
<div class="col-4 mb-3">
<div class="card">
@ -37,7 +60,7 @@ function createNewBoard(){
<div class="row">
<div class="col">
<div class="d-flex justify-content-around align-items-center">
<button class="btn btn-sm btn-primary">
<button class="btn btn-sm btn-primary" @click="editBoard(board)">
<FontAwesomeIcon :icon="['fas', 'edit']" />
Edit
</button>
@ -52,11 +75,22 @@ function createNewBoard(){
</div>
</div>
</template>
<div class="col-4 mb-3">
<button class="btn btn-outline-primary w-100 h-100 d-flex flex-column justify-content-center" @click="createNewBoard">
<div v-if="props.withNewOption" class="col-4 mb-3">
<button
class="btn btn-outline-primary w-100 h-100 d-flex flex-column justify-content-center align-items-center gap-2"
@click="createNewBoard"
>
Create new Board
<FontAwesomeIcon :icon="['fas', 'plus']" size="2x"/>
<FontAwesomeIcon :icon="['fas', 'plus']" size="2x" />
</button>
</div>
</template>
</div>
</template>
<style scoped>
.spinny-size{
max-width: 6.66em;
max-height: 6.66em;
}
</style>

View File

@ -29,7 +29,7 @@ function boardEntrySelected(cIndex: number, bEIndex: number){
<div class="row">
<div class="col text-center p-3">
<h2>
{{ board.name }}
{{ board.boardName }}
</h2>
</div>
</div>
@ -37,7 +37,12 @@ function boardEntrySelected(cIndex: number, bEIndex: number){
<template v-for="(category, categoryIndex) in props.board.categories " :key="category.name">
<div class="col pb-2">
<div class="d-flex flex-column h-100">
<button class="flex-fill board-card-max-height card bg-primary w-100 my-1" @click="categorySelected(categoryIndex)" :title="board.categories[categoryIndex].description">
<button
class="flex-fill board-card-max-height card bg-primary w-100 my-1"
@click="categorySelected(categoryIndex)"
:title="board.categories[categoryIndex].description"
:style="[board.categories[categoryIndex].customColor ? {'background-color': `${board.categories[categoryIndex].color} !important`} : {}]"
>
<div class="card-body d-flex align-items-center justify-content-center">
{{ category.name }}
</div>

View File

@ -36,8 +36,7 @@ function editQuestion(cIndex: number, bEIndex: number, qIndex: number){
</script>
<template>
<div class="d-flex flex-column h-100">
<div class="flex-grow-1 overflow-y-auto p-2">
<div>
<EditBoardPanel
v-if="props.categoryIndex === null && props.boardEntryIndex === null && props.questionIndex === null"
v-model="board"
@ -69,21 +68,6 @@ function editQuestion(cIndex: number, bEIndex: number, qIndex: number){
@editBoard="editBoard"
/>
</div>
<div class="border-top border-2 border-primary p-2">
<div class="d-flex align-items-center">
<div class="flex-grow-1 me-1">
<button class="btn btn-primary w-100">
Save
</button>
</div>
<div class="flex-grow-1 ms-1">
<button class="btn btn-danger w-100">
Exit
</button>
</div>
</div>
</div>
</div>
</template>
<style lang="css" scoped>

View File

@ -1,12 +1,12 @@
<script setup lang="ts">
import { computed, inject, ref } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { VueDraggable } from 'vue-draggable-plus';
import type { Board } from '@/models/board/Board';
import { Question } from '@/models/board/Question';
import { QuestionType } from '@/models/board/QuestionType';
import { questionTypesKey } from '@/services/UtilService';
import { QuestionType, type QuestionTypeType } from '@/models/board/QuestionType';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { AnswerType } from '@/models/board/AnswerType';
const { t } = useI18n();
@ -16,7 +16,6 @@ const props = defineProps<{
categoryIndex: number,
boardEntryIndex: number,
}>();
const questionTypes = inject(questionTypesKey);
const emit = defineEmits<{
editBoard: [],
@ -29,12 +28,12 @@ const boardEntry = computed( () => {
});
const newQuestionText = ref( '' );
const newQuestionType = ref<QuestionType | null>( (questionTypes ?? [null])[0] );
const newQuestionType = ref<QuestionTypeType>(QuestionType.TEXT);
function addQuestion() {
if( boardEntry.value.questions.length >= 10 || newQuestionType.value === null) {
if( boardEntry.value.questions.length >= 10) {
return;
}
const newQuestion = new Question(newQuestionText.value, newQuestionType.value, boardEntry.value );
const newQuestion = new Question(newQuestionText.value, newQuestionType.value);
boardEntry.value.questions.push( newQuestion );
}
@ -84,7 +83,7 @@ function openQuestion(categoryIndex: number, boardEntryIndex: number, questionIn
<ol class="breadcrumb mb-0 flex-nowrap">
<li class="breadcrumb-item text-truncate">
<a href="#" @click="openBoard">
{{ board.name }}
{{ board.boardName }}
</a>
</li>
<li class="breadcrumb-item text-truncate">
@ -108,7 +107,7 @@ function openQuestion(categoryIndex: number, boardEntryIndex: number, questionIn
<label for="category-name">{{ t( 'board.boardentry.name' ) }}</label>
<input type="text" id="category-name" class="form-control mb-2" v-model="boardEntry.name" :placeholder="t( 'board.boardentry.name' )">
<label for="board-entry-points">{{ t( 'board.boardentry.points' ) }}</label>
<input type="text" id="board-entry-points" class="form-control" v-model="boardEntry.points" :placeholder="t( 'board.boardentry.points' )">
<input type="number" id="board-entry-points" class="form-control" v-model="boardEntry.points" :placeholder="t( 'board.boardentry.points' )">
</div>
</div>
<div class="row mt-3">
@ -132,7 +131,7 @@ function openQuestion(categoryIndex: number, boardEntryIndex: number, questionIn
</div>
<div class="text-truncate">
<span class="ms-2">
{{ question.questionType.title }}
<FontAwesomeIcon v-if="question.questionType === QuestionType.TEXT" :icon="['fas', 'align-center']" />
<span class="fw-light">
({{ question.text.length === 0 ? 'No Text yet' : question.text }})
</span>
@ -165,12 +164,12 @@ function openQuestion(categoryIndex: number, boardEntryIndex: number, questionIn
</template>
</VueDraggable>
</template>
<label class="mt-2" for="new-category-name">{{ t( "board.question.add" ) }}</label>
<label class="mt-2" for="type-for-new-question">{{ t( "board.question.add" ) }}</label>
<div class="row mb-2">
<div class="col">
<select id="type-for-new-question" v-model="newQuestionType" class="form-select">
<template v-for="questionType in questionTypes" :key="questionType.id">
<option :value="questionType" :title="questionType.description">{{ questionType.title }}</option>
<template v-for="questionType in QuestionType" :key="questionType">
<option :value="questionType" :title="t(`board.question.types.description.${questionType}`)">{{ t(`board.question.types.title.${questionType}`) }}</option>
</template>
</select>
</div>
@ -183,7 +182,13 @@ function openQuestion(categoryIndex: number, boardEntryIndex: number, questionIn
<div class="row">
<div class="col">
<h4>{{ t('board.answer.label', 2) }}</h4>
<label for="answer-text">{{ t( 'board.answer.text' ) }}</label>
<label class="mt-2" for="type-for-answer">{{ t( "board.answer.types.label" ) }}</label>
<select id="type-for-answer" v-model="boardEntry.answer.answerType" class="form-select">
<template v-for="answerType in AnswerType" :key="answerType">
<option :value="answerType" :title="t(`board.answer.types.description.${answerType}`)">{{ t(`board.answer.types.title.${answerType}`) }}</option>
</template>
</select>
<label class="mt-2" for="answer-text">{{ t( 'board.answer.text' ) }}</label>
<textarea id="answer-text" v-model="boardEntry.answer.text" class="form-control mb-2"
:placeholder="t( 'board.answer.text' )"></textarea>
</div>

View File

@ -4,7 +4,6 @@ import { Category } from '@/models/board/Category';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { VueDraggable } from 'vue-draggable-plus';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
const { t } = useI18n();
@ -19,7 +18,7 @@ function addCategory() {
if( board.value.categories.length >= 12 ) {
return;
}
const newCategory = new Category( newCategoryName.value, '', board.value );
const newCategory = new Category( newCategoryName.value, '' );
board.value.categories.push( newCategory );
}
@ -57,7 +56,7 @@ function openCategory( index: number ) {
<div class="col">
<h2 class="border-bottom border-3 border-primary">{{ t( "board.infos" ) }}</h2>
<label for="board-name">{{ t( 'board.name' ) }}</label>
<input type="text" id="board-name" class="form-control" v-model="board.name" :placeholder="t( 'board.name' )">
<input type="text" id="board-name" class="form-control" v-model="board.boardName" :placeholder="t( 'board.name' )">
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="points-for-title" v-model="board.pointsAreTitle">
<label for="points-for-title" class="form-check-label">{{ t( 'board.options.pointsForTitle' ) }}</label>

View File

@ -3,9 +3,9 @@ import type { Board } from '@/models/board/Board';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { VueDraggable } from 'vue-draggable-plus';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { BoardEntry } from '@/models/board/BoardEntry';
import { Answer } from '@/models/board/Answer';
import { AnswerType } from '@/models/board/AnswerType';
const { t } = useI18n();
@ -26,8 +26,8 @@ function addBoardEntry() {
if( board.value.categories[props.categoryIndex].boardEntries.length >= 10 ) {
return;
}
const answer = new Answer('', undefined);
const newBoardEntry = new BoardEntry( newBoardEntryName.value, 0, board.value.categories[props.categoryIndex], answer, [] );
const answer = new Answer('', AnswerType.TEXT);
const newBoardEntry = new BoardEntry( newBoardEntryName.value, answer, [] );
board.value.categories[props.categoryIndex].boardEntries.push( newBoardEntry );
}
@ -66,22 +66,28 @@ function openBoard() {
<div class="flex-grow-1 p-2">
<div class="row">
<div class="col">
<div class="d-flex border-bottom border-3 border-primary align-items-center mb-2">
<div class="d-flex border-bottom border-3 border-primary align-items-center">
<button class="btn btn-sm btn-outline-primary mb-1" :title="t('board.category.back')" @click="openBoard">
<FontAwesomeIcon :icon="['fas', 'angle-left']"/>
</button>
<h2 class="flex-grow-1 ms-2">{{ t( "board.category.infos" ) }}</h2>
</div>
<h4 class="border-bottom border-2 border-primary">General Infos</h4>
<label for="category-name">{{ t( 'board.category.name' ) }}</label>
<input type="text" id="category-name" class="form-control mb-2" v-model="board.categories[props.categoryIndex].name" :placeholder="t( 'board.category.name' )">
<label for="category-description">{{ t( 'board.category.description' ) }}</label>
<textarea id="category-description" class="form-control" v-model="board.categories[props.categoryIndex].description" :placeholder="t( 'board.category.description' )">
</textarea>
<div class="form-check mt-2">
<input id="category-color-custom" type="checkbox" class="form-check-input" v-model="board.categories[props.categoryIndex].customColor">
<label for="category-color-custom">{{ t( 'board.category.custom-color' ) }}</label>
</div>
<input v-if="board.categories[props.categoryIndex].customColor" id="category-color" type="color" class="form-control" v-model="board.categories[props.categoryIndex].color"/>
</div>
</div>
<div class="row mt-3">
<div class="col">
<h4>{{ t( "board.boardentry.label", 2 ) }}</h4>
<h4 class="border-bottom border-2 border-primary">{{ t( "board.boardentry.label", 2 ) }}</h4>
<template v-if=" board.categories[props.categoryIndex].boardEntries.length === 0 ">
<p>
{{ t( "board.boardentry.label", 0 ) }}

View File

@ -1,11 +1,8 @@
<script setup lang="ts">
import type { Board } from '@/models/board/Board';
import { computed, inject, ref } from 'vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { questionTypesKey } from '@/services/UtilService';
const QUESTION_TYPE_IMAGE_ID = 2;
import { QuestionType } from '@/models/board/QuestionType';
const { t } = useI18n();
@ -16,7 +13,6 @@ const props = defineProps<{
boardEntryIndex: number,
questionIndex: number;
}>();
const questionTypes = inject( questionTypesKey );
const emit = defineEmits<{
editBoard: [],
@ -38,16 +34,21 @@ function openBoardEntry( categoryIndex: number, boardEntryIndex: number ) {
emit( "editBoardEntry", categoryIndex, boardEntryIndex );
}
const imageInput = ref<File | null>( null );
function newImageUploaded( event: Event ) {
const element = event.currentTarget as HTMLInputElement;
let files = element.files;
if( files === null || files.length === 0 ) {
return;
}
imageInput.value = files[0];
question.value.image = URL.createObjectURL(files[0]);
}
function newAudioUploaded( event: Event ) {
const element = event.currentTarget as HTMLInputElement;
let files = element.files;
if( files === null || files.length === 0 ) {
return;
}
question.value.audio = URL.createObjectURL(files[0]);
}
</script>
@ -65,7 +66,7 @@ function newImageUploaded( event: Event ) {
<ol class="breadcrumb mb-0 flex-nowrap">
<li class="breadcrumb-item text-truncate">
<a href="#" @click="openBoard">
{{ board.name }}
{{ board.boardName }}
</a>
</li>
<li class="breadcrumb-item text-truncate">
@ -79,7 +80,7 @@ function newImageUploaded( event: Event ) {
</a>
</li>
<li class="breadcrumb-item active text-truncate" style="max-width: 6em;" aria-current="page">
{{ question.text.length !== 0 ? question.text : question.questionType.title }}
{{ question.text.length !== 0 ? question.text : t(`board.question.types.title.${question.questionType}`) }}
</li>
</ol>
</nav>
@ -101,12 +102,12 @@ function newImageUploaded( event: Event ) {
<label for="board-entry-points">{{ t( 'board.question.type' ) }}</label>
<select id="question-type" v-model="question.questionType" class="form-select mb-2" aria-label="Question Type">
<template v-for=" questionType in questionTypes " :key="questionType.id">
<option :value="questionType" :title="questionType.description">{{ questionType.title }}</option>
<template v-for=" questionType in QuestionType " :key="questionType">
<option :value="questionType" :title="t(`board.question.types.description.${questionType}`)">{{ t(`board.question.types.title.${questionType}`) }}</option>
</template>
</select>
<template v-if=" question.questionType.id === QUESTION_TYPE_IMAGE_ID ">
<template v-if=" question.questionType === QuestionType.IMAGE ">
<label for="question-image-input">{{ t( 'board.question.upload.image' ) }}</label>
<input
id="question-image-input"
@ -116,6 +117,16 @@ function newImageUploaded( event: Event ) {
accept="image/png, image/jpeg"
>
</template>
<template v-if=" question.questionType === QuestionType.AUDIO ">
<label for="question-audio-input">{{ t( 'board.question.upload.audio' ) }}</label>
<input
id="question-audio-input"
type="file"
class="form-control mb-2"
@change="newAudioUploaded"
accept="audio/mpeg"
>
</template>
</div>
</div>
</div>

View File

@ -0,0 +1,16 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>
<template>
<div class="row g-2 w-100">
<div class="col d-flex justify-content-around">
<button class="btn btn-primary">
{{ t('game.host.button.letNextChoose') }}
</button>
</div>
</div>
</template>

View File

@ -0,0 +1,47 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
const emit = defineEmits<{
lockQuestion: [delay: number],
}>()
const { t } = useI18n();
const delayBeforeLock = ref(0);
function lockQuestion(){
emit("lockQuestion", delayBeforeLock.value);
}
</script>
<template>
<div class="row g-2 w-100">
<div class="col-12">
<div class="row g-2">
<div class="col-5">
<button class="btn btn-primary w-100" @click="lockQuestion">
<FontAwesomeIcon :icon="['fas', 'lock']" />
{{ t('game.host.button.lockIn') }}
</button>
</div>
<div class="col-7 d-flex align-items-center">
<p class="mb-0 mx-1">
{{ t('game.host.text.in') }}
</p>
<input class="form-control" type="number" id="time-before-lock" v-model="delayBeforeLock">
<p class="mb-0 ms-1">
{{ t('game.host.text.seconds') }}
</p>
</div>
</div>
</div>
<div class="col-12">
<button class="btn btn-primary w-100">
<FontAwesomeIcon :icon="['fas', 'eye']" />
{{ t('game.host.button.revealAll') }}
</button>
</div>
</div>
</template>

View File

@ -1,5 +1,4 @@
<script setup lang="ts">
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';

View File

@ -8,7 +8,6 @@ import ThemeChanger from '@/components/blocks/ThemeChanger.vue';
import LocaleChanger from '@/components/blocks/LocaleChanger.vue';
import { useUserStore } from '@/stores/UserStore';
import { authService } from '@/services/AuthService';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
const navNames = {
HOME: "home",

View File

@ -0,0 +1,121 @@
<script setup lang="ts">
import { useGameStore } from '@/stores/GameStore';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const gameStore = useGameStore();
function adjustPoints(){
}
</script>
<template>
<div class="row">
<div class="col">
<h3 class="border-bottom border-primary border-2">Players</h3>
</div>
</div>
<div class="row mb-3">
<div class="col">
<label for="points-manuel-adjustment-value">{{
t('game.pointsAdjustment')
}}</label>
<input
class="form-control form-control-sm"
type="number"
id="points-manuel-adjustment-value"
/>
</div>
</div>
<template v-for="player in gameStore.players" :key="player.uuid">
<div class="card bg-body-secondary mb-2">
<div class="card-header bg-primary p-2">
<div class="row">
<div class="col d-flex justify-content-between">
<span class="text-break me-3">
<FontAwesomeIcon
v-if="player.isAnswering"
:icon="['fas', 'angles-right']"
class="text-dark"
beat-fade
/>
<FontAwesomeIcon
v-if="player.isChoosing"
:icon="['fas', 'hand-pointer']"
class="text-dark"
/>
{{ player.name }}
</span>
<span>
{{ player.points }}
</span>
</div>
</div>
</div>
<div class="card-body p-2">
<div class="row">
<div class="col-3">Answer</div>
<div class="col-9">
<input
class="form-control form-control-sm"
type="text"
id="points-player-nr"
value=""
readonly
/>
</div>
</div>
<div v-if="gameStore.isHost" class="row mt-2">
<div class="col">
<div class="row g-2">
<div class="col-6">
<button class="btn btn-sm btn-outline-primary w-100" @click="adjustPoints()">
<FontAwesomeIcon :icon="['fas', 'plus']" />
{{ t('game.host.button.pointsAdd') }}
</button>
</div>
<div class="col-6">
<button class="btn btn-sm btn-outline-primary w-100" @click="adjustPoints()">
<FontAwesomeIcon :icon="['fas', 'minus']" />
{{ t('game.host.button.pointsSubtract') }}
</button>
</div>
<div class="col-6">
<button class="btn btn-sm btn-outline-primary w-100 me-2">
<FontAwesomeIcon :icon="['fas', 'eye']" />
{{ t('game.host.button.reveal') }}
</button>
</div>
<div class="col-6">
<button class="btn btn-sm btn-outline-primary w-100">
<FontAwesomeIcon :icon="['fas', 'hand-pointer']" />
{{ t('game.host.button.letChoose') }}
</button>
</div>
<div class="col-12">
<button class="btn btn-sm btn-outline-primary w-100">
<FontAwesomeIcon :icon="['fas', 'lock-open']" />
{{ t('game.host.button.unlock') }}
</button>
</div>
<template v-if="false /* isQuestion Locked */">
<div class="col-6">
<button class="btn btn-sm btn-success w-100">
{{ t('game.host.button.correct') }}
</button>
</div>
<div class="col-6">
<button class="btn btn-sm btn-danger w-100">
{{ t('game.host.button.wrong') }}
</button>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</template>
</template>

View File

@ -1,52 +1,44 @@
<script setup lang="ts">
import { computed, inject, provide, ref, type Ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { computed, inject, ref, type Ref } from 'vue';
import { navbarKey, questionTypesKey } from '@/services/UtilService';
import { navbarKey } from '@/services/UtilService';
import { Board } from '@/models/board/Board';
import type NavBar from '@/components/blocks/NavBar.vue';
import { BoardEntry } from '@/models/board/BoardEntry';
import type NavBar from '@/components/blocks/NavBar.vue';
import BoardView from '@/components/blocks/BoardView.vue';
import CreatePanel from '@/components/blocks/CreatePanel.vue';
import { Category } from '@/models/board/Category';
import BoardEntryView from '../blocks/BoardEntryView.vue';
import { BoardEntry } from '@/models/board/BoardEntry';
import { Answer } from '@/models/board/Answer';
import { QuestionType } from '@/models/board/QuestionType';
import BoardEntryView from '@/components/blocks/BoardEntryView.vue';
import { userService } from '@/services/UserService';
import { useRoute, useRouter } from 'vue-router';
const { t } = useI18n();
const navbar = inject<Ref<InstanceType<typeof NavBar> | null>>( navbarKey );
const navbarHeight = computed( () => {
const navbar = inject<Ref<InstanceType<typeof NavBar> | null>>(navbarKey);
const navbarHeight = computed(() => {
return navbar?.value?.navElement?.clientHeight;
} );
const restHeight = computed( () => {
});
const restHeight = computed(() => {
return { height: `calc(100vh - ${navbarHeight.value}px)` };
} );
});
const board = ref<Board>( new Board( "New Board" ) );
const board1 = ref<Board>( new Board( "New Board", [new Category( "Test1", "", board.value as Board ), new Category( "Test2", "", board.value as Board )] ) );
const answer = new Answer( '', undefined );
const newBoardEntry = new BoardEntry( "Test Entry 1", board1.value.categories[0] as Category, answer, [] );
board1.value.categories[0].boardEntries.push( newBoardEntry );
const board = ref<Board>(new Board('New Board'));
const categoryIndex = ref<number | null>( null );
const boardEntryIndex = ref<number | null>( null );
const questionIndex = ref<number | null>( null );
const categoryIndex = ref<number | null>(null);
const boardEntryIndex = ref<number | null>(null);
const questionIndex = ref<number | null>(null);
function showQuestion( cIndex: number, bEIndex: number, qIndex: number ) {
function showQuestionLayer(cIndex: number, bEIndex: number, qIndex: number) {
categoryIndex.value = cIndex;
boardEntryIndex.value = bEIndex;
questionIndex.value = qIndex;
}
function showBoardEntry( cIndex: number, bEIndex: number ) {
function showBoardEntry(cIndex: number, bEIndex: number) {
categoryIndex.value = cIndex;
boardEntryIndex.value = bEIndex;
questionIndex.value = null;
}
function showCategory( cIndex: number ) {
function showCategory(cIndex: number) {
categoryIndex.value = cIndex;
boardEntryIndex.value = null;
questionIndex.value = null;
@ -58,44 +50,139 @@ function showBoard() {
questionIndex.value = null;
}
const questionTypes = [
new QuestionType("Simple Text", "A simple question with just text", true, 1),
new QuestionType("Image Question", "A question with text and an image", true, 2),
new QuestionType("Audio Question", "A question with text and some audio", true, 3),
];
const isQuestionShown = ref(true);
const isAnswerShown = ref(true);
function showQuestion() {
isQuestionShown.value = true;
}
function hideQuestion() {
isQuestionShown.value = false;
}
function showAnswer() {
isAnswerShown.value = true;
}
function hideAnswer() {
isAnswerShown.value = false;
}
provide(questionTypesKey, questionTypes);
const savingBoardInProgress = ref(false);
function saveBoard() {
savingBoardInProgress.value = true;
let savePromise;
if( board.value.id ){
savePromise = userService.updateBoard(board.value);
} else {
savePromise = userService.saveNewBoard(board.value);
}
savePromise
.then((savedBoard) => {
board.value = savedBoard;
if( !route.params.boardId ){
router.replace({
name: 'create',
params: {
boardId: savedBoard.id
}
});
}
})
.catch( (error) => {
console.error(error);
})
.finally( () => {
savingBoardInProgress.value = false;
});
}
function exit() {
//TODO
}
const boardLoading = ref(true);
const route = useRoute();
const router = useRouter();
if (route.params.boardId) {
userService
.getBoard(route.params.boardId as string)
.then((boardResponse) => {
board.value = boardResponse;
})
.catch((error) => {
console.error(error);
})
.finally(() => {
boardLoading.value = false;
});
} else {
boardLoading.value = false;
}
</script>
<template>
<div :style="restHeight">
<div class="row h-100">
<div class="col-9 h-100 pe-0">
<template v-if="boardLoading">
<FontAwesomeIcon :icon="['fas', 'spinner']" size="xl" spin />
</template>
<template v-else>
<BoardEntryView
v-if=" categoryIndex !== null && boardEntryIndex !== null "
:boardEntry="( board1.categories[categoryIndex].boardEntries[boardEntryIndex] as BoardEntry )"
v-if="categoryIndex !== null && boardEntryIndex !== null"
:boardEntry="( board.categories[categoryIndex].boardEntries[boardEntryIndex] as BoardEntry )"
:categoryName="board.categories[categoryIndex].name"
:selectedQuestionIndex="questionIndex ?? 0"
:isAnswerShown="true"
@questionSelected="(qIndex) => showQuestion(categoryIndex!, boardEntryIndex!, qIndex)"
@questionSelected="( qIndex ) => showQuestionLayer( categoryIndex!, boardEntryIndex!, qIndex )"
:isQuestionShown="isQuestionShown"
:isAnswerShown="isAnswerShown"
@showQuestion="showQuestion"
@hideQuestion="hideQuestion"
@showAnswer="showAnswer"
@hideAnswer="hideAnswer"
@showBoard="showBoard"
/>
<BoardView
v-else
:board="( board1 as Board )"
:board="( board as Board )"
@categorySelected="showCategory"
@boardEntrySelected="showBoardEntry" />
@boardEntrySelected="showBoardEntry"
/>
</template>
</div>
<div class="col-3 ps-0 h-100 overflow-auto border-start border-2 border-primary">
<div
class="col-3 ps-0 h-100 overflow-auto border-start border-2 border-primary"
>
<div class="d-flex flex-column h-100">
<div class="flex-grow-1 overflow-y-auto p-2">
<CreatePanel
v-model="( board1 as Board )"
v-model="( board as Board )"
:categoryIndex="categoryIndex"
:boardEntryIndex="boardEntryIndex"
:questionIndex="questionIndex"
@editBoard="showBoard"
@editCategory="showCategory"
@editBoardEntry="showBoardEntry"
@editQuestion="showQuestion"
@editQuestion="showQuestionLayer"
/>
</div>
<div class="border-top border-2 border-primary p-2">
<div class="d-flex align-items-center">
<div class="flex-grow-1 me-1">
<button class="btn btn-primary w-100" @click="saveBoard">
<FontAwesomeIcon v-if="savingBoardInProgress" :icon="['fas', 'spinner']" spin />
<FontAwesomeIcon v-else :icon="['fas', 'save']" />
Save
</button>
</div>
<div class="flex-grow-1 ms-1">
<button class="btn btn-danger w-100" @click="exit">
<FontAwesomeIcon :icon="['fas', 'xmark']" />
Exit
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -1,12 +1,140 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { computed, inject, ref, type Ref } from 'vue';
const { t } = useI18n();
import { navbarKey } from '@/services/UtilService';
import { Board } from '@/models/board/Board';
import { Category } from '@/models/board/Category';
import { BoardEntry } from '@/models/board/BoardEntry';
import { Answer } from '@/models/board/Answer';
import { AnswerType } from '@/models/board/AnswerType';
import type NavBar from '@/components/blocks/NavBar.vue';
import BoardView from '@/components/blocks/BoardView.vue';
import BoardEntryView from '@/components/blocks/BoardEntryView.vue';
import PlayersPanel from '@/components/blocks/PlayersPanel.vue';
import { useGameStore } from '@/stores/GameStore';
import HostInteractionsBoard from '../blocks/HostInteractionsBoard.vue';
import HostInteractionsEntry from '../blocks/HostInteractionsEntry.vue';
const gameStore = useGameStore();
const navbar = inject<Ref<InstanceType<typeof NavBar> | null>>(navbarKey);
const navbarHeight = computed(() => {
return navbar?.value?.navElement?.clientHeight;
});
const restHeight = computed(() => {
return { height: `calc(100vh - ${navbarHeight.value}px)` };
});
const board = ref<Board>(new Board('New Board'));
const board1 = ref<Board>(
new Board('New Board', [
new Category('Test1', ''),
new Category('Test2', ''),
])
);
const answer = new Answer(
'',
AnswerType.TEXT
);
const newBoardEntry = new BoardEntry(
'Test Entry 1',
answer,
[]
);
board1.value.categories[0].boardEntries.push(newBoardEntry);
const categoryIndex = ref<number | null>(null);
const boardEntryIndex = ref<number | null>(null);
const questionIndex = ref<number | null>(null);
function showQuestionLayer(cIndex: number, bEIndex: number, qIndex: number) {
categoryIndex.value = cIndex;
boardEntryIndex.value = bEIndex;
questionIndex.value = qIndex;
}
function showBoardEntry(cIndex: number, bEIndex: number) {
categoryIndex.value = cIndex;
boardEntryIndex.value = bEIndex;
questionIndex.value = null;
}
function showBoard() {
categoryIndex.value = null;
boardEntryIndex.value = null;
questionIndex.value = null;
}
const isBoardShown = computed( () => {
return categoryIndex.value === null && boardEntryIndex.value === null && questionIndex.value === null;
});
const isQuestionShown = ref(true);
const isAnswerShown = ref(true);
function showQuestion() {
isQuestionShown.value = true;
}
function hideQuestion() {
isQuestionShown.value = false;
}
function showAnswer() {
isAnswerShown.value = true;
}
function hideAnswer() {
isAnswerShown.value = false;
}
</script>
<template>
<div>
{{ t( 'about.whatis' ) }}
<div :style="restHeight">
<div class="row h-100">
<div class="col-9 h-100 pe-0">
<BoardEntryView
v-if="categoryIndex !== null && boardEntryIndex !== null"
:boardEntry="( board1.categories[categoryIndex].boardEntries[boardEntryIndex] as BoardEntry )"
:categoryName="board1.categories[categoryIndex].name"
:selectedQuestionIndex="questionIndex ?? 0"
@questionSelected="( qIndex ) => showQuestionLayer( categoryIndex!, boardEntryIndex!, qIndex )"
:isQuestionShown="isQuestionShown"
:isAnswerShown="isAnswerShown"
@showQuestion="showQuestion"
@hideQuestion="hideQuestion"
@showAnswer="showAnswer"
@hideAnswer="hideAnswer"
@showBoard="showBoard"
/>
<BoardView
v-else
:board="( board1 as Board )"
@boardEntrySelected="showBoardEntry"
/>
</div>
<div
class="col-3 ps-0 h-100 overflow-auto border-start border-2 border-primary"
>
<div class="d-flex flex-column h-100">
<div class="flex-grow-1 overflow-y-auto p-2 overflow-x-hidden">
<PlayersPanel />
</div>
<div class="border-top border-2 border-primary p-2">
<template v-if="gameStore.isHost">
<div class="d-flex align-items-center">
<template v-if="isBoardShown">
<HostInteractionsBoard />
</template>
<template v-else>
<HostInteractionsEntry />
</template>
</div>
</template>
<template v-else>
<div class="d-flex align-items-center">
<PlayerActionPanel />
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -3,8 +3,11 @@ import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { userService } from '@/services/UserService';
import { useGameStore } from '@/stores/GameStore';
import IconJeobeardy from '@/components/icons/IconJeobeardy.vue';
import { useRouter } from 'vue-router';
import type { AxiosError } from 'axios';
const { t } = useI18n();
@ -17,6 +20,48 @@ onMounted( () => {
} );
} );
const gameStore = useGameStore()
const router = useRouter();
const inviteCode = ref('');
const joinLoading = ref(false);
const joinError = ref<null | string>(null);
function joinGame(){
joinLoading.value = true;
gameStore.initGame(inviteCode.value)
.then( (game) => {
router.push({name: 'join', params: { gameUuid: game.uuid } } )
})
.catch( (error: AxiosError) => {
joinError.value = error.message;
})
.finally(() => {
joinLoading.value = false;
})
}
const hostLoading = ref(false);
const hostError = ref<null | string>(null);
const errorTimeout = ref<number | null>(null)
function startHost(){
hostLoading.value = true;
gameStore.startHost()
.then( (game) => {
router.push({name: 'lobby', params: { gameUuid: game.uuid } } )
})
.catch( (error: AxiosError) => {
hostError.value = error.message ?? null;
if( errorTimeout.value ){
clearTimeout(errorTimeout.value);
}
errorTimeout.value = setTimeout(() => {
hostError.value = null;
}, 5000);
})
.finally(() => {
hostLoading.value = false;
})
}
</script>
<template>
@ -46,10 +91,14 @@ onMounted( () => {
</p>
<div class="row">
<div class="col-auto">
<input type="text" class="form-control" placeholder="Code">
<input type="text" class="form-control" placeholder="Code" v-model="inviteCode">
</div>
<div class="col-auto">
<button class="btn btn-primary">{{ t("join.button") }}</button>
<button class="btn btn-primary" @click="joinGame">
<FontAwesomeIcon v-if="joinLoading" :icon="['fas', 'spinner']" spin/>
<FontAwesomeIcon v-else :icon="['fas', 'arrow-right-to-bracket']" />
{{ t("join.button") }}
</button>
</div>
</div>
</div>
@ -70,19 +119,21 @@ onMounted( () => {
{{ t("host.text") }}
</h3>
<p>
{{ t("host.alreadyHostedGome") }}
{{ t("host.alreadyCreatedBoard") }}
</p>
<p>
{{ t("host.textCode") }}
{{ t("host.startHost") }}
</p>
<div class="row">
<div class="col-auto">
<input type="text" class="form-control" placeholder="Code">
</div>
<div class="col-auto">
<button class="btn btn-primary">{{ t("host.button") }}</button>
<button class="btn btn-primary" @click="startHost">
<FontAwesomeIcon v-if="hostLoading" :icon="['fas', 'spinner']" spin/>
<FontAwesomeIcon v-else :icon="['fas', 'users']"/>
{{ t("host.button") }}
</button>
</div>
</div>
<p v-show="hostError !== null">{{ hostError }}</p>
</div>
</div>
</div>

View File

@ -0,0 +1,175 @@
<script setup lang="ts">
import { GAME_STATUS_CONST } from '@/services/GameService';
import { useGameStore } from '@/stores/GameStore';
import { useUserStore } from '@/stores/UserStore';
import type { AxiosError } from 'axios';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const gameStore = useGameStore();
const userStore = useUserStore();
const playerName = ref('');
const inviteCode = ref('');
function backOrHome(){
if( window.history.length - userStore.initialHistoryLen > 0 ){
router.back();
} else {
router.push({name: 'home'});
}
}
function loadGame(){
gameStore
.initGame(inviteCode.value)
.catch((error: AxiosError) => {
joinError.value = error.message;
console.error(error);
})
.finally(() => {
joinLoading.value = false;
});
}
function joinGame(){
joinLoading.value = true;
gameStore.joinGame(playerName.value)
.then( () => {
if( gameStore.gameStatus === GAME_STATUS_CONST.lobby ) {
router.push({name: 'lobby', params: { gameUuid: gameId}})
}
if( gameStore.gameStatus === GAME_STATUS_CONST.inProgress ) {
router.push({name: 'game', params: { gameUuid: gameId}})
}
})
.catch( (error) => {
console.error(error);
})
.finally( () => {
joinLoading.value = false;
})
}
const joinError = ref<string | null>(null);
const joinLoading = ref(false);
const showingInviteCodeInput = ref(false);
const gameId = route.params.gameUuid as string | undefined;
const siteLoading = ref(true);
function initGame(gameId: string){
gameStore
.initGame(gameId)
.catch((error: AxiosError) => {
joinError.value = error.message;
showingInviteCodeInput.value = true;
})
.finally( () => {
siteLoading.value = false;
})
}
function checkIfGameSavedInSessionAndIsSameAsRoute(){
const sessionGame = gameStore.checkForGame();
if( sessionGame === null ){
if( gameId === undefined ){
showingInviteCodeInput.value = true;
siteLoading.value = false;
} else {
initGame(gameId);
}
} else {
if( gameId === undefined ){
showingInviteCodeInput.value = true;
inviteCode.value = sessionGame.inviteCode;
siteLoading.value = false;
} else {
if( gameId === sessionGame.uuid && !gameStore.isConnected ){
initGame(gameId);
return;
}
if( gameId === sessionGame.inviteCode && !gameStore.isConnected ){
initGame(gameId);
return;
}
siteLoading.value = false;
}
}
}
checkIfGameSavedInSessionAndIsSameAsRoute();
</script>
<template>
<div
class="d-flex flex-column w-100 justify-content-start align-items-center"
>
<div class="row mt-5">
<div class="col">
<template v-if="showingInviteCodeInput">
<div class="card">
<div class="card-header">
<h4>Join Game</h4>
</div>
<div class="card-body">
<label for="invite-code-input">{{ t("join.textCode") }}</label>
<input
class="form-control"
type="text"
id="invite-code-input"
v-model="inviteCode"
/>
<div class="d-flex justify-content-between align-items-center mt-3">
<button class="btn btn-outline-danger" @click="backOrHome">
<FontAwesomeIcon :icon="['fas', 'xmark']" />
Cancel
</button>
<button class="btn btn-primary" @click="loadGame">
Join
<FontAwesomeIcon :icon="['fas', 'arrow-right-to-bracket']" />
</button>
</div>
<p class="text-danger">{{ joinError }}</p>
</div>
</div>
</template>
<template v-else>
<div class="card">
<div class="card-header">
<h4>Join Game</h4>
</div>
<div class="card-body">
<ul>
<li>Host: {{ gameStore.hostUsername }}</li>
<li>Players: {{ gameStore.playerCount }}</li>
<li>Status: {{ t(gameStore.gameStatus) }}</li>
</ul>
<label for="player-name-input">{{ t('game.player.name') }}</label>
<input
class="form-control"
type="text"
id="player-name-input"
v-model="playerName"
/>
<div class="d-flex justify-content-between align-items-center mt-3">
<button class="btn btn-outline-danger" @click="backOrHome">
<FontAwesomeIcon :icon="['fas', 'xmark']" />
Cancel
</button>
<button class="btn btn-primary" @click="joinGame">
Join
<FontAwesomeIcon :icon="['fas', 'arrow-right-to-bracket']" />
</button>
</div>
<p class="text-danger">{{ joinError }}</p>
</div>
</div>
</template>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,130 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useClipboard } from '@vueuse/core';
import { userService } from '@/services/UserService';
import { useGameStore } from '@/stores/GameStore';
import { Board } from '@/models/board/Board';
import IconJeobeardy from '@/components/icons/IconJeobeardy.vue';
const { t } = useI18n();
const gameStore = useGameStore();
const boardSearchText = ref('');
const boardsLoading = ref(true);
const hostBoards = ref<Array<Board>>([])
if( gameStore.isHost ){
userService.getBoardsForUser()
.then( (boards) => {
hostBoards.value = boards;
} )
.catch( (error) => {
})
.finally( () => {
boardsLoading.value = false;
})
}
function startGame(){
gameStore.startGame();
}
const { copy, copied } = useClipboard();
</script>
<template>
<div
class="d-flex flex-column w-100 justify-content-start align-items-center"
>
<div class="row w-100 mt-4 mb-3">
<div :class="[{'col-6': gameStore.isHost},{'col-12': !gameStore.isHost}]" class="d-flex justify-content-center align-items-center gap-3">
<h4>Invite Code:</h4>
<div class="input-group w-invite-code">
<input
type="text"
class="form-control"
aria-label="Invite code"
readonly
:value="gameStore.gameInviteCode"
/>
<button
class="btn btn-outline-primary"
type="button"
id="copy-invite-code-to-clipboard"
@click="copy(gameStore.gameInviteCode ?? '')"
>
<FontAwesomeIcon :icon="['fas', 'copy']" />
<span v-if="copied" class="badge pe-0">
<FontAwesomeIcon :icon="['fas', 'check']" />
</span>
</button>
</div>
</div>
<div v-if="gameStore.isHost" class="col-6 d-flex justify-content-center align-items-center">
<button class="btn btn-primary" @click="startGame">
Start Game
<FontAwesomeIcon :icon="['fas', 'arrow-right']" />
</button>
</div>
</div>
<template v-if="gameStore.isHost">
<div class="row w-100 my-2 p-2 bg-body-secondary">
<div class="col-12 col-lg-9 text-start">
<h4>Boards</h4>
</div>
<div class="col-12 col-lg-3">
<input
class="form-control"
type="search"
id="search-for-board"
v-model="boardSearchText"
placeholder="Search for a board"
/>
</div>
</div>
<div class="row w-100 mb-3 pb-3 bg-body-secondary g-3 text-center">
<template v-if="boardsLoading">
<IconJeobeardy class="spinny-spin spinny-size" />
</template>
<template v-else>
<template v-for="board in hostBoards" :key="board.uuid">
<div class="col-12 col-lg-4">
<div class="card p-2">
{{ board.boardName }}
</div>
</div>
</template>
</template>
</div>
</template>
<div class="row">
<div class="col">
<h3>Players</h3>
</div>
</div>
<div class="row w-100 px-3">
<div class="col-12 col-lg-3">
<template v-for="player in gameStore.players" :key="player.uuid">
<div class="card bg-body-secondary p-2">
{{ player.name }}
</div>
</template>
</div>
</div>
</div>
</template>
<style lang="css" scoped>
.w-invite-code {
width: 12em;
}
.spinny-size{
max-width: 6.66em;
max-height: 6.66em;
}
</style>

View File

@ -25,7 +25,7 @@ const editProfileModalId = "edit-profile-modal-on-profile-page";
<img :src="userStore.pfpSource" alt="Your Profile Pic" />
</div>
<p class="fs-3">
{{ userStore.username }}
{{ userStore.getUserOutput }}
</p>
<div class="d-flex gap-3">
<button class="btn btn-outline-primary" :data-bs-target="`#${editProfileModalId}`" data-bs-toggle="modal">
@ -40,7 +40,7 @@ const editProfileModalId = "edit-profile-modal-on-profile-page";
{{ t('profile.yourBoards') }}
</h2>
<div class="container">
<BoardSelector />
<BoardSelector :withNewOption="true" />
</div>
</div>
</div>

View File

@ -66,8 +66,8 @@
"host": {
"button": "Host",
"text": "Host a Game",
"alreadyHostedGome": "Wanna create a board and host a game yourself?",
"textCode": "Wanna create a board and host a game yourself?"
"alreadyCreatedBoard": "Already created a board?",
"startHost": "Start hosting a game and invite some friends to play"
},
"board": {
"label": "Board",
@ -79,6 +79,7 @@
"category": {
"infos": "Category Infos",
"label": "No Category | Category | Categories | {count} Categories",
"custom-color": "Set Custom Color",
"name": "Category Name",
"description": "Category Description",
"add": "Add Category",
@ -98,10 +99,69 @@
"text": "Question Text",
"type": "Question Type",
"fontsize": "Font Size",
"types": {
"title": {
"0": "Simple Question",
"1": "Image Question",
"2": "Audio Question",
"3": "Location Question"
},
"description": {
"0": "A simple question with just text",
"1": "A question with text and an image",
"2": "A question with text and audio",
"3": "A question with an image for players to guess the location on the image"
}
},
"upload": {
"image": "Upload an image",
"audio": "Upload an audio file"
}
},
"answer": {
"label": "No Answer | Answer | Answers | {count} Answers",
"text": "Answer Text",
"types": {
"label": "Answer Type",
"title": {
"0": "Simple Answer",
"1": "Image Answer",
"2": "Audio Answer",
"3": "Location Answer"
},
"description": {
"0": "A simple answer with just text",
"1": "An answer with text and an image",
"2": "An answer with text and audio",
"3": "An answer for a location question with the correct location shown on an image"
}
}
}
},
"game": {
"host": {
"text": {
"seconds": "seconds",
"in": "in",
"pointsAdjustmentValue": "Value for manual adjustments"
},
"button": {
"lockIn": "Lock",
"letChoose": "Let Choose",
"letNextChoose": "Let next player choose",
"revealAll": "Reveal All Answers",
"reveal": "Reveal Answer",
"unlock": "Unlock Again",
"correct": "Correct",
"wrong": "Wrong",
"pointsAdd": "Add Points",
"pointsSubtract": "Subtract Points"
}
},
"status": {
"notAvailable": "No game found/available",
"lobby": "In lobby - not yet startet",
"inProgress": "Game in progress"
}
},
"theme": {
@ -111,7 +171,7 @@
"light": {
"name": "Light"
},
"highContrast": {
"high-contrast": {
"name": "High Contrast"
}
},

View File

@ -9,7 +9,7 @@ import '@/assets/scss/customized_bootstrap.scss';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { library } from '@fortawesome/fontawesome-svg-core';
import { faSun, faMoon, faCircleHalfStroke, faEdit, faPlay, faSpinner, faLanguage, faGlobe, faPlus, faTrash, faGripLines, faAngleUp, faAngleDown, faAngleLeft } from '@fortawesome/free-solid-svg-icons';
import { faSun, faMoon, faCircleHalfStroke, faEdit, faPlay, faSpinner, faLanguage, faGlobe, faPlus, faTrash, faGripLines, faAngleUp, faAngleDown, faAngleLeft, faAnglesRight, faHandPointer, faMinus, faEye, faLock, faLockOpen, faSave, faXmark, faUsers, faArrowRightToBracket, faCopy, faCheck, faArrowRight, faAlignCenter } from '@fortawesome/free-solid-svg-icons';
import enMessages from './locales/en.json';
import deMessages from './locales/de.json';
@ -34,17 +34,31 @@ library.add(
faLanguage,
faGlobe,
faPlus,
faMinus,
faTrash,
faGripLines,
faAngleUp,
faAngleDown,
faAngleLeft,
faAnglesRight,
faHandPointer,
faEye,
faLock,
faLockOpen,
faSave,
faXmark,
faUsers,
faArrowRightToBracket,
faCopy,
faCheck,
faArrowRight,
faAlignCenter,
)
const app = createApp( App );
app.component('FontAwesomeIcon', FontAwesomeIcon);
app.use( createPinia() );
app.component( 'FontAwesomeIcon', FontAwesomeIcon );
app.use( router );
app.use( i18n );

View File

@ -1,10 +1,12 @@
import type { BoardEntry } from './BoardEntry';
import type { AnswerType } from './AnswerType';
import type { Point } from './Point';
export class Answer{
constructor(
public text: string,
public boardEntry: BoardEntry | undefined,
public image: URL | undefined = undefined,
public answerType: AnswerType,
public location: Point | undefined = undefined,
public image: string | undefined = undefined,
public id: number | undefined = undefined,
){}
}

Some files were not shown because too many files have changed in this diff Show More