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-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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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">
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user