Merge pull request #455 from mawoka-myblock/refactor

This commit is contained in:
Marlon
2025-10-26 08:55:52 +01:00
committed by GitHub
47 changed files with 560 additions and 379 deletions
+55 -2
View File
@@ -1,16 +1,26 @@
# SPDX-FileCopyrightText: 2023 Marlon W (Mawoka) # SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
# #
# SPDX-License-Identifier: MPL-2.0 # SPDX-License-Identifier: MPL-2.0
from __future__ import annotations
import os import os
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional, Self
from classquiz.config import redis
import json
import ormar import ormar
from ormar import ReferentialAction from ormar import ReferentialAction
from pydantic import BaseModel, Json, field_validator, ConfigDict, RootModel, ValidationInfo from pydantic import (
BaseModel,
Json,
field_validator,
ConfigDict,
RootModel,
ValidationInfo,
)
from enum import Enum from enum import Enum
from . import metadata, database from . import metadata, database
from .quiztivity import QuizTivityPage from .quiztivity import QuizTivityPage
@@ -242,11 +252,34 @@ class PlayGame(BaseModel):
custom_field: str | None = None custom_field: str | None = None
question_show: bool = False question_show: bool = False
@classmethod
async def get_from_redis(self, game_pin: str) -> Self:
redis_data: str = await redis.get(f"game:{game_pin}")
data = self.model_validate_json(redis_data)
return data
async def save(self, game_pin: str, ex: int = 7200):
await redis.set(f"game:{game_pin}", self.model_dump_json(), ex=ex)
def to_player_data(self) -> dict:
return (
{
**json.loads(self.model_dump_json(exclude={"quiz_id", "questions", "user_id"})),
"question_count": len(self.questions),
},
)
class GamePlayer(BaseModel): class GamePlayer(BaseModel):
username: str username: str
sid: str | None = None sid: str | None = None
async def to_player_stack(self, game_pin: str):
await redis.sadd(
f"game_session:{game_pin}:players",
self.model_dump_json(),
)
class GameAnswer2(BaseModel): class GameAnswer2(BaseModel):
username: str username: str
@@ -265,6 +298,18 @@ class GameSession(BaseModel):
# players: list[GamePlayer | None] # players: list[GamePlayer | None]
answers: list[GameAnswer1 | None] answers: list[GameAnswer1 | None]
@classmethod
async def get_from_redis(self, game_pin: str) -> Self:
redis_data = await redis.get(f"game_session:{game_pin}")
return self.model_validate_json(redis_data)
async def save(self, game_pin: str, ex: int = 7200):
await redis.set(
f"game_session:{game_pin}",
self.model_dump_json(),
ex=7200,
)
class UpdatePassword(BaseModel): class UpdatePassword(BaseModel):
old_password: str old_password: str
@@ -294,6 +339,14 @@ class AnswerDataList(RootModel):
def __len__(self) -> int: def __len__(self) -> int:
return len(self.root) return len(self.root)
@classmethod
async def get_redis_or_empty(self, game_pin: str, question_number: str) -> Self:
redis_res = await redis.get(f"game_session:{game_pin}:{question_number}")
if redis_res is None:
return self([])
else:
return self.model_validate_json(redis_res)
class GameInLobby(BaseModel): class GameInLobby(BaseModel):
game_pin: str game_pin: str
+2
View File
@@ -94,6 +94,8 @@ async def import_quiz(quiz_id: str, user: User) -> Quiz | int:
img_obj = await handle_image_upload(quiz.kahoot.cover, user) img_obj = await handle_image_upload(quiz.kahoot.cover, user)
uploaded_images.append(img_obj) uploaded_images.append(img_obj)
cover = img_obj.id.hex cover = img_obj.id.hex
if quiz.kahoot.description is None or quiz.kahoot.description == "":
quiz.kahoot.description = "Description Missing!"
quiz_data = Quiz( quiz_data = Quiz(
id=quiz_id, id=quiz_id,
public=False, public=False,
+7 -3
View File
@@ -51,7 +51,7 @@ class GitHubOauthResponse(BaseModel):
events_url: Optional[str] = None events_url: Optional[str] = None
received_events_url: Optional[str] = None received_events_url: Optional[str] = None
type: Optional[str] = None type: Optional[str] = None
site_admin: Optional[str] = None site_admin: Optional[bool] = None
name: Optional[str] = None name: Optional[str] = None
company: Optional[str] = None company: Optional[str] = None
blog: Optional[str] = None blog: Optional[str] = None
@@ -103,7 +103,8 @@ async def auth(request: Request, response: Response):
except authlib.integrations.base_client.OAuthError: except authlib.integrations.base_client.OAuthError:
return RedirectResponse("/account/oauth-error") return RedirectResponse("/account/oauth-error")
resp = await oauth.github.get("user", token=token) resp = await oauth.github.get("user", token=token)
user_data = GitHubOauthResponse(**resp.json()) data = resp.json()
user_data = GitHubOauthResponse(**data)
if user_data.email is None: if user_data.email is None:
return RedirectResponse("/account/oauth-error?error=email") return RedirectResponse("/account/oauth-error?error=email")
user_in_db = await User.objects.get_or_none(email=user_data.email) user_in_db = await User.objects.get_or_none(email=user_data.email)
@@ -140,7 +141,10 @@ async def auth(request: Request, response: Response):
else: else:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
user = await User.objects.get_or_none( user = await User.objects.get_or_none(
email=user_data.email, username=user_data.login, auth_type=UserAuthTypes.GITHUB, verified=True email=user_data.email,
username=user_data.login,
auth_type=UserAuthTypes.GITHUB,
verified=True,
) )
await log_user_in(user=user, request=request, response=response) await log_user_in(user=user, request=request, response=response)
+24 -2
View File
@@ -4,15 +4,37 @@
from authlib.integrations.starlette_client import OAuth from authlib.integrations.starlette_client import OAuth
from classquiz.config import settings from classquiz.config import settings, redis
from functools import lru_cache from functools import lru_cache
import json
settings = settings() settings = settings()
class RedisCache:
def __init__(self) -> None:
pass
async def get(self, key: str) -> str | None:
value = await redis.get(f"authlib:{key}")
if value is None:
return None
try:
return json.loads(value)
except json.JSONDecodeError:
return value
async def set(self, key: str, value: str, expires: int | None = None):
data = json.dumps(value)
await redis.set(f"authlib:{key}", data, ex=expires)
async def delete(self, key: str) -> None:
await redis.delete(key)
@lru_cache() @lru_cache()
def init_oauth() -> OAuth: def init_oauth() -> OAuth:
oauth = OAuth() oauth = OAuth(cache=RedisCache())
if settings.google_client_secret is not None and settings.google_client_id is not None: if settings.google_client_secret is not None and settings.google_client_id is not None:
oauth.register( oauth.register(
name="google", name="google",
+17 -5
View File
@@ -11,15 +11,27 @@ import asyncpg.exceptions
import bleach import bleach
from fastapi import APIRouter, HTTPException, Depends from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel from pydantic import BaseModel
import html
from classquiz.config import settings, redis, storage, meilisearch, ALLOWED_TAGS_FOR_QUIZ, arq from classquiz.config import (
settings,
redis,
storage,
meilisearch,
ALLOWED_TAGS_FOR_QUIZ,
arq,
)
from classquiz.db.models import Quiz, QuizInput, User, QuizQuestionType, StorageItem from classquiz.db.models import Quiz, QuizInput, User, QuizQuestionType, StorageItem
from classquiz.auth import get_current_user from classquiz.auth import get_current_user
import os import os
from datetime import datetime from datetime import datetime
from uuid import UUID from uuid import UUID
from classquiz.helpers import get_meili_data, check_image_string, extract_image_ids_from_quiz from classquiz.helpers import (
get_meili_data,
check_image_string,
extract_image_ids_from_quiz,
)
from classquiz.storage.errors import DeletionFailedError from classquiz.storage.errors import DeletionFailedError
settings = settings() settings = settings()
@@ -79,15 +91,15 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput):
quiz_input.background_color = bleach.clean(quiz_input.background_color, tags=[], strip=True) quiz_input.background_color = bleach.clean(quiz_input.background_color, tags=[], strip=True)
for i, question in enumerate(quiz_input.questions): for i, question in enumerate(quiz_input.questions):
if question.type == QuizQuestionType.ABCD: if question.type == QuizQuestionType.ABCD or question.type == QuizQuestionType.VOTING:
for i2, answer in enumerate(question.answers): for i2, answer in enumerate(question.answers):
if answer.color is not None: if answer.color is not None:
quiz_input.questions[i].answers[i2].color = bleach.clean(answer.color, tags=[], strip=True) quiz_input.questions[i].answers[i2].color = bleach.clean(answer.color, tags=[], strip=True)
if answer.answer == "": if answer.answer == "":
quiz_input.questions[i].answers[i2].answer = None quiz_input.questions[i].answers[i2].answer = None
if answer.answer is not None: if answer.answer is not None:
quiz_input.questions[i].answers[i2].answer = bleach.clean( quiz_input.questions[i].answers[i2].answer = html.unescape(
answer.answer, tags=ALLOWED_TAGS_FOR_QUIZ, strip=True bleach.clean(answer.answer, tags=ALLOWED_TAGS_FOR_QUIZ, strip=True)
) )
images_to_delete = [] images_to_delete = []
+74 -225
View File
@@ -9,7 +9,6 @@ import json
import os import os
import random import random
import aiohttp
import socketio import socketio
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
@@ -19,14 +18,28 @@ from classquiz.db.models import (
QuizQuestionType, QuizQuestionType,
GameSession, GameSession,
GamePlayer, GamePlayer,
QuizQuestion,
VotingQuizAnswer, VotingQuizAnswer,
AnswerDataList, AnswerDataList,
AnswerData, AnswerData,
) )
from pydantic import BaseModel, ValidationError, field_validator, ValidationInfo from pydantic import BaseModel, ValidationError
from datetime import datetime from datetime import datetime
from classquiz.socket_server.helpers import (
check_answer,
check_captcha,
has_already_answered,
)
from .models import (
RejoinGameData,
JoinGameData,
ReturnQuestion,
SubmitAnswerData,
RegisterAsAdminData,
KickPlayerInput,
ConnectSessionIdEvent,
)
from classquiz.socket_server.export_helpers import save_quiz_to_storage from classquiz.socket_server.export_helpers import save_quiz_to_storage
from classquiz.socket_server.session import get_session, save_session from classquiz.socket_server.session import get_session, save_session
@@ -74,19 +87,6 @@ async def set_answer(answers, game_pin: str, q_index: int, data: AnswerData) ->
return answers return answers
class _JoinGameData(BaseModel):
username: str
game_pin: str
captcha: str | None = None
custom_field: str | None = None
class _RejoinGameData(BaseModel):
old_sid: str
game_pin: str
username: str
@sio.event @sio.event
async def rejoin_game(sid: str, data: dict): async def rejoin_game(sid: str, data: dict):
redis_res = await redis.get(f"game:{data['game_pin']}") redis_res = await redis.get(f"game:{data['game_pin']}")
@@ -94,7 +94,7 @@ async def rejoin_game(sid: str, data: dict):
await sio.emit("game_not_found", room=sid) await sio.emit("game_not_found", room=sid)
return return
try: try:
data = _RejoinGameData(**data) data = RejoinGameData(**data)
except ValidationError as e: except ValidationError as e:
await sio.emit("error", room=sid) await sio.emit("error", room=sid)
print(e) print(e)
@@ -106,10 +106,12 @@ async def rejoin_game(sid: str, data: dict):
await sio.emit("time_sync", encrypted_datetime, room=sid) await sio.emit("time_sync", encrypted_datetime, room=sid)
await redis.set(redis_sid_key, sid) await redis.set(redis_sid_key, sid)
await redis.srem( await redis.srem(
f"game_session:{data.game_pin}:players", GamePlayer(username=data.username, sid=data.old_sid).model_dump_json() f"game_session:{data.game_pin}:players",
GamePlayer(username=data.username, sid=data.old_sid).model_dump_json(),
) )
await redis.sadd( await redis.sadd(
f"game_session:{data.game_pin}:players", GamePlayer(username=data.username, sid=sid).model_dump_json() f"game_session:{data.game_pin}:players",
GamePlayer(username=data.username, sid=sid).model_dump_json(),
) )
game_data = PlayGame.model_validate_json(redis_res) game_data = PlayGame.model_validate_json(redis_res)
session = { session = {
@@ -122,10 +124,7 @@ async def rejoin_game(sid: str, data: dict):
await sio.enter_room(sid, data.game_pin) await sio.enter_room(sid, data.game_pin)
await sio.emit( await sio.emit(
"rejoined_game", "rejoined_game",
{ game_data.to_player_data(),
**json.loads(game_data.model_dump_json(exclude={"quiz_id", "questions", "user_id"})),
"question_count": len(game_data.questions),
},
room=sid, room=sid,
) )
@@ -137,7 +136,7 @@ async def join_game(sid: str, data: dict):
await sio.emit("game_not_found", room=sid) await sio.emit("game_not_found", room=sid)
return return
try: try:
data = _JoinGameData(**data) data = JoinGameData(**data)
except ValidationError as e: except ValidationError as e:
await sio.emit("error", room=sid) await sio.emit("error", room=sid)
print(e) print(e)
@@ -148,42 +147,9 @@ async def join_game(sid: str, data: dict):
return return
# +++ START checking captcha +++ # +++ START checking captcha +++
if game_data.captcha_enabled: if game_data.captcha_enabled:
async with aiohttp.ClientSession() as session: captcha_res = check_captcha(data.captcha)
try: if not captcha_res:
if settings.hcaptcha_key is not None: return
try:
async with session.post(
"https://hcaptcha.com/siteverify",
data={
"response": data.captcha,
"secret": settings.hcaptcha_key,
},
) as resp:
resp_data = await resp.model_dump_json()
if not resp_data["success"]:
print("CAPTCHA FAILED")
return
except KeyError:
print("CAPTCHA FAILED")
return
elif settings.recaptcha_key is not None:
async with session.post(
"https://www.google.com/recaptcha/api/siteverify",
data={
"secret": settings.recaptcha_key,
"response": data.captcha,
},
) as resp:
try:
resp_data = await resp.model_dump_json()
if not resp_data["success"]:
print("CAPTCHA FAILED")
return
except KeyError:
print("CAPTCHA FAILED")
return
except TypeError:
pass
# --- END checking captcha --- # --- END checking captcha ---
if await redis.get(f"game_session:{data.game_pin}:players:{data.username}") is not None: if await redis.get(f"game_session:{data.game_pin}:players:{data.username}") is not None:
await sio.emit("username_already_exists", room=sid) await sio.emit("username_already_exists", room=sid)
@@ -198,18 +164,12 @@ async def join_game(sid: str, data: dict):
await save_session(sid, sio, session) await save_session(sid, sio, session)
await sio.emit( await sio.emit(
"joined_game", "joined_game",
{ game_data.to_player_data(),
**json.loads(game_data.model_dump_json(exclude={"quiz_id", "questions", "user_id"})),
"question_count": len(game_data.questions),
},
room=sid, room=sid,
) )
redis_res = await redis.get(f"game_session:{data.game_pin}")
redis_res = GameSession.model_validate_json(redis_res)
await redis.set(f"game_session:{data.game_pin}:players:{data.username}", sid, ex=7200) await redis.set(f"game_session:{data.game_pin}:players:{data.username}", sid, ex=7200)
await redis.sadd( await GamePlayer(username=data.username, sid=sid).to_player_stack(data.game_pin)
f"game_session:{data.game_pin}:players", GamePlayer(username=data.username, sid=sid).model_dump_json()
)
if data.custom_field == "": if data.custom_field == "":
data.custom_field = None data.custom_field = None
if data.custom_field is not None: if data.custom_field is not None:
@@ -236,49 +196,36 @@ async def start_game(sid: str, _data: dict):
session = await get_session(sid, sio) session = await get_session(sid, sio)
if not session["admin"]: if not session["admin"]:
return return
game_data = PlayGame.model_validate_json(await redis.get(f"game:{session['game_pin']}")) game_data = await PlayGame.get_from_redis(session["game_pin"])
game_data.started = True game_data.started = True
await redis.set(f"game:{session['game_pin']}", game_data.model_dump_json(), ex=7200) await game_data.save(session["game_pin"])
await redis.delete(f"game_in_lobby:{game_data.user_id.hex}") await redis.delete(f"game_in_lobby:{game_data.user_id.hex}")
await sio.emit("start_game", room=session["game_pin"]) await sio.emit("start_game", room=session["game_pin"])
class _RegisterAsAdminData(BaseModel):
game_pin: str
game_id: str
@sio.event @sio.event
async def register_as_admin(sid: str, data: dict): async def register_as_admin(sid: str, data: dict):
try: try:
data = _RegisterAsAdminData(**data) data = RegisterAsAdminData(**data)
except ValidationError as e: except ValidationError as e:
await sio.emit("error", room=sid) await sio.emit("error", room=sid)
print(e) print(e)
return return
game_pin = data.game_pin game_pin = data.game_pin
game_id = data.game_id game_id = data.game_id
if (await redis.get(f"game_session:{game_pin}")) is None: if await redis.get(f"game_session:{game_pin}") is not None:
await redis.set(
f"game_session:{game_pin}",
GameSession(admin=sid, game_id=game_id, answers=[]).model_dump_json(),
ex=7200,
)
await sio.emit(
"registered_as_admin",
{"game_id": game_id, "game": await redis.get(f"game:{game_pin}")},
room=sid,
)
session = {}
session["game_pin"] = game_pin
session["admin"] = True
session["remote"] = False
await save_session(sid, sio, session)
await sio.enter_room(sid, game_pin)
await sio.enter_room(sid, f"admin:{data.game_pin}")
else:
await sio.emit("already_registered_as_admin", room=sid) await sio.emit("already_registered_as_admin", room=sid)
return
await GameSession(admin=sid, game_id=game_id, answers=[]).save(game_pin)
await sio.emit(
"registered_as_admin",
{"game_id": game_id, "game": await redis.get(f"game:{game_pin}")},
room=sid,
)
session = {"game_pin": game_pin, "admin": True, "remote": False}
await save_session(sid, sio, session)
await sio.enter_room(sid, game_pin)
await sio.enter_room(sid, f"admin:{data.game_pin}")
@sio.event @sio.event
@@ -286,57 +233,25 @@ async def get_question_results(sid: str, data: dict):
session = await get_session(sid, sio) session = await get_session(sid, sio)
if not session["admin"]: if not session["admin"]:
return return
redis_res = await redis.get(f"game_session:{session['game_pin']}:{data['question_number']}")
if redis_res is None:
redis_res = []
else:
redis_res = AnswerDataList.model_validate_json(redis_res).model_dump()
game_data = PlayGame.model_validate_json(await redis.get(f"game:{session['game_pin']}"))
game_data.question_show = False
await redis.set(f"game:{session['game_pin']}", game_data.model_dump_json())
game_pin = session["game_pin"] game_pin = session["game_pin"]
answer_data_list = await AnswerDataList.get_redis_or_empty(game_pin, data["question_number"])
await sio.emit("question_results", redis_res, room=game_pin) game_data = await PlayGame.get_from_redis(game_pin)
game_data.question_show = False
await game_data.save(game_pin)
class ABCDQuizAnswerWithoutSolution(BaseModel): await sio.emit("question_results", answer_data_list.model_dump(), room=game_pin)
answer: str
color: str | None = None
class RangeQuizAnswerWithoutSolution(BaseModel):
min: int
max: int
class ReturnQuestion(QuizQuestion):
answers: list[ABCDQuizAnswerWithoutSolution] | RangeQuizAnswerWithoutSolution | list[VotingQuizAnswer]
type: QuizQuestionType = QuizQuestionType.ABCD
@field_validator("answers")
def answers_not_none_if_abcd_type(cls, v, info: ValidationInfo):
if info.data["type"] == QuizQuestionType.ABCD and type(v[0]) is not ABCDQuizAnswerWithoutSolution:
raise ValueError("Answers can't be none if type is ABCD")
if info.data["type"] == QuizQuestionType.RANGE and type(v) is not RangeQuizAnswerWithoutSolution:
raise ValueError("Answer must be from type RangeQuizAnswer if type is RANGE")
# skipcq: PTC-W0047
if info.data["type"] == QuizQuestionType.VOTING and type(v[0]) is not VotingQuizAnswer:
pass
return v
@sio.event @sio.event
async def set_question_number(sid, data: str): async def set_question_number(sid: str, data: str):
# data is just a number (as a str) of the question # data is just a number (as a str) of the question
session = await get_session(sid, sio) session = await get_session(sid, sio)
if not session["admin"]: if not session["admin"]:
return return
game_pin = session["game_pin"] game_pin = session["game_pin"]
game_data = PlayGame.model_validate_json(await redis.get(f"game:{session['game_pin']}")) game_data = await PlayGame.get_from_redis(session["game_pin"])
game_data.current_question = int(float(data)) game_data.current_question = int(float(data))
game_data.question_show = True game_data.question_show = True
await redis.set(f"game:{session['game_pin']}", game_data.model_dump_json(), ex=7200) await game_data.save(session["game_pin"])
await redis.set(f"game:{session['game_pin']}:current_time", datetime.now().isoformat(), ex=7200) await redis.set(f"game:{session['game_pin']}:current_time", datetime.now().isoformat(), ex=7200)
temp_return = game_data.model_dump(include={"questions"})["questions"][int(float(data))] temp_return = game_data.model_dump(include={"questions"})["questions"][int(float(data))]
if game_data.questions[int(float(data))].type == QuizQuestionType.SLIDE: if game_data.questions[int(float(data))].type == QuizQuestionType.SLIDE:
@@ -364,93 +279,39 @@ async def set_question_number(sid, data: str):
) )
class _SubmitAnswerDataOrderType(BaseModel):
answer: str
class _SubmitAnswerData(BaseModel):
question_index: int
answer: str | int
complex_answer: list[_SubmitAnswerDataOrderType] | None = None
@sio.event @sio.event
async def submit_answer(sid: str, data: dict): async def submit_answer(sid: str, data: dict):
now = datetime.now() now = datetime.now()
try: try:
data = _SubmitAnswerData(**data) data = SubmitAnswerData(**data)
except ValidationError as e: except ValidationError as e:
await sio.emit("error", room=sid) await sio.emit("error", room=sid)
print(e) print(e)
return return
data.answer = str(data.answer) data.answer = str(data.answer)
session = await get_session(sid, sio) session = await get_session(sid, sio)
game_data = PlayGame.model_validate_json(await redis.get(f"game:{session['game_pin']}")) question_index = int(float(data.question_index))
answer_right = False game_data = await PlayGame.get_from_redis(session["game_pin"])
if game_data.questions[int(float(data.question_index))].type == QuizQuestionType.ABCD: already_answered = await has_already_answered(session["game_pin"], question_index, session["username"])
for answer in game_data.questions[int(float(data.question_index))].answers: if already_answered:
if answer.answer == data.answer and answer.right: await sio.emit("already_replied", room=sid)
answer_right = True return
break answer_right, answer = check_answer(game_data, data)
elif game_data.questions[int(float(data.question_index))].type == QuizQuestionType.RANGE: latency = int(float(session["ping"]))
if (
game_data.questions[int(float(data.question_index))].answers.min_correct
<= int(float(data.answer))
<= game_data.questions[int(float(data.question_index))].answers.max_correct
):
answer_right = True
elif game_data.questions[int(float(data.question_index))].type == QuizQuestionType.VOTING:
answer_right = False
elif game_data.questions[int(float(data.question_index))].type == QuizQuestionType.ORDER:
if data.complex_answer is None:
answer_right = False
else:
question = game_data.questions[int(float(data.question_index))]
correct_answers = []
for a in question.answers:
correct_answers.append({"answer": a.answer})
answer_order = []
for a in data.model_dump()["complex_answer"]:
answer_order.append(a["answer"])
data.answer = ", ".join(answer_order)
if correct_answers == data.model_dump()["complex_answer"]:
answer_right = True
elif game_data.questions[int(float(data.question_index))].type == QuizQuestionType.TEXT:
answer_right = False
for q in game_data.questions[int(float(data.question_index))].answers:
if q.case_sensitive:
if data.answer == q.answer:
answer_right = True
break
else:
if data.answer.lower() == q.answer.lower():
answer_right = True
break
elif game_data.questions[int(data.question_index)].type == QuizQuestionType.CHECK:
correct_string = ""
for i, a in enumerate(game_data.questions[int(float(data.question_index))].answers):
if a.right:
correct_string += str(i)
answer_right = bool(correct_string == data.answer)
else:
raise NotImplementedError
latency = int(float((await get_session(sid, sio))["ping"]))
time_q_started = datetime.fromisoformat(await redis.get(f"game:{session['game_pin']}:current_time")) time_q_started = datetime.fromisoformat(await redis.get(f"game:{session['game_pin']}:current_time"))
diff = (time_q_started - now).total_seconds() * 1000 # - timedelta(milliseconds=latency) diff = (time_q_started - now).total_seconds() * 1000 # - timedelta(milliseconds=latency)
score = 0 score = 0
if answer_right: if answer_right:
score = calculate_score( score = calculate_score(
abs(diff) - latency, abs(diff) - latency,
int(float(game_data.questions[int(float(data.question_index))].time)), int(float(game_data.questions[question_index].time)),
) )
if score > 1000: if score > 1000:
score = 1000 score = 1000
await redis.hincrby(f"game_session:{session['game_pin']}:player_scores", session["username"], score) await redis.hincrby(f"game_session:{session['game_pin']}:player_scores", session["username"], score)
answer_data = AnswerData( answer_data = AnswerData(
username=session["username"], username=session["username"],
answer=data.answer, answer=answer,
right=answer_right, right=answer_right,
time_taken=abs(diff) - latency, time_taken=abs(diff) - latency,
score=score, score=score,
@@ -465,26 +326,18 @@ async def submit_answer(sid: str, data: dict):
player_count = await redis.scard(f"game_session:{session['game_pin']}:players") player_count = await redis.scard(f"game_session:{session['game_pin']}:players")
await sio.emit("player_answer", {}) await sio.emit("player_answer", {})
if len(answers) == player_count: if len(answers) == player_count:
# await sio.emit( game_data = await PlayGame.get_from_redis(session["game_pin"])
# "question_results",
# await redis.get(f"game_session:{session['game_pin']}:{data.question_index}"),
# room=session["game_pin"],
# )
game_data = PlayGame.model_validate_json(await redis.get(f"game:{session['game_pin']}"))
game_data.question_show = False game_data.question_show = False
await redis.set(f"game:{session['game_pin']}", game_data.model_dump_json()) await game_data.save(session["game_pin"])
await sio.emit("everyone_answered", {}) await sio.emit("everyone_answered", {})
# await redis.set(f"game_data:{session['game_pin']}", json.dumps(data))
@sio.event @sio.event
async def get_final_results(sid: str, _data: dict): async def get_final_results(sid: str, _data: dict):
session: dict = await get_session(sid, sio) session: dict = await get_session(sid, sio)
game_data = PlayGame(**json.loads(await redis.get(f"game:{session['game_pin']}")))
if not session["admin"]: if not session["admin"]:
return return
game_data = await PlayGame.get_from_redis(session["game_pin"])
results = await generate_final_results(game_data, session["game_pin"]) results = await generate_final_results(game_data, session["game_pin"])
await sio.emit("final_results", results, room=session["game_pin"]) await sio.emit("final_results", results, room=session["game_pin"])
@@ -494,7 +347,7 @@ async def get_export_token(sid: str):
session = await get_session(sid, sio) session = await get_session(sid, sio)
if not session["admin"]: if not session["admin"]:
return return
game_data = PlayGame(**json.loads(await redis.get(f"game:{session['game_pin']}"))) game_data = await PlayGame.get_from_redis(session["game_pin"])
results = await generate_final_results(game_data, session["game_pin"]) results = await generate_final_results(game_data, session["game_pin"])
token = os.urandom(32).hex() token = os.urandom(32).hex()
await redis.set(f"export_token:{token}", json.dumps(results), ex=7200) await redis.set(f"export_token:{token}", json.dumps(results), ex=7200)
@@ -504,10 +357,14 @@ async def get_export_token(sid: str):
@sio.event @sio.event
async def show_solutions(sid: str, _data: dict): async def show_solutions(sid: str, _data: dict):
session: dict = await get_session(sid, sio) session: dict = await get_session(sid, sio)
game_data = PlayGame(**json.loads(await redis.get(f"game:{session['game_pin']}"))) game_data = await PlayGame.get_from_redis(session["game_pin"])
if not session["admin"]: if not session["admin"]:
return return
await sio.emit("solutions", game_data.questions[game_data.current_question].model_dump(), room=session["game_pin"]) await sio.emit(
"solutions",
game_data.questions[game_data.current_question].model_dump(),
room=session["game_pin"],
)
@sio.event @sio.event
@@ -521,14 +378,10 @@ async def echo_time_sync(sid: str, data: str):
await save_session(sid, sio, session) await save_session(sid, sio, session)
class _KickPlayerInput(BaseModel):
username: str
@sio.event @sio.event
async def kick_player(sid: str, data: dict): async def kick_player(sid: str, data: dict):
try: try:
data = _KickPlayerInput(**data) data = KickPlayerInput(**data)
except ValidationError as e: except ValidationError as e:
await sio.emit("error", room=sid) await sio.emit("error", room=sid)
print(e) print(e)
@@ -604,10 +457,6 @@ async def save_quiz(sid: str):
await sio.emit("results_saved_successfully") await sio.emit("results_saved_successfully")
class ConnectSessionIdEvent(BaseModel):
session_id: str
@sio.event @sio.event
async def connect(sid: str, _environ, _auth): async def connect(sid: str, _environ, _auth):
session_id = os.urandom(16).hex() session_id = os.urandom(16).hex()
+139
View File
@@ -0,0 +1,139 @@
# SPDX-FileCopyrightText: 2025 Marlon W (Mawoka)
#
# SPDX-License-Identifier: MPL-2.0
import aiohttp
from classquiz.config import settings
from classquiz.db.models import (
PlayGame,
QuizQuestionType,
TextQuizAnswer,
ABCDQuizAnswer,
VotingQuizAnswer,
RangeQuizAnswer,
AnswerDataList,
)
from classquiz.socket_server.models import SubmitAnswerData
from .models import SubmitAnswerDataOrderType
async def check_captcha(captcha_data: str) -> bool:
async with aiohttp.ClientSession() as session:
try:
if settings.hcaptcha_key is not None:
try:
async with session.post(
"https://hcaptcha.com/siteverify",
data={
"response": captcha_data,
"secret": settings.hcaptcha_key,
},
) as resp:
resp_data = await resp.model_dump_json()
if not resp_data["success"]:
return
except KeyError:
return False
elif settings.recaptcha_key is not None:
async with session.post(
"https://www.google.com/recaptcha/api/siteverify",
data={
"secret": settings.recaptcha_key,
"response": captcha_data,
},
) as resp:
try:
resp_data = await resp.model_dump_json()
if not resp_data["success"]:
return False
except KeyError:
return False
except TypeError:
pass
return False
return True
def check_answer(game_data: PlayGame, data: SubmitAnswerData) -> (bool, str):
q_i = int(float(data.question_index))
q_type = game_data.questions[q_i].type
q_answers = game_data.questions[q_i].answers
q_answer = data.answer
if q_type == QuizQuestionType.ABCD:
return (check_abcd_question(q_answer, q_answers), data.answer)
elif q_type == QuizQuestionType.RANGE:
return (
check_range_question(q_answer, q_answers),
q_answer,
)
elif q_type == QuizQuestionType.VOTING:
return (False, q_answer)
elif q_type == QuizQuestionType.ORDER:
return check_order_question(data.complex_answer, q_answer, q_answers)
elif q_type == QuizQuestionType.TEXT:
return (
check_text_question(q_answer, q_answers),
q_answer,
)
elif q_type == QuizQuestionType.CHECK:
return (
check_check_question(q_answer, q_answers),
q_answer,
)
else:
return (False, q_answer)
return (False, q_answer)
def check_abcd_question(answer: str, answers: ABCDQuizAnswer) -> bool:
for a in answers:
if a.answer == answer and a.right:
return True
return False
def check_range_question(answer: str, answers: RangeQuizAnswer) -> bool:
if answers.min_correct <= int(float(answer)) <= answers.max_correct:
return answers.min_correct <= int(float(answer)) <= answers.max_correct
def check_order_question(
complex_answer: list[SubmitAnswerDataOrderType] | None,
answer: str,
answers: list[VotingQuizAnswer],
) -> (bool, str):
if complex_answer is None:
return (False, answer)
correct_answers = [{"answer": a.answer} for a in answers]
submitted_answers = [{"answer": a.answer} for a in complex_answer]
answer_str = ", ".join(a["answer"] for a in submitted_answers)
is_correct = submitted_answers == correct_answers
return is_correct, answer_str
def check_text_question(answer: str, answers: list[TextQuizAnswer]) -> bool:
for q in answers:
if q.case_sensitive:
if answer == q.answer:
return True
else:
if answer.lower() == q.answer.lower():
return True
return False
def check_check_question(answer: str, answers: list[ABCDQuizAnswer]) -> bool:
correct_string = ""
for i, a in enumerate(answers):
if a.right:
correct_string += str(i)
return bool(correct_string == answer)
async def has_already_answered(game_pin: str, q_index: int, username: str) -> bool:
answers = await AnswerDataList.get_redis_or_empty(game_pin, q_index)
if answers is None:
return False
else:
answers = list(filter(lambda a: a.username == username, answers.root))
return len(answers) > 0
+68
View File
@@ -0,0 +1,68 @@
# SPDX-FileCopyrightText: 2025 Marlon W (Mawoka)
#
# SPDX-License-Identifier: MPL-2.0
from pydantic import BaseModel, field_validator, ValidationInfo
from classquiz.db.models import QuizQuestion, QuizQuestionType, VotingQuizAnswer
class JoinGameData(BaseModel):
username: str
game_pin: str
captcha: str | None = None
custom_field: str | None = None
class RejoinGameData(BaseModel):
old_sid: str
game_pin: str
username: str
class RegisterAsAdminData(BaseModel):
game_pin: str
game_id: str
class ABCDQuizAnswerWithoutSolution(BaseModel):
answer: str
color: str | None = None
class RangeQuizAnswerWithoutSolution(BaseModel):
min: int
max: int
class ReturnQuestion(QuizQuestion):
answers: list[ABCDQuizAnswerWithoutSolution] | RangeQuizAnswerWithoutSolution | list[VotingQuizAnswer]
type: QuizQuestionType = QuizQuestionType.ABCD
@field_validator("answers")
def answers_not_none_if_abcd_type(cls, v, info: ValidationInfo):
if info.data["type"] == QuizQuestionType.ABCD and type(v[0]) is not ABCDQuizAnswerWithoutSolution:
raise ValueError("Answers can't be none if type is ABCD")
if info.data["type"] == QuizQuestionType.RANGE and type(v) is not RangeQuizAnswerWithoutSolution:
raise ValueError("Answer must be from type RangeQuizAnswer if type is RANGE")
# skipcq: PTC-W0047
if info.data["type"] == QuizQuestionType.VOTING and type(v[0]) is not VotingQuizAnswer:
pass
return v
class SubmitAnswerDataOrderType(BaseModel):
answer: str
class SubmitAnswerData(BaseModel):
question_index: int
answer: str | int
complex_answer: list[SubmitAnswerDataOrderType] | None = None
class KickPlayerInput(BaseModel):
username: str
class ConnectSessionIdEvent(BaseModel):
session_id: str
@@ -5,19 +5,14 @@ SPDX-License-Identifier: MPL-2.0
--> -->
<script lang="ts"> <script lang="ts">
import { createBubbler } from 'svelte/legacy';
const bubble = createBubbler();
interface Props { interface Props {
disabled?: boolean; disabled?: boolean;
flex?: boolean; flex?: boolean;
href?: undefined | string; href?: undefined | string;
target?: undefined | string; target?: undefined | string;
type?: undefined | string; type?: 'button' | 'submit' | 'reset';
children?: import('svelte').Snippet; children?: import('svelte').Snippet;
onclick?: (event: MouseEvent) => void;
} }
let { let {
@@ -26,7 +21,8 @@ SPDX-License-Identifier: MPL-2.0
href = undefined, href = undefined,
target = '_self', target = '_self',
type = 'button', type = 'button',
children children,
onclick
}: Props = $props(); }: Props = $props();
</script> </script>
@@ -39,7 +35,7 @@ SPDX-License-Identifier: MPL-2.0
class:cursor-not-allowed={disabled} class:cursor-not-allowed={disabled}
class:pointer-events-none={disabled} class:pointer-events-none={disabled}
class="text-black hover:bg-bg-[#B07156]/80 w-full px-4 py-2 leading-5 transition-all duration-200 transform bg-[#B07156] rounded-sm text-center outline-hidden hover:cursor-pointer" class="text-black hover:bg-bg-[#B07156]/80 w-full px-4 py-2 leading-5 transition-all duration-200 transform bg-[#B07156] rounded-sm text-center outline-hidden hover:cursor-pointer"
onclick={bubble('click')} {onclick}
class:flex class:flex
class:justify-center={flex} class:justify-center={flex}
> >
@@ -50,7 +46,7 @@ SPDX-License-Identifier: MPL-2.0
{disabled} {disabled}
{type} {type}
class="text-black hover:cursor-pointer hover:opacity-80 w-full px-4 py-2 leading-5 transition-all duration-200 transform bg-[#B07156] rounded-sm text-center focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 outline-hidden" class="text-black hover:cursor-pointer hover:opacity-80 w-full px-4 py-2 leading-5 transition-all duration-200 transform bg-[#B07156] rounded-sm text-center focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 outline-hidden"
onclick={bubble('click')} {onclick}
class:flex class:flex
class:justify-center={flex} class:justify-center={flex}
> >
@@ -5,16 +5,13 @@ SPDX-License-Identifier: MPL-2.0
--> -->
<script lang="ts"> <script lang="ts">
import { createBubbler } from 'svelte/legacy';
const bubble = createBubbler();
interface Props { interface Props {
disabled?: boolean; disabled?: boolean;
flex?: boolean; flex?: boolean;
href?: undefined | string; href?: undefined | string;
target?: undefined | string; target?: undefined | string;
children?: import('svelte').Snippet; children?: import('svelte').Snippet;
onclick?: (event: MouseEvent) => void;
} }
let { let {
@@ -22,7 +19,8 @@ SPDX-License-Identifier: MPL-2.0
flex = false, flex = false,
href = undefined, href = undefined,
target = '_self', target = '_self',
children children,
onclick
}: Props = $props(); }: Props = $props();
</script> </script>
@@ -31,7 +29,7 @@ SPDX-License-Identifier: MPL-2.0
{href} {href}
{target} {target}
class="w-full px-4 py-2 leading-5 text-black dark:text-white transition-colors duration-200 transform bg-gray-50 dark:bg-gray-700 rounded-sm text-center hover:bg-gray-300 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-gray-600" class="w-full px-4 py-2 leading-5 text-black dark:text-white transition-colors duration-200 transform bg-gray-50 dark:bg-gray-700 rounded-sm text-center hover:bg-gray-300 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-gray-600"
onclick={bubble('click')} {onclick}
class:flex class:flex
class:block={!flex} class:block={!flex}
class:justify-center={flex} class:justify-center={flex}
@@ -42,7 +40,7 @@ SPDX-License-Identifier: MPL-2.0
<button <button
{disabled} {disabled}
class="w-full px-4 py-2 leading-5 text-black dark:text-white transition-colors duration-200 transform bg-gray-50 dark:bg-gray-700 rounded-sm text-center hover:bg-gray-300 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-gray-600" class="w-full px-4 py-2 leading-5 text-black dark:text-white transition-colors duration-200 transform bg-gray-50 dark:bg-gray-700 rounded-sm text-center hover:bg-gray-300 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-gray-600"
onclick={bubble('click')} {onclick}
class:flex class:flex
class:justify-center={flex} class:justify-center={flex}
> >
@@ -240,6 +240,7 @@ This should be okay, right?
class="fixed top-0 left-0 w-screen h-screen flex bg-black/50 z-50" class="fixed top-0 left-0 w-screen h-screen flex bg-black/50 z-50"
onclick={close_on_outside} onclick={close_on_outside}
onkeyup={close_on_outside} onkeyup={close_on_outside}
role="generic"
transition:fade|global={{ duration: 60 }} transition:fade|global={{ duration: 60 }}
> >
<div class="m-auto w-1/3 h-2/3 rounded-sm bg-black flex flex-col"> <div class="m-auto w-1/3 h-2/3 rounded-sm bg-black flex flex-col">
@@ -253,6 +254,7 @@ This should be okay, right?
type="text" type="text"
class="col-start-1 row-start-1 bg-transparent w-full p-4 outline-hidden bg-gray-700 rounded-sm" class="col-start-1 row-start-1 bg-transparent w-full p-4 outline-hidden bg-gray-700 rounded-sm"
bind:value={input} bind:value={input}
autofocus
/> />
</div> </div>
<div class="flex flex-col p-2 gap-2 overflow-scroll"> <div class="flex flex-col p-2 gap-2 overflow-scroll">
@@ -264,6 +266,8 @@ This should be okay, right?
class:bg-gray-700={selected !== i} class:bg-gray-700={selected !== i}
onmouseenter={() => (selected = i)} onmouseenter={() => (selected = i)}
onmousedown={execute_action} onmousedown={execute_action}
tabindex="-2"
role="button"
> >
<div class="flex"> <div class="flex">
<h3 class="text-lg my-auto">{vi.title}</h3> <h3 class="text-lg my-auto">{vi.title}</h3>
+7 -5
View File
@@ -34,7 +34,11 @@ SPDX-License-Identifier: MPL-2.0
edit_id: string; edit_id: string;
} }
let { data = $bindable(), selected_question = $bindable(), edit_id = $bindable() }: Props = $props(); let {
data = $bindable(),
selected_question = $bindable(),
edit_id = $bindable()
}: Props = $props();
let advanced_options_open = $state(false); let advanced_options_open = $state(false);
@@ -155,9 +159,7 @@ SPDX-License-Identifier: MPL-2.0
'questions[].question' 'questions[].question'
).isValidSync(data.questions[selected_question].question)} ).isValidSync(data.questions[selected_question].question)}
> >
<c.default <c.default bind:text={data.questions[selected_question].question} />
bind:text={data.questions[selected_question].question}
/>
</div> </div>
{/await} {/await}
{/key} {/key}
@@ -287,7 +289,7 @@ SPDX-License-Identifier: MPL-2.0
/> />
</label> </label>
<div class="mt-auto w-full"> <div class="mt-auto w-full">
<BrownButton on:click={() => (advanced_options_open = false)} <BrownButton onclick={() => (advanced_options_open = false)}
>{$t('words.close')}</BrownButton >{$t('words.close')}</BrownButton
> >
</div> </div>
+1 -1
View File
@@ -76,7 +76,7 @@ SPDX-License-Identifier: MPL-2.0
<div class="h-screen relative"> <div class="h-screen relative">
<div class="h-10 flex justify-center w-full p-1 absolute z-20"> <div class="h-10 flex justify-center w-full p-1 absolute z-20">
<div> <div>
<BrownButton on:click={() => (reorder_mode = !reorder_mode)} <BrownButton onclick={() => (reorder_mode = !reorder_mode)}
>{#if reorder_mode}{$t('editor.disable_reorder')}{:else}{$t( >{#if reorder_mode}{$t('editor.disable_reorder')}{:else}{$t(
'editor.enable_reorder' 'editor.enable_reorder'
)}{/if}</BrownButton )}{/if}</BrownButton
+5 -5
View File
@@ -150,7 +150,7 @@ SPDX-License-Identifier: MPL-2.0
<div class="flex flex-row gap-4"> <div class="flex flex-row gap-4">
<div class="w-full"> <div class="w-full">
<BrownButton <BrownButton
on:click={() => { onclick={() => {
selected_type = AvailableUploadTypes.Image; selected_type = AvailableUploadTypes.Image;
}} }}
>{$t('words.image')} >{$t('words.image')}
@@ -159,7 +159,7 @@ SPDX-License-Identifier: MPL-2.0
<div class="w-full"> <div class="w-full">
<BrownButton <BrownButton
disabled={!video_upload} disabled={!video_upload}
on:click={() => { onclick={() => {
selected_type = AvailableUploadTypes.Video; selected_type = AvailableUploadTypes.Video;
}} }}
>{$t('words.video')} >{$t('words.video')}
@@ -168,7 +168,7 @@ SPDX-License-Identifier: MPL-2.0
{#if library_enabled} {#if library_enabled}
<div class="w-full"> <div class="w-full">
<BrownButton <BrownButton
on:click={() => { onclick={() => {
selected_type = AvailableUploadTypes.Library; selected_type = AvailableUploadTypes.Library;
}} }}
>{$t('words.library')} >{$t('words.library')}
@@ -177,7 +177,7 @@ SPDX-License-Identifier: MPL-2.0
{/if} {/if}
<div class="w-full"> <div class="w-full">
<BrownButton <BrownButton
on:click={() => { onclick={() => {
selected_type = AvailableUploadTypes.Pixabay; selected_type = AvailableUploadTypes.Pixabay;
}} }}
>Pixabay >Pixabay
@@ -202,7 +202,7 @@ SPDX-License-Identifier: MPL-2.0
{$t('uploader.upload_video_popup_notice')} {$t('uploader.upload_video_popup_notice')}
</p> </p>
{:else} {:else}
<BrownButton on:click={upload_video} type="button" <BrownButton onclick={upload_video} type="button"
>{$t('uploader.upload_video')}</BrownButton >{$t('uploader.upload_video')}</BrownButton
> >
{/if} {/if}
@@ -58,7 +58,7 @@ SPDX-License-Identifier: MPL-2.0
</div> </div>
<p class="text-center">{image.filename ?? 'No name available'}</p> <p class="text-center">{image.filename ?? 'No name available'}</p>
<BrownButton <BrownButton
on:click={() => { onclick={() => {
set_image(image.id); set_image(image.id);
}}>{$t('words.select')}</BrownButton }}>{$t('words.select')}</BrownButton
> >
@@ -93,7 +93,7 @@ SPDX-License-Identifier: MPL-2.0
/> />
</div> </div>
<BrownButton <BrownButton
on:click={() => { onclick={() => {
set_image(image.id); set_image(image.id);
}}>{$t('words.select')}</BrownButton }}>{$t('words.select')}</BrownButton
> >
@@ -102,14 +102,14 @@ SPDX-License-Identifier: MPL-2.0
<div class="flex gap-2"> <div class="flex gap-2">
<BrownButton <BrownButton
disabled={page < 2} disabled={page < 2}
on:click={() => { onclick={() => {
page -= 1; page -= 1;
fetched_data = fetch_data(); fetched_data = fetch_data();
}} }}
>{$t('uploader.previous_page')} >{$t('uploader.previous_page')}
</BrownButton> </BrownButton>
<BrownButton <BrownButton
on:click={() => { onclick={() => {
page += 1; page += 1;
fetched_data = fetch_data(); fetched_data = fetch_data();
}}>{$t('uploader.next_page')}</BrownButton }}>{$t('uploader.next_page')}</BrownButton
+2 -1
View File
@@ -378,7 +378,8 @@
"time_taken": "benötigte Zeit", "time_taken": "benötigte Zeit",
"player_name": "Spielername", "player_name": "Spielername",
"correct_answer_plural": "{{count}} richtige Antworten", "correct_answer_plural": "{{count}} richtige Antworten",
"player_score": "Spieler Score" "player_score": "Spieler Score",
"player_correct_questions": "Richtige Antworten"
}, },
"navbar": { "navbar": {
"donate": "Spenden" "donate": "Spenden"
+2 -1
View File
@@ -366,7 +366,8 @@
"correct_answer": "{{count}} correct answer", "correct_answer": "{{count}} correct answer",
"correct_answer_plural": "{{count}} correct answers", "correct_answer_plural": "{{count}} correct answers",
"time_taken": "Time taken", "time_taken": "Time taken",
"player_score": "Player Score" "player_score": "Player Score",
"player_correct_questions": "Correct Answers"
}, },
"controllers": { "controllers": {
"add_new_controller": "Add new controller", "add_new_controller": "Add new controller",
@@ -18,12 +18,7 @@ SPDX-License-Identifier: MPL-2.0
cqc_code: string; cqc_code: string;
} }
let { let { game_pin, players = $bindable(), socket, cqc_code = $bindable() }: Props = $props();
game_pin,
players = $bindable(),
socket,
cqc_code = $bindable()
}: Props = $props();
let fullscreen_open = $state(false); let fullscreen_open = $state(false);
const { t } = getLocalization(); const { t } = getLocalization();
@@ -97,7 +92,7 @@ SPDX-License-Identifier: MPL-2.0
<div> <div>
<GrayButton <GrayButton
disabled={players.length < 1} disabled={players.length < 1}
on:click={() => { onclick={() => {
socket.emit('start_game', ''); socket.emit('start_game', '');
}} }}
>{$t('admin_page.start_game')} >{$t('admin_page.start_game')}
+10 -3
View File
@@ -25,7 +25,11 @@ SPDX-License-Identifier: MPL-2.0
username: any; username: any;
} }
let { game_pin = $bindable(), game_mode = $bindable(), username = $bindable() }: Props = $props(); let {
game_pin = $bindable(),
game_mode = $bindable(),
username = $bindable()
}: Props = $props();
let custom_field = $state(); let custom_field = $state();
let custom_field_value = $state(); let custom_field_value = $state();
let captcha_enabled = $state(); let captcha_enabled = $state();
@@ -207,7 +211,10 @@ SPDX-License-Identifier: MPL-2.0
{#if game_pin === '' || game_pin.length < 6} {#if game_pin === '' || game_pin.length < 6}
<div class="flex flex-col justify-center align-center w-screen h-screen"> <div class="flex flex-col justify-center align-center w-screen h-screen">
<form onsubmit={preventDefault(bubble('submit'))} class="flex-col flex justify-center align-center mx-auto"> <form
onsubmit={preventDefault(bubble('submit'))}
class="flex-col flex justify-center align-center mx-auto"
>
<h1 class="text-lg text-center">{$t('words.game_pin')}</h1> <h1 class="text-lg text-center">{$t('words.game_pin')}</h1>
<input <input
class="border border-gray-400 self-center text-center text-black ring-0 outline-hidden p-2 rounded-lg focus:shadow-2xl transition-all" class="border border-gray-400 self-center text-center text-black ring-0 outline-hidden p-2 rounded-lg focus:shadow-2xl transition-all"
@@ -244,7 +251,7 @@ SPDX-License-Identifier: MPL-2.0
{/if} {/if}
<div class="mt-2"> <div class="mt-2">
<BrownButton disabled={username.length <= 3} on:click={setUsername} <BrownButton disabled={username.length <= 3} onclick={setUsername}
>{$t('words.submit')}</BrownButton >{$t('words.submit')}</BrownButton
> >
</div> </div>
+10 -11
View File
@@ -241,9 +241,7 @@ SPDX-License-Identifier: MPL-2.0
</div> </div>
<div class="flex justify-center"> <div class="flex justify-center">
<div class="w-1/2"> <div class="w-1/2">
<BrownButton <BrownButton onclick={() => selectAnswer(slider_value[0])}
disabled={selected_answer !== undefined}
on:click={() => selectAnswer(slider_value[0])}
>{$t('words.submit')} >{$t('words.submit')}
</BrownButton> </BrownButton>
</div> </div>
@@ -262,7 +260,7 @@ SPDX-License-Identifier: MPL-2.0
<input <input
type="text" type="text"
bind:value={text_input} bind:value={text_input}
disabled={selected_answer} disabled={selected_answer !== undefined}
class="bg-gray-50 focus:ring text-gray-900 rounded-lg focus:ring-blue-500 block w-full p-2 dark:bg-gray-700 dark:text-white dark:focus:ring-blue-500 outline-hidden transition text-center disabled:opacity-50 disabled:cursor-not-allowed" class="bg-gray-50 focus:ring text-gray-900 rounded-lg focus:ring-blue-500 block w-full p-2 dark:bg-gray-700 dark:text-white dark:focus:ring-blue-500 outline-hidden transition text-center disabled:opacity-50 disabled:cursor-not-allowed"
/> />
</div> </div>
@@ -271,8 +269,8 @@ SPDX-License-Identifier: MPL-2.0
<div class="w-1/3"> <div class="w-1/3">
<BrownButton <BrownButton
type="button" type="button"
disabled={selected_answer} disabled={!text_input || text_input.length === 0}
on:click={() => { onclick={() => {
selectAnswer(text_input); selectAnswer(text_input);
}} }}
> >
@@ -368,7 +366,7 @@ SPDX-License-Identifier: MPL-2.0
<BrownButton <BrownButton
type="button" type="button"
disabled={selected_answer} disabled={selected_answer}
on:click={() => { onclick={() => {
select_complex_answer(question.answers); select_complex_answer(question.answers);
}}>{$t('words.submit')}</BrownButton }}>{$t('words.submit')}</BrownButton
> >
@@ -380,17 +378,18 @@ SPDX-License-Identifier: MPL-2.0
<Spinner /> <Spinner />
{:then c} {:then c}
<c.default <c.default
bind:question {question}
bind:selected_answer bind:selected_answer
bind:game_mode {game_mode}
{timer_res} {timer_res}
{circular_progress} {circular_progress}
/> />
<div class="flex justify-center h-[5%]"> <div class="flex justify-center h-[5%]">
<div class="w-1/2"> <div class="w-1/2">
<BrownButton <BrownButton
disabled={!selected_answer} type="button"
on:click={() => selectAnswer(selected_answer)} disabled={selected_answer === undefined}
onclick={() => selectAnswer(selected_answer)}
>{$t('words.submit')} >{$t('words.submit')}
</BrownButton> </BrownButton>
</div> </div>
+4 -5
View File
@@ -12,7 +12,6 @@ SPDX-License-Identifier: MPL-2.0
// import CircularTimer from '$lib/play/circular_progress.svelte'; // import CircularTimer from '$lib/play/circular_progress.svelte';
const default_colors = ['#D6EDC9', '#B07156', '#7F7057', '#4E6E58']; const default_colors = ['#D6EDC9', '#B07156', '#7F7057', '#4E6E58'];
interface Props { interface Props {
question: Question; question: Question;
selected_answer?: string; selected_answer?: string;
@@ -23,10 +22,10 @@ SPDX-License-Identifier: MPL-2.0
let { let {
question, question,
selected_answer = $bindable(''), selected_answer = $bindable(),
game_mode, game_mode,
timer_res = $bindable(), timer_res,
circular_progress = $bindable() circular_progress
}: Props = $props(); }: Props = $props();
let _selected_answers = $state([false, false, false, false]); let _selected_answers = $state([false, false, false, false]);
@@ -58,7 +57,7 @@ SPDX-License-Identifier: MPL-2.0
<div <div
class="absolute top-0 bottom-0 left-0 right-0 m-auto rounded-full h-fit w-fit border-2 border-black shadow-2xl z-40" class="absolute top-0 bottom-0 left-0 right-0 m-auto rounded-full h-fit w-fit border-2 border-black shadow-2xl z-40"
> >
<CircularTimer bind:text={timer_res} bind:progress={circular_progress} color="#ef4444" /> <CircularTimer text={timer_res} progress={circular_progress} color="#ef4444" />
</div> </div>
<div class="grid grid-rows-2 grid-flow-col auto-cols-auto gap-2 w-full p-4 h-full"> <div class="grid grid-rows-2 grid-flow-col auto-cols-auto gap-2 w-full p-4 h-full">
+5 -2
View File
@@ -45,7 +45,10 @@ SPDX-License-Identifier: MPL-2.0
if (question.type === QuizQuestionType.RANGE) { if (question.type === QuizQuestionType.RANGE) {
slider_value[0] = (question.answers.max - question.answers.min) / 2 + question.answers.min; slider_value[0] = (question.answers.max - question.answers.min) / 2 + question.answers.min;
} }
let slider_values = $state([question.answers.min_correct ?? 0, question.answers.max_correct ?? 0]); let slider_values = $state([
question.answers.min_correct ?? 0,
question.answers.max_correct ?? 0
]);
let text_input = $state(); let text_input = $state();
timer(question.time); timer(question.time);
@@ -347,7 +350,7 @@ SPDX-License-Identifier: MPL-2.0
{/each} {/each}
<BrownButton <BrownButton
type="button" type="button"
on:click={() => { onclick={() => {
timer_res = '0'; timer_res = '0';
}}>{$t('words.submit')}</BrownButton }}>{$t('words.submit')}</BrownButton
> >
@@ -84,7 +84,7 @@ SPDX-License-Identifier: MPL-2.0
{#if data.answers.length < 4} {#if data.answers.length < 4}
<div class="rounded-sm p-6 bg-gray-700"> <div class="rounded-sm p-6 bg-gray-700">
<BrownButton <BrownButton
on:click={() => { onclick={() => {
data.answers = [...data.answers, { ...{ answer: '', correct: false } }]; data.answers = [...data.answers, { ...{ answer: '', correct: false } }];
}}>{$t('editor_page.add_an_answer')}</BrownButton }}>{$t('editor_page.add_an_answer')}</BrownButton
> >
@@ -96,7 +96,7 @@ SPDX-License-Identifier: MPL-2.0
</div> </div>
</div> </div>
<div class="flex justify-center p-2"> <div class="flex justify-center p-2">
<BrownButton on:click={add_card} <BrownButton onclick={add_card}
>{$t('quiztivity.memory.editor.add_pair')}</BrownButton >{$t('quiztivity.memory.editor.add_pair')}</BrownButton
> >
</div> </div>
@@ -115,13 +115,13 @@ SPDX-License-Identifier: MPL-2.0
</div> </div>
<div class="grid grid-cols-2 p-2 gap-2"> <div class="grid grid-cols-2 p-2 gap-2">
<BrownButton <BrownButton
on:click={() => { onclick={() => {
move_card_left(i); move_card_left(i);
}} }}
disabled={i === 0}>{$t('quiztivity.editor.move_left')}</BrownButton disabled={i === 0}>{$t('quiztivity.editor.move_left')}</BrownButton
> >
<BrownButton <BrownButton
on:click={() => { onclick={() => {
move_card_right(i); move_card_right(i);
}} }}
disabled={i + 1 === data.cards.length} disabled={i + 1 === data.cards.length}
+9 -9
View File
@@ -83,7 +83,7 @@ SPDX-License-Identifier: MPL-2.0
{#if data.id} {#if data.id}
<div class="mr-auto w-fit pl-2"> <div class="mr-auto w-fit pl-2">
<BrownButton <BrownButton
on:click={() => { onclick={() => {
shares_menu_open = true; shares_menu_open = true;
}}>{$t('quiztivity.editor.open_shares_menu')}</BrownButton }}>{$t('quiztivity.editor.open_shares_menu')}</BrownButton
> >
@@ -99,7 +99,7 @@ SPDX-License-Identifier: MPL-2.0
<div class="self-end pr-2 w-full"> <div class="self-end pr-2 w-full">
<div class="ml-auto w-fit"> <div class="ml-auto w-fit">
<BrownButton <BrownButton
on:click={() => { onclick={() => {
dispatch('save'); dispatch('save');
}} }}
disabled={!data.title}>{$t('words.save')}</BrownButton disabled={!data.title}>{$t('words.save')}</BrownButton
@@ -109,20 +109,20 @@ SPDX-License-Identifier: MPL-2.0
</div> </div>
<div class="flex flex-row gap-2 w-full p-2"> <div class="flex flex-row gap-2 w-full p-2">
<BrownButton <BrownButton
on:click={() => { onclick={() => {
selected_type = null; selected_type = null;
}}>{$t('quiztivity.editor.add_new')}</BrownButton }}>{$t('quiztivity.editor.add_new')}</BrownButton
> >
<BrownButton on:click={delete_slide} disabled={selected_slide === null} <BrownButton onclick={delete_slide} disabled={selected_slide === null}
>{$t('quiztivity.editor.delete')}</BrownButton >{$t('quiztivity.editor.delete')}</BrownButton
> >
<BrownButton <BrownButton
on:click={move_slide_left} onclick={move_slide_left}
disabled={selected_slide === null || selected_slide === 0} disabled={selected_slide === null || selected_slide === 0}
>{$t('quiztivity.editor.move_left')}</BrownButton >{$t('quiztivity.editor.move_left')}</BrownButton
> >
<BrownButton <BrownButton
on:click={move_slide_right} onclick={move_slide_right}
disabled={selected_slide === null || selected_slide === data.pages.length - 1} disabled={selected_slide === null || selected_slide === data.pages.length - 1}
>{$t('quiztivity.editor.move_right')}</BrownButton >{$t('quiztivity.editor.move_right')}</BrownButton
> >
@@ -140,7 +140,7 @@ SPDX-License-Identifier: MPL-2.0
class:opacity-0={selected_slide !== i} class:opacity-0={selected_slide !== i}
> >
<BrownButton <BrownButton
on:click={() => { onclick={() => {
selected_slide = selected_slide === i ? null : i; selected_slide = selected_slide === i ? null : i;
}} }}
> >
@@ -149,7 +149,7 @@ SPDX-License-Identifier: MPL-2.0
)}{/if} )}{/if}
</BrownButton> </BrownButton>
<BrownButton <BrownButton
on:click={() => { onclick={() => {
opened_slide = i; opened_slide = i;
}}>{$t('words.edit')}</BrownButton }}>{$t('words.edit')}</BrownButton
> >
@@ -164,7 +164,7 @@ SPDX-License-Identifier: MPL-2.0
<div class="h-full"> <div class="h-full">
<div class="mb-2"> <div class="mb-2">
<BrownButton <BrownButton
on:click={() => { onclick={() => {
opened_slide = null; opened_slide = null;
}}>{$t('words.back')}</BrownButton }}>{$t('words.back')}</BrownButton
> >
@@ -117,7 +117,7 @@ SPDX-License-Identifier: MPL-2.0
? !navigator.canShare({ ? !navigator.canShare({
title: 'title', title: 'title',
url: `${window.location.origin}/quiztivity` url: `${window.location.origin}/quiztivity`
}) })
: false; : false;
} catch { } catch {
return false; return false;
@@ -137,7 +137,7 @@ SPDX-License-Identifier: MPL-2.0
> >
<div class="flex justify-center flex-col"> <div class="flex justify-center flex-col">
<BrownButton <BrownButton
on:click={() => { onclick={() => {
add_shares_open = !add_shares_open; add_shares_open = !add_shares_open;
}}>{$t('quiztivity.editor.shares.add_new_share')}</BrownButton }}>{$t('quiztivity.editor.shares.add_new_share')}</BrownButton
> >
@@ -175,7 +175,7 @@ SPDX-License-Identifier: MPL-2.0
<BrownButton <BrownButton
flex={true} flex={true}
disabled={share_available()} disabled={share_available()}
on:click={() => { onclick={() => {
navigator.share({ navigator.share({
title: 'Quiztivity on ClassQuiz', title: 'Quiztivity on ClassQuiz',
text: 'Play this Quiztivity now on ClassQuiz!', text: 'Play this Quiztivity now on ClassQuiz!',
@@ -204,7 +204,7 @@ SPDX-License-Identifier: MPL-2.0
<div class="w-full mx-auto"> <div class="w-full mx-auto">
<BrownButton <BrownButton
flex={true} flex={true}
on:click={() => { onclick={() => {
copyToClipboard( copyToClipboard(
`${window.location.origin}/quiztivity/share/${share.id}?ref=copy` `${window.location.origin}/quiztivity/share/${share.id}?ref=copy`
); );
@@ -242,7 +242,7 @@ SPDX-License-Identifier: MPL-2.0
</p> </p>
<div class="w-fit my-auto ml-auto"> <div class="w-fit my-auto ml-auto">
<BrownButton <BrownButton
on:click={() => { onclick={() => {
delete_share(share.id); delete_share(share.id);
}}>{$t('words.delete')}</BrownButton }}>{$t('words.delete')}</BrownButton
> >
+1 -1
View File
@@ -108,7 +108,7 @@ SPDX-License-Identifier: MPL-2.0
<!-- extra content above slot --> <!-- extra content above slot -->
</div> </div>
{/if} {/if}
<slot /> {@render children?.()}
<CommandPalette /> <CommandPalette />
<!--{#if $alertModal.open ?? false} <!--{#if $alertModal.open ?? false}
+1 -1
View File
@@ -322,7 +322,7 @@ SPDX-License-Identifier: MPL-2.0
</div> </div>
<!--<div <!--<div
class="m-2 rounded-lg p-2 bg-opacity-40 bg-white transition-all cursor-pointer lg:h-full" class="m-2 rounded-lg p-2 bg-opacity-40 bg-white transition-all cursor-pointer lg:h-full"
on:click={() => { onclick={() => {
selected_create_thing = SelectedCreateThing.Import; selected_create_thing = SelectedCreateThing.Import;
}} }}
class:shadow-2xl={selected_create_thing === SelectedCreateThing.Import} class:shadow-2xl={selected_create_thing === SelectedCreateThing.Import}
@@ -131,7 +131,7 @@ SPDX-License-Identifier: MPL-2.0
<div class="mx-auto"> <div class="mx-auto">
<BrownButton <BrownButton
disabled={newest_version === controller.os_version} disabled={newest_version === controller.os_version}
on:click={allow_update_to_version} onclick={allow_update_to_version}
> >
{#if newest_version === controller.os_version} {#if newest_version === controller.os_version}
{$t('controllers.already_latest_version')} {$t('controllers.already_latest_version')}
@@ -88,7 +88,7 @@ SPDX-License-Identifier: MPL-2.0
<svelte:head> <svelte:head>
<title>ClassQuiz - Login</title> <title>ClassQuiz - Login</title>
</svelte:head> </svelte:head>
<div class="flex items-center justify-center h-full px-4"> <div class="flex items-center justify-center h-screen">
{#if verified} {#if verified}
<VerifiedBadge /> <VerifiedBadge />
{/if} {/if}
@@ -214,7 +214,7 @@ SPDX-License-Identifier: MPL-2.0
</form> </form>
<div> <div>
<div class="w-fit"> <div class="w-fit">
<BrownButton on:click={add_api_key} <BrownButton onclick={add_api_key}
>{$t('settings_page.add_api_key')}</BrownButton >{$t('settings_page.add_api_key')}</BrownButton
> >
</div> </div>
@@ -226,7 +226,7 @@ SPDX-License-Identifier: MPL-2.0
{key.key} {key.key}
<div class="inline-block"> <div class="inline-block">
<BrownButton <BrownButton
on:click={() => { onclick={() => {
delete_api_key(key.key); delete_api_key(key.key);
}} }}
>{$t('words.delete')} >{$t('words.delete')}
@@ -95,7 +95,7 @@ SPDX-License-Identifier: MPL-2.0
<div class="flex pl-2"> <div class="flex pl-2">
<div class="mr-auto"> <div class="mr-auto">
<BrownButton <BrownButton
on:click={() => { onclick={() => {
index = index - 1; index = index - 1;
}} }}
disabled={index < 1}>{$t('words.back')}</BrownButton disabled={index < 1}>{$t('words.back')}</BrownButton
@@ -150,12 +150,12 @@ SPDX-License-Identifier: MPL-2.0
/> />
<div class="m-auto grid grid-cols-2 gap-4" in:fade|global={{ delay: 3500 }}> <div class="m-auto grid grid-cols-2 gap-4" in:fade|global={{ delay: 3500 }}>
<BrownButton <BrownButton
on:click={() => { onclick={() => {
index = 0; index = 0;
finished = false; finished = false;
}}>{$t('avatar_settings.start_over')}</BrownButton }}>{$t('avatar_settings.start_over')}</BrownButton
> >
<BrownButton on:click={save_avatar} flex={true} disabled={save_finished === true}> <BrownButton onclick={save_avatar} flex={true} disabled={save_finished === true}>
{#if save_finished === undefined}{$t('words.save')} {#if save_finished === undefined}{$t('words.save')}
{:else if save_finished === true} {:else if save_finished === true}
<svg <svg
@@ -175,7 +175,7 @@ SPDX-License-Identifier: MPL-2.0
</BrownButton> </BrownButton>
<BrownButton href="/account/settings">{$t('avatar_settings.go_back')}</BrownButton> <BrownButton href="/account/settings">{$t('avatar_settings.go_back')}</BrownButton>
<BrownButton <BrownButton
on:click={() => { onclick={() => {
finished = false; finished = false;
}}>{$t('words.close')}</BrownButton }}>{$t('words.close')}</BrownButton
> >
@@ -162,7 +162,7 @@ SPDX-License-Identifier: MPL-2.0
<h2 class="text-center text-2xl">{$t('security_settings.backup_code')}</h2> <h2 class="text-center text-2xl">{$t('security_settings.backup_code')}</h2>
<div class="flex h-full w-full justify-center"> <div class="flex h-full w-full justify-center">
<div class="m-auto"> <div class="m-auto">
<BrownButton on:click={get_backup_code} <BrownButton onclick={get_backup_code}
>{$t('security_settings.get_backup_code')}</BrownButton >{$t('security_settings.get_backup_code')}</BrownButton
> >
</div> </div>
@@ -238,7 +238,7 @@ SPDX-License-Identifier: MPL-2.0
</div> </div>
<div class="flex justify-center"> <div class="flex justify-center">
<div class="m-auto"> <div class="m-auto">
<BrownButton on:click={add_security_key} <BrownButton onclick={add_security_key}
>{$t('security_settings.add_security_key')}</BrownButton >{$t('security_settings.add_security_key')}</BrownButton
> >
</div> </div>
@@ -271,11 +271,11 @@ SPDX-License-Identifier: MPL-2.0
<div class="flex justify-center"> <div class="flex justify-center">
<div class="m-auto"> <div class="m-auto">
{#if totp_activated} {#if totp_activated}
<BrownButton on:click={disable_totp} <BrownButton onclick={disable_totp}
>{$t('security_settings.disable_totp')}</BrownButton >{$t('security_settings.disable_totp')}</BrownButton
> >
{:else} {:else}
<BrownButton on:click={enable_totp} <BrownButton onclick={enable_totp}
>{$t('security_settings.enable_totp')}</BrownButton >{$t('security_settings.enable_totp')}</BrownButton
> >
{/if} {/if}
+2 -2
View File
@@ -165,7 +165,7 @@ SPDX-License-Identifier: MPL-2.0
<div class="w-screen flex justify-center mt-16"> <div class="w-screen flex justify-center mt-16">
<div class="w-fit"> <div class="w-fit">
{#if export_token === undefined} {#if export_token === undefined}
<GrayButton on:click={request_answer_export} <GrayButton onclick={request_answer_export}
>{$t('admin_page.request_export_results')}</GrayButton >{$t('admin_page.request_export_results')}</GrayButton
> >
{:else} {:else}
@@ -179,7 +179,7 @@ SPDX-License-Identifier: MPL-2.0
</div> </div>
<div class="w-screen flex justify-center mt-2"> <div class="w-screen flex justify-center mt-2">
<div class="w-fit"> <div class="w-fit">
<GrayButton on:click={save_quiz} flex={true} disabled={results_saved}> <GrayButton onclick={save_quiz} flex={true} disabled={results_saved}>
{#if results_saved} {#if results_saved}
<svg <svg
class="w-4 h-4" class="w-4 h-4"
+5 -5
View File
@@ -161,7 +161,7 @@ SPDX-License-Identifier: MPL-2.0
</div> </div>
{:else} {:else}
<BrownButton <BrownButton
on:click={() => { onclick={() => {
create_button_clicked = true; create_button_clicked = true;
}}>{$t('words.create')}</BrownButton }}>{$t('words.create')}</BrownButton
> >
@@ -267,7 +267,7 @@ SPDX-License-Identifier: MPL-2.0
</BrownButton> </BrownButton>
<BrownButton <BrownButton
flex={true} flex={true}
on:click={() => (analytics_quiz_selected = quiz)} onclick={() => (analytics_quiz_selected = quiz)}
> >
<!-- heroicons/legacy-outline/ChartBar --> <!-- heroicons/legacy-outline/ChartBar -->
<svg <svg
@@ -311,7 +311,7 @@ SPDX-License-Identifier: MPL-2.0
</BrownButton> </BrownButton>
{#if quiz.type === 'quiz'} {#if quiz.type === 'quiz'}
<BrownButton <BrownButton
on:click={() => { onclick={() => {
start_game = quiz.id; start_game = quiz.id;
}} }}
flex={true} flex={true}
@@ -365,7 +365,7 @@ SPDX-License-Identifier: MPL-2.0
{/if} {/if}
<BrownButton <BrownButton
on:click={() => { onclick={() => {
deleteQuiz(quiz.id, quiz.type); deleteQuiz(quiz.id, quiz.type);
}} }}
flex={true} flex={true}
@@ -387,7 +387,7 @@ SPDX-License-Identifier: MPL-2.0
</svg> </svg>
</BrownButton> </BrownButton>
<BrownButton <BrownButton
on:click={() => (download_id = quiz.id)} onclick={() => (download_id = quiz.id)}
flex={true} flex={true}
disabled={quiz.type !== 'quiz'} disabled={quiz.type !== 'quiz'}
><!-- heroicons/download --> ><!-- heroicons/download -->
@@ -33,7 +33,7 @@ SPDX-License-Identifier: MPL-2.0
{#if quiz} {#if quiz}
<div <div
class="fixed w-full h-full top-0 flex bg-black/50 z-50 overflow-scroll" class="fixed w-full h-full top-0 flex bg-black/50 z-50 overflow-scroll"
on:click={on_parent_click} onclick={on_parent_click}
transition:fade={{ duration: 100 }} transition:fade={{ duration: 100 }}
> >
<div <div
+2 -2
View File
@@ -104,14 +104,14 @@ SPDX-License-Identifier: MPL-2.0
</p> </p>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<BrownButton <BrownButton
on:click={() => { onclick={() => {
edit_popup = image; edit_popup = image;
}} }}
>{$t('file_dashboard.edit_details')} >{$t('file_dashboard.edit_details')}
</BrownButton> </BrownButton>
{#if image.quiztivities.length === 0 && image.quizzes.length === 0} {#if image.quiztivities.length === 0 && image.quizzes.length === 0}
<BrownButton <BrownButton
on:click={() => { onclick={() => {
delete_image(image.id); delete_image(image.id);
}}>{$t('file_dashboard.delete_image')}</BrownButton }}>{$t('file_dashboard.delete_image')}</BrownButton
> >
+3 -3
View File
@@ -13,7 +13,7 @@ SPDX-License-Identifier: MPL-2.0
const { t } = getLocalization(); const { t } = getLocalization();
let file_input: HTMLInputElement = $state(); let file_input: HTMLInputElement = $state();
navbarVisible.visible= false; navbarVisible.visible = false;
let stats: { progress: number; time_elapsed: number; speed: number } = $state({ let stats: { progress: number; time_elapsed: number; speed: number } = $state({
progress: 0, progress: 0,
@@ -197,7 +197,7 @@ SPDX-License-Identifier: MPL-2.0
</div> </div>
<div class="flex relative my-2"> <div class="flex relative my-2">
<div class="w-full"> <div class="w-full">
<BrownButton disabled={status !== Status.CompressDone} on:click={upload_video}> <BrownButton disabled={status !== Status.CompressDone} onclick={upload_video}>
{$t('words.upload')} {$t('words.upload')}
{file_size_in_mi ? `(${file_size_in_mi.toFixed(2)} Mi)` : ''}</BrownButton {file_size_in_mi ? `(${file_size_in_mi.toFixed(2)} Mi)` : ''}</BrownButton
> >
@@ -208,7 +208,7 @@ SPDX-License-Identifier: MPL-2.0
></span> ></span>
</div> </div>
<div class="flex justify-center"> <div class="flex justify-center">
<BrownButton on:click={compress_video} disabled={status !== Status.Idle} <BrownButton onclick={compress_video} disabled={status !== Status.Idle}
>{$t('words.submit')}</BrownButton >{$t('words.submit')}</BrownButton
> >
</div> </div>
@@ -22,7 +22,7 @@ SPDX-License-Identifier: MPL-2.0
<div class="my-auto justify-start ml-4"> <div class="my-auto justify-start ml-4">
<BrownButton <BrownButton
disabled={current_slide_index < 1} disabled={current_slide_index < 1}
on:click={() => { onclick={() => {
current_slide_index -= 1; current_slide_index -= 1;
}}>{$t('words.back')}</BrownButton }}>{$t('words.back')}</BrownButton
> >
@@ -33,7 +33,7 @@ SPDX-License-Identifier: MPL-2.0
<div class="my-auto justify-end mr-4"> <div class="my-auto justify-end mr-4">
<BrownButton <BrownButton
disabled={current_slide_index === 1} disabled={current_slide_index === 1}
on:click={() => { onclick={() => {
current_slide_index += 1; current_slide_index += 1;
}}>{$t('words.next')}</BrownButton }}>{$t('words.next')}</BrownButton
> >
+1 -1
View File
@@ -43,7 +43,7 @@ SPDX-License-Identifier: MPL-2.0
<tr class="text-left"> <tr class="text-left">
<td class="border-r dark:border-gray-500 p-1 border-gray-300" <td class="border-r dark:border-gray-500 p-1 border-gray-300"
><a href="/results/{result.id}" class="underline text-lg" ><a href="/results/{result.id}" class="underline text-lg"
>{result.title}</a >{@html result.title}</a
></td ></td
> >
<td class="border-r dark:border-gray-500 p-1 border-gray-300" <td class="border-r dark:border-gray-500 p-1 border-gray-300"
@@ -95,6 +95,7 @@ SPDX-License-Identifier: MPL-2.0
<PlayerOverview <PlayerOverview
custom_field={data.results.custom_field_data} custom_field={data.results.custom_field_data}
scores={data.results.player_scores} scores={data.results.player_scores}
answers={data.results.answers}
/> />
</div> </div>
{/if} {/if}
@@ -10,30 +10,23 @@ SPDX-License-Identifier: MPL-2.0
const { t } = getLocalization(); const { t } = getLocalization();
interface Props { interface Props {
questions: Question[]; questions: Question[];
answers: { answers: {
username: string; username: string;
answer: string; answer: string;
right: boolean; right: boolean;
tike_taken: number; tike_taken: number;
score: number; score: number;
}[][]; }[][];
scores: { scores: {
[key: string]: string; [key: string]: string;
}; };
title: string; title: string;
timestamp: string; timestamp: string;
} }
let { let { questions, answers, scores, title, timestamp }: Props = $props();
questions,
answers,
scores,
title,
timestamp
}: Props = $props();
const usernames = Object.keys(scores); const usernames = Object.keys(scores);
@@ -49,7 +42,7 @@ SPDX-License-Identifier: MPL-2.0
<div class="w-full"> <div class="w-full">
<div class="flex justify-center w-full"> <div class="flex justify-center w-full">
<p class="text-3xl w-5/6 text-center"> <p class="text-3xl w-5/6 text-center">
{$t('results_page.general_overview.sentence', { {@html $t('results_page.general_overview.sentence', {
title, title,
date: new Date(timestamp).toLocaleString(), date: new Date(timestamp).toLocaleString(),
player_count: usernames.length, player_count: usernames.length,
@@ -10,16 +10,29 @@ SPDX-License-Identifier: MPL-2.0
const { t } = getLocalization(); const { t } = getLocalization();
interface Props { interface Props {
scores: { scores: {
[key: string]: string; [key: string]: string;
}; };
custom_field: { custom_field: {
[key: string]: string; [key: string]: string;
}; };
answers: { [key: string]: any }[];
} }
let { scores, custom_field }: Props = $props(); let { scores, custom_field, answers }: Props = $props();
let usernames = Object.keys(scores); let usernames = Object.keys(scores);
const correctCounts = {};
answers.forEach((questionAnswers) => {
questionAnswers.forEach((answer) => {
const user = answer.username;
if (!correctCounts[user]) {
correctCounts[user] = 0;
}
if (answer.right) {
correctCounts[user] += 1;
}
});
});
console.log(custom_field); console.log(custom_field);
</script> </script>
@@ -31,6 +44,9 @@ SPDX-License-Identifier: MPL-2.0
<th class="border-r dark:border-gray-500 p-1 mx-auto border-gray-300" <th class="border-r dark:border-gray-500 p-1 mx-auto border-gray-300"
>{$t('result_page.player_name')} >{$t('result_page.player_name')}
</th> </th>
<th class="border-r dark:border-gray-500 p-1 mx-auto border-gray-300"
>{$t('result_page.player_correct_questions')}
</th>
<th class="p-1 mx-auto">{$t('result_page.player_score')}</th> <th class="p-1 mx-auto">{$t('result_page.player_score')}</th>
{#if Object.keys(custom_field).length !== 0} {#if Object.keys(custom_field).length !== 0}
<th class="border-l dark:border-gray-500 p-1 mx-auto border-gray-300" <th class="border-l dark:border-gray-500 p-1 mx-auto border-gray-300"
@@ -43,6 +59,9 @@ SPDX-License-Identifier: MPL-2.0
{#each usernames as uname} {#each usernames as uname}
<tr class="text-left"> <tr class="text-left">
<td class="border-r dark:border-gray-500 p-1 border-gray-300">{uname}</td> <td class="border-r dark:border-gray-500 p-1 border-gray-300">{uname}</td>
<td class="border-r dark:border-gray-500 p-1 border-gray-300"
>{correctCounts[uname]}</td
>
<td class="p-1">{scores[uname]}</td> <td class="p-1">{scores[uname]}</td>
{#if custom_field[uname]} {#if custom_field[uname]}
<td class="border-l dark:border-gray-500 p-1 border-gray-300" <td class="border-l dark:border-gray-500 p-1 border-gray-300"
@@ -124,7 +124,7 @@ SPDX-License-Identifier: MPL-2.0
{#if logged_in} {#if logged_in}
<div class="w-full"> <div class="w-full">
<GrayButton <GrayButton
on:click={() => { onclick={() => {
start_game = quiz.id; start_game = quiz.id;
}} }}
flex={true} flex={true}
@@ -188,7 +188,7 @@ SPDX-License-Identifier: MPL-2.0
</div> </div>
<div class="w-full"> <div class="w-full">
{#if logged_in} {#if logged_in}
<GrayButton flex={true} on:click={() => (download_id = quiz.id)}> <GrayButton flex={true} onclick={() => (download_id = quiz.id)}>
<svg <svg
class="w-5 h-5 inline-block" class="w-5 h-5 inline-block"
fill="none" fill="none"
@@ -35,25 +35,25 @@ SPDX-License-Identifier: MPL-2.0
<div class="rounded-sm border-2 border-[#B07156] flex flex-col w-fit gap-2 p-2"> <div class="rounded-sm border-2 border-[#B07156] flex flex-col w-fit gap-2 p-2">
<div class:opacity-50={mod_rating !== null && mod_rating !== undefined} class="transition"> <div class:opacity-50={mod_rating !== null && mod_rating !== undefined} class="transition">
<BrownButton on:click={() => (mod_rating = null)}>Not Checked</BrownButton> <BrownButton onclick={() => (mod_rating = null)}>Not Checked</BrownButton>
</div> </div>
<div class:opacity-50={mod_rating !== 0 && mod_rating !== undefined} class="transition"> <div class:opacity-50={mod_rating !== 0 && mod_rating !== undefined} class="transition">
<BrownButton on:click={() => (mod_rating = 0)}>Ok</BrownButton> <BrownButton onclick={() => (mod_rating = 0)}>Ok</BrownButton>
</div> </div>
<div class:opacity-50={mod_rating !== 1 && mod_rating !== undefined} class="transition"> <div class:opacity-50={mod_rating !== 1 && mod_rating !== undefined} class="transition">
<BrownButton on:click={() => (mod_rating = 1)}>Attention</BrownButton> <BrownButton onclick={() => (mod_rating = 1)}>Attention</BrownButton>
</div> </div>
<div class:opacity-50={mod_rating !== 2 && mod_rating !== undefined} class="transition"> <div class:opacity-50={mod_rating !== 2 && mod_rating !== undefined} class="transition">
<BrownButton on:click={() => (mod_rating = 2)}>NFSW</BrownButton> <BrownButton onclick={() => (mod_rating = 2)}>NFSW</BrownButton>
</div> </div>
<div class:opacity-50={mod_rating !== 3 && mod_rating !== undefined} class="transition"> <div class:opacity-50={mod_rating !== 3 && mod_rating !== undefined} class="transition">
<BrownButton on:click={() => (mod_rating = 3)}>Plausibility Checked</BrownButton> <BrownButton onclick={() => (mod_rating = 3)}>Plausibility Checked</BrownButton>
</div> </div>
<div class:opacity-50={mod_rating !== 4 && mod_rating !== undefined} class="transition"> <div class:opacity-50={mod_rating !== 4 && mod_rating !== undefined} class="transition">
<BrownButton on:click={() => (mod_rating = 4)}>Fact Checked</BrownButton> <BrownButton onclick={() => (mod_rating = 4)}>Fact Checked</BrownButton>
</div> </div>
<div class:opacity-50={mod_rating !== 5 && mod_rating !== undefined} class="transition"> <div class:opacity-50={mod_rating !== 5 && mod_rating !== undefined} class="transition">
<BrownButton on:click={() => (mod_rating = 5)}>Exceptional</BrownButton> <BrownButton onclick={() => (mod_rating = 5)}>Exceptional</BrownButton>
</div> </div>
<GrayButton on:click={submit} disabled={mod_rating === undefined}>Submit</GrayButton> <GrayButton onclick={submit} disabled={mod_rating === undefined}>Submit</GrayButton>
</div> </div>
+14
View File
@@ -134,3 +134,17 @@ data: `{PLAYER_NAME} = {CUSTOM_FIELD_VALUE}`
{cqc_code} is the code used to join with a **C**lass**Q**uiz**C**ontroller {cqc_code} is the code used to join with a **C**lass**Q**uiz**C**ontroller
Only holds the game-pin Only holds the game-pin
## game_in_lobby:{admin_user_id} [string]
Set if game is waiting for players to join for the admin to enable remote control
model: GameInLobby
example:
```json
{
"game_pin": "1234543",
"quiz_title": "Some random title",
"game_id": "uuid of game"
}
```