Merge pull request #455 from mawoka-myblock/refactor
This commit is contained in:
+55
-2
@@ -1,16 +1,26 @@
|
||||
# SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
|
||||
#
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import Optional, Self
|
||||
from classquiz.config import redis
|
||||
import json
|
||||
|
||||
import ormar
|
||||
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 . import metadata, database
|
||||
from .quiztivity import QuizTivityPage
|
||||
@@ -242,11 +252,34 @@ class PlayGame(BaseModel):
|
||||
custom_field: str | None = None
|
||||
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):
|
||||
username: str
|
||||
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):
|
||||
username: str
|
||||
@@ -265,6 +298,18 @@ class GameSession(BaseModel):
|
||||
# players: list[GamePlayer | 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):
|
||||
old_password: str
|
||||
@@ -294,6 +339,14 @@ class AnswerDataList(RootModel):
|
||||
def __len__(self) -> int:
|
||||
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):
|
||||
game_pin: str
|
||||
|
||||
@@ -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)
|
||||
uploaded_images.append(img_obj)
|
||||
cover = img_obj.id.hex
|
||||
if quiz.kahoot.description is None or quiz.kahoot.description == "":
|
||||
quiz.kahoot.description = "Description Missing!"
|
||||
quiz_data = Quiz(
|
||||
id=quiz_id,
|
||||
public=False,
|
||||
|
||||
@@ -51,7 +51,7 @@ class GitHubOauthResponse(BaseModel):
|
||||
events_url: Optional[str] = None
|
||||
received_events_url: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
site_admin: Optional[str] = None
|
||||
site_admin: Optional[bool] = None
|
||||
name: Optional[str] = None
|
||||
company: Optional[str] = None
|
||||
blog: Optional[str] = None
|
||||
@@ -103,7 +103,8 @@ async def auth(request: Request, response: Response):
|
||||
except authlib.integrations.base_client.OAuthError:
|
||||
return RedirectResponse("/account/oauth-error")
|
||||
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:
|
||||
return RedirectResponse("/account/oauth-error?error=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:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
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)
|
||||
|
||||
@@ -4,15 +4,37 @@
|
||||
|
||||
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
from classquiz.config import settings
|
||||
from classquiz.config import settings, redis
|
||||
from functools import lru_cache
|
||||
import json
|
||||
|
||||
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()
|
||||
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:
|
||||
oauth.register(
|
||||
name="google",
|
||||
|
||||
@@ -11,15 +11,27 @@ import asyncpg.exceptions
|
||||
import bleach
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
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.auth import get_current_user
|
||||
import os
|
||||
from datetime import datetime
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
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):
|
||||
if answer.color is not None:
|
||||
quiz_input.questions[i].answers[i2].color = bleach.clean(answer.color, tags=[], strip=True)
|
||||
if answer.answer == "":
|
||||
quiz_input.questions[i].answers[i2].answer = None
|
||||
if answer.answer is not None:
|
||||
quiz_input.questions[i].answers[i2].answer = bleach.clean(
|
||||
answer.answer, tags=ALLOWED_TAGS_FOR_QUIZ, strip=True
|
||||
quiz_input.questions[i].answers[i2].answer = html.unescape(
|
||||
bleach.clean(answer.answer, tags=ALLOWED_TAGS_FOR_QUIZ, strip=True)
|
||||
)
|
||||
|
||||
images_to_delete = []
|
||||
|
||||
@@ -9,7 +9,6 @@ import json
|
||||
import os
|
||||
import random
|
||||
|
||||
import aiohttp
|
||||
import socketio
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
@@ -19,14 +18,28 @@ from classquiz.db.models import (
|
||||
QuizQuestionType,
|
||||
GameSession,
|
||||
GamePlayer,
|
||||
QuizQuestion,
|
||||
VotingQuizAnswer,
|
||||
AnswerDataList,
|
||||
AnswerData,
|
||||
)
|
||||
from pydantic import BaseModel, ValidationError, field_validator, ValidationInfo
|
||||
from pydantic import BaseModel, ValidationError
|
||||
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.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
|
||||
|
||||
|
||||
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
|
||||
async def rejoin_game(sid: str, data: dict):
|
||||
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)
|
||||
return
|
||||
try:
|
||||
data = _RejoinGameData(**data)
|
||||
data = RejoinGameData(**data)
|
||||
except ValidationError as e:
|
||||
await sio.emit("error", room=sid)
|
||||
print(e)
|
||||
@@ -106,10 +106,12 @@ async def rejoin_game(sid: str, data: dict):
|
||||
await sio.emit("time_sync", encrypted_datetime, room=sid)
|
||||
await redis.set(redis_sid_key, sid)
|
||||
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(
|
||||
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)
|
||||
session = {
|
||||
@@ -122,10 +124,7 @@ async def rejoin_game(sid: str, data: dict):
|
||||
await sio.enter_room(sid, data.game_pin)
|
||||
await sio.emit(
|
||||
"rejoined_game",
|
||||
{
|
||||
**json.loads(game_data.model_dump_json(exclude={"quiz_id", "questions", "user_id"})),
|
||||
"question_count": len(game_data.questions),
|
||||
},
|
||||
game_data.to_player_data(),
|
||||
room=sid,
|
||||
)
|
||||
|
||||
@@ -137,7 +136,7 @@ async def join_game(sid: str, data: dict):
|
||||
await sio.emit("game_not_found", room=sid)
|
||||
return
|
||||
try:
|
||||
data = _JoinGameData(**data)
|
||||
data = JoinGameData(**data)
|
||||
except ValidationError as e:
|
||||
await sio.emit("error", room=sid)
|
||||
print(e)
|
||||
@@ -148,42 +147,9 @@ async def join_game(sid: str, data: dict):
|
||||
return
|
||||
# +++ START checking captcha +++
|
||||
if game_data.captcha_enabled:
|
||||
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": data.captcha,
|
||||
"secret": settings.hcaptcha_key,
|
||||
},
|
||||
) as resp:
|
||||
resp_data = await resp.model_dump_json()
|
||||
if not resp_data["success"]:
|
||||
print("CAPTCHA FAILED")
|
||||
captcha_res = check_captcha(data.captcha)
|
||||
if not captcha_res:
|
||||
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 ---
|
||||
if await redis.get(f"game_session:{data.game_pin}:players:{data.username}") is not None:
|
||||
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 sio.emit(
|
||||
"joined_game",
|
||||
{
|
||||
**json.loads(game_data.model_dump_json(exclude={"quiz_id", "questions", "user_id"})),
|
||||
"question_count": len(game_data.questions),
|
||||
},
|
||||
game_data.to_player_data(),
|
||||
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.sadd(
|
||||
f"game_session:{data.game_pin}:players", GamePlayer(username=data.username, sid=sid).model_dump_json()
|
||||
)
|
||||
await GamePlayer(username=data.username, sid=sid).to_player_stack(data.game_pin)
|
||||
|
||||
if data.custom_field == "":
|
||||
data.custom_field = 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)
|
||||
if not session["admin"]:
|
||||
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
|
||||
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 sio.emit("start_game", room=session["game_pin"])
|
||||
|
||||
|
||||
class _RegisterAsAdminData(BaseModel):
|
||||
game_pin: str
|
||||
game_id: str
|
||||
|
||||
|
||||
@sio.event
|
||||
async def register_as_admin(sid: str, data: dict):
|
||||
try:
|
||||
data = _RegisterAsAdminData(**data)
|
||||
data = RegisterAsAdminData(**data)
|
||||
except ValidationError as e:
|
||||
await sio.emit("error", room=sid)
|
||||
print(e)
|
||||
return
|
||||
game_pin = data.game_pin
|
||||
game_id = data.game_id
|
||||
if (await redis.get(f"game_session:{game_pin}")) is None:
|
||||
await redis.set(
|
||||
f"game_session:{game_pin}",
|
||||
GameSession(admin=sid, game_id=game_id, answers=[]).model_dump_json(),
|
||||
ex=7200,
|
||||
)
|
||||
|
||||
if await redis.get(f"game_session:{game_pin}") is not None:
|
||||
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 = {}
|
||||
session["game_pin"] = game_pin
|
||||
session["admin"] = True
|
||||
session["remote"] = False
|
||||
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}")
|
||||
else:
|
||||
await sio.emit("already_registered_as_admin", room=sid)
|
||||
|
||||
|
||||
@sio.event
|
||||
@@ -286,57 +233,25 @@ async def get_question_results(sid: str, data: dict):
|
||||
session = await get_session(sid, sio)
|
||||
if not session["admin"]:
|
||||
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"]
|
||||
|
||||
await sio.emit("question_results", redis_res, room=game_pin)
|
||||
|
||||
|
||||
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
|
||||
answer_data_list = await AnswerDataList.get_redis_or_empty(game_pin, data["question_number"])
|
||||
game_data = await PlayGame.get_from_redis(game_pin)
|
||||
game_data.question_show = False
|
||||
await game_data.save(game_pin)
|
||||
await sio.emit("question_results", answer_data_list.model_dump(), room=game_pin)
|
||||
|
||||
|
||||
@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
|
||||
session = await get_session(sid, sio)
|
||||
if not session["admin"]:
|
||||
return
|
||||
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.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)
|
||||
temp_return = game_data.model_dump(include={"questions"})["questions"][int(float(data))]
|
||||
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
|
||||
async def submit_answer(sid: str, data: dict):
|
||||
now = datetime.now()
|
||||
try:
|
||||
data = _SubmitAnswerData(**data)
|
||||
data = SubmitAnswerData(**data)
|
||||
except ValidationError as e:
|
||||
await sio.emit("error", room=sid)
|
||||
print(e)
|
||||
return
|
||||
data.answer = str(data.answer)
|
||||
session = await get_session(sid, sio)
|
||||
game_data = PlayGame.model_validate_json(await redis.get(f"game:{session['game_pin']}"))
|
||||
answer_right = False
|
||||
if game_data.questions[int(float(data.question_index))].type == QuizQuestionType.ABCD:
|
||||
for answer in game_data.questions[int(float(data.question_index))].answers:
|
||||
if answer.answer == data.answer and answer.right:
|
||||
answer_right = True
|
||||
break
|
||||
elif game_data.questions[int(float(data.question_index))].type == QuizQuestionType.RANGE:
|
||||
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"]))
|
||||
question_index = int(float(data.question_index))
|
||||
game_data = await PlayGame.get_from_redis(session["game_pin"])
|
||||
already_answered = await has_already_answered(session["game_pin"], question_index, session["username"])
|
||||
if already_answered:
|
||||
await sio.emit("already_replied", room=sid)
|
||||
return
|
||||
answer_right, answer = check_answer(game_data, data)
|
||||
latency = int(float(session["ping"]))
|
||||
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)
|
||||
score = 0
|
||||
if answer_right:
|
||||
score = calculate_score(
|
||||
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:
|
||||
score = 1000
|
||||
await redis.hincrby(f"game_session:{session['game_pin']}:player_scores", session["username"], score)
|
||||
answer_data = AnswerData(
|
||||
username=session["username"],
|
||||
answer=data.answer,
|
||||
answer=answer,
|
||||
right=answer_right,
|
||||
time_taken=abs(diff) - latency,
|
||||
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")
|
||||
await sio.emit("player_answer", {})
|
||||
if len(answers) == player_count:
|
||||
# await sio.emit(
|
||||
# "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 = await PlayGame.get_from_redis(session["game_pin"])
|
||||
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 redis.set(f"game_data:{session['game_pin']}", json.dumps(data))
|
||||
|
||||
|
||||
@sio.event
|
||||
async def get_final_results(sid: str, _data: dict):
|
||||
session: dict = await get_session(sid, sio)
|
||||
game_data = PlayGame(**json.loads(await redis.get(f"game:{session['game_pin']}")))
|
||||
if not session["admin"]:
|
||||
return
|
||||
game_data = await PlayGame.get_from_redis(session["game_pin"])
|
||||
results = await generate_final_results(game_data, 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)
|
||||
if not session["admin"]:
|
||||
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"])
|
||||
token = os.urandom(32).hex()
|
||||
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
|
||||
async def show_solutions(sid: str, _data: dict):
|
||||
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"]:
|
||||
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
|
||||
@@ -521,14 +378,10 @@ async def echo_time_sync(sid: str, data: str):
|
||||
await save_session(sid, sio, session)
|
||||
|
||||
|
||||
class _KickPlayerInput(BaseModel):
|
||||
username: str
|
||||
|
||||
|
||||
@sio.event
|
||||
async def kick_player(sid: str, data: dict):
|
||||
try:
|
||||
data = _KickPlayerInput(**data)
|
||||
data = KickPlayerInput(**data)
|
||||
except ValidationError as e:
|
||||
await sio.emit("error", room=sid)
|
||||
print(e)
|
||||
@@ -604,10 +457,6 @@ async def save_quiz(sid: str):
|
||||
await sio.emit("results_saved_successfully")
|
||||
|
||||
|
||||
class ConnectSessionIdEvent(BaseModel):
|
||||
session_id: str
|
||||
|
||||
|
||||
@sio.event
|
||||
async def connect(sid: str, _environ, _auth):
|
||||
session_id = os.urandom(16).hex()
|
||||
|
||||
@@ -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
|
||||
@@ -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">
|
||||
import { createBubbler } from 'svelte/legacy';
|
||||
|
||||
const bubble = createBubbler();
|
||||
|
||||
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
flex?: boolean;
|
||||
href?: undefined | string;
|
||||
target?: undefined | string;
|
||||
type?: undefined | string;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
children?: import('svelte').Snippet;
|
||||
onclick?: (event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -26,7 +21,8 @@ SPDX-License-Identifier: MPL-2.0
|
||||
href = undefined,
|
||||
target = '_self',
|
||||
type = 'button',
|
||||
children
|
||||
children,
|
||||
onclick
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -39,7 +35,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
class:cursor-not-allowed={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"
|
||||
onclick={bubble('click')}
|
||||
{onclick}
|
||||
class:flex
|
||||
class:justify-center={flex}
|
||||
>
|
||||
@@ -50,7 +46,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
{disabled}
|
||||
{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"
|
||||
onclick={bubble('click')}
|
||||
{onclick}
|
||||
class:flex
|
||||
class:justify-center={flex}
|
||||
>
|
||||
|
||||
@@ -5,16 +5,13 @@ SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { createBubbler } from 'svelte/legacy';
|
||||
|
||||
const bubble = createBubbler();
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
flex?: boolean;
|
||||
href?: undefined | string;
|
||||
target?: undefined | string;
|
||||
children?: import('svelte').Snippet;
|
||||
onclick?: (event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -22,7 +19,8 @@ SPDX-License-Identifier: MPL-2.0
|
||||
flex = false,
|
||||
href = undefined,
|
||||
target = '_self',
|
||||
children
|
||||
children,
|
||||
onclick
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -31,7 +29,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
{href}
|
||||
{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"
|
||||
onclick={bubble('click')}
|
||||
{onclick}
|
||||
class:flex
|
||||
class:block={!flex}
|
||||
class:justify-center={flex}
|
||||
@@ -42,7 +40,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<button
|
||||
{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"
|
||||
onclick={bubble('click')}
|
||||
{onclick}
|
||||
class: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"
|
||||
onclick={close_on_outside}
|
||||
onkeyup={close_on_outside}
|
||||
role="generic"
|
||||
transition:fade|global={{ duration: 60 }}
|
||||
>
|
||||
<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"
|
||||
class="col-start-1 row-start-1 bg-transparent w-full p-4 outline-hidden bg-gray-700 rounded-sm"
|
||||
bind:value={input}
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<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}
|
||||
onmouseenter={() => (selected = i)}
|
||||
onmousedown={execute_action}
|
||||
tabindex="-2"
|
||||
role="button"
|
||||
>
|
||||
<div class="flex">
|
||||
<h3 class="text-lg my-auto">{vi.title}</h3>
|
||||
|
||||
@@ -34,7 +34,11 @@ SPDX-License-Identifier: MPL-2.0
|
||||
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);
|
||||
|
||||
@@ -155,9 +159,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
'questions[].question'
|
||||
).isValidSync(data.questions[selected_question].question)}
|
||||
>
|
||||
<c.default
|
||||
bind:text={data.questions[selected_question].question}
|
||||
/>
|
||||
<c.default bind:text={data.questions[selected_question].question} />
|
||||
</div>
|
||||
{/await}
|
||||
{/key}
|
||||
@@ -287,7 +289,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
/>
|
||||
</label>
|
||||
<div class="mt-auto w-full">
|
||||
<BrownButton on:click={() => (advanced_options_open = false)}
|
||||
<BrownButton onclick={() => (advanced_options_open = false)}
|
||||
>{$t('words.close')}</BrownButton
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -76,7 +76,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div class="h-screen relative">
|
||||
<div class="h-10 flex justify-center w-full p-1 absolute z-20">
|
||||
<div>
|
||||
<BrownButton on:click={() => (reorder_mode = !reorder_mode)}
|
||||
<BrownButton onclick={() => (reorder_mode = !reorder_mode)}
|
||||
>{#if reorder_mode}{$t('editor.disable_reorder')}{:else}{$t(
|
||||
'editor.enable_reorder'
|
||||
)}{/if}</BrownButton
|
||||
|
||||
@@ -150,7 +150,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div class="flex flex-row gap-4">
|
||||
<div class="w-full">
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
selected_type = AvailableUploadTypes.Image;
|
||||
}}
|
||||
>{$t('words.image')}
|
||||
@@ -159,7 +159,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div class="w-full">
|
||||
<BrownButton
|
||||
disabled={!video_upload}
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
selected_type = AvailableUploadTypes.Video;
|
||||
}}
|
||||
>{$t('words.video')}
|
||||
@@ -168,7 +168,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
{#if library_enabled}
|
||||
<div class="w-full">
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
selected_type = AvailableUploadTypes.Library;
|
||||
}}
|
||||
>{$t('words.library')}
|
||||
@@ -177,7 +177,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
{/if}
|
||||
<div class="w-full">
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
selected_type = AvailableUploadTypes.Pixabay;
|
||||
}}
|
||||
>Pixabay
|
||||
@@ -202,7 +202,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
{$t('uploader.upload_video_popup_notice')}
|
||||
</p>
|
||||
{:else}
|
||||
<BrownButton on:click={upload_video} type="button"
|
||||
<BrownButton onclick={upload_video} type="button"
|
||||
>{$t('uploader.upload_video')}</BrownButton
|
||||
>
|
||||
{/if}
|
||||
|
||||
@@ -58,7 +58,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</div>
|
||||
<p class="text-center">{image.filename ?? 'No name available'}</p>
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
set_image(image.id);
|
||||
}}>{$t('words.select')}</BrownButton
|
||||
>
|
||||
|
||||
@@ -93,7 +93,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
/>
|
||||
</div>
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
set_image(image.id);
|
||||
}}>{$t('words.select')}</BrownButton
|
||||
>
|
||||
@@ -102,14 +102,14 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div class="flex gap-2">
|
||||
<BrownButton
|
||||
disabled={page < 2}
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
page -= 1;
|
||||
fetched_data = fetch_data();
|
||||
}}
|
||||
>{$t('uploader.previous_page')}
|
||||
</BrownButton>
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
page += 1;
|
||||
fetched_data = fetch_data();
|
||||
}}>{$t('uploader.next_page')}</BrownButton
|
||||
|
||||
@@ -378,7 +378,8 @@
|
||||
"time_taken": "benötigte Zeit",
|
||||
"player_name": "Spielername",
|
||||
"correct_answer_plural": "{{count}} richtige Antworten",
|
||||
"player_score": "Spieler Score"
|
||||
"player_score": "Spieler Score",
|
||||
"player_correct_questions": "Richtige Antworten"
|
||||
},
|
||||
"navbar": {
|
||||
"donate": "Spenden"
|
||||
|
||||
@@ -366,7 +366,8 @@
|
||||
"correct_answer": "{{count}} correct answer",
|
||||
"correct_answer_plural": "{{count}} correct answers",
|
||||
"time_taken": "Time taken",
|
||||
"player_score": "Player Score"
|
||||
"player_score": "Player Score",
|
||||
"player_correct_questions": "Correct Answers"
|
||||
},
|
||||
"controllers": {
|
||||
"add_new_controller": "Add new controller",
|
||||
|
||||
@@ -18,12 +18,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
cqc_code: string;
|
||||
}
|
||||
|
||||
let {
|
||||
game_pin,
|
||||
players = $bindable(),
|
||||
socket,
|
||||
cqc_code = $bindable()
|
||||
}: Props = $props();
|
||||
let { game_pin, players = $bindable(), socket, cqc_code = $bindable() }: Props = $props();
|
||||
|
||||
let fullscreen_open = $state(false);
|
||||
const { t } = getLocalization();
|
||||
@@ -97,7 +92,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div>
|
||||
<GrayButton
|
||||
disabled={players.length < 1}
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
socket.emit('start_game', '');
|
||||
}}
|
||||
>{$t('admin_page.start_game')}
|
||||
|
||||
@@ -25,7 +25,11 @@ SPDX-License-Identifier: MPL-2.0
|
||||
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_value = $state();
|
||||
let captcha_enabled = $state();
|
||||
@@ -207,7 +211,10 @@ SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
{#if game_pin === '' || game_pin.length < 6}
|
||||
<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>
|
||||
<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"
|
||||
@@ -244,7 +251,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
{/if}
|
||||
|
||||
<div class="mt-2">
|
||||
<BrownButton disabled={username.length <= 3} on:click={setUsername}
|
||||
<BrownButton disabled={username.length <= 3} onclick={setUsername}
|
||||
>{$t('words.submit')}</BrownButton
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -241,9 +241,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<div class="w-1/2">
|
||||
<BrownButton
|
||||
disabled={selected_answer !== undefined}
|
||||
on:click={() => selectAnswer(slider_value[0])}
|
||||
<BrownButton onclick={() => selectAnswer(slider_value[0])}
|
||||
>{$t('words.submit')}
|
||||
</BrownButton>
|
||||
</div>
|
||||
@@ -262,7 +260,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -271,8 +269,8 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div class="w-1/3">
|
||||
<BrownButton
|
||||
type="button"
|
||||
disabled={selected_answer}
|
||||
on:click={() => {
|
||||
disabled={!text_input || text_input.length === 0}
|
||||
onclick={() => {
|
||||
selectAnswer(text_input);
|
||||
}}
|
||||
>
|
||||
@@ -368,7 +366,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<BrownButton
|
||||
type="button"
|
||||
disabled={selected_answer}
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
select_complex_answer(question.answers);
|
||||
}}>{$t('words.submit')}</BrownButton
|
||||
>
|
||||
@@ -380,17 +378,18 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<Spinner />
|
||||
{:then c}
|
||||
<c.default
|
||||
bind:question
|
||||
{question}
|
||||
bind:selected_answer
|
||||
bind:game_mode
|
||||
{game_mode}
|
||||
{timer_res}
|
||||
{circular_progress}
|
||||
/>
|
||||
<div class="flex justify-center h-[5%]">
|
||||
<div class="w-1/2">
|
||||
<BrownButton
|
||||
disabled={!selected_answer}
|
||||
on:click={() => selectAnswer(selected_answer)}
|
||||
type="button"
|
||||
disabled={selected_answer === undefined}
|
||||
onclick={() => selectAnswer(selected_answer)}
|
||||
>{$t('words.submit')}
|
||||
</BrownButton>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
// import CircularTimer from '$lib/play/circular_progress.svelte';
|
||||
const default_colors = ['#D6EDC9', '#B07156', '#7F7057', '#4E6E58'];
|
||||
|
||||
|
||||
interface Props {
|
||||
question: Question;
|
||||
selected_answer?: string;
|
||||
@@ -23,10 +22,10 @@ SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
let {
|
||||
question,
|
||||
selected_answer = $bindable(''),
|
||||
selected_answer = $bindable(),
|
||||
game_mode,
|
||||
timer_res = $bindable(),
|
||||
circular_progress = $bindable()
|
||||
timer_res,
|
||||
circular_progress
|
||||
}: Props = $props();
|
||||
let _selected_answers = $state([false, false, false, false]);
|
||||
|
||||
@@ -58,7 +57,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<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"
|
||||
>
|
||||
<CircularTimer bind:text={timer_res} bind:progress={circular_progress} color="#ef4444" />
|
||||
<CircularTimer text={timer_res} progress={circular_progress} color="#ef4444" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-rows-2 grid-flow-col auto-cols-auto gap-2 w-full p-4 h-full">
|
||||
|
||||
@@ -45,7 +45,10 @@ SPDX-License-Identifier: MPL-2.0
|
||||
if (question.type === QuizQuestionType.RANGE) {
|
||||
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();
|
||||
timer(question.time);
|
||||
@@ -347,7 +350,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
{/each}
|
||||
<BrownButton
|
||||
type="button"
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
timer_res = '0';
|
||||
}}>{$t('words.submit')}</BrownButton
|
||||
>
|
||||
|
||||
@@ -84,7 +84,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
{#if data.answers.length < 4}
|
||||
<div class="rounded-sm p-6 bg-gray-700">
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
data.answers = [...data.answers, { ...{ answer: '', correct: false } }];
|
||||
}}>{$t('editor_page.add_an_answer')}</BrownButton
|
||||
>
|
||||
|
||||
@@ -96,7 +96,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center p-2">
|
||||
<BrownButton on:click={add_card}
|
||||
<BrownButton onclick={add_card}
|
||||
>{$t('quiztivity.memory.editor.add_pair')}</BrownButton
|
||||
>
|
||||
</div>
|
||||
@@ -115,13 +115,13 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</div>
|
||||
<div class="grid grid-cols-2 p-2 gap-2">
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
move_card_left(i);
|
||||
}}
|
||||
disabled={i === 0}>{$t('quiztivity.editor.move_left')}</BrownButton
|
||||
>
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
move_card_right(i);
|
||||
}}
|
||||
disabled={i + 1 === data.cards.length}
|
||||
|
||||
@@ -83,7 +83,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
{#if data.id}
|
||||
<div class="mr-auto w-fit pl-2">
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
shares_menu_open = true;
|
||||
}}>{$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="ml-auto w-fit">
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
dispatch('save');
|
||||
}}
|
||||
disabled={!data.title}>{$t('words.save')}</BrownButton
|
||||
@@ -109,20 +109,20 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</div>
|
||||
<div class="flex flex-row gap-2 w-full p-2">
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
selected_type = null;
|
||||
}}>{$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
|
||||
>
|
||||
<BrownButton
|
||||
on:click={move_slide_left}
|
||||
onclick={move_slide_left}
|
||||
disabled={selected_slide === null || selected_slide === 0}
|
||||
>{$t('quiztivity.editor.move_left')}</BrownButton
|
||||
>
|
||||
<BrownButton
|
||||
on:click={move_slide_right}
|
||||
onclick={move_slide_right}
|
||||
disabled={selected_slide === null || selected_slide === data.pages.length - 1}
|
||||
>{$t('quiztivity.editor.move_right')}</BrownButton
|
||||
>
|
||||
@@ -140,7 +140,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
class:opacity-0={selected_slide !== i}
|
||||
>
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
selected_slide = selected_slide === i ? null : i;
|
||||
}}
|
||||
>
|
||||
@@ -149,7 +149,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
)}{/if}
|
||||
</BrownButton>
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
opened_slide = i;
|
||||
}}>{$t('words.edit')}</BrownButton
|
||||
>
|
||||
@@ -164,7 +164,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div class="h-full">
|
||||
<div class="mb-2">
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
opened_slide = null;
|
||||
}}>{$t('words.back')}</BrownButton
|
||||
>
|
||||
|
||||
@@ -137,7 +137,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
>
|
||||
<div class="flex justify-center flex-col">
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
add_shares_open = !add_shares_open;
|
||||
}}>{$t('quiztivity.editor.shares.add_new_share')}</BrownButton
|
||||
>
|
||||
@@ -175,7 +175,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<BrownButton
|
||||
flex={true}
|
||||
disabled={share_available()}
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
navigator.share({
|
||||
title: 'Quiztivity 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">
|
||||
<BrownButton
|
||||
flex={true}
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
copyToClipboard(
|
||||
`${window.location.origin}/quiztivity/share/${share.id}?ref=copy`
|
||||
);
|
||||
@@ -242,7 +242,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</p>
|
||||
<div class="w-fit my-auto ml-auto">
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
delete_share(share.id);
|
||||
}}>{$t('words.delete')}</BrownButton
|
||||
>
|
||||
|
||||
@@ -108,7 +108,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<!-- extra content above slot -->
|
||||
</div>
|
||||
{/if}
|
||||
<slot />
|
||||
{@render children?.()}
|
||||
<CommandPalette />
|
||||
|
||||
<!--{#if $alertModal.open ?? false}
|
||||
|
||||
@@ -322,7 +322,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</div>
|
||||
<!--<div
|
||||
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;
|
||||
}}
|
||||
class:shadow-2xl={selected_create_thing === SelectedCreateThing.Import}
|
||||
|
||||
@@ -131,7 +131,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div class="mx-auto">
|
||||
<BrownButton
|
||||
disabled={newest_version === controller.os_version}
|
||||
on:click={allow_update_to_version}
|
||||
onclick={allow_update_to_version}
|
||||
>
|
||||
{#if newest_version === controller.os_version}
|
||||
{$t('controllers.already_latest_version')}
|
||||
|
||||
@@ -88,7 +88,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<svelte:head>
|
||||
<title>ClassQuiz - Login</title>
|
||||
</svelte:head>
|
||||
<div class="flex items-center justify-center h-full px-4">
|
||||
<div class="flex items-center justify-center h-screen">
|
||||
{#if verified}
|
||||
<VerifiedBadge />
|
||||
{/if}
|
||||
|
||||
@@ -214,7 +214,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</form>
|
||||
<div>
|
||||
<div class="w-fit">
|
||||
<BrownButton on:click={add_api_key}
|
||||
<BrownButton onclick={add_api_key}
|
||||
>{$t('settings_page.add_api_key')}</BrownButton
|
||||
>
|
||||
</div>
|
||||
@@ -226,7 +226,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
{key.key}
|
||||
<div class="inline-block">
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
delete_api_key(key.key);
|
||||
}}
|
||||
>{$t('words.delete')}
|
||||
|
||||
@@ -95,7 +95,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div class="flex pl-2">
|
||||
<div class="mr-auto">
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
index = index - 1;
|
||||
}}
|
||||
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 }}>
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
index = 0;
|
||||
finished = false;
|
||||
}}>{$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')}
|
||||
{:else if save_finished === true}
|
||||
<svg
|
||||
@@ -175,7 +175,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</BrownButton>
|
||||
<BrownButton href="/account/settings">{$t('avatar_settings.go_back')}</BrownButton>
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
finished = false;
|
||||
}}>{$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>
|
||||
<div class="flex h-full w-full justify-center">
|
||||
<div class="m-auto">
|
||||
<BrownButton on:click={get_backup_code}
|
||||
<BrownButton onclick={get_backup_code}
|
||||
>{$t('security_settings.get_backup_code')}</BrownButton
|
||||
>
|
||||
</div>
|
||||
@@ -238,7 +238,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<div class="m-auto">
|
||||
<BrownButton on:click={add_security_key}
|
||||
<BrownButton onclick={add_security_key}
|
||||
>{$t('security_settings.add_security_key')}</BrownButton
|
||||
>
|
||||
</div>
|
||||
@@ -271,11 +271,11 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div class="flex justify-center">
|
||||
<div class="m-auto">
|
||||
{#if totp_activated}
|
||||
<BrownButton on:click={disable_totp}
|
||||
<BrownButton onclick={disable_totp}
|
||||
>{$t('security_settings.disable_totp')}</BrownButton
|
||||
>
|
||||
{:else}
|
||||
<BrownButton on:click={enable_totp}
|
||||
<BrownButton onclick={enable_totp}
|
||||
>{$t('security_settings.enable_totp')}</BrownButton
|
||||
>
|
||||
{/if}
|
||||
|
||||
@@ -165,7 +165,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div class="w-screen flex justify-center mt-16">
|
||||
<div class="w-fit">
|
||||
{#if export_token === undefined}
|
||||
<GrayButton on:click={request_answer_export}
|
||||
<GrayButton onclick={request_answer_export}
|
||||
>{$t('admin_page.request_export_results')}</GrayButton
|
||||
>
|
||||
{:else}
|
||||
@@ -179,7 +179,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</div>
|
||||
<div class="w-screen flex justify-center mt-2">
|
||||
<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}
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
|
||||
@@ -161,7 +161,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</div>
|
||||
{:else}
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
create_button_clicked = true;
|
||||
}}>{$t('words.create')}</BrownButton
|
||||
>
|
||||
@@ -267,7 +267,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</BrownButton>
|
||||
<BrownButton
|
||||
flex={true}
|
||||
on:click={() => (analytics_quiz_selected = quiz)}
|
||||
onclick={() => (analytics_quiz_selected = quiz)}
|
||||
>
|
||||
<!-- heroicons/legacy-outline/ChartBar -->
|
||||
<svg
|
||||
@@ -311,7 +311,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</BrownButton>
|
||||
{#if quiz.type === 'quiz'}
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
start_game = quiz.id;
|
||||
}}
|
||||
flex={true}
|
||||
@@ -365,7 +365,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
{/if}
|
||||
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
deleteQuiz(quiz.id, quiz.type);
|
||||
}}
|
||||
flex={true}
|
||||
@@ -387,7 +387,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</svg>
|
||||
</BrownButton>
|
||||
<BrownButton
|
||||
on:click={() => (download_id = quiz.id)}
|
||||
onclick={() => (download_id = quiz.id)}
|
||||
flex={true}
|
||||
disabled={quiz.type !== 'quiz'}
|
||||
><!-- heroicons/download -->
|
||||
|
||||
@@ -33,7 +33,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
{#if quiz}
|
||||
<div
|
||||
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 }}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -104,14 +104,14 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
edit_popup = image;
|
||||
}}
|
||||
>{$t('file_dashboard.edit_details')}
|
||||
</BrownButton>
|
||||
{#if image.quiztivities.length === 0 && image.quizzes.length === 0}
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
delete_image(image.id);
|
||||
}}>{$t('file_dashboard.delete_image')}</BrownButton
|
||||
>
|
||||
|
||||
@@ -13,7 +13,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
const { t } = getLocalization();
|
||||
|
||||
let file_input: HTMLInputElement = $state();
|
||||
navbarVisible.visible= false;
|
||||
navbarVisible.visible = false;
|
||||
|
||||
let stats: { progress: number; time_elapsed: number; speed: number } = $state({
|
||||
progress: 0,
|
||||
@@ -197,7 +197,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</div>
|
||||
<div class="flex relative my-2">
|
||||
<div class="w-full">
|
||||
<BrownButton disabled={status !== Status.CompressDone} on:click={upload_video}>
|
||||
<BrownButton disabled={status !== Status.CompressDone} onclick={upload_video}>
|
||||
{$t('words.upload')}
|
||||
{file_size_in_mi ? `(${file_size_in_mi.toFixed(2)} Mi)` : ''}</BrownButton
|
||||
>
|
||||
@@ -208,7 +208,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
></span>
|
||||
</div>
|
||||
<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
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div class="my-auto justify-start ml-4">
|
||||
<BrownButton
|
||||
disabled={current_slide_index < 1}
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
current_slide_index -= 1;
|
||||
}}>{$t('words.back')}</BrownButton
|
||||
>
|
||||
@@ -33,7 +33,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div class="my-auto justify-end mr-4">
|
||||
<BrownButton
|
||||
disabled={current_slide_index === 1}
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
current_slide_index += 1;
|
||||
}}>{$t('words.next')}</BrownButton
|
||||
>
|
||||
|
||||
@@ -43,7 +43,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<tr class="text-left">
|
||||
<td class="border-r dark:border-gray-500 p-1 border-gray-300"
|
||||
><a href="/results/{result.id}" class="underline text-lg"
|
||||
>{result.title}</a
|
||||
>{@html result.title}</a
|
||||
></td
|
||||
>
|
||||
<td class="border-r dark:border-gray-500 p-1 border-gray-300"
|
||||
|
||||
@@ -95,6 +95,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<PlayerOverview
|
||||
custom_field={data.results.custom_field_data}
|
||||
scores={data.results.player_scores}
|
||||
answers={data.results.answers}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -10,7 +10,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
const { t } = getLocalization();
|
||||
|
||||
|
||||
interface Props {
|
||||
questions: Question[];
|
||||
answers: {
|
||||
@@ -27,13 +26,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
let {
|
||||
questions,
|
||||
answers,
|
||||
scores,
|
||||
title,
|
||||
timestamp
|
||||
}: Props = $props();
|
||||
let { questions, answers, scores, title, timestamp }: Props = $props();
|
||||
|
||||
const usernames = Object.keys(scores);
|
||||
|
||||
@@ -49,7 +42,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div class="w-full">
|
||||
<div class="flex justify-center w-full">
|
||||
<p class="text-3xl w-5/6 text-center">
|
||||
{$t('results_page.general_overview.sentence', {
|
||||
{@html $t('results_page.general_overview.sentence', {
|
||||
title,
|
||||
date: new Date(timestamp).toLocaleString(),
|
||||
player_count: usernames.length,
|
||||
|
||||
@@ -15,11 +15,24 @@ SPDX-License-Identifier: MPL-2.0
|
||||
custom_field: {
|
||||
[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);
|
||||
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);
|
||||
</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"
|
||||
>{$t('result_page.player_name')}
|
||||
</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>
|
||||
{#if Object.keys(custom_field).length !== 0}
|
||||
<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}
|
||||
<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"
|
||||
>{correctCounts[uname]}</td
|
||||
>
|
||||
<td class="p-1">{scores[uname]}</td>
|
||||
{#if custom_field[uname]}
|
||||
<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}
|
||||
<div class="w-full">
|
||||
<GrayButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
start_game = quiz.id;
|
||||
}}
|
||||
flex={true}
|
||||
@@ -188,7 +188,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</div>
|
||||
<div class="w-full">
|
||||
{#if logged_in}
|
||||
<GrayButton flex={true} on:click={() => (download_id = quiz.id)}>
|
||||
<GrayButton flex={true} onclick={() => (download_id = quiz.id)}>
|
||||
<svg
|
||||
class="w-5 h-5 inline-block"
|
||||
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: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 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 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 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 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 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 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>
|
||||
<GrayButton on:click={submit} disabled={mod_rating === undefined}>Submit</GrayButton>
|
||||
<GrayButton onclick={submit} disabled={mod_rating === undefined}>Submit</GrayButton>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
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"
|
||||
}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user