Merge branch 'mawoka-myblock:master' into master
This commit is contained in:
@@ -16,7 +16,7 @@ repos:
|
||||
# - id: check-added-large-files
|
||||
|
||||
- repo: https://github.com/psf/black-pre-commit-mirror
|
||||
rev: 25.9.0
|
||||
rev: 25.12.0
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
@@ -36,6 +36,6 @@ repos:
|
||||
# so --exclude in .flake8 does not work
|
||||
exclude: ^migrations/versions/
|
||||
- repo: https://github.com/fsfe/reuse-tool
|
||||
rev: v6.0.0
|
||||
rev: v6.2.0
|
||||
hooks:
|
||||
- id: reuse
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
#
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
FROM python:3.11-slim
|
||||
FROM python:3.13-slim
|
||||
|
||||
COPY Pipfile* /app/
|
||||
WORKDIR /app/
|
||||
|
||||
@@ -48,6 +48,8 @@ pypng = "*"
|
||||
ormar = "*"
|
||||
pydantic = "*"
|
||||
pydantic-settings = "*"
|
||||
asyncpg = "*"
|
||||
psycopg2 = "*"
|
||||
|
||||
[dev-packages]
|
||||
coverage = "*"
|
||||
@@ -61,7 +63,7 @@ pre-commit = "*"
|
||||
python-socketio = {extras = ["client"], version = "*"}
|
||||
|
||||
[requires]
|
||||
python_version = "3.11"
|
||||
python_version = "3.13"
|
||||
|
||||
[scripts]
|
||||
format = "black ."
|
||||
|
||||
Generated
+1812
-1467
File diff suppressed because it is too large
Load Diff
+33
-7
@@ -77,12 +77,25 @@ async def auth_middleware_wrapper(request: Request, call_next):
|
||||
return await rememberme_middleware(request, call_next)
|
||||
|
||||
|
||||
app.include_router(moderation.router, tags=["moderation"], prefix="/api/v1/moderation", include_in_schema=True)
|
||||
app.include_router(
|
||||
moderation.router,
|
||||
tags=["moderation"],
|
||||
prefix="/api/v1/moderation",
|
||||
include_in_schema=True,
|
||||
)
|
||||
app.include_router(pixabay.router, tags=["pixabay"], prefix="/api/v1/pixabay", include_in_schema=True)
|
||||
app.include_router(quiztivity.router, tags=["quiztivity"], prefix="/api/v1/quiztivity", include_in_schema=True)
|
||||
app.include_router(
|
||||
quiztivity.router,
|
||||
tags=["quiztivity"],
|
||||
prefix="/api/v1/quiztivity",
|
||||
include_in_schema=True,
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
box_controller.router, tags=["boxcontroller"], prefix="/api/v1/box-controller", include_in_schema=True
|
||||
box_controller.router,
|
||||
tags=["boxcontroller"],
|
||||
prefix="/api/v1/box-controller",
|
||||
include_in_schema=True,
|
||||
)
|
||||
app.include_router(results.router, tags=["results"], prefix="/api/v1/results", include_in_schema=True)
|
||||
app.include_router(remote.router, tags=["remote"], prefix="/api/v1/remote", include_in_schema=True)
|
||||
@@ -94,15 +107,28 @@ app.include_router(quiz.router, tags=["quiz"], prefix="/api/v1/quiz", include_in
|
||||
app.include_router(utils.router, tags=["utils"], prefix="/api/v1/utils", include_in_schema=True)
|
||||
app.include_router(stats.router, tags=["stats"], prefix="/api/v1/stats", include_in_schema=True)
|
||||
app.include_router(storage.router, tags=["storage"], prefix="/api/v1/storage", include_in_schema=True)
|
||||
app.include_router(search.router, tags=["search"], prefix="/api/v1/search", include_in_schema=True),
|
||||
app.include_router(search.router, tags=["search"], prefix="/api/v1/search", include_in_schema=True)
|
||||
app.include_router(live.router, tags=["live"], prefix="/api/v1/live", include_in_schema=True)
|
||||
app.include_router(
|
||||
testing_routes.router, tags=["internal", "testing"], prefix="/api/v1/internal/testing", include_in_schema=False
|
||||
testing_routes.router,
|
||||
tags=["internal", "testing"],
|
||||
prefix="/api/v1/internal/testing",
|
||||
include_in_schema=False,
|
||||
)
|
||||
app.include_router(editor.router, tags=["editor"], prefix="/api/v1/editor", include_in_schema=True)
|
||||
app.include_router(eximport.router, tags=["export", "import"], prefix="/api/v1/eximport", include_in_schema=True)
|
||||
app.include_router(
|
||||
eximport.router,
|
||||
tags=["export", "import"],
|
||||
prefix="/api/v1/eximport",
|
||||
include_in_schema=True,
|
||||
)
|
||||
app.include_router(sitemap.router, tags=["sitemap"], prefix="/api/v1/sitemap", include_in_schema=True)
|
||||
app.include_router(community.router, tags=["community"], prefix="/api/v1/community", include_in_schema=True)
|
||||
app.include_router(
|
||||
community.router,
|
||||
tags=["community"],
|
||||
prefix="/api/v1/community",
|
||||
include_in_schema=True,
|
||||
)
|
||||
app.include_router(avatar.router, tags=["avatar"], prefix="/api/v1/avatar", include_in_schema=True)
|
||||
app.include_router(admin.router, tags=["admin"], prefix="/api/v1/admin", include_in_schema=True)
|
||||
app.mount("/", ASGIApp(sio))
|
||||
|
||||
+7
-1
@@ -12,9 +12,12 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
import meilisearch as MeiliSearch
|
||||
from arq import create_pool
|
||||
from arq.connections import RedisSettings, ArqRedis
|
||||
import logging
|
||||
|
||||
from classquiz.storage import Storage
|
||||
|
||||
LOGGER = logging.getLogger(f"uvicorn.{__name__}")
|
||||
|
||||
|
||||
class CustomOpenIDProvider(BaseModel):
|
||||
scopes: str | None = "openid email profile"
|
||||
@@ -29,7 +32,10 @@ class Settings(BaseSettings):
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env", extra="ignore", env_nested_delimiter="__", env_file_encoding="utf-8"
|
||||
env_file=".env",
|
||||
extra="ignore",
|
||||
env_nested_delimiter="__",
|
||||
env_file_encoding="utf-8",
|
||||
)
|
||||
root_address: str = "http://127.0.0.1:8000"
|
||||
redis: RedisDsn = "redis://localhost:6379/0?decode_responses=True"
|
||||
|
||||
+55
-2
@@ -1,16 +1,26 @@
|
||||
# SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
|
||||
#
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import Optional, Self
|
||||
from classquiz.config import redis
|
||||
import json
|
||||
|
||||
import ormar
|
||||
from ormar import ReferentialAction
|
||||
from pydantic import BaseModel, Json, field_validator, ConfigDict, RootModel, ValidationInfo
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
Json,
|
||||
field_validator,
|
||||
ConfigDict,
|
||||
RootModel,
|
||||
ValidationInfo,
|
||||
)
|
||||
from enum import Enum
|
||||
from . import metadata, database
|
||||
from .quiztivity import QuizTivityPage
|
||||
@@ -242,11 +252,34 @@ class PlayGame(BaseModel):
|
||||
custom_field: str | None = None
|
||||
question_show: bool = False
|
||||
|
||||
@classmethod
|
||||
async def get_from_redis(self, game_pin: str) -> Self:
|
||||
redis_data: str = await redis.get(f"game:{game_pin}")
|
||||
data = self.model_validate_json(redis_data)
|
||||
return data
|
||||
|
||||
async def save(self, game_pin: str, ex: int = 7200):
|
||||
await redis.set(f"game:{game_pin}", self.model_dump_json(), ex=ex)
|
||||
|
||||
def to_player_data(self) -> dict:
|
||||
return (
|
||||
{
|
||||
**json.loads(self.model_dump_json(exclude={"quiz_id", "questions", "user_id"})),
|
||||
"question_count": len(self.questions),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class GamePlayer(BaseModel):
|
||||
username: str
|
||||
sid: str | None = None
|
||||
|
||||
async def to_player_stack(self, game_pin: str):
|
||||
await redis.sadd(
|
||||
f"game_session:{game_pin}:players",
|
||||
self.model_dump_json(),
|
||||
)
|
||||
|
||||
|
||||
class GameAnswer2(BaseModel):
|
||||
username: str
|
||||
@@ -265,6 +298,18 @@ class GameSession(BaseModel):
|
||||
# players: list[GamePlayer | None]
|
||||
answers: list[GameAnswer1 | None]
|
||||
|
||||
@classmethod
|
||||
async def get_from_redis(self, game_pin: str) -> Self:
|
||||
redis_data = await redis.get(f"game_session:{game_pin}")
|
||||
return self.model_validate_json(redis_data)
|
||||
|
||||
async def save(self, game_pin: str, ex: int = 7200):
|
||||
await redis.set(
|
||||
f"game_session:{game_pin}",
|
||||
self.model_dump_json(),
|
||||
ex=7200,
|
||||
)
|
||||
|
||||
|
||||
class UpdatePassword(BaseModel):
|
||||
old_password: str
|
||||
@@ -294,6 +339,14 @@ class AnswerDataList(RootModel):
|
||||
def __len__(self) -> int:
|
||||
return len(self.root)
|
||||
|
||||
@classmethod
|
||||
async def get_redis_or_empty(self, game_pin: str, question_number: str) -> Self:
|
||||
redis_res = await redis.get(f"game_session:{game_pin}:{question_number}")
|
||||
if redis_res is None:
|
||||
return self([])
|
||||
else:
|
||||
return self.model_validate_json(redis_res)
|
||||
|
||||
|
||||
class GameInLobby(BaseModel):
|
||||
game_pin: str
|
||||
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
<style>
|
||||
.link-hover {
|
||||
background: linear-gradient(90deg, #009444, #39b54a, #8dc63f);
|
||||
background: #4E6E58;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-decoration: none;
|
||||
|
||||
@@ -55,7 +55,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<td style="box-sizing: border-box;padding: 0;font-family: 'Open Sans', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif;font-size: 16px;vertical-align: top;background-color: #348eda;border-radius: 2px;text-align: center;background-color: #348eda"
|
||||
valign='top' align='center'><a
|
||||
href='{{ base_url }}/account/password-reset?token={{ token }}'
|
||||
style='box-sizing: border-box;font-weight: 400;text-decoration: none;display: inline-block;margin: 0;color: #ffffff;background: linear-gradient(90deg, #009444, #39b54a, #8dc63f);border: solid 1px black;border-radius: 2px;font-size: 14px;padding: 12px 45px'
|
||||
style='box-sizing: border-box;font-weight: 400;text-decoration: none;display: inline-block;margin: 0;color: #ffffff;background: #4E6E58;border: solid 1px black;border-radius: 2px;font-size: 14px;padding: 12px 45px'
|
||||
rel='external nofollow noopener noreferrer' target='_blank'
|
||||
tabindex='-1'>Confirm Password Reset</a></td>
|
||||
</tr>
|
||||
|
||||
@@ -55,7 +55,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<td style="box-sizing: border-box;padding: 0;font-family: 'Open Sans', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif;font-size: 16px;vertical-align: top;background-color: #348eda;border-radius: 2px;text-align: center;background-color: #348eda"
|
||||
valign='top' align='center'><a
|
||||
href='{{ base_url }}/api/v1/users/verify/{{ token }}'
|
||||
style='box-sizing: border-box;font-weight: 400;text-decoration: none;display: inline-block;margin: 0;color: #ffffff;background: linear-gradient(90deg, #009444, #39b54a, #8dc63f);border: solid 1px black;border-radius: 2px;font-size: 14px;padding: 12px 45px'
|
||||
style='box-sizing: border-box;font-weight: 400;text-decoration: none;display: inline-block;margin: 0;color: #ffffff;background: #4E6E58;border: solid 1px black;border-radius: 2px;font-size: 14px;padding: 12px 45px'
|
||||
rel='external nofollow noopener noreferrer' target='_blank'
|
||||
tabindex='-1'>Confirm Email Address</a></td>
|
||||
</tr>
|
||||
|
||||
@@ -6,25 +6,24 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional, BinaryIO
|
||||
from typing import BinaryIO, Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from openpyxl import load_workbook
|
||||
|
||||
import ormar.exceptions
|
||||
|
||||
from classquiz.db.models import Quiz, User, InstanceData, QuizQuestion, ABCDQuizAnswer
|
||||
from classquiz.db.models import Quiz, User, QuizQuestion, ABCDQuizAnswer
|
||||
import xlsxwriter
|
||||
from aiohttp import ClientSession
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
from classquiz.config import meilisearch, settings
|
||||
from classquiz.config import meilisearch, settings, LOGGER
|
||||
from classquiz.helpers.hashcash import check as hc_check
|
||||
|
||||
settings = settings()
|
||||
|
||||
|
||||
async def get_meili_data(quiz: Quiz) -> dict:
|
||||
async def get_meili_data(quiz: Quiz) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(quiz.id),
|
||||
"title": quiz.title,
|
||||
@@ -35,14 +34,19 @@ async def get_meili_data(quiz: Quiz) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def generate_spreadsheet(quiz_results: dict, quiz: Quiz, player_fields: dict, player_scores: dict) -> BytesIO:
|
||||
async def generate_spreadsheet(
|
||||
quiz_results: dict[str, Any],
|
||||
quiz: Quiz,
|
||||
player_fields: dict[str, Any],
|
||||
player_scores: dict[str, Any],
|
||||
) -> BytesIO:
|
||||
storage = BytesIO()
|
||||
workbook = xlsxwriter.Workbook(storage, {"in_memory": True})
|
||||
player_worksheet = workbook.add_worksheet()
|
||||
player_worksheet.name = "Players"
|
||||
player_worksheet.write(0, 0, "Username")
|
||||
player_worksheet.write(0, 1, "Score")
|
||||
player_worksheet.write(0, 2, "Custom-Field")
|
||||
_ = player_worksheet.write(0, 0, "Username")
|
||||
_ = player_worksheet.write(0, 1, "Score")
|
||||
_ = player_worksheet.write(0, 2, "Custom-Field")
|
||||
for i, player in enumerate(player_scores.keys()):
|
||||
player_worksheet.write(i + 1, 0, player)
|
||||
player_worksheet.write(i + 1, 1, player_scores[player])
|
||||
@@ -53,33 +57,33 @@ async def generate_spreadsheet(quiz_results: dict, quiz: Quiz, player_fields: di
|
||||
|
||||
worksheet = workbook.add_worksheet()
|
||||
worksheet.name = "Questions"
|
||||
worksheet.write(0, 0, "Question")
|
||||
worksheet.write(0, 1, "Time")
|
||||
worksheet.write(0, 2, "Image")
|
||||
worksheet.write(0, 3, "Correct answers")
|
||||
worksheet.write(0, 4, "Correct answers")
|
||||
worksheet.write(0, 5, "Wrong answers")
|
||||
_ = worksheet.write(0, 0, "Question")
|
||||
_ = worksheet.write(0, 1, "Time")
|
||||
_ = worksheet.write(0, 2, "Image")
|
||||
_ = worksheet.write(0, 3, "Correct answers")
|
||||
_ = worksheet.write(0, 4, "Correct answers")
|
||||
_ = worksheet.write(0, 5, "Wrong answers")
|
||||
for i, _ in enumerate(quiz_results):
|
||||
question = quiz.questions[i]
|
||||
# print(quiz_results)
|
||||
try:
|
||||
answer_data = quiz_results[str(i)]
|
||||
except KeyError:
|
||||
continue
|
||||
worksheet.write(i + 1, 0, question["question"])
|
||||
worksheet.write(i + 1, 1, question["time"])
|
||||
_ = worksheet.write(i + 1, 0, question["question"])
|
||||
_ = worksheet.write(i + 1, 1, question["time"])
|
||||
|
||||
try:
|
||||
async with (
|
||||
ClientSession() as session,
|
||||
session.get(f"{settings.root_address}/api/v1/storage/download/{question['image']}") as response,
|
||||
):
|
||||
if "image" in response.headers.get("Content-Type"):
|
||||
content_type = response.headers.get("Content-Type")
|
||||
if content_type is not None and "image" in content_type:
|
||||
img_data = BytesIO(await response.read())
|
||||
worksheet.insert_image(i + 1, 2, question["image"], {"image_data": img_data})
|
||||
_ = worksheet.insert_image(i + 1, 2, question["image"], {"image_data": img_data})
|
||||
image = Image.open(img_data)
|
||||
worksheet.set_row(i + 1, image.height)
|
||||
worksheet.set_column(2, 2, image.width)
|
||||
_ = worksheet.set_row(i + 1, image.height)
|
||||
_ = worksheet.set_column(2, 2, image.width)
|
||||
except TypeError:
|
||||
pass
|
||||
answer_amount = len(answer_data)
|
||||
@@ -91,24 +95,24 @@ async def generate_spreadsheet(quiz_results: dict, quiz: Quiz, player_fields: di
|
||||
correct_answers += 1
|
||||
else:
|
||||
wrong_answers += 1
|
||||
worksheet.write(i + 1, 3, f"{round(correct_answers / answer_amount * 100)}%")
|
||||
worksheet.write(i + 1, 4, correct_answers)
|
||||
worksheet.write(i + 1, 5, wrong_answers)
|
||||
_ = worksheet.write(i + 1, 3, f"{round(correct_answers / answer_amount * 100)}%")
|
||||
_ = worksheet.write(i + 1, 4, correct_answers)
|
||||
_ = worksheet.write(i + 1, 5, wrong_answers)
|
||||
|
||||
ws = workbook.add_worksheet(f"{i + 1}. Question")
|
||||
ws.write(0, 0, "Answer")
|
||||
ws.write(0, 1, "Correct")
|
||||
ws.write(0, 2, "Username")
|
||||
_ = ws.write(0, 0, "Answer")
|
||||
_ = ws.write(0, 1, "Correct")
|
||||
_ = ws.write(0, 2, "Username")
|
||||
for j, _ in enumerate(answer_data):
|
||||
ws.write(j + 1, 0, answer_data[j]["answer"])
|
||||
_ = ws.write(j + 1, 0, answer_data[j]["answer"])
|
||||
if answer_data[j]["right"]:
|
||||
ws.write(j + 1, 1, "True")
|
||||
_ = ws.write(j + 1, 1, "True")
|
||||
else:
|
||||
ws.write(j + 1, 1, "False")
|
||||
ws.write(j + 1, 2, answer_data[j]["username"])
|
||||
_ = ws.write(j + 1, 1, "False")
|
||||
_ = ws.write(j + 1, 2, answer_data[j]["username"])
|
||||
|
||||
workbook.close()
|
||||
storage.seek(0)
|
||||
_ = storage.seek(0)
|
||||
return storage
|
||||
|
||||
|
||||
@@ -118,9 +122,11 @@ async def handle_import_from_excel(data: BinaryIO, user: User) -> Quiz:
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=400, detail="File not in excel format")
|
||||
ws = wb.active
|
||||
if ws is None:
|
||||
raise Exception("Workbook is None")
|
||||
questions: list[dict] = []
|
||||
title = ws["C5"].value
|
||||
description = ws["C6"].value
|
||||
title: str | None = ws["C5"].value
|
||||
description: str | None = ws["C6"].value
|
||||
if title is None:
|
||||
raise HTTPException(status_code=400, detail="Title missing")
|
||||
if description is None:
|
||||
@@ -147,7 +153,10 @@ async def handle_import_from_excel(data: BinaryIO, user: User) -> Quiz:
|
||||
if answer is None:
|
||||
continue
|
||||
if len(str(answer)) > 150:
|
||||
raise HTTPException(status_code=400, detail=f"Answer {a + 1} in Question {i + 1} is longer than 150")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Answer {a + 1} in Question {i + 1} is longer than 150",
|
||||
)
|
||||
if len(answers) < 2:
|
||||
raise HTTPException(status_code=400, detail=f"Less than 2 answers in Question {i + 1}")
|
||||
correct_answers: str = str(row[6])
|
||||
@@ -179,12 +188,12 @@ async def handle_import_from_excel(data: BinaryIO, user: User) -> Quiz:
|
||||
questions=questions,
|
||||
imported_from_kahoot=False,
|
||||
)
|
||||
await quiz.save()
|
||||
_ = await quiz.save()
|
||||
else:
|
||||
existing_quiz.questions = [*existing_quiz.questions, *questions]
|
||||
existing_quiz.updated_at = datetime.now()
|
||||
existing_quiz.mod_rating = None
|
||||
await existing_quiz.update()
|
||||
_ = await existing_quiz.update()
|
||||
quiz = existing_quiz
|
||||
return quiz
|
||||
|
||||
@@ -200,7 +209,7 @@ async def meilisearch_init():
|
||||
classquiz_index_found = False
|
||||
# +++ Check if the index does not exist and creates it
|
||||
if not classquiz_index_found:
|
||||
print("Creating MeiliSearch Index")
|
||||
LOGGER.debug("Creating MeiliSearch Index")
|
||||
meilisearch.create_index(settings.meilisearch_index)
|
||||
await asyncio.sleep(1)
|
||||
# --- END
|
||||
@@ -211,7 +220,7 @@ async def meilisearch_init():
|
||||
number_of_docs = i[1]
|
||||
break
|
||||
if number_of_docs != quiz_count:
|
||||
print("MeiliSearch and Database got out of sync, syncthing them")
|
||||
LOGGER.warn("MeiliSearch and Database got out of sync, syncthing them")
|
||||
meilisearch.delete_index(settings.meilisearch_index)
|
||||
meilisearch.create_index(settings.meilisearch_index)
|
||||
quizzes = await Quiz.objects.filter(public=True).all()
|
||||
@@ -221,30 +230,10 @@ async def meilisearch_init():
|
||||
meilisearch.index(settings.meilisearch_index).add_documents(meili_data)
|
||||
# --- END ---
|
||||
meilisearch.index(settings.meilisearch_index).update_settings({"sortableAttributes": ["created_at"]})
|
||||
print("Finished MeiliSearch synchronisation")
|
||||
LOGGER.info("Finished MeiliSearch synchronisation")
|
||||
|
||||
|
||||
async def telemetry_ping():
|
||||
try:
|
||||
instance_data = await InstanceData.objects.first()
|
||||
except ormar.exceptions.NoMatch:
|
||||
instance_data = InstanceData()
|
||||
await instance_data.save()
|
||||
async with (
|
||||
ClientSession() as session,
|
||||
session.post(
|
||||
f"https://cit.mawoka.eu.org/public/{instance_data.instance_id}",
|
||||
json={
|
||||
"public_quizzes": await Quiz.objects.filter(public=True).count(),
|
||||
"private_quizzes": await Quiz.objects.filter(public=False).count(),
|
||||
"users": await User.objects.count(),
|
||||
},
|
||||
),
|
||||
):
|
||||
return
|
||||
|
||||
|
||||
def check_hashcash(data: str, input_data: str, claim_in: Optional[str] = "19") -> bool:
|
||||
def check_hashcash(data: str, input_data: str, claim_in: str | None = "19") -> bool:
|
||||
"""
|
||||
It checks that the hashcash is valid, and if it is, it returns True
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ This library is also tested.
|
||||
```python
|
||||
from classquiz.kahoot_importer.get import get, _Response
|
||||
from asyncio import run
|
||||
# _Response ia a pydantic-object, so you have access to
|
||||
# .dict() or .json(exclude={"kahoot"})
|
||||
# _Response is a pydantic-object, so you have access to
|
||||
# .dict() or .model_dump_json(exclude={"kahoot"})
|
||||
|
||||
async def main():
|
||||
kahoot_quiz: _Response = await get("GAME_ID")
|
||||
@@ -31,7 +31,7 @@ run(main())
|
||||
from classquiz.kahoot_importer.search import search, _Response
|
||||
from asyncio import run
|
||||
# _Response ia a pydantic-object, so you have access to
|
||||
# .dict() or .json(exclude={"kahoot"})
|
||||
# .dict() or .model_dump_json(exclude={"kahoot"})
|
||||
|
||||
async def main():
|
||||
kahoot_quizzes: _Response = await search("QUERY")
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
|
||||
from typing import List, Any, Optional
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
@@ -33,10 +33,10 @@ class _LastEdit(BaseModel):
|
||||
|
||||
class _ImageMetadata(BaseModel):
|
||||
id: UUID | None = None
|
||||
content_type: Optional[str] = None
|
||||
width: Optional[int] = None
|
||||
height: Optional[int] = None
|
||||
resources: Optional[str] = None
|
||||
content_type: str | None = None
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
resources: str | None = None
|
||||
|
||||
|
||||
class _SampleQuestion(BaseModel):
|
||||
@@ -48,11 +48,11 @@ class _SampleQuestion(BaseModel):
|
||||
|
||||
|
||||
class _Access(BaseModel):
|
||||
groupRead: List[Any | None]
|
||||
folderGroupIds: List[Any | None]
|
||||
groupRead: list[Any | None]
|
||||
folderGroupIds: list[Any | None]
|
||||
|
||||
|
||||
class _Card(BaseModel):
|
||||
class Card(BaseModel):
|
||||
type: str
|
||||
title: str
|
||||
description: str
|
||||
@@ -60,12 +60,12 @@ class _Card(BaseModel):
|
||||
cover: str | None = None
|
||||
coverMetadata: _CoverMetadata | dict[None, None] | None = None
|
||||
draftExists: bool
|
||||
inventoryItemIds: List[Any] = None
|
||||
inventoryItemIds: list[Any] | None = None
|
||||
number_of_questions: int
|
||||
creator: UUID
|
||||
creator_username: str
|
||||
creator_avatar: _CreatorAvatar | dict[None, None] | None = None
|
||||
badges: List[str]
|
||||
badges: list[str]
|
||||
visibility: int
|
||||
locked: bool
|
||||
writeProtection: bool
|
||||
@@ -76,11 +76,11 @@ class _Card(BaseModel):
|
||||
draft: bool
|
||||
combined: bool
|
||||
compatibility_level: int
|
||||
sample_questions: List[_SampleQuestion]
|
||||
sample_questions: list[_SampleQuestion]
|
||||
number_of_plays: int
|
||||
number_of_players: int
|
||||
total_favourites: int
|
||||
question_types: List[str]
|
||||
question_types: list[str]
|
||||
created: int
|
||||
modified: int
|
||||
access: _Access
|
||||
@@ -89,7 +89,7 @@ class _Card(BaseModel):
|
||||
|
||||
|
||||
class _Entity(BaseModel):
|
||||
card: _Card
|
||||
card: Card
|
||||
|
||||
|
||||
class _Origin(BaseModel):
|
||||
@@ -130,8 +130,8 @@ class _Video(BaseModel):
|
||||
startTime: float
|
||||
endTime: float
|
||||
service: str
|
||||
full_url: Optional[str] = None
|
||||
id: Optional[str] = None
|
||||
full_url: str | None = None
|
||||
id: str | None = None
|
||||
|
||||
|
||||
class _Question(BaseModel):
|
||||
@@ -140,17 +140,17 @@ class _Question(BaseModel):
|
||||
time: int
|
||||
points: bool
|
||||
pointsMultiplier: int
|
||||
choices: List[_Choice]
|
||||
choices: list[_Choice]
|
||||
image: str | None = None
|
||||
imageMetadata: _ImageMetadata | None = None
|
||||
resources: Optional[str] = None
|
||||
resources: str | None = None
|
||||
video: _Video
|
||||
questionFormat: int
|
||||
languageInfo: _LanguageInfo | None = None
|
||||
media: List[Any]
|
||||
media: list[Any]
|
||||
|
||||
|
||||
class _Kahoot(BaseModel):
|
||||
class Kahoot(BaseModel):
|
||||
uuid: UUID
|
||||
language: str
|
||||
creator: UUID
|
||||
@@ -161,20 +161,19 @@ class _Kahoot(BaseModel):
|
||||
visibility: int
|
||||
difficulty: int | None = None
|
||||
audience: str
|
||||
audience: str
|
||||
title: str
|
||||
description: str
|
||||
quizType: str
|
||||
tags: str | None | List[str] = None
|
||||
tags: str | None | list[str] = None
|
||||
cover: str | None = None
|
||||
coverMetadata: _CoverMetadata | dict[None, None] | None = None
|
||||
questions: List[_Question]
|
||||
questions: list[_Question]
|
||||
metadata: _Metadata
|
||||
parent: _Parent | None = None
|
||||
resources: str | None = None
|
||||
slug: str
|
||||
languageInfo: _LanguageInfo | None = None
|
||||
inventoryItemIds: List[Any]
|
||||
inventoryItemIds: list[Any]
|
||||
type: str
|
||||
created: int
|
||||
modified: int
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
from aiohttp import ClientSession
|
||||
from pydantic import BaseModel
|
||||
|
||||
from classquiz.kahoot_importer import _Card, _Kahoot
|
||||
from classquiz.kahoot_importer import Card, Kahoot
|
||||
|
||||
|
||||
class _Response(BaseModel):
|
||||
card: _Card
|
||||
kahoot: _Kahoot
|
||||
card: Card
|
||||
kahoot: Kahoot
|
||||
|
||||
|
||||
async def get(game_id: str) -> _Response | int:
|
||||
|
||||
@@ -58,7 +58,7 @@ async def import_quiz(quiz_id: str, user: User) -> Quiz | int:
|
||||
if type(quiz) is int:
|
||||
return quiz
|
||||
quiz_questions: list[dict] = []
|
||||
quiz_id = uuid.uuid4()
|
||||
new_quiz_id = uuid.uuid4()
|
||||
meilisearch.delete_index(settings.meilisearch_index)
|
||||
meilisearch.create_index(settings.meilisearch_index)
|
||||
uploaded_images: list[StorageItem] = []
|
||||
@@ -94,8 +94,10 @@ async def import_quiz(quiz_id: str, user: User) -> Quiz | int:
|
||||
img_obj = await handle_image_upload(quiz.kahoot.cover, user)
|
||||
uploaded_images.append(img_obj)
|
||||
cover = img_obj.id.hex
|
||||
if quiz.kahoot.description is None or quiz.kahoot.description == "":
|
||||
quiz.kahoot.description = "Description Missing!"
|
||||
quiz_data = Quiz(
|
||||
id=quiz_id,
|
||||
id=new_quiz_id,
|
||||
public=False,
|
||||
title=bleach.clean(quiz.kahoot.title, tags=ALLOWED_TAGS_FOR_QUIZ, strip=True),
|
||||
description=bleach.clean(quiz.kahoot.description, tags=ALLOWED_TAGS_FOR_QUIZ, strip=True),
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
|
||||
from typing import List
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -13,7 +11,7 @@ from classquiz.kahoot_importer import _Entity
|
||||
|
||||
# noqa : E501
|
||||
class _Response(BaseModel):
|
||||
entities: List[_Entity]
|
||||
entities: list[_Entity]
|
||||
totalHits: int
|
||||
cursor: int | None = None
|
||||
pageTimestamp: int
|
||||
@@ -28,7 +26,7 @@ async def search(
|
||||
) -> _Response:
|
||||
"""
|
||||
|
||||
:param inventory_item_id: I dkon't know
|
||||
:param inventory_item_id: I don't know
|
||||
:param search_cluster: Doesn't seeem to matter
|
||||
:param cursor: The position in the result-list (page)
|
||||
:param query: The search query
|
||||
|
||||
@@ -22,7 +22,7 @@ router.include_router(github.router, prefix="/github")
|
||||
router.include_router(custom.router, prefix="/custom")
|
||||
|
||||
|
||||
async def rememberme_middleware(request: Request, call_next):
|
||||
async def rememberme_middleware(request: Request, call_next) -> Response:
|
||||
rememberme_cookie = request.cookies.get("rememberme_token")
|
||||
bearer_token = request.cookies.get("access_token")
|
||||
conditions_to_handle_met = True
|
||||
@@ -41,13 +41,13 @@ async def rememberme_middleware(request: Request, call_next):
|
||||
|
||||
# Verifying the bearer
|
||||
try:
|
||||
jwt.decode(
|
||||
_ = jwt.decode(
|
||||
param, settings.secret_key, algorithms=["HS256"]
|
||||
) # checking if the token is valid, throws error if not
|
||||
conditions_to_handle_met = False
|
||||
except JWTError:
|
||||
try:
|
||||
jws.verify(
|
||||
_ = jws.verify(
|
||||
param, settings.secret_key, algorithms=["HS256"]
|
||||
) # Verifying only the signature of the jwt, throws error if signature is invalid
|
||||
except JWSError:
|
||||
|
||||
@@ -14,18 +14,19 @@ from classquiz.auth import create_access_token
|
||||
settings = settings()
|
||||
|
||||
|
||||
async def log_user_in(user: User, request: Request, response: Response):
|
||||
async def log_user_in(user: User | None, request: Request, response: Response):
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="User not matched!")
|
||||
remote_ip = None
|
||||
if request.headers.get("X-Forwarded-For") is None:
|
||||
forwarded_for_header = request.headers.get("X-Forwarded-For")
|
||||
if forwarded_for_header is None:
|
||||
remote_ip = request.client.host
|
||||
|
||||
else:
|
||||
if "," in request.headers.get("X-Forwarded-For"):
|
||||
remote_ip = request.headers.get("X-Forwarded-For").split(", ")[0]
|
||||
if "," in forwarded_for_header:
|
||||
remote_ip = forwarded_for_header.split(", ")[0]
|
||||
else:
|
||||
remote_ip = request.headers.get("X-Forwarded-For")
|
||||
remote_ip = forwarded_for_header
|
||||
session_key = os.urandom(32).hex()
|
||||
user_session = UserSession(
|
||||
user=user,
|
||||
@@ -47,7 +48,11 @@ async def log_user_in(user: User, request: Request, response: Response):
|
||||
max_age=60 * 60 * 24 * 365,
|
||||
)
|
||||
response.set_cookie(
|
||||
key="rememberme_token", value=session_key, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 365
|
||||
key="rememberme_token",
|
||||
value=session_key,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
max_age=60 * 60 * 24 * 365,
|
||||
)
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
|
||||
+20
-36
@@ -77,43 +77,27 @@ async def auth(request: Request, response: Response):
|
||||
except (TypeError, ValidationError):
|
||||
raise HTTPException(status_code=401, detail="Something went wrong.")
|
||||
|
||||
user_in_db = await User.objects.get_or_none(email=user_data.email)
|
||||
if user_in_db is None:
|
||||
# REGISTER USER
|
||||
try:
|
||||
await User.objects.create(
|
||||
id=uuid.uuid4(),
|
||||
email=user_data.email,
|
||||
username=user_data.preferred_username,
|
||||
verified=user_data.email_verified,
|
||||
auth_type=UserAuthTypes.CUSTOM,
|
||||
google_uid=user_data.sub.hex,
|
||||
avatar=gzipped_user_avatar(),
|
||||
)
|
||||
# skipcq: PYL-W0703
|
||||
except Exception as e:
|
||||
if type(e) is asyncpg.exceptions.UniqueViolationError:
|
||||
error = True
|
||||
counter = 1
|
||||
while error:
|
||||
try:
|
||||
await User.objects.create(
|
||||
id=uuid.uuid4(),
|
||||
email=user_data.email,
|
||||
username=f"{user_data.preferred_username}{counter}",
|
||||
verified=user_data.email_verified,
|
||||
auth_type=UserAuthTypes.CUSTOM,
|
||||
google_uid=user_data.sub.hex,
|
||||
avatar=gzipped_user_avatar(),
|
||||
)
|
||||
error = False
|
||||
except asyncpg.exceptions.UniqueViolationError:
|
||||
counter += 1
|
||||
error = True
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
try:
|
||||
await User.objects.create(
|
||||
id=uuid.uuid4(),
|
||||
email=user_data.email,
|
||||
username=user_data.preferred_username,
|
||||
verified=user_data.email_verified,
|
||||
auth_type=UserAuthTypes.CUSTOM,
|
||||
google_uid=user_data.sub.hex,
|
||||
avatar=gzipped_user_avatar(),
|
||||
)
|
||||
# skipcq: PYL-W0703
|
||||
except asyncpg.exceptions.UniqueViolationError:
|
||||
# Most likely a duplicate email/username, not UUID.
|
||||
raise HTTPException(status_code=400, detail="User already exists.")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
user = await User.objects.get_or_none(
|
||||
email=user_data.email, google_uid=user_data.sub.hex, auth_type=UserAuthTypes.CUSTOM, verified=True
|
||||
email=user_data.email,
|
||||
google_uid=user_data.sub.hex,
|
||||
auth_type=UserAuthTypes.CUSTOM,
|
||||
verified=True,
|
||||
)
|
||||
print(user_data)
|
||||
|
||||
|
||||
+54
-70
@@ -4,7 +4,6 @@
|
||||
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
import asyncpg
|
||||
import authlib.integrations.base_client
|
||||
@@ -35,43 +34,43 @@ class Plan(BaseModel):
|
||||
class GitHubOauthResponse(BaseModel):
|
||||
login: str
|
||||
id: int
|
||||
email: Optional[str] = None
|
||||
node_id: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
gravatar_id: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
html_url: Optional[str] = None
|
||||
followers_url: Optional[str] = None
|
||||
following_url: Optional[str] = None
|
||||
gists_url: Optional[str] = None
|
||||
starred_url: Optional[str] = None
|
||||
subscriptions_url: Optional[str] = None
|
||||
organizations_url: Optional[str] = None
|
||||
repos_url: Optional[str] = None
|
||||
events_url: Optional[str] = None
|
||||
received_events_url: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
site_admin: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
company: Optional[str] = None
|
||||
blog: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
hireable: Optional[bool] = None
|
||||
bio: Optional[str] = None
|
||||
twitter_username: Optional[str] = None
|
||||
public_repos: Optional[int] = None
|
||||
public_gists: Optional[int] = None
|
||||
followers: Optional[int] = None
|
||||
following: Optional[int] = None
|
||||
email: str | None = None
|
||||
node_id: str | None = None
|
||||
avatar_url: str | None = None
|
||||
gravatar_id: str | None = None
|
||||
url: str | None = None
|
||||
html_url: str | None = None
|
||||
followers_url: str | None = None
|
||||
following_url: str | None = None
|
||||
gists_url: str | None = None
|
||||
starred_url: str | None = None
|
||||
subscriptions_url: str | None = None
|
||||
organizations_url: str | None = None
|
||||
repos_url: str | None = None
|
||||
events_url: str | None = None
|
||||
received_events_url: str | None = None
|
||||
type: str | None = None
|
||||
site_admin: bool | None = None
|
||||
name: str | None = None
|
||||
company: str | None = None
|
||||
blog: str | None = None
|
||||
location: str | None = None
|
||||
hireable: bool | None = None
|
||||
bio: str | None = None
|
||||
twitter_username: str | None = None
|
||||
public_repos: int | None = None
|
||||
public_gists: int | None = None
|
||||
followers: int | None = None
|
||||
following: int | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
private_gists: Optional[int] = None
|
||||
total_private_repos: Optional[int] = None
|
||||
owned_private_repos: Optional[int] = None
|
||||
disk_usage: Optional[int] = None
|
||||
collaborators: Optional[int] = None
|
||||
two_factor_authentication: Optional[bool] = None
|
||||
plan: Optional[Plan] = None
|
||||
private_gists: int | None = None
|
||||
total_private_repos: int | None = None
|
||||
owned_private_repos: int | None = None
|
||||
disk_usage: int | None = None
|
||||
collaborators: int | None = None
|
||||
two_factor_authentication: bool | None = None
|
||||
plan: Plan | None = None
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
@@ -103,44 +102,29 @@ async def auth(request: Request, response: Response):
|
||||
except authlib.integrations.base_client.OAuthError:
|
||||
return RedirectResponse("/account/oauth-error")
|
||||
resp = await oauth.github.get("user", token=token)
|
||||
user_data = GitHubOauthResponse(**resp.json())
|
||||
data = resp.json()
|
||||
user_data = GitHubOauthResponse(**data)
|
||||
if user_data.email is None:
|
||||
return RedirectResponse("/account/oauth-error?error=email")
|
||||
user_in_db = await User.objects.get_or_none(email=user_data.email)
|
||||
if user_in_db is None:
|
||||
# REGISTER USER
|
||||
try:
|
||||
await User.objects.create(
|
||||
id=uuid.uuid4(),
|
||||
email=user_data.email,
|
||||
username=user_data.login,
|
||||
verified=True,
|
||||
auth_type=UserAuthTypes.GITHUB,
|
||||
avatar=gzipped_user_avatar(),
|
||||
)
|
||||
# skipcq: PYL-W0703
|
||||
except Exception as e:
|
||||
if type(e) is asyncpg.exceptions.UniqueViolationError:
|
||||
error = True
|
||||
counter = 1
|
||||
while error:
|
||||
try:
|
||||
await User.objects.create(
|
||||
id=uuid.uuid4(),
|
||||
email=user_data.email,
|
||||
username=user_data.login,
|
||||
verified=True,
|
||||
auth_type=UserAuthTypes.GITHUB,
|
||||
avatar=gzipped_user_avatar(),
|
||||
)
|
||||
error = False
|
||||
except asyncpg.exceptions.UniqueViolationError:
|
||||
counter += 1
|
||||
error = True
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
try:
|
||||
await User.objects.create(
|
||||
id=uuid.uuid4(),
|
||||
email=user_data.email,
|
||||
username=user_data.login,
|
||||
verified=True,
|
||||
auth_type=UserAuthTypes.GITHUB,
|
||||
avatar=gzipped_user_avatar(),
|
||||
)
|
||||
except asyncpg.exceptions.UniqueViolationError:
|
||||
raise HTTPException(status_code=400, detail="User already exists.")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
user = await User.objects.get_or_none(
|
||||
email=user_data.email, username=user_data.login, auth_type=UserAuthTypes.GITHUB, verified=True
|
||||
email=user_data.email,
|
||||
username=user_data.login,
|
||||
auth_type=UserAuthTypes.GITHUB,
|
||||
verified=True,
|
||||
)
|
||||
|
||||
await log_user_in(user=user, request=request, response=response)
|
||||
|
||||
+18
-35
@@ -79,43 +79,26 @@ async def auth(request: Request, response: Response):
|
||||
user_data = OauthGoogleResponse(**user_data).userinfo
|
||||
except (TypeError, ValidationError):
|
||||
raise HTTPException(status_code=401, detail="Something went wrong.")
|
||||
user_in_db = await User.objects.get_or_none(email=user_data.email)
|
||||
if user_in_db is None:
|
||||
# REGISTER USER
|
||||
try:
|
||||
await User.objects.create(
|
||||
id=uuid.uuid4(),
|
||||
email=user_data.email,
|
||||
username=user_data.name,
|
||||
verified=user_data.email_verified,
|
||||
auth_type=UserAuthTypes.GOOGLE,
|
||||
google_uid=user_data.sub,
|
||||
avatar=gzipped_user_avatar(),
|
||||
)
|
||||
# skipcq: PYL-W0703
|
||||
except Exception as e:
|
||||
if type(e) is asyncpg.exceptions.UniqueViolationError:
|
||||
error = True
|
||||
counter = 1
|
||||
while error:
|
||||
try:
|
||||
await User.objects.create(
|
||||
id=uuid.uuid4(),
|
||||
email=user_data.email,
|
||||
username=f"{user_data.name}{counter}",
|
||||
verified=user_data.email_verified,
|
||||
auth_type=UserAuthTypes.GOOGLE,
|
||||
google_uid=user_data.sub,
|
||||
avatar=gzipped_user_avatar(),
|
||||
)
|
||||
error = False
|
||||
except asyncpg.exceptions.UniqueViolationError:
|
||||
counter += 1
|
||||
error = True
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
try:
|
||||
await User.objects.create(
|
||||
id=uuid.uuid4(),
|
||||
email=user_data.email,
|
||||
username=user_data.name,
|
||||
verified=user_data.email_verified,
|
||||
auth_type=UserAuthTypes.GOOGLE,
|
||||
google_uid=user_data.sub,
|
||||
avatar=gzipped_user_avatar(),
|
||||
)
|
||||
except asyncpg.exceptions.UniqueViolationError:
|
||||
raise HTTPException(status_code=400, detail="User already exists.")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
user = await User.objects.get_or_none(
|
||||
email=user_data.email, google_uid=user_data.sub, auth_type=UserAuthTypes.GOOGLE, verified=True
|
||||
email=user_data.email,
|
||||
google_uid=user_data.sub,
|
||||
auth_type=UserAuthTypes.GOOGLE,
|
||||
verified=True,
|
||||
)
|
||||
|
||||
await log_user_in(user=user, request=request, response=response)
|
||||
|
||||
@@ -4,15 +4,37 @@
|
||||
|
||||
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
from classquiz.config import settings
|
||||
from classquiz.config import settings, redis
|
||||
from functools import lru_cache
|
||||
import json
|
||||
|
||||
settings = settings()
|
||||
|
||||
|
||||
class RedisCache:
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
async def get(self, key: str) -> str | None:
|
||||
value = await redis.get(f"authlib:{key}")
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
|
||||
async def set(self, key: str, value: str, expires: int | None = None):
|
||||
data = json.dumps(value)
|
||||
await redis.set(f"authlib:{key}", data, ex=expires)
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
await redis.delete(f"authlib:{key}")
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def init_oauth() -> OAuth:
|
||||
oauth = OAuth()
|
||||
oauth = OAuth(cache=RedisCache())
|
||||
if settings.google_client_secret is not None and settings.google_client_id is not None:
|
||||
oauth.register(
|
||||
name="google",
|
||||
|
||||
@@ -14,15 +14,15 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.delete("/user/id")
|
||||
async def delete_user_by_id(user_id: UUID, user: User = Depends(get_admin_user)):
|
||||
async def delete_user_by_id(user_id: UUID, _: User = Depends(get_admin_user)):
|
||||
return {"deleted": await User.objects.delete(id=user_id)}
|
||||
|
||||
|
||||
@router.delete("/user/username")
|
||||
async def delete_user_by_username(username: str, user: User = Depends(get_admin_user)):
|
||||
async def delete_user_by_username(username: str, _: User = Depends(get_admin_user)):
|
||||
return {"deleted": await User.objects.delete(username=username)}
|
||||
|
||||
|
||||
@router.delete("/user/email")
|
||||
async def delete_user_by_email(email: str, user: User = Depends(get_admin_user)):
|
||||
async def delete_user_by_email(email: str, _: User = Depends(get_admin_user)):
|
||||
return {"deleted": await User.objects.delete(email=email)}
|
||||
|
||||
@@ -83,19 +83,6 @@ async def get_customized_avatar(
|
||||
clothe_graphic_type=clothe_graphic_type,
|
||||
).render_svg()
|
||||
# skipcq: PY-W0069
|
||||
# print(f"skin_color: {len(AvatarItemsAsList.skin_color)},")
|
||||
# print(f"hair_color: {len(AvatarItemsAsList.hair_color)},")
|
||||
# print(f"facial_hair_type: {len(AvatarItemsAsList.facial_hair_type)},")
|
||||
# print(f"facial_hair_color: {len(AvatarItemsAsList.facial_hair_color)},")
|
||||
# print(f"top_type: {len(AvatarItemsAsList.top_type)},")
|
||||
# print(f"hat_color: {len(AvatarItemsAsList.hat_color)},")
|
||||
# print(f"mouth_type: {len(AvatarItemsAsList.mouth_type)},")
|
||||
# print(f"eyebrow_type: {len(AvatarItemsAsList.eyebrow_type)},")
|
||||
# print(f"nose_type: {len(AvatarItemsAsList.nose_type)},")
|
||||
# print(f"accessories_type: {len(AvatarItemsAsList.accessories_type)},")
|
||||
# print(f"clothe_type: {len(AvatarItemsAsList.clothe_type)},")
|
||||
# print(f"clothe_color: {len(AvatarItemsAsList.clothe_color)},")
|
||||
# print(f"clothe_graphic_type: {len(AvatarItemsAsList.clothe_graphic_type)},")
|
||||
return avatar
|
||||
|
||||
|
||||
|
||||
@@ -17,18 +17,24 @@ router = APIRouter()
|
||||
|
||||
|
||||
#
|
||||
@router.get("/user/{user_id}", response_model_include={"username", "created_at", "id"}, response_model=User)
|
||||
@router.get(
|
||||
"/user/{user_id}",
|
||||
response_model_include={"username", "created_at", "id"},
|
||||
response_model=User,
|
||||
)
|
||||
async def get_user_by_user_id(user_id: UUID):
|
||||
user = await User.objects.get_or_none(id=user_id)
|
||||
# .select_related("quizs")
|
||||
# print(user)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="user not found")
|
||||
else:
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/quizzes/{user_id}", response_model_exclude={"questions", "user_id"}, response_model=list[Quiz])
|
||||
@router.get(
|
||||
"/quizzes/{user_id}",
|
||||
response_model_exclude={"questions", "user_id"},
|
||||
response_model=list[Quiz],
|
||||
)
|
||||
async def get_quizzes_from_user(user_id: UUID, imported: bool | None = None):
|
||||
if imported is None:
|
||||
quizzes = await Quiz.objects.all(user_id=user_id, public=True)
|
||||
@@ -68,7 +74,13 @@ async def rate_quiz(data: RateQuizInput, quiz_id: uuid.UUID, user: User = Depend
|
||||
quiz.likes -= 1
|
||||
else:
|
||||
quiz.dislikes -= 1
|
||||
rating = Rating(id=uuid.uuid4(), user=user, positive=positive, quiz=quiz, created_at=datetime.now())
|
||||
rating = Rating(
|
||||
id=uuid.uuid4(),
|
||||
user=user,
|
||||
positive=positive,
|
||||
quiz=quiz,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
await rating.save()
|
||||
if positive:
|
||||
quiz.likes += 1
|
||||
|
||||
@@ -11,15 +11,27 @@ import asyncpg.exceptions
|
||||
import bleach
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
import html
|
||||
|
||||
from classquiz.config import settings, redis, storage, meilisearch, ALLOWED_TAGS_FOR_QUIZ, arq
|
||||
from classquiz.config import (
|
||||
settings,
|
||||
redis,
|
||||
storage,
|
||||
meilisearch,
|
||||
ALLOWED_TAGS_FOR_QUIZ,
|
||||
arq,
|
||||
)
|
||||
from classquiz.db.models import Quiz, QuizInput, User, QuizQuestionType, StorageItem
|
||||
from classquiz.auth import get_current_user
|
||||
import os
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from classquiz.helpers import get_meili_data, check_image_string, extract_image_ids_from_quiz
|
||||
from classquiz.helpers import (
|
||||
get_meili_data,
|
||||
check_image_string,
|
||||
extract_image_ids_from_quiz,
|
||||
)
|
||||
from classquiz.storage.errors import DeletionFailedError
|
||||
|
||||
settings = settings()
|
||||
@@ -79,15 +91,15 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput):
|
||||
quiz_input.background_color = bleach.clean(quiz_input.background_color, tags=[], strip=True)
|
||||
|
||||
for i, question in enumerate(quiz_input.questions):
|
||||
if question.type == QuizQuestionType.ABCD:
|
||||
if question.type == QuizQuestionType.ABCD or question.type == QuizQuestionType.VOTING:
|
||||
for i2, answer in enumerate(question.answers):
|
||||
if answer.color is not None:
|
||||
quiz_input.questions[i].answers[i2].color = bleach.clean(answer.color, tags=[], strip=True)
|
||||
if answer.answer == "":
|
||||
quiz_input.questions[i].answers[i2].answer = None
|
||||
if answer.answer is not None:
|
||||
quiz_input.questions[i].answers[i2].answer = bleach.clean(
|
||||
answer.answer, tags=ALLOWED_TAGS_FOR_QUIZ, strip=True
|
||||
quiz_input.questions[i].answers[i2].answer = html.unescape(
|
||||
bleach.clean(answer.answer, tags=ALLOWED_TAGS_FOR_QUIZ, strip=True)
|
||||
)
|
||||
|
||||
images_to_delete = []
|
||||
|
||||
@@ -37,7 +37,7 @@ class UUIDEncoder(json.JSONEncoder):
|
||||
|
||||
|
||||
@router.get("/{quiz_id}")
|
||||
async def export_quiz(quiz_id: uuid.UUID, user: User = Depends(get_current_user)):
|
||||
async def export_quiz(quiz_id: uuid.UUID, _: User = Depends(get_current_user)):
|
||||
try:
|
||||
quiz: Quiz = await Quiz.objects.filter(Quiz.id == quiz_id).first()
|
||||
except ormar.exceptions.NoMatch:
|
||||
@@ -138,7 +138,7 @@ async def import_quiz(file: UploadFile = File(), user: User = Depends(get_curren
|
||||
|
||||
|
||||
@router.get("/excel/{quiz_id}")
|
||||
async def export_quiz_as_excel(quiz_id: uuid.UUID, user: User = Depends(get_current_user)):
|
||||
async def export_quiz_as_excel(quiz_id: uuid.UUID, _: User = Depends(get_current_user)):
|
||||
try:
|
||||
quiz: Quiz = await Quiz.objects.filter(Quiz.id == quiz_id).first()
|
||||
except ormar.exceptions.NoMatch:
|
||||
|
||||
@@ -15,8 +15,14 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def list_game_results(user: User = Depends(get_current_user)) -> list[GameResults]:
|
||||
results = await GameResults.objects.select_related(GameResults.quiz).all(user=user.id)
|
||||
async def list_game_results(
|
||||
user: User = Depends(get_current_user),
|
||||
) -> list[GameResults]:
|
||||
results = (
|
||||
await GameResults.objects.select_related(GameResults.quiz)
|
||||
.order_by(GameResults.timestamp.desc())
|
||||
.all(user=user.id)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
|
||||
@@ -10,22 +10,18 @@ from datetime import datetime
|
||||
import ormar
|
||||
import pydantic
|
||||
from email_validator import validate_email, EmailNotValidError
|
||||
from fastapi import APIRouter, Response, HTTPException, Request, Depends, status
|
||||
from fastapi import APIRouter, Response, HTTPException, Request, Depends
|
||||
from fastapi.background import BackgroundTasks
|
||||
from fastapi.responses import JSONResponse, RedirectResponse, PlainTextResponse
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
from jose import jwt, JWTError
|
||||
|
||||
from classquiz import oauth
|
||||
from classquiz.helpers.avatar import gzipped_user_avatar
|
||||
import base64
|
||||
from classquiz.oauth.authenticate_user import rememberme_check, log_user_in
|
||||
|
||||
from classquiz.auth import (
|
||||
get_password_hash,
|
||||
verify_password,
|
||||
authenticate_user,
|
||||
get_current_user,
|
||||
)
|
||||
from classquiz.cache import clear_cache_for_account
|
||||
@@ -33,7 +29,7 @@ from classquiz.config import redis, settings, meilisearch
|
||||
import uuid
|
||||
import bleach
|
||||
from pydantic import BaseModel
|
||||
from classquiz.db.models import User, UserSession, UpdatePassword, Token, Quiz, ApiKey
|
||||
from classquiz.db.models import User, UserSession, UpdatePassword, Quiz, ApiKey
|
||||
from classquiz.emails import send_register_email, send_forgotten_password_email
|
||||
from classquiz.routers.users import webauthn, twofa
|
||||
|
||||
@@ -66,7 +62,12 @@ router.include_router(oauth.router, tags=["users", "oauth"], prefix="/oauth")
|
||||
async def create_user(user: RouteUser, background_task: BackgroundTasks) -> User | JSONResponse:
|
||||
if settings.registration_disabled:
|
||||
raise HTTPException(status_code=423)
|
||||
user = User(**user.model_dump(), id=uuid.uuid4(), avatar=gzipped_user_avatar(), created_at=datetime.now())
|
||||
user: User = User(
|
||||
**user.model_dump(),
|
||||
id=uuid.uuid4(),
|
||||
avatar=gzipped_user_avatar(),
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
try:
|
||||
validate_email(user.email)
|
||||
except EmailNotValidError as e:
|
||||
@@ -91,31 +92,6 @@ async def create_user(user: RouteUser, background_task: BackgroundTasks) -> User
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/token/cookie", response_model=Token, deprecated=True)
|
||||
async def login_for_cookie_access_token(
|
||||
request: Request,
|
||||
response: Response,
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
):
|
||||
user = await authenticate_user(form_data.username, form_data.password)
|
||||
user = await User.objects.select_related("fidocredentialss").get(id=user.id)
|
||||
if not user or user.totp_secret is not None or user or len(user.fidocredentialss) != 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
)
|
||||
|
||||
return await log_user_in(response=response, request=request, user=user)
|
||||
|
||||
|
||||
@router.get("/token/rememberme")
|
||||
async def rememberme_token(request: Request, response: Response):
|
||||
rememberme_token_lol = request.cookies.get("rememberme_token")
|
||||
if rememberme_token_lol is None:
|
||||
raise HTTPException(status_code=400, detail="No rememberme cookie")
|
||||
return await rememberme_check(rememberme_token=rememberme_token_lol, response=response)
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
async def logout(request: Request, response: Response):
|
||||
remember_token = request.cookies.get("rememberme_token")
|
||||
@@ -229,24 +205,26 @@ async def reset_password_with_token(reset_password: ResetPassword, response: Res
|
||||
return {"message": "Password updated successfully"}
|
||||
|
||||
|
||||
@router.get("/sessions/list", response_model=list[UserSession], response_model_exclude={"user", "session_key", "quizs"})
|
||||
@router.get(
|
||||
"/sessions/list",
|
||||
response_model=list[UserSession],
|
||||
response_model_exclude={"user", "session_key", "quizs"},
|
||||
)
|
||||
async def list_sessions(user: User = Depends(get_current_user)):
|
||||
sessions = await UserSession.objects.filter(user=user).all()
|
||||
return [session.model_dump() for session in sessions]
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}")
|
||||
async def delete_session(session_id: str, user: User = Depends(get_current_user)):
|
||||
try:
|
||||
session_id = uuid.UUID(session_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid session id")
|
||||
async def delete_session(session_id: uuid.UUID, user: User = Depends(get_current_user)):
|
||||
await UserSession.objects.filter(user=user, id=session_id).delete()
|
||||
return {"message": "Session deleted"}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/session", response_model=UserSession, response_model_exclude={"user": ..., "session_key": ..., "quizs": ...}
|
||||
"/session",
|
||||
response_model=UserSession,
|
||||
response_model_exclude={"user": ..., "session_key": ..., "quizs": ...},
|
||||
)
|
||||
async def get_session(request: Request, user: User = Depends(get_current_user)):
|
||||
try:
|
||||
@@ -300,43 +278,10 @@ class InternalAuthData(BaseModel):
|
||||
jwt: str | None = None
|
||||
|
||||
|
||||
@router.post("/auth/internal")
|
||||
async def internal_auth(data: InternalAuthData, resp: Response):
|
||||
try:
|
||||
data.jwt = data.jwt.replace("Bearer ", "")
|
||||
except AttributeError:
|
||||
pass
|
||||
if data.jwt is not None:
|
||||
try:
|
||||
payload = jwt.decode(data.jwt, settings.secret_key, algorithms=["HS256"])
|
||||
email: str = payload.get("sub")
|
||||
if email is None:
|
||||
resp.status_code = 401
|
||||
return resp
|
||||
except JWTError:
|
||||
resp.status_code = 401
|
||||
return resp
|
||||
else:
|
||||
return await rememberme_check(data.rememberme, resp)
|
||||
|
||||
|
||||
class GetEmailFromJWT(BaseModel):
|
||||
jwt: str
|
||||
|
||||
|
||||
@router.post("/auth/internal/email")
|
||||
async def get_email_from_jwt(data: GetEmailFromJWT):
|
||||
try:
|
||||
data.jwt = data.jwt.replace("Bearer ", "")
|
||||
except AttributeError:
|
||||
pass
|
||||
try:
|
||||
payload = jwt.decode(data.jwt, settings.secret_key, algorithms=["HS256"])
|
||||
return payload.get("sub")
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=401)
|
||||
|
||||
|
||||
@router.post("/api_keys", response_model=ApiKey, response_model_include={"key"})
|
||||
async def generate_api_key(user: User = Depends(get_current_user)):
|
||||
key = ApiKey(key=os.urandom(24).hex(), user=user)
|
||||
@@ -351,7 +296,7 @@ async def list_api_keys(user: User = Depends(get_current_user)):
|
||||
|
||||
|
||||
@router.delete("/api_keys")
|
||||
async def delete_api_key(api_key: str, user: User = Depends(get_current_user)):
|
||||
async def delete_api_key(api_key: str, _: User = Depends(get_current_user)):
|
||||
key = await ApiKey.objects.get_or_none(key=api_key)
|
||||
if key is None:
|
||||
raise HTTPException(status_code=404, detail="Key not found")
|
||||
|
||||
@@ -9,7 +9,6 @@ import json
|
||||
import os
|
||||
import random
|
||||
|
||||
import aiohttp
|
||||
import socketio
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
@@ -19,14 +18,28 @@ from classquiz.db.models import (
|
||||
QuizQuestionType,
|
||||
GameSession,
|
||||
GamePlayer,
|
||||
QuizQuestion,
|
||||
VotingQuizAnswer,
|
||||
AnswerDataList,
|
||||
AnswerData,
|
||||
)
|
||||
from pydantic import BaseModel, ValidationError, field_validator, ValidationInfo
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from datetime import datetime
|
||||
|
||||
from classquiz.socket_server.helpers import (
|
||||
check_answer,
|
||||
check_captcha,
|
||||
has_already_answered,
|
||||
)
|
||||
from .models import (
|
||||
RejoinGameData,
|
||||
JoinGameData,
|
||||
ReturnQuestion,
|
||||
SubmitAnswerData,
|
||||
RegisterAsAdminData,
|
||||
KickPlayerInput,
|
||||
ConnectSessionIdEvent,
|
||||
)
|
||||
|
||||
from classquiz.socket_server.export_helpers import save_quiz_to_storage
|
||||
from classquiz.socket_server.session import get_session, save_session
|
||||
|
||||
@@ -74,19 +87,6 @@ async def set_answer(answers, game_pin: str, q_index: int, data: AnswerData) ->
|
||||
return answers
|
||||
|
||||
|
||||
class _JoinGameData(BaseModel):
|
||||
username: str
|
||||
game_pin: str
|
||||
captcha: str | None = None
|
||||
custom_field: str | None = None
|
||||
|
||||
|
||||
class _RejoinGameData(BaseModel):
|
||||
old_sid: str
|
||||
game_pin: str
|
||||
username: str
|
||||
|
||||
|
||||
@sio.event
|
||||
async def rejoin_game(sid: str, data: dict):
|
||||
redis_res = await redis.get(f"game:{data['game_pin']}")
|
||||
@@ -94,7 +94,7 @@ async def rejoin_game(sid: str, data: dict):
|
||||
await sio.emit("game_not_found", room=sid)
|
||||
return
|
||||
try:
|
||||
data = _RejoinGameData(**data)
|
||||
data = RejoinGameData(**data)
|
||||
except ValidationError as e:
|
||||
await sio.emit("error", room=sid)
|
||||
print(e)
|
||||
@@ -106,10 +106,12 @@ async def rejoin_game(sid: str, data: dict):
|
||||
await sio.emit("time_sync", encrypted_datetime, room=sid)
|
||||
await redis.set(redis_sid_key, sid)
|
||||
await redis.srem(
|
||||
f"game_session:{data.game_pin}:players", GamePlayer(username=data.username, sid=data.old_sid).model_dump_json()
|
||||
f"game_session:{data.game_pin}:players",
|
||||
GamePlayer(username=data.username, sid=data.old_sid).model_dump_json(),
|
||||
)
|
||||
await redis.sadd(
|
||||
f"game_session:{data.game_pin}:players", GamePlayer(username=data.username, sid=sid).model_dump_json()
|
||||
f"game_session:{data.game_pin}:players",
|
||||
GamePlayer(username=data.username, sid=sid).model_dump_json(),
|
||||
)
|
||||
game_data = PlayGame.model_validate_json(redis_res)
|
||||
session = {
|
||||
@@ -122,10 +124,7 @@ async def rejoin_game(sid: str, data: dict):
|
||||
await sio.enter_room(sid, data.game_pin)
|
||||
await sio.emit(
|
||||
"rejoined_game",
|
||||
{
|
||||
**json.loads(game_data.model_dump_json(exclude={"quiz_id", "questions", "user_id"})),
|
||||
"question_count": len(game_data.questions),
|
||||
},
|
||||
game_data.to_player_data(),
|
||||
room=sid,
|
||||
)
|
||||
|
||||
@@ -137,7 +136,7 @@ async def join_game(sid: str, data: dict):
|
||||
await sio.emit("game_not_found", room=sid)
|
||||
return
|
||||
try:
|
||||
data = _JoinGameData(**data)
|
||||
data = JoinGameData(**data)
|
||||
except ValidationError as e:
|
||||
await sio.emit("error", room=sid)
|
||||
print(e)
|
||||
@@ -148,42 +147,9 @@ async def join_game(sid: str, data: dict):
|
||||
return
|
||||
# +++ START checking captcha +++
|
||||
if game_data.captcha_enabled:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
try:
|
||||
if settings.hcaptcha_key is not None:
|
||||
try:
|
||||
async with session.post(
|
||||
"https://hcaptcha.com/siteverify",
|
||||
data={
|
||||
"response": data.captcha,
|
||||
"secret": settings.hcaptcha_key,
|
||||
},
|
||||
) as resp:
|
||||
resp_data = await resp.model_dump_json()
|
||||
if not resp_data["success"]:
|
||||
print("CAPTCHA FAILED")
|
||||
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
|
||||
captcha_res = check_captcha(data.captcha)
|
||||
if not captcha_res:
|
||||
return
|
||||
# --- END checking captcha ---
|
||||
if await redis.get(f"game_session:{data.game_pin}:players:{data.username}") is not None:
|
||||
await sio.emit("username_already_exists", room=sid)
|
||||
@@ -198,18 +164,12 @@ async def join_game(sid: str, data: dict):
|
||||
await save_session(sid, sio, session)
|
||||
await sio.emit(
|
||||
"joined_game",
|
||||
{
|
||||
**json.loads(game_data.model_dump_json(exclude={"quiz_id", "questions", "user_id"})),
|
||||
"question_count": len(game_data.questions),
|
||||
},
|
||||
game_data.to_player_data(),
|
||||
room=sid,
|
||||
)
|
||||
redis_res = await redis.get(f"game_session:{data.game_pin}")
|
||||
redis_res = GameSession.model_validate_json(redis_res)
|
||||
await redis.set(f"game_session:{data.game_pin}:players:{data.username}", sid, ex=7200)
|
||||
await redis.sadd(
|
||||
f"game_session:{data.game_pin}:players", GamePlayer(username=data.username, sid=sid).model_dump_json()
|
||||
)
|
||||
await GamePlayer(username=data.username, sid=sid).to_player_stack(data.game_pin)
|
||||
|
||||
if data.custom_field == "":
|
||||
data.custom_field = None
|
||||
if data.custom_field is not None:
|
||||
@@ -236,49 +196,36 @@ async def start_game(sid: str, _data: dict):
|
||||
session = await get_session(sid, sio)
|
||||
if not session["admin"]:
|
||||
return
|
||||
game_data = PlayGame.model_validate_json(await redis.get(f"game:{session['game_pin']}"))
|
||||
game_data = await PlayGame.get_from_redis(session["game_pin"])
|
||||
game_data.started = True
|
||||
await redis.set(f"game:{session['game_pin']}", game_data.model_dump_json(), ex=7200)
|
||||
await game_data.save(session["game_pin"])
|
||||
await redis.delete(f"game_in_lobby:{game_data.user_id.hex}")
|
||||
await sio.emit("start_game", room=session["game_pin"])
|
||||
|
||||
|
||||
class _RegisterAsAdminData(BaseModel):
|
||||
game_pin: str
|
||||
game_id: str
|
||||
|
||||
|
||||
@sio.event
|
||||
async def register_as_admin(sid: str, data: dict):
|
||||
try:
|
||||
data = _RegisterAsAdminData(**data)
|
||||
data = RegisterAsAdminData(**data)
|
||||
except ValidationError as e:
|
||||
await sio.emit("error", room=sid)
|
||||
print(e)
|
||||
return
|
||||
game_pin = data.game_pin
|
||||
game_id = data.game_id
|
||||
if (await redis.get(f"game_session:{game_pin}")) is None:
|
||||
await redis.set(
|
||||
f"game_session:{game_pin}",
|
||||
GameSession(admin=sid, game_id=game_id, answers=[]).model_dump_json(),
|
||||
ex=7200,
|
||||
)
|
||||
|
||||
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:
|
||||
if await redis.get(f"game_session:{game_pin}") is not None:
|
||||
await sio.emit("already_registered_as_admin", room=sid)
|
||||
return
|
||||
await GameSession(admin=sid, game_id=game_id, answers=[]).save(game_pin)
|
||||
await sio.emit(
|
||||
"registered_as_admin",
|
||||
{"game_id": game_id, "game": await redis.get(f"game:{game_pin}")},
|
||||
room=sid,
|
||||
)
|
||||
session = {"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
|
||||
@@ -286,57 +233,25 @@ async def get_question_results(sid: str, data: dict):
|
||||
session = await get_session(sid, sio)
|
||||
if not session["admin"]:
|
||||
return
|
||||
|
||||
redis_res = await redis.get(f"game_session:{session['game_pin']}:{data['question_number']}")
|
||||
if redis_res is None:
|
||||
redis_res = []
|
||||
else:
|
||||
redis_res = AnswerDataList.model_validate_json(redis_res).model_dump()
|
||||
game_data = PlayGame.model_validate_json(await redis.get(f"game:{session['game_pin']}"))
|
||||
game_data.question_show = False
|
||||
await redis.set(f"game:{session['game_pin']}", game_data.model_dump_json())
|
||||
game_pin = session["game_pin"]
|
||||
|
||||
await sio.emit("question_results", redis_res, room=game_pin)
|
||||
|
||||
|
||||
class ABCDQuizAnswerWithoutSolution(BaseModel):
|
||||
answer: str
|
||||
color: str | None = None
|
||||
|
||||
|
||||
class RangeQuizAnswerWithoutSolution(BaseModel):
|
||||
min: int
|
||||
max: int
|
||||
|
||||
|
||||
class ReturnQuestion(QuizQuestion):
|
||||
answers: list[ABCDQuizAnswerWithoutSolution] | RangeQuizAnswerWithoutSolution | list[VotingQuizAnswer]
|
||||
type: QuizQuestionType = QuizQuestionType.ABCD
|
||||
|
||||
@field_validator("answers")
|
||||
def answers_not_none_if_abcd_type(cls, v, info: ValidationInfo):
|
||||
if info.data["type"] == QuizQuestionType.ABCD and type(v[0]) is not ABCDQuizAnswerWithoutSolution:
|
||||
raise ValueError("Answers can't be none if type is ABCD")
|
||||
if info.data["type"] == QuizQuestionType.RANGE and type(v) is not RangeQuizAnswerWithoutSolution:
|
||||
raise ValueError("Answer must be from type RangeQuizAnswer if type is RANGE")
|
||||
# skipcq: PTC-W0047
|
||||
if info.data["type"] == QuizQuestionType.VOTING and type(v[0]) is not VotingQuizAnswer:
|
||||
pass
|
||||
return v
|
||||
answer_data_list = await AnswerDataList.get_redis_or_empty(game_pin, data["question_number"])
|
||||
game_data = await PlayGame.get_from_redis(game_pin)
|
||||
game_data.question_show = False
|
||||
await game_data.save(game_pin)
|
||||
await sio.emit("question_results", answer_data_list.model_dump(), room=game_pin)
|
||||
|
||||
|
||||
@sio.event
|
||||
async def set_question_number(sid, data: str):
|
||||
async def set_question_number(sid: str, data: str):
|
||||
# data is just a number (as a str) of the question
|
||||
session = await get_session(sid, sio)
|
||||
if not session["admin"]:
|
||||
return
|
||||
game_pin = session["game_pin"]
|
||||
game_data = PlayGame.model_validate_json(await redis.get(f"game:{session['game_pin']}"))
|
||||
game_data = await PlayGame.get_from_redis(session["game_pin"])
|
||||
game_data.current_question = int(float(data))
|
||||
game_data.question_show = True
|
||||
await redis.set(f"game:{session['game_pin']}", game_data.model_dump_json(), ex=7200)
|
||||
await game_data.save(session["game_pin"])
|
||||
await redis.set(f"game:{session['game_pin']}:current_time", datetime.now().isoformat(), ex=7200)
|
||||
temp_return = game_data.model_dump(include={"questions"})["questions"][int(float(data))]
|
||||
if game_data.questions[int(float(data))].type == QuizQuestionType.SLIDE:
|
||||
@@ -364,93 +279,39 @@ async def set_question_number(sid, data: str):
|
||||
)
|
||||
|
||||
|
||||
class _SubmitAnswerDataOrderType(BaseModel):
|
||||
answer: str
|
||||
|
||||
|
||||
class _SubmitAnswerData(BaseModel):
|
||||
question_index: int
|
||||
answer: str | int
|
||||
complex_answer: list[_SubmitAnswerDataOrderType] | None = None
|
||||
|
||||
|
||||
@sio.event
|
||||
async def submit_answer(sid: str, data: dict):
|
||||
now = datetime.now()
|
||||
try:
|
||||
data = _SubmitAnswerData(**data)
|
||||
data = SubmitAnswerData(**data)
|
||||
except ValidationError as e:
|
||||
await sio.emit("error", room=sid)
|
||||
print(e)
|
||||
return
|
||||
data.answer = str(data.answer)
|
||||
session = await get_session(sid, sio)
|
||||
game_data = PlayGame.model_validate_json(await redis.get(f"game:{session['game_pin']}"))
|
||||
answer_right = False
|
||||
if game_data.questions[int(float(data.question_index))].type == QuizQuestionType.ABCD:
|
||||
for answer in game_data.questions[int(float(data.question_index))].answers:
|
||||
if answer.answer == data.answer and answer.right:
|
||||
answer_right = True
|
||||
break
|
||||
elif game_data.questions[int(float(data.question_index))].type == QuizQuestionType.RANGE:
|
||||
if (
|
||||
game_data.questions[int(float(data.question_index))].answers.min_correct
|
||||
<= int(float(data.answer))
|
||||
<= game_data.questions[int(float(data.question_index))].answers.max_correct
|
||||
):
|
||||
answer_right = True
|
||||
elif game_data.questions[int(float(data.question_index))].type == QuizQuestionType.VOTING:
|
||||
answer_right = False
|
||||
elif game_data.questions[int(float(data.question_index))].type == QuizQuestionType.ORDER:
|
||||
if data.complex_answer is None:
|
||||
answer_right = False
|
||||
else:
|
||||
question = game_data.questions[int(float(data.question_index))]
|
||||
correct_answers = []
|
||||
for a in question.answers:
|
||||
correct_answers.append({"answer": a.answer})
|
||||
answer_order = []
|
||||
for a in data.model_dump()["complex_answer"]:
|
||||
answer_order.append(a["answer"])
|
||||
data.answer = ", ".join(answer_order)
|
||||
if correct_answers == data.model_dump()["complex_answer"]:
|
||||
answer_right = True
|
||||
|
||||
elif game_data.questions[int(float(data.question_index))].type == QuizQuestionType.TEXT:
|
||||
answer_right = False
|
||||
for q in game_data.questions[int(float(data.question_index))].answers:
|
||||
if q.case_sensitive:
|
||||
if data.answer == q.answer:
|
||||
answer_right = True
|
||||
break
|
||||
else:
|
||||
if data.answer.lower() == q.answer.lower():
|
||||
answer_right = True
|
||||
break
|
||||
elif game_data.questions[int(data.question_index)].type == QuizQuestionType.CHECK:
|
||||
correct_string = ""
|
||||
for i, a in enumerate(game_data.questions[int(float(data.question_index))].answers):
|
||||
if a.right:
|
||||
correct_string += str(i)
|
||||
answer_right = bool(correct_string == data.answer)
|
||||
else:
|
||||
raise NotImplementedError
|
||||
latency = int(float((await get_session(sid, sio))["ping"]))
|
||||
question_index = int(float(data.question_index))
|
||||
game_data = await PlayGame.get_from_redis(session["game_pin"])
|
||||
already_answered = await has_already_answered(session["game_pin"], question_index, session["username"])
|
||||
if already_answered:
|
||||
await sio.emit("already_replied", room=sid)
|
||||
return
|
||||
answer_right, answer = check_answer(game_data, data)
|
||||
latency = int(float(session["ping"]))
|
||||
time_q_started = datetime.fromisoformat(await redis.get(f"game:{session['game_pin']}:current_time"))
|
||||
|
||||
diff = (time_q_started - now).total_seconds() * 1000 # - timedelta(milliseconds=latency)
|
||||
score = 0
|
||||
if answer_right:
|
||||
score = calculate_score(
|
||||
abs(diff) - latency,
|
||||
int(float(game_data.questions[int(float(data.question_index))].time)),
|
||||
int(float(game_data.questions[question_index].time)),
|
||||
)
|
||||
if score > 1000:
|
||||
score = 1000
|
||||
await redis.hincrby(f"game_session:{session['game_pin']}:player_scores", session["username"], score)
|
||||
answer_data = AnswerData(
|
||||
username=session["username"],
|
||||
answer=data.answer,
|
||||
answer=answer,
|
||||
right=answer_right,
|
||||
time_taken=abs(diff) - latency,
|
||||
score=score,
|
||||
@@ -465,26 +326,18 @@ async def submit_answer(sid: str, data: dict):
|
||||
player_count = await redis.scard(f"game_session:{session['game_pin']}:players")
|
||||
await sio.emit("player_answer", {})
|
||||
if len(answers) == player_count:
|
||||
# await sio.emit(
|
||||
# "question_results",
|
||||
# await redis.get(f"game_session:{session['game_pin']}:{data.question_index}"),
|
||||
# room=session["game_pin"],
|
||||
# )
|
||||
game_data = PlayGame.model_validate_json(await redis.get(f"game:{session['game_pin']}"))
|
||||
game_data = await PlayGame.get_from_redis(session["game_pin"])
|
||||
game_data.question_show = False
|
||||
await redis.set(f"game:{session['game_pin']}", game_data.model_dump_json())
|
||||
await game_data.save(session["game_pin"])
|
||||
await sio.emit("everyone_answered", {})
|
||||
|
||||
|
||||
# await redis.set(f"game_data:{session['game_pin']}", json.dumps(data))
|
||||
|
||||
|
||||
@sio.event
|
||||
async def get_final_results(sid: str, _data: dict):
|
||||
session: dict = await get_session(sid, sio)
|
||||
game_data = PlayGame(**json.loads(await redis.get(f"game:{session['game_pin']}")))
|
||||
if not session["admin"]:
|
||||
return
|
||||
game_data = await PlayGame.get_from_redis(session["game_pin"])
|
||||
results = await generate_final_results(game_data, session["game_pin"])
|
||||
await sio.emit("final_results", results, room=session["game_pin"])
|
||||
|
||||
@@ -494,7 +347,7 @@ async def get_export_token(sid: str):
|
||||
session = await get_session(sid, sio)
|
||||
if not session["admin"]:
|
||||
return
|
||||
game_data = PlayGame(**json.loads(await redis.get(f"game:{session['game_pin']}")))
|
||||
game_data = await PlayGame.get_from_redis(session["game_pin"])
|
||||
results = await generate_final_results(game_data, session["game_pin"])
|
||||
token = os.urandom(32).hex()
|
||||
await redis.set(f"export_token:{token}", json.dumps(results), ex=7200)
|
||||
@@ -504,10 +357,14 @@ async def get_export_token(sid: str):
|
||||
@sio.event
|
||||
async def show_solutions(sid: str, _data: dict):
|
||||
session: dict = await get_session(sid, sio)
|
||||
game_data = PlayGame(**json.loads(await redis.get(f"game:{session['game_pin']}")))
|
||||
game_data = await PlayGame.get_from_redis(session["game_pin"])
|
||||
if not session["admin"]:
|
||||
return
|
||||
await sio.emit("solutions", game_data.questions[game_data.current_question].model_dump(), room=session["game_pin"])
|
||||
await sio.emit(
|
||||
"solutions",
|
||||
game_data.questions[game_data.current_question].model_dump(),
|
||||
room=session["game_pin"],
|
||||
)
|
||||
|
||||
|
||||
@sio.event
|
||||
@@ -521,14 +378,10 @@ async def echo_time_sync(sid: str, data: str):
|
||||
await save_session(sid, sio, session)
|
||||
|
||||
|
||||
class _KickPlayerInput(BaseModel):
|
||||
username: str
|
||||
|
||||
|
||||
@sio.event
|
||||
async def kick_player(sid: str, data: dict):
|
||||
try:
|
||||
data = _KickPlayerInput(**data)
|
||||
data = KickPlayerInput(**data)
|
||||
except ValidationError as e:
|
||||
await sio.emit("error", room=sid)
|
||||
print(e)
|
||||
@@ -604,10 +457,6 @@ async def save_quiz(sid: str):
|
||||
await sio.emit("results_saved_successfully")
|
||||
|
||||
|
||||
class ConnectSessionIdEvent(BaseModel):
|
||||
session_id: str
|
||||
|
||||
|
||||
@sio.event
|
||||
async def connect(sid: str, _environ, _auth):
|
||||
session_id = os.urandom(16).hex()
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# SPDX-FileCopyrightText: 2025 Marlon W (Mawoka)
|
||||
#
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
import aiohttp
|
||||
from classquiz.config import settings
|
||||
from classquiz.db.models import (
|
||||
PlayGame,
|
||||
QuizQuestionType,
|
||||
TextQuizAnswer,
|
||||
ABCDQuizAnswer,
|
||||
VotingQuizAnswer,
|
||||
RangeQuizAnswer,
|
||||
AnswerDataList,
|
||||
)
|
||||
from classquiz.socket_server.models import SubmitAnswerData
|
||||
from .models import SubmitAnswerDataOrderType
|
||||
|
||||
|
||||
async def check_captcha(captcha_data: str) -> bool:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
try:
|
||||
if settings.hcaptcha_key is not None:
|
||||
try:
|
||||
async with session.post(
|
||||
"https://hcaptcha.com/siteverify",
|
||||
data={
|
||||
"response": captcha_data,
|
||||
"secret": settings.hcaptcha_key,
|
||||
},
|
||||
) as resp:
|
||||
resp_data = await resp.model_dump_json()
|
||||
if not resp_data["success"]:
|
||||
return
|
||||
except KeyError:
|
||||
return False
|
||||
elif settings.recaptcha_key is not None:
|
||||
async with session.post(
|
||||
"https://www.google.com/recaptcha/api/siteverify",
|
||||
data={
|
||||
"secret": settings.recaptcha_key,
|
||||
"response": captcha_data,
|
||||
},
|
||||
) as resp:
|
||||
try:
|
||||
resp_data = await resp.model_dump_json()
|
||||
if not resp_data["success"]:
|
||||
return False
|
||||
except KeyError:
|
||||
return False
|
||||
except TypeError:
|
||||
pass
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def check_answer(game_data: PlayGame, data: SubmitAnswerData) -> (bool, str):
|
||||
q_i = int(float(data.question_index))
|
||||
q_type = game_data.questions[q_i].type
|
||||
q_answers = game_data.questions[q_i].answers
|
||||
q_answer = data.answer
|
||||
if q_type == QuizQuestionType.ABCD:
|
||||
return (check_abcd_question(q_answer, q_answers), data.answer)
|
||||
elif q_type == QuizQuestionType.RANGE:
|
||||
return (
|
||||
check_range_question(q_answer, q_answers),
|
||||
q_answer,
|
||||
)
|
||||
elif q_type == QuizQuestionType.VOTING:
|
||||
return (False, q_answer)
|
||||
elif q_type == QuizQuestionType.ORDER:
|
||||
return check_order_question(data.complex_answer, q_answer, q_answers)
|
||||
elif q_type == QuizQuestionType.TEXT:
|
||||
return (
|
||||
check_text_question(q_answer, q_answers),
|
||||
q_answer,
|
||||
)
|
||||
|
||||
elif q_type == QuizQuestionType.CHECK:
|
||||
return (
|
||||
check_check_question(q_answer, q_answers),
|
||||
q_answer,
|
||||
)
|
||||
else:
|
||||
return (False, q_answer)
|
||||
return (False, q_answer)
|
||||
|
||||
|
||||
def check_abcd_question(answer: str, answers: ABCDQuizAnswer) -> bool:
|
||||
for a in answers:
|
||||
if a.answer == answer and a.right:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def check_range_question(answer: str, answers: RangeQuizAnswer) -> bool:
|
||||
if answers.min_correct <= int(float(answer)) <= answers.max_correct:
|
||||
return answers.min_correct <= int(float(answer)) <= answers.max_correct
|
||||
|
||||
|
||||
def check_order_question(
|
||||
complex_answer: list[SubmitAnswerDataOrderType] | None,
|
||||
answer: str,
|
||||
answers: list[VotingQuizAnswer],
|
||||
) -> (bool, str):
|
||||
if complex_answer is None:
|
||||
return (False, answer)
|
||||
correct_answers = [{"answer": a.answer} for a in answers]
|
||||
submitted_answers = [{"answer": a.answer} for a in complex_answer]
|
||||
answer_str = ", ".join(a["answer"] for a in submitted_answers)
|
||||
is_correct = submitted_answers == correct_answers
|
||||
return is_correct, answer_str
|
||||
|
||||
|
||||
def check_text_question(answer: str, answers: list[TextQuizAnswer]) -> bool:
|
||||
for q in answers:
|
||||
if q.case_sensitive:
|
||||
if answer == q.answer:
|
||||
return True
|
||||
else:
|
||||
if answer.lower() == q.answer.lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def check_check_question(answer: str, answers: list[ABCDQuizAnswer]) -> bool:
|
||||
correct_string = ""
|
||||
for i, a in enumerate(answers):
|
||||
if a.right:
|
||||
correct_string += str(i)
|
||||
return bool(correct_string == answer)
|
||||
|
||||
|
||||
async def has_already_answered(game_pin: str, q_index: int, username: str) -> bool:
|
||||
answers = await AnswerDataList.get_redis_or_empty(game_pin, q_index)
|
||||
if answers is None:
|
||||
return False
|
||||
else:
|
||||
answers = list(filter(lambda a: a.username == username, answers.root))
|
||||
return len(answers) > 0
|
||||
@@ -0,0 +1,68 @@
|
||||
# SPDX-FileCopyrightText: 2025 Marlon W (Mawoka)
|
||||
#
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
from pydantic import BaseModel, field_validator, ValidationInfo
|
||||
from classquiz.db.models import QuizQuestion, QuizQuestionType, VotingQuizAnswer
|
||||
|
||||
|
||||
class JoinGameData(BaseModel):
|
||||
username: str
|
||||
game_pin: str
|
||||
captcha: str | None = None
|
||||
custom_field: str | None = None
|
||||
|
||||
|
||||
class RejoinGameData(BaseModel):
|
||||
old_sid: str
|
||||
game_pin: str
|
||||
username: str
|
||||
|
||||
|
||||
class RegisterAsAdminData(BaseModel):
|
||||
game_pin: str
|
||||
game_id: str
|
||||
|
||||
|
||||
class ABCDQuizAnswerWithoutSolution(BaseModel):
|
||||
answer: str
|
||||
color: str | None = None
|
||||
|
||||
|
||||
class RangeQuizAnswerWithoutSolution(BaseModel):
|
||||
min: int
|
||||
max: int
|
||||
|
||||
|
||||
class ReturnQuestion(QuizQuestion):
|
||||
answers: list[ABCDQuizAnswerWithoutSolution] | RangeQuizAnswerWithoutSolution | list[VotingQuizAnswer]
|
||||
type: QuizQuestionType = QuizQuestionType.ABCD
|
||||
|
||||
@field_validator("answers")
|
||||
def answers_not_none_if_abcd_type(cls, v, info: ValidationInfo):
|
||||
if info.data["type"] == QuizQuestionType.ABCD and type(v[0]) is not ABCDQuizAnswerWithoutSolution:
|
||||
raise ValueError("Answers can't be none if type is ABCD")
|
||||
if info.data["type"] == QuizQuestionType.RANGE and type(v) is not RangeQuizAnswerWithoutSolution:
|
||||
raise ValueError("Answer must be from type RangeQuizAnswer if type is RANGE")
|
||||
# skipcq: PTC-W0047
|
||||
if info.data["type"] == QuizQuestionType.VOTING and type(v[0]) is not VotingQuizAnswer:
|
||||
pass
|
||||
return v
|
||||
|
||||
|
||||
class SubmitAnswerDataOrderType(BaseModel):
|
||||
answer: str
|
||||
|
||||
|
||||
class SubmitAnswerData(BaseModel):
|
||||
question_index: int
|
||||
answer: str | int
|
||||
complex_answer: list[SubmitAnswerDataOrderType] | None = None
|
||||
|
||||
|
||||
class KickPlayerInput(BaseModel):
|
||||
username: str
|
||||
|
||||
|
||||
class ConnectSessionIdEvent(BaseModel):
|
||||
session_id: str
|
||||
@@ -11,7 +11,11 @@ from typing import Tuple, BinaryIO, Generator
|
||||
from aiohttp import ClientSession
|
||||
import minio
|
||||
from pydantic import BaseModel
|
||||
from classquiz.storage.errors import DeletionFailedError, SavingFailedError, DownloadingFailedError
|
||||
from classquiz.storage.errors import (
|
||||
DeletionFailedError,
|
||||
SavingFailedError,
|
||||
DownloadingFailedError,
|
||||
)
|
||||
|
||||
|
||||
class S3Storage:
|
||||
@@ -19,7 +23,14 @@ class S3Storage:
|
||||
params: dict[str, str]
|
||||
headers: dict[str, str]
|
||||
|
||||
def __init__(self, base_url: str, access_key: str, secret_key: str, bucket_name: str, region: str = "us-east-1"):
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
access_key: str,
|
||||
secret_key: str,
|
||||
bucket_name: str,
|
||||
region: str = "us-east-1",
|
||||
):
|
||||
self.base_url = base_url
|
||||
self.access_key = access_key
|
||||
self.secret_key = secret_key
|
||||
@@ -31,85 +42,98 @@ class S3Storage:
|
||||
if not self.client.bucket_exists(self.bucket_name):
|
||||
self.client.make_bucket(self.bucket_name)
|
||||
|
||||
def _generate_aws_signature_v4(self, method: str, path: str, expiry: int = None) -> Tuple[dict, str]:
|
||||
def _generate_aws_signature_v4(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
expiry: int | None = None,
|
||||
payload_hash: str | None = None,
|
||||
) -> Tuple[dict, str]:
|
||||
path = f"/{self.bucket_name}{path}"
|
||||
service = "s3"
|
||||
|
||||
# Create a timestamp for the request
|
||||
# --- Timestamp ---
|
||||
t = datetime.utcnow()
|
||||
amz_date = t.strftime("%Y%m%dT%H%M%SZ")
|
||||
datestamp = t.strftime("%Y%m%d")
|
||||
|
||||
# Create a canonical request
|
||||
# --- Canonical request parts ---
|
||||
canonical_uri = path
|
||||
canonical_querystring = ""
|
||||
if expiry is not None:
|
||||
canonical_querystring = f"Expires={expiry}"
|
||||
canonical_headers = "host:" + self.host + "\n" + "x-amz-date:" + amz_date + "\n"
|
||||
signed_headers = "host;x-amz-date"
|
||||
payload_hash = hashlib.sha256("".encode("utf-8")).hexdigest()
|
||||
|
||||
# For S3, the payload hash must be included and signed
|
||||
if payload_hash is None:
|
||||
payload_hash = hashlib.sha256(b"").hexdigest()
|
||||
|
||||
canonical_headers = f"host:{self.host}\n" f"x-amz-content-sha256:{payload_hash}\n" f"x-amz-date:{amz_date}\n"
|
||||
signed_headers = "host;x-amz-content-sha256;x-amz-date"
|
||||
|
||||
canonical_request = (
|
||||
method
|
||||
+ "\n"
|
||||
+ canonical_uri
|
||||
+ "\n"
|
||||
+ canonical_querystring
|
||||
+ "\n"
|
||||
+ canonical_headers
|
||||
+ "\n"
|
||||
+ signed_headers
|
||||
+ "\n"
|
||||
+ payload_hash
|
||||
f"{method}\n"
|
||||
f"{canonical_uri}\n"
|
||||
f"{canonical_querystring}\n"
|
||||
f"{canonical_headers}\n"
|
||||
f"{signed_headers}\n"
|
||||
f"{payload_hash}"
|
||||
)
|
||||
|
||||
# Create a string to sign
|
||||
# --- String to sign ---
|
||||
algorithm = "AWS4-HMAC-SHA256"
|
||||
credential_scope = datestamp + "/" + self.region + "/" + service + "/" + "aws4_request"
|
||||
credential_scope = f"{datestamp}/{self.region}/{service}/aws4_request"
|
||||
string_to_sign = (
|
||||
algorithm
|
||||
+ "\n"
|
||||
+ amz_date
|
||||
+ "\n"
|
||||
+ credential_scope
|
||||
+ "\n"
|
||||
+ hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()
|
||||
f"{algorithm}\n"
|
||||
f"{amz_date}\n"
|
||||
f"{credential_scope}\n"
|
||||
f"{hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()}"
|
||||
)
|
||||
|
||||
# Create a signing key
|
||||
k_date = hmac.new(
|
||||
("AWS4" + self.secret_key).encode("utf-8"), datestamp.encode("utf-8"), hashlib.sha256
|
||||
).digest()
|
||||
k_region = hmac.new(k_date, self.region.encode("utf-8"), hashlib.sha256).digest()
|
||||
k_service = hmac.new(k_region, service.encode("utf-8"), hashlib.sha256).digest()
|
||||
k_signing = hmac.new(k_service, b"aws4_request", hashlib.sha256).digest()
|
||||
# --- Derive signing key ---
|
||||
def sign(key, msg):
|
||||
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
||||
|
||||
# Calculate the signature
|
||||
k_date = sign(("AWS4" + self.secret_key).encode("utf-8"), datestamp)
|
||||
k_region = sign(k_date, self.region)
|
||||
k_service = sign(k_region, service)
|
||||
k_signing = sign(k_service, "aws4_request")
|
||||
|
||||
# --- Signature ---
|
||||
signature = hmac.new(k_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
# Add the signature to the request as an Authorization header
|
||||
# --- Authorization header ---
|
||||
authorization_header = (
|
||||
algorithm
|
||||
+ " "
|
||||
+ "Credential="
|
||||
+ self.access_key
|
||||
+ "/"
|
||||
+ credential_scope
|
||||
+ ", "
|
||||
+ "SignedHeaders="
|
||||
+ signed_headers
|
||||
+ ", "
|
||||
+ "Signature="
|
||||
+ signature
|
||||
f"{algorithm} "
|
||||
f"Credential={self.access_key}/{credential_scope}, "
|
||||
f"SignedHeaders={signed_headers}, "
|
||||
f"Signature={signature}"
|
||||
)
|
||||
# Send the request with the authorization header
|
||||
headers = {"x-amz-date": amz_date, "Authorization": authorization_header}
|
||||
request_url = self.base_url + path + "?" + canonical_querystring
|
||||
|
||||
headers = {
|
||||
"x-amz-date": amz_date,
|
||||
"x-amz-content-sha256": payload_hash,
|
||||
"Authorization": authorization_header,
|
||||
}
|
||||
|
||||
request_url = self.base_url + path
|
||||
if canonical_querystring:
|
||||
request_url += f"?{canonical_querystring}"
|
||||
|
||||
return headers, request_url
|
||||
|
||||
# skipcq: PYL-W0613
|
||||
async def upload(self, file: BinaryIO, file_name: str, size: int | None, mime_type: str | None = None) -> None:
|
||||
headers, url = self._generate_aws_signature_v4(method="PUT", path=f"/{file_name}")
|
||||
async def upload(
|
||||
self,
|
||||
file: BinaryIO,
|
||||
file_name: str,
|
||||
size: int | None,
|
||||
mime_type: str | None = None,
|
||||
) -> None:
|
||||
file.seek(0)
|
||||
payload = file.read()
|
||||
payload_hash = hashlib.sha256(payload).hexdigest()
|
||||
file.seek(0)
|
||||
headers, url = self._generate_aws_signature_v4(method="PUT", path=f"/{file_name}", payload_hash=payload_hash)
|
||||
file_size = 0
|
||||
while True:
|
||||
chunk = file.read(1024)
|
||||
@@ -119,7 +143,10 @@ class S3Storage:
|
||||
break
|
||||
headers["Content-Length"] = str(file_size)
|
||||
|
||||
async with ClientSession() as session, session.put(url, headers=headers, data=file) as resp:
|
||||
async with (
|
||||
ClientSession() as session,
|
||||
session.put(url, headers=headers, data=file) as resp,
|
||||
):
|
||||
if resp.status == 200:
|
||||
return None
|
||||
else:
|
||||
@@ -129,7 +156,10 @@ class S3Storage:
|
||||
async def delete(self, file_names: list[str]) -> None:
|
||||
for file in file_names:
|
||||
headers, url = self._generate_aws_signature_v4(method="DELETE", path=f"/{file}")
|
||||
async with ClientSession() as session, session.delete(url, headers=headers) as resp:
|
||||
async with (
|
||||
ClientSession() as session,
|
||||
session.delete(url, headers=headers) as resp,
|
||||
):
|
||||
if resp.status == 204:
|
||||
return None
|
||||
else:
|
||||
@@ -137,7 +167,9 @@ class S3Storage:
|
||||
|
||||
def get_url(self, expire: int, file_name: str) -> str:
|
||||
return self.client.presigned_get_object(
|
||||
object_name=file_name, bucket_name=self.bucket_name, expires=timedelta(seconds=expire)
|
||||
object_name=file_name,
|
||||
bucket_name=self.bucket_name,
|
||||
expires=timedelta(seconds=expire),
|
||||
)
|
||||
|
||||
def size(self, file_name: str) -> int | None:
|
||||
@@ -150,7 +182,10 @@ class S3Storage:
|
||||
async def download(self, file_name: str) -> Generator:
|
||||
headers, url = self._generate_aws_signature_v4(method="GET", path=f"/{file_name}")
|
||||
|
||||
async with ClientSession() as session, session.get(url, headers=headers) as resp:
|
||||
async with (
|
||||
ClientSession() as session,
|
||||
session.get(url, headers=headers) as resp,
|
||||
):
|
||||
if resp.status == 200:
|
||||
async for i in resp.content.iter_chunked(1024):
|
||||
yield i
|
||||
|
||||
+12
-9
@@ -4,10 +4,10 @@
|
||||
|
||||
services:
|
||||
db:
|
||||
# build:
|
||||
# context: pg_uuidv7
|
||||
# args:
|
||||
# PG_MAJOR_VERSION: 16
|
||||
# build:
|
||||
# context: pg_uuidv7
|
||||
# args:
|
||||
# PG_MAJOR_VERSION: 16
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_PASSWORD: mysecretpassword
|
||||
@@ -34,21 +34,24 @@ services:
|
||||
privileged: true
|
||||
restart: unless-stopped
|
||||
minio:
|
||||
image: bitnami/minio
|
||||
image: docker.io/minio/minio
|
||||
environment:
|
||||
MINIO_ROOT_USER: fediprint
|
||||
MINIO_ROOT_PASSWORD: fediprint
|
||||
MINIO_ROOT_USER: classquiz
|
||||
MINIO_ROOT_PASSWORD: classquiz
|
||||
MINIO_ADDRESS: ":9000"
|
||||
MINIO_CONSOLE_ADDRESS: ":9001"
|
||||
volumes:
|
||||
- minio:/bitnami/minio/data
|
||||
- minio:/data
|
||||
ports:
|
||||
- 9000:9000
|
||||
- 9001:9001
|
||||
restart: unless-stopped
|
||||
command: minio server /data
|
||||
redis:
|
||||
image: redis:alpine
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: [ "CMD", "redis-cli","ping" ]
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
ports:
|
||||
- 6379:6379
|
||||
volumes:
|
||||
|
||||
+4
-4
@@ -4,7 +4,7 @@
|
||||
|
||||
### Build Step
|
||||
# pull the Node.js Docker image
|
||||
FROM node:19-bullseye as builder
|
||||
FROM node:24-trixie AS builder
|
||||
# ENV API_URL=http://api:80
|
||||
ENV API_URL=https://mawoka.eu
|
||||
#Ah, I've noticed that!!!
|
||||
@@ -27,7 +27,7 @@ COPY package*.json ./
|
||||
COPY pnpm-lock.yaml ./
|
||||
|
||||
# run npm install in our local machine
|
||||
RUN corepack enable && corepack prepare pnpm@8.14.0 --activate && pnpm i
|
||||
RUN corepack enable && corepack prepare pnpm@10.14.0 --activate && pnpm i
|
||||
|
||||
# copy the generated modules and all other files to the container
|
||||
COPY . .
|
||||
@@ -37,13 +37,13 @@ RUN pnpm run build
|
||||
|
||||
### Serve Step
|
||||
# pull the Node.js Docker image
|
||||
FROM node:19-bullseye-slim
|
||||
FROM node:24-trixie-slim
|
||||
|
||||
# change working directory
|
||||
WORKDIR /app
|
||||
COPY --from=builder /usr/src/app/package.json .
|
||||
COPY --from=builder /usr/src/app/pnpm-lock.yaml .
|
||||
RUN corepack enable && corepack prepare pnpm@8.14.0 --activate && pnpm i
|
||||
RUN corepack enable && corepack prepare pnpm@10.14.0 --activate && pnpm i
|
||||
# copy files from previous step
|
||||
COPY --from=builder /usr/src/app/build .
|
||||
COPY --from=builder /usr/src/app/node_modules ./node_modules
|
||||
|
||||
+31
-29
@@ -21,23 +21,24 @@
|
||||
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||
"@ffmpeg/util": "^0.12.2",
|
||||
"@fontsource/marck-script": "^5.2.8",
|
||||
"@sentry/browser": "^10.12.0",
|
||||
"@sentry/browser": "^10.22.0",
|
||||
"@sentry/tracing": "^7.120.4",
|
||||
"@simplewebauthn/browser": "^13.2.0",
|
||||
"@sveltejs/adapter-auto": "^6.1.0",
|
||||
"@sveltejs/adapter-node": "^5.3.2",
|
||||
"@sveltejs/kit": "^2.5.27",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@tailwindcss/typography": "^0.5.18",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/adapter-node": "^5.4.0",
|
||||
"@sveltejs/kit": "^2.47.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/postcss": "^4.1.16",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/jws": "^3.2.11",
|
||||
"@types/luxon": "^3.7.1",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/sortablejs": "^1.15.8",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/sortablejs": "^1.15.9",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"@typescript-eslint/eslint-plugin": "^8.44.0",
|
||||
"@typescript-eslint/parser": "^8.44.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.2",
|
||||
"@typescript-eslint/parser": "^8.46.2",
|
||||
"@uppy/compressor": "^2.3.2",
|
||||
"@uppy/core": "^4.5.3",
|
||||
"@uppy/dashboard": "^4.4.3",
|
||||
@@ -49,23 +50,24 @@
|
||||
"@uppy/svelte": "^4.6.2",
|
||||
"@uppy/xhr-upload": "^4.4.2",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"ckeditor5": "^46.1.1",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"ckeditor5": "^47.1.0",
|
||||
"cookie": "^1.0.2",
|
||||
"crypto-js": "^4.2.0",
|
||||
"cssnano": "^7.1.1",
|
||||
"dompurify": "^3.2.7",
|
||||
"eslint": "^9.36.0",
|
||||
"dompurify": "^3.3.0",
|
||||
"eslint": "^9.38.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"felte": "^1.3.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"i18next": "^25.5.2",
|
||||
"i18next": "^25.6.0",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jose": "^6.1.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jws": "^4.0.0",
|
||||
"jws": "^4.0.1",
|
||||
"luxon": "^3.7.2",
|
||||
"marked": "^16.3.0",
|
||||
"marked": "^16.4.1",
|
||||
"mdsvex": "^0.12.6",
|
||||
"minisearch": "^7.2.0",
|
||||
"pikaso": "^2.9.0",
|
||||
@@ -76,25 +78,25 @@
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"sass": "^1.93.0",
|
||||
"sass": "^1.93.2",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.3.1",
|
||||
"svelte": "^5.42.2",
|
||||
"svelte-check": "^4.3.3",
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"svelte-range-slider-pips": "^4.0.7",
|
||||
"svelte-range-slider-pips": "^4.1.0",
|
||||
"svelte-tippy": "^1.3.2",
|
||||
"swiper": "^12.0.2",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"swiper": "^12.0.3",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"thumbhash": "^0.1.1",
|
||||
"tinykeys": "^3.0.0",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.5.0",
|
||||
"ua-parser-js": "^2.0.5",
|
||||
"vite": "^7.1.6",
|
||||
"typescript": "^5.9.3",
|
||||
"ua-parser-js": "^2.0.6",
|
||||
"vite": "^7.1.12",
|
||||
"vite-plugin-cross-origin-isolation": "^0.1.6",
|
||||
"vite-plugin-iso-import": "^1.3.0",
|
||||
"yup": "^1.7.0"
|
||||
"yup": "^1.7.1"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
|
||||
Generated
+1516
-1571
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="description" content="" />
|
||||
<!-- <link rel="icon" href="%sveltekit.assets%/favicon.png" />-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script>
|
||||
window.plausible =
|
||||
|
||||
Vendored
+9
-2
@@ -5,8 +5,15 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_TITLE: string;
|
||||
// more env variables...
|
||||
readonly VITE_GOOGLE_AUTH_ENABLED?: string;
|
||||
readonly VITE_GITHUB_AUTH_ENABLED?: string;
|
||||
readonly VITE_CAPTCHA_ENABLED?: string;
|
||||
readonly VITE_CUSTOM_OAUTH_NAME?: string;
|
||||
readonly VITE_REGISTRATION_DISABLED?: string;
|
||||
readonly VITE_HCAPTCHA?: string;
|
||||
readonly VITE_RECAPTCHA?: string;
|
||||
readonly VITE_SENTRY?: string;
|
||||
readonly VITE_PLAUSIBLE_DATA_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import jws from 'jws';
|
||||
import * as jose from 'jose';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Handle} */
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
@@ -12,9 +12,13 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
event.locals.email = null;
|
||||
return resolve(event);
|
||||
}
|
||||
const jwt = jws.decode(access_token.replace('Bearer ', ''));
|
||||
const jwt = jose.decodeJwt(access_token.replace('Bearer ', ''));
|
||||
if (!jwt) {
|
||||
event.locals.email = null;
|
||||
return resolve(event);
|
||||
}
|
||||
// if token expires, do a request to get a new one and set the response-cookies on the response
|
||||
if (Date.now() >= jwt.payload.exp * 1000) {
|
||||
if (Date.now() >= jwt.exp * 1000) {
|
||||
const res = await fetch(`${process.env.API_URL}/api/v1/users/check`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -33,13 +37,6 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
event.locals.email = jwt.payload.sub;
|
||||
event.locals.email = jwt.sub;
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
/*export const getSession: GetSession = async (event) => {
|
||||
return {
|
||||
email: event.locals.email,
|
||||
authenticated: Boolean(event.locals.email)
|
||||
};
|
||||
};*/
|
||||
|
||||
@@ -15,7 +15,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
import Controls from '$lib/play/admin/controls.svelte';
|
||||
import Question from '$lib/play/admin/question.svelte';
|
||||
|
||||
|
||||
const { t } = getLocalization();
|
||||
const default_colors = ['#D6EDC9', '#B07156', '#7F7057', '#4E6E58'];
|
||||
|
||||
@@ -24,13 +23,12 @@ SPDX-License-Identifier: MPL-2.0
|
||||
let timer_res: string = $state();
|
||||
let shown_question_now: number = $state();
|
||||
let final_results_clicked = $state(false);
|
||||
let timer_interval;
|
||||
let timer_interval: NodeJS.Timeout;
|
||||
let answer_count = $state(0);
|
||||
|
||||
interface Props {
|
||||
game_token: string;
|
||||
quiz_data: QuizData;
|
||||
game_mode: string;
|
||||
bg_color: string;
|
||||
final_results?: Array<null> | Array<Array<PlayerAnswer>>;
|
||||
control_visible: boolean;
|
||||
@@ -40,7 +38,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
let {
|
||||
game_token,
|
||||
quiz_data = $bindable(),
|
||||
game_mode,
|
||||
bg_color,
|
||||
final_results = $bindable([null]),
|
||||
control_visible,
|
||||
@@ -92,7 +89,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
timer_interval = setInterval(() => {
|
||||
if (timer_res === '0') {
|
||||
clearInterval(timer_interval);
|
||||
// socket.emit('show_solutions', {});
|
||||
return;
|
||||
} else {
|
||||
seconds--;
|
||||
@@ -132,9 +128,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
{#await import('$lib/play/admin/slide.svelte')}
|
||||
<Spinner my_20={false} />
|
||||
{:then c}
|
||||
<c.default
|
||||
bind:question={quiz_data.questions[selected_question]}
|
||||
/>
|
||||
<c.default question={quiz_data.questions[selected_question]} />
|
||||
{/await}
|
||||
{:else}
|
||||
<Question {quiz_data} {selected_question} {timer_res} {answer_count} {default_colors} />
|
||||
@@ -153,8 +147,8 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<Spinner />
|
||||
{:then c}
|
||||
<c.default
|
||||
bind:data={question_results}
|
||||
bind:question={quiz_data.questions[selected_question]}
|
||||
data={question_results}
|
||||
question={quiz_data.questions[selected_question]}
|
||||
/>
|
||||
{/await}
|
||||
{:else}
|
||||
@@ -164,7 +158,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<c.default
|
||||
bind:data={player_scores}
|
||||
question={quiz_data.questions[selected_question]}
|
||||
bind:new_data={question_results}
|
||||
new_data={question_results}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
@@ -5,19 +5,14 @@ SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { createBubbler } from 'svelte/legacy';
|
||||
|
||||
const bubble = createBubbler();
|
||||
|
||||
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
flex?: boolean;
|
||||
href?: undefined | string;
|
||||
target?: undefined | string;
|
||||
type?: undefined | string;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
children?: import('svelte').Snippet;
|
||||
onclick?: (event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -26,7 +21,8 @@ SPDX-License-Identifier: MPL-2.0
|
||||
href = undefined,
|
||||
target = '_self',
|
||||
type = 'button',
|
||||
children
|
||||
children,
|
||||
onclick
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -39,7 +35,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
class:cursor-not-allowed={disabled}
|
||||
class:pointer-events-none={disabled}
|
||||
class="text-black hover:bg-bg-[#B07156]/80 w-full px-4 py-2 leading-5 transition-all duration-200 transform bg-[#B07156] rounded-sm text-center outline-hidden hover:cursor-pointer"
|
||||
onclick={bubble('click')}
|
||||
{onclick}
|
||||
class:flex
|
||||
class:justify-center={flex}
|
||||
>
|
||||
@@ -50,7 +46,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
{disabled}
|
||||
{type}
|
||||
class="text-black hover:cursor-pointer hover:opacity-80 w-full px-4 py-2 leading-5 transition-all duration-200 transform bg-[#B07156] rounded-sm text-center focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 outline-hidden"
|
||||
onclick={bubble('click')}
|
||||
{onclick}
|
||||
class:flex
|
||||
class:justify-center={flex}
|
||||
>
|
||||
|
||||
@@ -5,16 +5,13 @@ SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { createBubbler } from 'svelte/legacy';
|
||||
|
||||
const bubble = createBubbler();
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
flex?: boolean;
|
||||
href?: undefined | string;
|
||||
target?: undefined | string;
|
||||
children?: import('svelte').Snippet;
|
||||
onclick?: (event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -22,7 +19,8 @@ SPDX-License-Identifier: MPL-2.0
|
||||
flex = false,
|
||||
href = undefined,
|
||||
target = '_self',
|
||||
children
|
||||
children,
|
||||
onclick
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -31,7 +29,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
{href}
|
||||
{target}
|
||||
class="w-full px-4 py-2 leading-5 text-black dark:text-white transition-colors duration-200 transform bg-gray-50 dark:bg-gray-700 rounded-sm text-center hover:bg-gray-300 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-gray-600"
|
||||
onclick={bubble('click')}
|
||||
{onclick}
|
||||
class:flex
|
||||
class:block={!flex}
|
||||
class:justify-center={flex}
|
||||
@@ -42,7 +40,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<button
|
||||
{disabled}
|
||||
class="w-full px-4 py-2 leading-5 text-black dark:text-white transition-colors duration-200 transform bg-gray-50 dark:bg-gray-700 rounded-sm text-center hover:bg-gray-300 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-gray-600"
|
||||
onclick={bubble('click')}
|
||||
{onclick}
|
||||
class:flex
|
||||
class:justify-center={flex}
|
||||
>
|
||||
|
||||
@@ -8,8 +8,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
This should be okay, right?
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy';
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { tinykeys } from '$lib/tinykeys';
|
||||
import { fade } from 'svelte/transition';
|
||||
@@ -104,7 +102,6 @@ This should be okay, right?
|
||||
const toggle_open = (e: KeyboardEvent | undefined) => {
|
||||
e.preventDefault();
|
||||
open = !open;
|
||||
console.log('TOGGLE!');
|
||||
};
|
||||
|
||||
const close_cp = (e: KeyboardEvent | undefined) => {
|
||||
@@ -159,7 +156,6 @@ This should be okay, right?
|
||||
|
||||
visible_items = [];
|
||||
|
||||
console.log(res);
|
||||
for (const quiz_data of res) {
|
||||
visible_items.push(actions[quiz_data.id]);
|
||||
}
|
||||
@@ -206,13 +202,6 @@ This should be okay, right?
|
||||
input = '';
|
||||
};
|
||||
|
||||
run(() => {
|
||||
search(input);
|
||||
});
|
||||
// $: input = lower_input(input)
|
||||
run(() => {
|
||||
input = input.toLowerCase();
|
||||
});
|
||||
onMount(async () => {
|
||||
tinykeys(window, {
|
||||
'$mod+k': toggle_open,
|
||||
@@ -240,6 +229,9 @@ This should be okay, right?
|
||||
class="fixed top-0 left-0 w-screen h-screen flex bg-black/50 z-50"
|
||||
onclick={close_on_outside}
|
||||
onkeyup={close_on_outside}
|
||||
role="button"
|
||||
aria-label="Close"
|
||||
tabindex="0"
|
||||
transition:fade|global={{ duration: 60 }}
|
||||
>
|
||||
<div class="m-auto w-1/3 h-2/3 rounded-sm bg-black flex flex-col">
|
||||
@@ -251,8 +243,10 @@ This should be okay, right?
|
||||
</p>
|
||||
<input
|
||||
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 w-full p-4 outline-hidden bg-gray-700 rounded-sm"
|
||||
bind:value={input}
|
||||
oninput={() => search(input)}
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col p-2 gap-2 overflow-scroll">
|
||||
@@ -264,6 +258,8 @@ This should be okay, right?
|
||||
class:bg-gray-700={selected !== i}
|
||||
onmouseenter={() => (selected = i)}
|
||||
onmousedown={execute_action}
|
||||
tabindex="-2"
|
||||
role="button"
|
||||
>
|
||||
<div class="flex">
|
||||
<h3 class="text-lg my-auto">{vi.title}</h3>
|
||||
|
||||
@@ -5,17 +5,12 @@ SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy';
|
||||
|
||||
import { fly } from 'svelte/transition';
|
||||
import { getLocalization } from '$lib/i18n';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { PopoverTypes } from './smalltop';
|
||||
|
||||
const { t } = getLocalization();
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
type: PopoverTypes;
|
||||
@@ -23,10 +18,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
}
|
||||
|
||||
let { open = $bindable(false), type, data = undefined }: Props = $props();
|
||||
|
||||
run(() => {
|
||||
dispatch('open', open);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
|
||||
@@ -5,9 +5,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { run, preventDefault } from 'svelte/legacy';
|
||||
|
||||
// import { mint } from '$lib/hashcash';
|
||||
import { dataSchema } from '$lib/yupSchemas';
|
||||
import type { EditorData, Question } from './quiz_types';
|
||||
import Sidebar from '$lib/editor/sidebar.svelte';
|
||||
@@ -28,52 +25,25 @@ SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
let { data = $bindable(), quiz_id }: Props = $props();
|
||||
let selected_question = $state(-1);
|
||||
let imgur_links_valid = $state(false);
|
||||
|
||||
const validateInput = async (data: EditorData) => {
|
||||
// console.log("input", data)
|
||||
try {
|
||||
await dataSchema.validate(data, { abortEarly: false });
|
||||
schemaInvalid = false;
|
||||
yupErrorMessage = '';
|
||||
} catch (err) {
|
||||
console.log('erro!', err.errors);
|
||||
schemaInvalid = true;
|
||||
yupErrorMessage = err.errors ? err.errors[0] : '';
|
||||
}
|
||||
};
|
||||
run(() => {
|
||||
$effect(() => {
|
||||
validateInput(data);
|
||||
});
|
||||
|
||||
const checkIfAllQuestionImagesComplyWithRegex = (questions: Array<Question>) => {
|
||||
let NoteverythingValid = false;
|
||||
// const regex = /^https:\/\/i\.imgur\.com\/.{7}.(jpg|png|gif)$/;
|
||||
// const local_regex = /^http(|s):\/\/\w*(|:)\d*\/api\/v1\/storage\/download\/.{36}--.{36}$/g;
|
||||
const main_regex =
|
||||
/^(http(|s):\/\/.*(|:)\d*\/api\/v1\/storage\/download\/.{36}--.{36}|https:\/\/i\.imgur\.com\/.{7}.(jpg|png|gif))$/;
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const question = questions[i];
|
||||
|
||||
if (question.image && !main_regex.test(question.image)) {
|
||||
NoteverythingValid = true;
|
||||
}
|
||||
}
|
||||
return NoteverythingValid;
|
||||
};
|
||||
|
||||
run(() => {
|
||||
imgur_links_valid = checkIfAllQuestionImagesComplyWithRegex(data.questions);
|
||||
});
|
||||
let edit_id = $state();
|
||||
let edit_id: string = $state();
|
||||
let confirm_to_leave = true;
|
||||
|
||||
run(() => {
|
||||
console.log('data', data);
|
||||
});
|
||||
|
||||
const getEditID = async () => {
|
||||
let res;
|
||||
let res: Response;
|
||||
if (quiz_id === null) {
|
||||
res = await fetch(`/api/v1/editor/start?edit=false`, {
|
||||
method: 'POST'
|
||||
@@ -91,8 +61,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
}
|
||||
};
|
||||
|
||||
const confirmUnload = (event) => {
|
||||
console.log(confirm_to_leave);
|
||||
const confirmUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!confirm_to_leave) {
|
||||
return;
|
||||
}
|
||||
@@ -101,7 +70,8 @@ SPDX-License-Identifier: MPL-2.0
|
||||
localStorage.setItem('edit_game', JSON.stringify(data));
|
||||
return 'unload';
|
||||
};
|
||||
const saveQuiz = async () => {
|
||||
const saveQuiz = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
if (schemaInvalid) {
|
||||
return;
|
||||
}
|
||||
@@ -126,7 +96,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
{#await getEditID()}
|
||||
<Spinner />
|
||||
{:then _}
|
||||
<form onsubmit={preventDefault(saveQuiz)}>
|
||||
<form onsubmit={saveQuiz}>
|
||||
<div class="grid grid-cols-6 h-screen w-screen">
|
||||
<div>
|
||||
<Sidebar bind:data bind:selected_question />
|
||||
|
||||
@@ -5,8 +5,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy';
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { thumbHashToDataURL } from 'thumbhash';
|
||||
@@ -36,7 +34,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
return Uint8Array.from(binString, (m) => m.codePointAt(0));
|
||||
}
|
||||
|
||||
const get_media = async () => {
|
||||
const get_media = async (_: string) => {
|
||||
if (!browser) {
|
||||
return;
|
||||
}
|
||||
@@ -55,14 +53,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
thumbhash_data = undefined;
|
||||
}
|
||||
};
|
||||
const update_url = () => {
|
||||
media = get_media();
|
||||
};
|
||||
let media = $state(get_media());
|
||||
run(() => {
|
||||
src;
|
||||
update_url();
|
||||
});
|
||||
let media = $derived(get_media(src));
|
||||
|
||||
let fullscreen_open = $state(false);
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { run, preventDefault } from 'svelte/legacy';
|
||||
import { run } from 'svelte/legacy';
|
||||
|
||||
import { getLocalization } from '$lib/i18n';
|
||||
import type { EditorData, OrderQuizAnswer } from '$lib/quiz_types';
|
||||
@@ -170,9 +170,10 @@ SPDX-License-Identifier: MPL-2.0
|
||||
class="rounded-lg p-1 border-black border"
|
||||
type="color"
|
||||
bind:value={answer.color}
|
||||
oncontextmenu={preventDefault(() => {
|
||||
oncontextmenu={(e) => {
|
||||
e.preventDefault();
|
||||
answer.color = null;
|
||||
})}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -27,19 +27,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
const correct_numbers = (data: number[]) => {
|
||||
console.log(data, data[1] <= data[0])
|
||||
if (data[1] <= data[0]) {
|
||||
range_arr[1] = range_arr[0] + 2
|
||||
}
|
||||
if (data[0] <= data[1]) {
|
||||
range_arr[0] = range_arr[1] - 2
|
||||
}
|
||||
}
|
||||
$: correct_numbers(range_arr)
|
||||
|
||||
*/
|
||||
let answer = question.answers;
|
||||
let range_arr = $state([answer.min_correct, answer.max_correct]);
|
||||
run(() => {
|
||||
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { run, preventDefault } from 'svelte/legacy';
|
||||
import { run } from 'svelte/legacy';
|
||||
|
||||
import type { EditorData } from '../quiz_types';
|
||||
import { fade } from 'svelte/transition';
|
||||
@@ -103,9 +103,10 @@ SPDX-License-Identifier: MPL-2.0
|
||||
class="rounded-lg p-1 border-black border"
|
||||
type="color"
|
||||
bind:value={answer.color}
|
||||
oncontextmenu={preventDefault(() => {
|
||||
oncontextmenu={(e) => {
|
||||
e.preventDefault();
|
||||
answer.color = default_colors[index];
|
||||
})}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -34,7 +34,11 @@ SPDX-License-Identifier: MPL-2.0
|
||||
edit_id: string;
|
||||
}
|
||||
|
||||
let { data = $bindable(), selected_question = $bindable(), edit_id = $bindable() }: Props = $props();
|
||||
let {
|
||||
data = $bindable(),
|
||||
selected_question = $bindable(),
|
||||
edit_id = $bindable()
|
||||
}: Props = $props();
|
||||
|
||||
let advanced_options_open = $state(false);
|
||||
|
||||
@@ -155,9 +159,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
'questions[].question'
|
||||
).isValidSync(data.questions[selected_question].question)}
|
||||
>
|
||||
<c.default
|
||||
bind:text={data.questions[selected_question].question}
|
||||
/>
|
||||
<c.default bind:text={data.questions[selected_question].question} />
|
||||
</div>
|
||||
{/await}
|
||||
{/key}
|
||||
@@ -287,7 +289,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
/>
|
||||
</label>
|
||||
<div class="mt-auto w-full">
|
||||
<BrownButton on:click={() => (advanced_options_open = false)}
|
||||
<BrownButton onclick={() => (advanced_options_open = false)}
|
||||
>{$t('words.close')}</BrownButton
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { run, preventDefault } from 'svelte/legacy';
|
||||
|
||||
import type { EditorData } from '$lib/quiz_types';
|
||||
import { getLocalization } from '$lib/i18n';
|
||||
import Spinner from '$lib/Spinner.svelte';
|
||||
@@ -30,7 +28,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
animation: 'perspective-subtle'
|
||||
});
|
||||
|
||||
run(() => {
|
||||
$effect(() => {
|
||||
data.background_color = custom_bg_color ? data.background_color : undefined;
|
||||
});
|
||||
</script>
|
||||
@@ -57,11 +55,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
: `unset`}"
|
||||
>
|
||||
<div class="flex justify-center pt-10 w-full">
|
||||
<!--<input
|
||||
type="text"
|
||||
bind:value={data.title}
|
||||
class="p-3 rounded-lg border-gray-500 border text-center w-1/3 text-lg font-semibold dark:bg-gray-500"
|
||||
/>-->
|
||||
{#await import('$lib/inline-editor.svelte')}
|
||||
<Spinner my_20={false} />
|
||||
{:then c}
|
||||
@@ -70,7 +63,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</div>
|
||||
<div class="flex justify-center pt-10 w-full max-h-32">
|
||||
<textarea
|
||||
type="text"
|
||||
placeholder="Description"
|
||||
bind:value={data.description}
|
||||
class="p-3 rounded-lg border-gray-500 border text-center w-1/3 h-20 resize-none dark:bg-gray-500 outline-hidden focus:shadow-2xl transition-all"
|
||||
@@ -78,26 +70,22 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</div>
|
||||
|
||||
{#if data.cover_image != undefined && data.cover_image !== ''}
|
||||
<div class="flex justify-center pt-10 w-full max-h-72 w-full">
|
||||
<div class="flex justify-center pt-10 w-full max-h-72">
|
||||
<img
|
||||
src="/api/v1/storage/download/{data.cover_image}"
|
||||
alt="not available"
|
||||
class="max-h-72 h-auto w-auto"
|
||||
oncontextmenu={preventDefault(() => {
|
||||
oncontextmenu={(e: Event) => {
|
||||
e.preventDefault();
|
||||
data.cover_image = null;
|
||||
})}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
{#await import('$lib/editor/uploader.svelte')}
|
||||
<Spinner my_20={false} />
|
||||
{:then c}
|
||||
<c.default
|
||||
bind:modalOpen={uppyOpen}
|
||||
bind:edit_id
|
||||
bind:data
|
||||
video_upload={false}
|
||||
/>
|
||||
<c.default bind:modalOpen={uppyOpen} {data} video_upload={false} />
|
||||
{/await}
|
||||
{/if}
|
||||
<div class="pt-10 w-full flex justify-center">
|
||||
@@ -154,8 +142,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
class="bg-gray-200 rounded-lg w-full h-full p-1"
|
||||
class:pointer-events-none={custom_bg_color}
|
||||
>
|
||||
<span
|
||||
class="inline-block w-full h-full bg-[#d6edc9] dark:bg-[#4e6e58]"
|
||||
<span class="inline-block w-full h-full bg-[#d6edc9] dark:bg-[#4e6e58]"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -209,8 +196,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
{:then c}
|
||||
<c.default
|
||||
bind:modalOpen={bg_uppy_open}
|
||||
bind:edit_id
|
||||
bind:data
|
||||
{data}
|
||||
selected_question={-1}
|
||||
video_upload={false}
|
||||
/>
|
||||
|
||||
@@ -76,7 +76,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div class="h-screen relative">
|
||||
<div class="h-10 flex justify-center w-full p-1 absolute z-20">
|
||||
<div>
|
||||
<BrownButton on:click={() => (reorder_mode = !reorder_mode)}
|
||||
<BrownButton onclick={() => (reorder_mode = !reorder_mode)}
|
||||
>{#if reorder_mode}{$t('editor.disable_reorder')}{:else}{$t(
|
||||
'editor.enable_reorder'
|
||||
)}{/if}</BrownButton
|
||||
|
||||
@@ -5,14 +5,12 @@ SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy';
|
||||
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let { title = $bindable(), time = $bindable() } = $props();
|
||||
let time_local = $state(120);
|
||||
/*eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }]*/
|
||||
run(() => {
|
||||
$effect(() => {
|
||||
time = String(time_local);
|
||||
});
|
||||
if (time) {
|
||||
|
||||
@@ -27,19 +27,17 @@ SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
const { t } = getLocalization();
|
||||
let {
|
||||
modalOpen = false,
|
||||
edit_id,
|
||||
modalOpen = $bindable(),
|
||||
data,
|
||||
selected_question,
|
||||
video_upload = false,
|
||||
library_enabled = true
|
||||
}: {
|
||||
modalOpen: boolean;
|
||||
edit_id: string;
|
||||
data: EditorData;
|
||||
selected_question: number;
|
||||
selected_question?: number;
|
||||
video_upload: boolean;
|
||||
library_enabled: boolean;
|
||||
library_enabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
@@ -83,7 +81,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
// allowedFileTypes: ['.gif', '.jpg', '.jpeg', '.png', '.svg', '.webp']
|
||||
}
|
||||
};
|
||||
let image_id;
|
||||
let image_id: string;
|
||||
uppy.on('upload-success', (file, response) => {
|
||||
image_id = response.body.id;
|
||||
});
|
||||
@@ -142,6 +140,10 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div
|
||||
class="w-screen h-screen fixed top-0 left-0 bg-black/50 z-20 flex justify-center"
|
||||
onclick={handle_on_click}
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label="Close modal"
|
||||
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ' ? handle_on_click(e) : null)}
|
||||
transition:fade={{ duration: 100 }}
|
||||
>
|
||||
{#if selected_type === null}
|
||||
@@ -150,7 +152,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div class="flex flex-row gap-4">
|
||||
<div class="w-full">
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
selected_type = AvailableUploadTypes.Image;
|
||||
}}
|
||||
>{$t('words.image')}
|
||||
@@ -159,7 +161,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div class="w-full">
|
||||
<BrownButton
|
||||
disabled={!video_upload}
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
selected_type = AvailableUploadTypes.Video;
|
||||
}}
|
||||
>{$t('words.video')}
|
||||
@@ -168,7 +170,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
{#if library_enabled}
|
||||
<div class="w-full">
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
selected_type = AvailableUploadTypes.Library;
|
||||
}}
|
||||
>{$t('words.library')}
|
||||
@@ -177,7 +179,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
{/if}
|
||||
<div class="w-full">
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
selected_type = AvailableUploadTypes.Pixabay;
|
||||
}}
|
||||
>Pixabay
|
||||
@@ -202,7 +204,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
{$t('uploader.upload_video_popup_notice')}
|
||||
</p>
|
||||
{:else}
|
||||
<BrownButton on:click={upload_video} type="button"
|
||||
<BrownButton onclick={upload_video} type="button"
|
||||
>{$t('uploader.upload_video')}</BrownButton
|
||||
>
|
||||
{/if}
|
||||
|
||||
@@ -58,7 +58,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</div>
|
||||
<p class="text-center">{image.filename ?? 'No name available'}</p>
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
set_image(image.id);
|
||||
}}>{$t('words.select')}</BrownButton
|
||||
>
|
||||
|
||||
@@ -5,8 +5,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { preventDefault } from 'svelte/legacy';
|
||||
|
||||
import type { EditorData } from '$lib/quiz_types';
|
||||
import Spinner from '$lib/Spinner.svelte';
|
||||
import BrownButton from '$lib/components/buttons/brown.svelte';
|
||||
@@ -70,7 +68,10 @@ SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
<form
|
||||
class="w-full flex gap-2"
|
||||
onsubmit={preventDefault(() => (fetched_data = fetch_data()))}
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
fetched_data = fetch_data();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
class="w-full outline-hidden p-1 rounded-sm dark:bg-gray-500 bg-gray-300"
|
||||
@@ -93,7 +94,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
/>
|
||||
</div>
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
set_image(image.id);
|
||||
}}>{$t('words.select')}</BrownButton
|
||||
>
|
||||
@@ -102,14 +103,14 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div class="flex gap-2">
|
||||
<BrownButton
|
||||
disabled={page < 2}
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
page -= 1;
|
||||
fetched_data = fetch_data();
|
||||
}}
|
||||
>{$t('uploader.previous_page')}
|
||||
</BrownButton>
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
page += 1;
|
||||
fetched_data = fetch_data();
|
||||
}}>{$t('uploader.next_page')}</BrownButton
|
||||
|
||||
@@ -19,24 +19,20 @@ import uk from './locales/uk.json';
|
||||
import nl from './locales/nl.json';
|
||||
import hu from './locales/hu.json';
|
||||
import vi from './locales/vi.json';
|
||||
// import uz from './locales/uz.json'
|
||||
// import zh_Hans from './locales/zh_Hans.json';
|
||||
import ta from './locales/ta.json';
|
||||
import pt_BR from './locales/pt_BR.json';
|
||||
import ja from './locales/ja.json';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
import type { i18n } from 'i18next';
|
||||
|
||||
export class I18nService {
|
||||
// expose i18next
|
||||
i18n: i18n;
|
||||
|
||||
constructor() {
|
||||
this.i18n = i18next;
|
||||
this.initialize();
|
||||
// this.changeLanguage("de")
|
||||
//this.changeLanguage(INITIAL_LANGUAGE);
|
||||
}
|
||||
|
||||
// Our translation function
|
||||
t(key: string, replacements?: Record<string, unknown>): string {
|
||||
return this.i18n.t(key, replacements);
|
||||
}
|
||||
@@ -54,10 +50,6 @@ export class I18nService {
|
||||
},
|
||||
returnEmptyString: false,
|
||||
simplifyPluralSuffix: true,
|
||||
// detection: {
|
||||
// order: ['browser', 'querystring', 'navigator', 'localStorage', 'htmlTag'],
|
||||
// lookupQuerystring: 'lng'
|
||||
// }
|
||||
detection: {
|
||||
order: ['querystring', 'cookie', 'localStorage', 'navigator'],
|
||||
lookupQuerystring: 'lng',
|
||||
@@ -82,7 +74,9 @@ export class I18nService {
|
||||
this.i18n.addResourceBundle('nl', 'translation', nl);
|
||||
this.i18n.addResourceBundle('hu', 'translation', hu);
|
||||
this.i18n.addResourceBundle('vi', 'translation', vi);
|
||||
// this.i18n.addResourceBundle('uz', 'translation', uz);
|
||||
this.i18n.addResourceBundle('ta', 'translation', ta);
|
||||
this.i18n.addResourceBundle('pt_BR', 'translation', pt_BR);
|
||||
this.i18n.addResourceBundle('ja', 'translation', ja);
|
||||
}
|
||||
|
||||
changeLanguage(language: string): void {
|
||||
|
||||
@@ -45,7 +45,8 @@
|
||||
"see_all_quizzes": "رؤية جميع الاختبارات الخاصة بك",
|
||||
"download_quizzes_content": "يمكن تنزيل الاختبارات كملف واحد واستيرادها في أي وقت. يتيح لك هذا أيضًا نقل اختباراتك إلى مثيلات ClassQuiz الأخرى.",
|
||||
"no_player_limit": "لا يوجد حد لعدد اللاعبين",
|
||||
"no_player_limit_content": "ليس لدى ClassQuiz حد اصطناعي للاعبين. من الناحية النظرية، يمكن لأكثر من 1000 لاعب لعب نفس الاختبار، ولكن يتم اختباره مع ما يصل إلى 300 لاعب في كل اختبار."
|
||||
"no_player_limit_content": "ليس لدى ClassQuiz حد اصطناعي للاعبين. من الناحية النظرية، يمكن لأكثر من 1000 لاعب لعب نفس الاختبار، ولكن يتم اختباره مع ما يصل إلى 300 لاعب في كل اختبار.",
|
||||
"import_quiz_from_kahoot_and_edit": "أحضر اختباراتك من Kahoot وعدلها في ClassQuiz !"
|
||||
},
|
||||
"result_page": {
|
||||
"correct_answer_plural": "",
|
||||
@@ -355,7 +356,9 @@
|
||||
"need_more_help": "هل تريد المزيد من المساعدة؟",
|
||||
"visit_docs": "قم بزيارة المستندات.",
|
||||
"enable_reorder": "تمكين وضع إعادة الترتيب",
|
||||
"disable_reorder": "تعطيل وضع إعادة الترتيب"
|
||||
"disable_reorder": "تعطيل وضع إعادة الترتيب",
|
||||
"advanced_settings": "الإعدادات المتقدمة",
|
||||
"hide_question_results": "هل تريد إخفاء نتائج كل سؤال؟"
|
||||
},
|
||||
"controllers": {
|
||||
"last_seen": "",
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
"find_or_explore": "",
|
||||
"completely_free_content": "",
|
||||
"stats": "",
|
||||
"see_how_many_true_and_false": "",
|
||||
"see_how_many_true_and_false": "Podívejte se, kolik z nich mělo pravdu nebo se mýlilo",
|
||||
"download_quizzes": "",
|
||||
"dark_mode_content": "",
|
||||
"multilingual": "",
|
||||
@@ -145,12 +145,12 @@
|
||||
"get_ranking_and_winners": "",
|
||||
"meta": {
|
||||
"description": "ClassQuiz je kvízová aplikace podobná aplikaci Kahoot! pro studenty, která má otevřený zdrojový kód a je zdarma.",
|
||||
"title": ""
|
||||
"title": "Domů"
|
||||
},
|
||||
"list_winners": "",
|
||||
"students_site": "",
|
||||
"self_hostable": "",
|
||||
"see_what_true_and_false": "",
|
||||
"see_what_true_and_false": "Podívejte se, co bylo správné nebo špatné",
|
||||
"german_server": "",
|
||||
"create_a_quiz_from_scratch": "",
|
||||
"teachers_site": "",
|
||||
|
||||
@@ -378,7 +378,8 @@
|
||||
"time_taken": "benötigte Zeit",
|
||||
"player_name": "Spielername",
|
||||
"correct_answer_plural": "{{count}} richtige Antworten",
|
||||
"player_score": "Spieler Score"
|
||||
"player_score": "Spieler Score",
|
||||
"player_correct_questions": "Richtige Antworten"
|
||||
},
|
||||
"navbar": {
|
||||
"donate": "Spenden"
|
||||
|
||||
@@ -366,7 +366,8 @@
|
||||
"correct_answer": "{{count}} correct answer",
|
||||
"correct_answer_plural": "{{count}} correct answers",
|
||||
"time_taken": "Time taken",
|
||||
"player_score": "Player Score"
|
||||
"player_score": "Player Score",
|
||||
"player_correct_questions": "Correct Answers"
|
||||
},
|
||||
"controllers": {
|
||||
"add_new_controller": "Add new controller",
|
||||
|
||||
@@ -371,7 +371,8 @@
|
||||
"correct_answer_plural": "{{count}} respuestas correctas",
|
||||
"time_taken": "Tiempo empleado",
|
||||
"average_score": "Puntuación media: {{average_score}}",
|
||||
"player_score": "Puntuación del jugador"
|
||||
"player_score": "Puntuación del jugador",
|
||||
"player_correct_questions": "Respuestas correctas"
|
||||
},
|
||||
"navbar": {
|
||||
"donate": "Donar"
|
||||
@@ -404,7 +405,7 @@
|
||||
"enable_totp": "Activar TOTP"
|
||||
},
|
||||
"view_quiz_page": {
|
||||
"made_by": "",
|
||||
"made_by": "Hecho por",
|
||||
"view_on_kahoot": "Ver en el original"
|
||||
},
|
||||
"start_game": {
|
||||
|
||||
@@ -423,7 +423,8 @@
|
||||
"time_taken": "Durée de l'opération",
|
||||
"player_score": "Score du joueur",
|
||||
"correct_answer": "{{count}} réponse correcte",
|
||||
"correct_answer_plural": "{{count}} réponses correctes"
|
||||
"correct_answer_plural": "{{count}} réponses correctes",
|
||||
"player_correct_questions": "Réponses correctes"
|
||||
},
|
||||
"security_settings": {
|
||||
"totp_setup": {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
SPDX-FileCopyrightText: 2025 Marlon W (Mawoka)
|
||||
|
||||
SPDX-License-Identifier: MPL-2.0
|
||||
@@ -367,7 +367,8 @@
|
||||
"correct_answer": "{{count}} risposta corretta",
|
||||
"correct_answer_plural": "{{count}} risposte corrette",
|
||||
"time_taken": "Tempo impiegato",
|
||||
"player_score": "Punteggio del giocatore"
|
||||
"player_score": "Punteggio del giocatore",
|
||||
"player_correct_questions": "Risposte corrette"
|
||||
},
|
||||
"error_page": {
|
||||
"404_text": "La pagina che stavi cercando è sparita o non è mai esistita. Chi lo sa?",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
SPDX-FileCopyrightText: 2025 Marlon W (Mawoka)
|
||||
|
||||
SPDX-License-Identifier: MPL-2.0
|
||||
@@ -0,0 +1,3 @@
|
||||
SPDX-FileCopyrightText: 2025 Marlon W (Mawoka)
|
||||
|
||||
SPDX-License-Identifier: MPL-2.0
|
||||
@@ -230,7 +230,10 @@
|
||||
"need_more_help": "Meer hulp nodig?",
|
||||
"enter_answer": "Voer een antwoord in",
|
||||
"visit_docs": "Bekijk de documenten.",
|
||||
"advanced_settings": "Geavanceerde instellingen"
|
||||
"advanced_settings": "Geavanceerde instellingen",
|
||||
"enable_reorder": "Zet volgorde wijzigingen modus aan",
|
||||
"disable_reorder": "Zet volgorde wijzigingen modus uit",
|
||||
"hide_question_results": "Verberg vraagresultaten?"
|
||||
},
|
||||
"import_page": {
|
||||
"url_should_look_like_this": "De URL zou er als volgt uit moeten zien: https://create.kahoot.it/details/...",
|
||||
@@ -241,7 +244,7 @@
|
||||
"a_kahoot_quiz": "Een Kahoot!-Quiz",
|
||||
"need_help": "Hulp nodig?",
|
||||
"classquiz_quiz": "Een ClassQuiz-Quiz",
|
||||
"this_side_classquiz_excel": "Je kunt ook Excel-bestanden uploaden, als je het volgende sjabloon hebt ingevuld.",
|
||||
"this_side_classquiz_excel": "Je kunt ook Excel-bestanden uploaden, als je het volgende sjabloon hebt ingevuld. Als er al een quiz met die titel bestaat, worden de vragen aan de bestaande quiz toegevoegd.",
|
||||
"download_template_here": "Download het sjabloon"
|
||||
},
|
||||
"admin_page": {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
SPDX-FileCopyrightText: 2025 Marlon W (Mawoka)
|
||||
|
||||
SPDX-License-Identifier: MPL-2.0
|
||||
@@ -0,0 +1,3 @@
|
||||
SPDX-FileCopyrightText: 2025 Marlon W (Mawoka)
|
||||
|
||||
SPDX-License-Identifier: MPL-2.0
|
||||
@@ -31,7 +31,7 @@
|
||||
"dark_mode_content": "Одна з найважливіших функцій веб-сайту!",
|
||||
"multilingual": "Багатомовний",
|
||||
"community_driven_content": "ClassQuiz залежить від своєї спільноти щодо фінансування, тестування ідей, запитів на функції, перекладів тощо! Ви також можете стати частиною ClassQuiz-спільноти!",
|
||||
"get_a_quiz": "1. Пройдіть тест",
|
||||
"get_a_quiz": "1. Оберіть тест",
|
||||
"quiz_results_downloadable_content": "Результати тестів можна легко експортувати в електронну таблицю Excel. (Не знав, що інші не можуть цього зробити)",
|
||||
"view_results": "Переглянути результати",
|
||||
"choose_answer_wisely": "Вибирайте відповідь мудро",
|
||||
@@ -40,7 +40,7 @@
|
||||
"get_ranking_and_winners": "Отримайте рейтинг і подивіться, хто переміг",
|
||||
"list_winners": "Список переможців",
|
||||
"create_a_quiz_from_scratch": "Створіть вікторину з нуля за допомогою редактора та додайте зображення тощо",
|
||||
"check_if_chosen_wisely": "Перевірте, чи мудро ви вибрали",
|
||||
"check_if_chosen_wisely": "Перевірте, чи правильно ви обрали",
|
||||
"user_friendly_content": "ClassQuiz має бути простим, тому ним може користуватися кожен.",
|
||||
"no_tracking_content": "Інші відстежують вас і надсилають цю інформацію третім сторонам, але ClassQuiz цього не робить.",
|
||||
"multilingual_content": "ClassQuiz доступний англійською, французькою, німецькою, італійською, норвезькою (букмол), турецькою та частково індонезійською та каталонською мовами.",
|
||||
@@ -60,7 +60,8 @@
|
||||
"time_taken": "Витрачений час",
|
||||
"player_name": "Ім'я гравця",
|
||||
"average_score": "Середній бал: {{average_score}}",
|
||||
"custom_field": "Спеціальне поле"
|
||||
"custom_field": "Спеціальне поле",
|
||||
"player_correct_questions": "Правильні відповіді"
|
||||
},
|
||||
"register_page": {
|
||||
"create_account": "Створити акаунт",
|
||||
@@ -71,7 +72,7 @@
|
||||
"words": {
|
||||
"delete": "Видалити",
|
||||
"import": "Імпорт",
|
||||
"public": "Громадський",
|
||||
"public": "Публічно",
|
||||
"upload": "Завантаження",
|
||||
"player_plural": "Гравці",
|
||||
"next": "Далі",
|
||||
@@ -85,23 +86,23 @@
|
||||
"backup_code": "Резервний код",
|
||||
"question": "Питання",
|
||||
"files_library": "Бібліотека файлів",
|
||||
"check_choice": "Перевірте вибір",
|
||||
"check_choice": "Кілька пташок",
|
||||
"start": "Старт",
|
||||
"voting": "Голосування",
|
||||
"pin": "PIN",
|
||||
"point": "Точка",
|
||||
"docs": "Документи",
|
||||
"docs": "Документація",
|
||||
"description": "Опис",
|
||||
"settings": "Налаштування",
|
||||
"features": "Особливості",
|
||||
"other": "інше",
|
||||
"title": "Назва",
|
||||
"text": "Текст",
|
||||
"register": "Журнал",
|
||||
"register": "Зареєструватися",
|
||||
"username": "Ім'я користувача",
|
||||
"practice": "Практика",
|
||||
"find": "Знайти",
|
||||
"private": "Приватнi",
|
||||
"private": "Приватно",
|
||||
"answer_plural": "Відповіді",
|
||||
"version": "Версія",
|
||||
"update": "Оновлення",
|
||||
@@ -124,7 +125,7 @@
|
||||
"note": "Примітка",
|
||||
"order": "Порядок",
|
||||
"dislike_plural": "Не сподобалося",
|
||||
"multiple_choice": "Мульти-Хайце",
|
||||
"multiple_choice": "Множинний вибір",
|
||||
"info": "Інформація",
|
||||
"question_plural": "Питань",
|
||||
"results": "Результати",
|
||||
@@ -132,7 +133,7 @@
|
||||
"yes": "Так",
|
||||
"point_plural": "Очки",
|
||||
"continue": "Продовжити",
|
||||
"game_pin": "Гра PIN",
|
||||
"game_pin": "PIN-код гри",
|
||||
"like": "Подобається",
|
||||
"dislike": "Не подобається",
|
||||
"search": "Пошук",
|
||||
@@ -216,7 +217,7 @@
|
||||
"filename_word": "Ім'я файлу",
|
||||
"imported": "Імпортовано: {{yes_or_no}}",
|
||||
"filename": "Ім'я файлу: {{filename}}",
|
||||
"missing": "ВІДСУТИЙ!",
|
||||
"missing": "НЕМА!",
|
||||
"delete_image": "Видалити зображення",
|
||||
"size": "Розмір: {{size}} MiB",
|
||||
"storage_usage": "Ви використали {{used}} MiB із {{total}} MiB пам’яті. Це еквівалентно {{percent}}% вашого обсягу пам’яті.",
|
||||
@@ -246,7 +247,7 @@
|
||||
},
|
||||
"overview_page": {
|
||||
"question_count": "Кількість питань",
|
||||
"created_at": "Створено в",
|
||||
"created_at": "Створено",
|
||||
"no_quizzes": "Натисніть кнопку «Створити» або імпортуйте тест, щоб почати."
|
||||
},
|
||||
"settings_page": {
|
||||
@@ -279,10 +280,10 @@
|
||||
"title_placeholder": "Введіть назву тут",
|
||||
"delete": "Видалити",
|
||||
"open_shares_menu": "Відкрийте меню «Спільний доступ»",
|
||||
"select_page_type": "Виберіть Тип сторінки",
|
||||
"select_page_type": "Виберіть тип сторінки",
|
||||
"move_right": "Рух праворуч"
|
||||
},
|
||||
"share_expired": "Термін дії частки минув",
|
||||
"share_expired": "Термін спільного доступу сплив",
|
||||
"play": {
|
||||
"memory": {
|
||||
"try_count": "Спроб: {{try_count}}"
|
||||
@@ -360,7 +361,7 @@
|
||||
"advanced_settings": "Розширені налаштування",
|
||||
"hide_question_results": "Приховати результати запитання?",
|
||||
"need_more_help": "Потрібна додаткова допомога?",
|
||||
"visit_docs": "Відвідайте документи.",
|
||||
"visit_docs": "Відвідайте документацію.",
|
||||
"enter_answer": "Введіть відповідь",
|
||||
"enable_reorder": "Увімкнути режим зміни порядку",
|
||||
"disable_reorder": "Вимкнути режим зміни порядку"
|
||||
@@ -384,7 +385,7 @@
|
||||
"this_side_classquiz_excel": "Ви також можете завантажити файли Excel, якщо ви заповнили наведений нижче шаблон. Якщо вікторина з назвою вже існує, вона додасть запитання до наявної вікторини.",
|
||||
"visit_docs": "Відвідайте документацію",
|
||||
"download_template_here": "Завантажте шаблон",
|
||||
"classquiz_quiz": "А ClassQuiz-вікторина",
|
||||
"classquiz_quiz": "ClassQuiz-вікторина",
|
||||
"this_side_classquiz": "На цій стороні ви можете імпортувати тести, які були експортовані з ClassQuiz.",
|
||||
"a_kahoot_quiz": "Вікторина Kahoot",
|
||||
"side_import_kahoot": "На цій стороні ви можете імпортувати тести, які живуть на Kahoot!.",
|
||||
@@ -400,7 +401,7 @@
|
||||
"normal_mode_description": "Питання та відповідь відображатимуться лише на екрані адміністратора. На екрані адміністратора гравці матимуть лише кольорові кнопки з відповідними символами."
|
||||
},
|
||||
"login_page": {
|
||||
"use_backup_code": "Використовуйте резервний код",
|
||||
"use_backup_code": "Використати резервний код",
|
||||
"email_or_username": "Електронна пошта або ім'я користувача",
|
||||
"already_have_account": "Немає облікового запису?",
|
||||
"modal": {
|
||||
@@ -476,7 +477,7 @@
|
||||
},
|
||||
"public_user_page": {
|
||||
"no_original_quizzes": "Цей користувач не має оригінальних тестів",
|
||||
"joined_on": "Приєднався {{date}}",
|
||||
"joined_on": "Приєднується {{date}}",
|
||||
"user_not_found": "Користувача не знайдено"
|
||||
},
|
||||
"components": {
|
||||
|
||||
@@ -20,14 +20,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
Strikethrough
|
||||
} from 'ckeditor5';
|
||||
import 'ckeditor5/ckeditor5.css';
|
||||
// import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
|
||||
// import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold.js';
|
||||
// import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic.js';
|
||||
// import Strikethrough from '@ckeditor/ckeditor5-basic-styles/src/strikethrough';
|
||||
// import Subscript from '@ckeditor/ckeditor5-basic-styles/src/subscript.js';
|
||||
// import Superscript from '@ckeditor/ckeditor5-basic-styles/src/superscript.js';
|
||||
|
||||
|
||||
|
||||
const triggerChange = () => {
|
||||
text = editor.getData();
|
||||
@@ -46,33 +38,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
run(() => {
|
||||
text = text.replace('<p>', '').replace('</p>', '');
|
||||
});
|
||||
|
||||
/* Editor.builtinPlugins = [
|
||||
Autoformat,
|
||||
Bold,
|
||||
Essentials,
|
||||
Italic,
|
||||
Paragraph,
|
||||
Strikethrough,
|
||||
Subscript,
|
||||
Superscript,
|
||||
TextTransformation
|
||||
];*/
|
||||
/*Editor.defaultConfig = {
|
||||
toolbar: {
|
||||
items: [
|
||||
'bold',
|
||||
'italic',
|
||||
'strikethrough',
|
||||
'superscript',
|
||||
'subscript',
|
||||
'|',
|
||||
'undo',
|
||||
'redo'
|
||||
]
|
||||
},
|
||||
language: 'en'
|
||||
};*/
|
||||
let editor;
|
||||
onMount(() => {
|
||||
class Editor extends BalloonEditor {
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
|
||||
|
||||
SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy';
|
||||
|
||||
let { text } = $props();
|
||||
let internal_text = $state('');
|
||||
/*
|
||||
const markSelection = (function() {
|
||||
const markerTextChar = '\ufeff';
|
||||
const markerTextCharEntity = '';
|
||||
|
||||
let markerEl, markerId = 'sel_' + new Date().getTime() + '_' + Math.random().toString().substr(2);
|
||||
|
||||
let selectionEl;
|
||||
|
||||
return function(win) {
|
||||
win = win || window;
|
||||
const doc = win.document;
|
||||
let sel, range;
|
||||
// Branch for IE <= 8
|
||||
if (doc.selection && doc.selection.createRange) {
|
||||
// Clone the TextRange and collapse
|
||||
range = doc.selection.createRange().duplicate();
|
||||
range.collapse(false);
|
||||
|
||||
// Create the marker element containing a single invisible character by creating literal HTML and insert it
|
||||
range.pasteHTML('<span id="' + markerId + '" style="position: relative;">' + markerTextCharEntity + '</span>');
|
||||
markerEl = doc.getElementById(markerId);
|
||||
} else if (win.getSelection) {
|
||||
sel = win.getSelection();
|
||||
range = sel.getRangeAt(0).cloneRange();
|
||||
range.collapse(false);
|
||||
|
||||
// Create the marker element containing a single invisible character using DOM methods and insert it
|
||||
markerEl = doc.createElement('span');
|
||||
markerEl.id = markerId;
|
||||
markerEl.appendChild(doc.createTextNode(markerTextChar));
|
||||
range.insertNode(markerEl);
|
||||
}
|
||||
|
||||
if (markerEl) {
|
||||
// Lazily create element to be placed next to the selection
|
||||
if (!selectionEl) {
|
||||
selectionEl = doc.createElement('div');
|
||||
selectionEl.style.border = 'solid darkblue 1px';
|
||||
selectionEl.style.backgroundColor = 'lightgoldenrodyellow';
|
||||
selectionEl.innerHTML = '<- selection';
|
||||
selectionEl.style.position = 'absolute';
|
||||
|
||||
doc.body.appendChild(selectionEl);
|
||||
}
|
||||
|
||||
// Find markerEl position http://www.quirksmode.org/js/findpos.html
|
||||
var obj = markerEl;
|
||||
var left = 0, top = 0;
|
||||
do {
|
||||
left += obj.offsetLeft;
|
||||
top += obj.offsetTop;
|
||||
} while (obj = obj.offsetParent);
|
||||
|
||||
// Move the button into place.
|
||||
// Substitute your jQuery stuff in here
|
||||
selectionEl.style.left = left + 'px';
|
||||
selectionEl.style.top = top + 'px';
|
||||
|
||||
markerEl.parentNode.removeChild(markerEl);
|
||||
}
|
||||
};
|
||||
})();*/
|
||||
// $: console.log(document.getSelection().toString())
|
||||
/*
|
||||
const markSelection = () => {
|
||||
const sel = document.getSelection();
|
||||
console.log(sel.toString());
|
||||
};
|
||||
*/
|
||||
const bold_regex = /.*\*\*(.+)\*\*.*/gm; // **bold**
|
||||
const italic_regex = /.*\|\|(.+)\|\|.*/gm; // *italic*
|
||||
const strikethrough_regex = /.*~~(.+)~~.*/gm; // ~~strikethrough~~
|
||||
const sub_regex = /.*__(.+)__.*/gm; // __sub__
|
||||
const sup_regex = /.*--(.+)--.*/gm; // --sup--
|
||||
const process_input = () => {
|
||||
let output = internal_text;
|
||||
output = output.replace(bold_regex, '<b>$1</b>');
|
||||
output = output.replace(italic_regex, '<i>$1</i>');
|
||||
output = output.replace(strikethrough_regex, '<del>$1</del>');
|
||||
output = output.replace(sub_regex, '<sub>$1</sub>');
|
||||
output = output.replace(sup_regex, '<sup>$1</sup>');
|
||||
console.log(output);
|
||||
};
|
||||
|
||||
run(() => {
|
||||
process_input();
|
||||
internal_text;
|
||||
});
|
||||
</script>
|
||||
|
||||
<input bind:value={internal_text} />
|
||||
@@ -3,13 +3,20 @@ SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
|
||||
|
||||
SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
<!--
|
||||
<script context='module' lang='ts'>
|
||||
export const prerender = true;
|
||||
export const load = async ({}) => {
|
||||
return {};
|
||||
const languages = [
|
||||
interface Props {
|
||||
languages?: Array<{
|
||||
flag: string;
|
||||
name: string;
|
||||
code: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
let {
|
||||
languages = [
|
||||
{
|
||||
code: 'de',
|
||||
name: 'Deutsch',
|
||||
@@ -29,125 +36,88 @@ SPDX-License-Identifier: MPL-2.0
|
||||
code: 'fr',
|
||||
name: 'Français',
|
||||
flag: '🇫🇷'
|
||||
},
|
||||
{
|
||||
code: 'id',
|
||||
name: 'Bahasa Indonesia',
|
||||
flag: '🇮🇩'
|
||||
},
|
||||
{
|
||||
code: 'ca',
|
||||
name: 'Català',
|
||||
flag: '🇪🇸'
|
||||
},
|
||||
{
|
||||
code: 'it',
|
||||
name: 'Italiano',
|
||||
flag: '🇮🇹'
|
||||
},
|
||||
{
|
||||
code: 'es',
|
||||
name: 'Español',
|
||||
flag: '🇪🇸'
|
||||
},
|
||||
{
|
||||
code: 'nb_NO',
|
||||
name: 'Norsk',
|
||||
flag: '🇳🇴'
|
||||
},
|
||||
{
|
||||
code: 'zh_Hant',
|
||||
name: 'Chinese (traditional)',
|
||||
flag: '🇨🇳'
|
||||
},
|
||||
{
|
||||
code: 'pl',
|
||||
name: 'Polski',
|
||||
flag: '🇵🇱'
|
||||
},
|
||||
{
|
||||
code: 'pt',
|
||||
name: 'Português',
|
||||
flag: '🇵🇹'
|
||||
},
|
||||
{
|
||||
code: 'uk',
|
||||
name: 'Українська',
|
||||
flag: '🇺🇦'
|
||||
},
|
||||
{
|
||||
code: 'nl',
|
||||
name: 'Nederlands',
|
||||
flag: '🇳🇱'
|
||||
},
|
||||
{
|
||||
code: 'hu',
|
||||
name: 'Magyar',
|
||||
flag: '🇭🇺'
|
||||
},
|
||||
{
|
||||
code: 'vi',
|
||||
name: 'tiếng Việt',
|
||||
flag: '🇻🇳'
|
||||
},
|
||||
{
|
||||
code: 'ta',
|
||||
flag: '🇮🇳',
|
||||
name: 'Tamil'
|
||||
},
|
||||
{
|
||||
code: 'pt_BR',
|
||||
flag: '🇧🇷',
|
||||
name: 'Brazil'
|
||||
},
|
||||
{
|
||||
code: 'ja',
|
||||
flag: '🇯🇵',
|
||||
name: 'Japan'
|
||||
}
|
||||
];
|
||||
let final_arr = [];
|
||||
const set_percents = async () => {
|
||||
for (const lang of languages) {
|
||||
const res = await fetch(`https://translate.mawoka.eu/api/translations/classquiz/frontend/${lang.code}/?format=json`);
|
||||
const json = await res.json();
|
||||
console.log(json);
|
||||
// return Math.floor(json.translated_percent);
|
||||
final_arr.push({ ...lang, percent: json.translated_percent });
|
||||
}
|
||||
};
|
||||
await set_percents();
|
||||
return {
|
||||
slot: {
|
||||
final_arr
|
||||
}
|
||||
};
|
||||
};
|
||||
</script>
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
interface Props {
|
||||
languages?: Array<{
|
||||
flag: string;
|
||||
name: string;
|
||||
code: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
let { languages = [
|
||||
{
|
||||
code: 'de',
|
||||
name: 'Deutsch',
|
||||
flag: '🇩🇪'
|
||||
},
|
||||
{
|
||||
code: 'en',
|
||||
name: 'English',
|
||||
flag: '🇺🇲'
|
||||
},
|
||||
{
|
||||
code: 'tr',
|
||||
name: 'Türkçe',
|
||||
flag: '🇹🇷'
|
||||
},
|
||||
{
|
||||
code: 'fr',
|
||||
name: 'Français',
|
||||
flag: '🇫🇷'
|
||||
},
|
||||
{
|
||||
code: 'id',
|
||||
name: 'Bahasa Indonesia',
|
||||
flag: '🇮🇩'
|
||||
},
|
||||
{
|
||||
code: 'ca',
|
||||
name: 'Català',
|
||||
flag: '🇪🇸'
|
||||
},
|
||||
{
|
||||
code: 'it',
|
||||
name: 'Italiano',
|
||||
flag: '🇮🇹'
|
||||
},
|
||||
{
|
||||
code: 'es',
|
||||
name: 'Español',
|
||||
flag: '🇪🇸'
|
||||
},
|
||||
{
|
||||
code: 'nb_NO',
|
||||
name: 'Norsk',
|
||||
flag: '🇳🇴'
|
||||
},
|
||||
{
|
||||
code: 'zh_Hant',
|
||||
name: 'Chinese (traditional)',
|
||||
flag: '🇨🇳'
|
||||
},
|
||||
{
|
||||
code: 'pl',
|
||||
name: 'Polski',
|
||||
flag: '🇵🇱'
|
||||
},
|
||||
{
|
||||
code: 'pt',
|
||||
name: 'Português',
|
||||
flag: '🇵🇹'
|
||||
},
|
||||
{
|
||||
code: 'uk',
|
||||
name: 'Українська',
|
||||
flag: '🇺🇦'
|
||||
},
|
||||
{
|
||||
code: 'nl',
|
||||
name: 'Nederlands',
|
||||
flag: '🇳🇱'
|
||||
},
|
||||
{
|
||||
code: 'hu',
|
||||
name: 'Magyar',
|
||||
flag: '🇭🇺'
|
||||
},
|
||||
{
|
||||
code: 'vi',
|
||||
name: 'tiếng Việt',
|
||||
flag: '🇻🇳'
|
||||
}
|
||||
] }: Props = $props();
|
||||
]
|
||||
}: Props = $props();
|
||||
const get_selected_language = (): string => {
|
||||
return localStorage.getItem('language');
|
||||
};
|
||||
let selected_language = $state();
|
||||
let selected_language: string = $state();
|
||||
onMount(() => {
|
||||
selected_language = get_selected_language();
|
||||
});
|
||||
|
||||
@@ -5,8 +5,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy';
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { getLocalization } from '$lib/i18n';
|
||||
|
||||
@@ -16,13 +14,14 @@ SPDX-License-Identifier: MPL-2.0
|
||||
import confetti from 'canvas-confetti';
|
||||
interface Props {
|
||||
data: any;
|
||||
username: any;
|
||||
username?: any;
|
||||
show_final_results: boolean;
|
||||
}
|
||||
|
||||
let { data = $bindable(), username, show_final_results }: Props = $props();
|
||||
let sorted_data = $derived(sortObjectbyValue(data));
|
||||
|
||||
function sortObjectbyValue(obj) {
|
||||
function sortObjectbyValue(obj: object) {
|
||||
const ret = {};
|
||||
Object.keys(obj)
|
||||
.sort((a, b) => obj[b] - obj[a])
|
||||
@@ -30,30 +29,22 @@ SPDX-License-Identifier: MPL-2.0
|
||||
return ret;
|
||||
}
|
||||
|
||||
run(() => {
|
||||
data = sortObjectbyValue(data);
|
||||
});
|
||||
run(() => {
|
||||
console.log(data, 'sorted, fina');
|
||||
});
|
||||
|
||||
let player_names = $derived(Object.keys(data).sort(function (a, b) {
|
||||
return data[b] - data[a];
|
||||
}));
|
||||
|
||||
let player_names = $derived(Object.keys(sorted_data));
|
||||
|
||||
let player_count_or_five = $derived(player_names.length >= 5 ? 5 : player_names.length);
|
||||
|
||||
|
||||
let canvas = $state();
|
||||
let canvas: HTMLCanvasElement = $state();
|
||||
onMount(() => {
|
||||
setTimeout(() => {
|
||||
confetti.create(canvas, {
|
||||
resize: true,
|
||||
useWorker: true
|
||||
});
|
||||
confetti({ particleCount: 200, spread: 160 });
|
||||
}, player_count_or_five * 1200 - 800);
|
||||
setTimeout(
|
||||
() => {
|
||||
confetti.create(canvas, {
|
||||
resize: true,
|
||||
useWorker: true
|
||||
});
|
||||
confetti({ particleCount: 200, spread: 160 });
|
||||
},
|
||||
player_count_or_five * 1200 - 800
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -70,13 +61,13 @@ SPDX-License-Identifier: MPL-2.0
|
||||
{$t('play_page.final_result_rank', {
|
||||
place: i + 1,
|
||||
username: player,
|
||||
points: data[player]
|
||||
points: sorted_data[player]
|
||||
})}
|
||||
</p>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{#if data[username]}
|
||||
{#if sorted_data[username]}
|
||||
<div class="fixed bottom-0 left-0 flex justify-center w-full mb-6">
|
||||
<div class="mx-auto p-2 border-[#B07156] border-4 rounded-sm">
|
||||
<p class="text-center">{$t('play_page.your_score', { score: data[username] })}</p>
|
||||
|
||||
@@ -18,12 +18,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
cqc_code: string;
|
||||
}
|
||||
|
||||
let {
|
||||
game_pin,
|
||||
players = $bindable(),
|
||||
socket,
|
||||
cqc_code = $bindable()
|
||||
}: Props = $props();
|
||||
let { game_pin, players = $bindable(), socket, cqc_code = $bindable() }: Props = $props();
|
||||
|
||||
let fullscreen_open = $state(false);
|
||||
const { t } = getLocalization();
|
||||
@@ -97,7 +92,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div>
|
||||
<GrayButton
|
||||
disabled={players.length < 1}
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
socket.emit('start_game', '');
|
||||
}}
|
||||
>{$t('admin_page.start_game')}
|
||||
@@ -126,6 +121,15 @@ SPDX-License-Identifier: MPL-2.0
|
||||
class="fixed top-0 left-0 z-50 w-screen h-screen bg-black/50 flex p-2"
|
||||
transition:fade|global={{ duration: 80 }}
|
||||
onclick={() => (fullscreen_open = false)}
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label="Close modal"
|
||||
onkeydown={(e) =>
|
||||
e.key === 'Enter' || e.key === ' '
|
||||
? () => {
|
||||
fullscreen_open = false;
|
||||
}
|
||||
: null}
|
||||
>
|
||||
<img
|
||||
alt="QR code to join the game"
|
||||
|
||||
@@ -5,8 +5,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy';
|
||||
|
||||
import { QuizQuestionType } from '$lib/quiz_types';
|
||||
import type { QuizData } from '$lib/quiz_types';
|
||||
import { get_foreground_color } from '$lib/helpers.js';
|
||||
@@ -15,7 +13,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
import MediaComponent from '$lib/editor/MediaComponent.svelte';
|
||||
import { getLocalization } from '$lib/i18n';
|
||||
|
||||
|
||||
interface Props {
|
||||
quiz_data: QuizData;
|
||||
selected_question: number;
|
||||
@@ -34,16 +31,16 @@ SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
const { t } = getLocalization();
|
||||
|
||||
let circular_progress = $state(0);
|
||||
run(() => {
|
||||
let circular_progress = $derived.by(() => {
|
||||
try {
|
||||
circular_progress =
|
||||
return (
|
||||
1 -
|
||||
((100 / parseInt(quiz_data.questions[selected_question].time)) *
|
||||
parseInt(timer_res)) /
|
||||
100;
|
||||
100
|
||||
);
|
||||
} catch {
|
||||
circular_progress = 0;
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -56,11 +53,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div class="grid grid-cols-3 my-2">
|
||||
<span></span>
|
||||
<div class="m-auto">
|
||||
<CircularTimer
|
||||
bind:text={timer_res}
|
||||
bind:progress={circular_progress}
|
||||
color="#ef4444"
|
||||
/>
|
||||
<CircularTimer text={timer_res} progress={circular_progress} color="#ef4444" />
|
||||
</div>
|
||||
<p class="m-auto text-3xl">
|
||||
{$t('admin_page.answers_submitted', { answer_count: answer_count })}
|
||||
|
||||
@@ -5,8 +5,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy';
|
||||
|
||||
import VotingResults from './voting_results.svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { fly } from 'svelte/transition';
|
||||
@@ -31,7 +29,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
let { data = $bindable(), question, new_data }: Props = $props();
|
||||
|
||||
function sortObjectbyValue(obj) {
|
||||
function sortObjectbyValue(obj: object) {
|
||||
const ret = {};
|
||||
Object.keys(obj)
|
||||
.sort((a, b) => obj[b] - obj[a])
|
||||
@@ -39,26 +37,20 @@ SPDX-License-Identifier: MPL-2.0
|
||||
return ret;
|
||||
}
|
||||
|
||||
let sorted_data = $derived(sortObjectbyValue(data));
|
||||
|
||||
// let data_by_username = {};
|
||||
let score_by_username = $state({});
|
||||
|
||||
const do_sth = () => {
|
||||
const group_username_by_score = (new_d: any[]): object => {
|
||||
let ret_data = {};
|
||||
for (const i of new_data) {
|
||||
score_by_username[i.username] = i.score;
|
||||
ret_data[i.username] = i.score;
|
||||
}
|
||||
return ret_data;
|
||||
};
|
||||
let score_by_username = $derived(group_username_by_score(new_data));
|
||||
|
||||
run(() => {
|
||||
new_data;
|
||||
score_by_username;
|
||||
do_sth();
|
||||
});
|
||||
|
||||
let player_names = $derived(
|
||||
Object.keys(data).sort(function (a, b) {
|
||||
return data[b] - data[a];
|
||||
})
|
||||
);
|
||||
let player_names = $derived(Object.keys(sorted_data));
|
||||
|
||||
if (JSON.stringify(data) === '{}') {
|
||||
for (const i of new_data) {
|
||||
@@ -66,15 +58,9 @@ SPDX-License-Identifier: MPL-2.0
|
||||
}
|
||||
}
|
||||
|
||||
run(() => {
|
||||
data = sortObjectbyValue(data);
|
||||
});
|
||||
|
||||
let show_new_score_clicked = $state(false);
|
||||
|
||||
const show_new_score = () => {
|
||||
// for (let i = 0; i++; i < player_names.length) {
|
||||
// console.log(data)
|
||||
for (const i of player_names) {
|
||||
if (isNaN(data[i])) {
|
||||
data[i] = 0;
|
||||
@@ -91,8 +77,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
setTimeout(() => {
|
||||
data = data;
|
||||
}, 800);
|
||||
|
||||
// console.log(data)
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
@@ -127,7 +111,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<td class:hidden={i > 3} class="p-2 border-r border-r-black"
|
||||
>{player}</td
|
||||
>
|
||||
<td class:hidden={i > 3} class="p-2">{data[player]}</td>
|
||||
<td class:hidden={i > 3} class="p-2">{sorted_data[player]}</td>
|
||||
{#if show_new_score_clicked}
|
||||
<td
|
||||
in:fly|global={{ x: 300 }}
|
||||
|
||||
@@ -40,6 +40,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div bind:this={canvas_el} class="w-full h-full block"></div>
|
||||
</div>
|
||||
<div class="w-full h-full flex justify-center">
|
||||
<img src={img_src} />
|
||||
<img src={img_src} alt="Slide image" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy';
|
||||
|
||||
import Audio1 from '$lib/assets/music/1-128.mp3';
|
||||
|
||||
interface Props {
|
||||
@@ -25,10 +23,10 @@ SPDX-License-Identifier: MPL-2.0
|
||||
audio.pause();
|
||||
}
|
||||
};
|
||||
run(() => {
|
||||
$effect(() => {
|
||||
audio.volume = volume / 100;
|
||||
});
|
||||
run(() => {
|
||||
$effect(() => {
|
||||
control_audio(play);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -5,8 +5,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy';
|
||||
|
||||
interface Props {
|
||||
progress: number;
|
||||
text: string;
|
||||
@@ -14,25 +12,12 @@ SPDX-License-Identifier: MPL-2.0
|
||||
}
|
||||
|
||||
let { progress, text, color }: Props = $props();
|
||||
let angle = $state(360 * progress);
|
||||
|
||||
run(() => {
|
||||
angle = 360 * progress;
|
||||
});
|
||||
run(() => {
|
||||
console.log(angle);
|
||||
});
|
||||
let angle = $derived(360 * progress);
|
||||
|
||||
// Adapt the logic according to the approach
|
||||
let background = $state(`radial-gradient(white 50%, transparent 51%),
|
||||
conic-gradient(transparent 0deg ${angle}deg, gainsboro ${angle}deg 360deg),
|
||||
conic-gradient(green 0deg, green 90deg, green 180deg, green);`);
|
||||
|
||||
run(() => {
|
||||
background = `radial-gradient(white 50%, transparent 51%),
|
||||
let background = $derived(`radial-gradient(white 50%, transparent 51%),
|
||||
conic-gradient(transparent 0deg ${angle}deg, gainsboro ${angle}deg 360deg),
|
||||
conic-gradient(${color} 0deg, ${color} 90deg, ${color} 180deg, ${color});`;
|
||||
});
|
||||
conic-gradient(${color} 0deg, ${color} 90deg, ${color} 180deg, ${color});`);
|
||||
|
||||
let cssVarStyles = $derived(`--background:${background}`);
|
||||
</script>
|
||||
|
||||
@@ -5,8 +5,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy';
|
||||
|
||||
import { getLocalization } from '$lib/i18n';
|
||||
|
||||
const { t } = getLocalization();
|
||||
@@ -85,9 +83,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
}
|
||||
};
|
||||
let winners = getWinnersSorted();
|
||||
run(() => {
|
||||
console.log(winners, winners_arr);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -5,14 +5,10 @@ SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { run, createBubbler, preventDefault } from 'svelte/legacy';
|
||||
|
||||
const bubble = createBubbler();
|
||||
import { socket } from '$lib/socket';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import * as Sentry from '@sentry/browser';
|
||||
// import { alertModal } from '../stores';
|
||||
import { getLocalization } from '$lib/i18n';
|
||||
import Cookies from 'js-cookie';
|
||||
import BrownButton from '$lib/components/buttons/brown.svelte';
|
||||
@@ -25,7 +21,11 @@ SPDX-License-Identifier: MPL-2.0
|
||||
username: any;
|
||||
}
|
||||
|
||||
let { game_pin = $bindable(), game_mode = $bindable(), username = $bindable() }: Props = $props();
|
||||
let {
|
||||
game_pin = $bindable(),
|
||||
game_mode = $bindable(),
|
||||
username = $bindable()
|
||||
}: Props = $props();
|
||||
let custom_field = $state();
|
||||
let custom_field_value = $state();
|
||||
let captcha_enabled = $state();
|
||||
@@ -112,14 +112,14 @@ SPDX-License-Identifier: MPL-2.0
|
||||
}
|
||||
};
|
||||
|
||||
run(() => {
|
||||
$effect(() => {
|
||||
if (game_pin.length > 5) {
|
||||
console.log('Setting game pin');
|
||||
set_game_pin();
|
||||
}
|
||||
});
|
||||
|
||||
const setUsername = async () => {
|
||||
const setUsername = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
if (username.length <= 3) {
|
||||
return;
|
||||
}
|
||||
@@ -185,12 +185,12 @@ SPDX-License-Identifier: MPL-2.0
|
||||
alert('Game not found');
|
||||
}
|
||||
});
|
||||
|
||||
run(() => {
|
||||
console.log(game_pin, game_pin.length > 6);
|
||||
});
|
||||
run(() => {
|
||||
game_pin = game_pin.replace(/\D/g, '');
|
||||
$effect(() => {
|
||||
const cleaned = game_pin.replace(/\D/g, '');
|
||||
if (game_pin.replace(/\D/g, '') === game_pin) {
|
||||
return;
|
||||
}
|
||||
game_pin = cleaned;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -207,7 +207,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
{#if game_pin === '' || game_pin.length < 6}
|
||||
<div class="flex flex-col justify-center align-center w-screen h-screen">
|
||||
<form onsubmit={preventDefault(bubble('submit'))} class="flex-col flex justify-center align-center mx-auto">
|
||||
<form class="flex-col flex justify-center align-center mx-auto">
|
||||
<h1 class="text-lg text-center">{$t('words.game_pin')}</h1>
|
||||
<input
|
||||
class="border border-gray-400 self-center text-center text-black ring-0 outline-hidden p-2 rounded-lg focus:shadow-2xl transition-all"
|
||||
@@ -225,10 +225,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col justify-center align-center w-screen h-screen">
|
||||
<form
|
||||
onsubmit={preventDefault(setUsername)}
|
||||
class="flex-col flex justify-center align-center mx-auto"
|
||||
>
|
||||
<form onsubmit={setUsername} class="flex-col flex justify-center align-center mx-auto">
|
||||
<h1 class="text-lg text-center">{$t('words.username')}</h1>
|
||||
<input
|
||||
class="border border-gray-400 self-center text-center text-black ring-0 outline-hidden p-2 rounded-lg focus:shadow-2xl transition-all"
|
||||
@@ -244,7 +241,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
{/if}
|
||||
|
||||
<div class="mt-2">
|
||||
<BrownButton disabled={username.length <= 3} on:click={setUsername}
|
||||
<BrownButton disabled={username.length <= 3} onclick={setUsername}
|
||||
>{$t('words.submit')}</BrownButton
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy';
|
||||
|
||||
import type { Question } from '$lib/quiz_types';
|
||||
import { QuizQuestionType } from '$lib/quiz_types';
|
||||
import { socket } from '$lib/socket';
|
||||
@@ -35,23 +33,12 @@ SPDX-License-Identifier: MPL-2.0
|
||||
solution
|
||||
}: Props = $props();
|
||||
|
||||
run(() => {
|
||||
console.log(question_index, question, 'hi!');
|
||||
});
|
||||
|
||||
console.log(question);
|
||||
if (question.type === undefined) {
|
||||
question.type = QuizQuestionType.ABCD;
|
||||
} else {
|
||||
question.type = QuizQuestionType[question.type];
|
||||
}
|
||||
|
||||
/* if (typeof question_index === 'string') {
|
||||
question_index = parseInt(question_index);
|
||||
} else {
|
||||
throw new Error('question_index must be a string or number');
|
||||
}*/
|
||||
|
||||
let timer_res = $state(question.time);
|
||||
let selected_answer: string = $state();
|
||||
|
||||
@@ -75,7 +62,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
timer(question.time);
|
||||
|
||||
run(() => {
|
||||
$effect(() => {
|
||||
if (solution !== undefined) {
|
||||
timer_res = '0';
|
||||
}
|
||||
@@ -83,7 +70,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
const selectAnswer = (answer: string) => {
|
||||
selected_answer = answer;
|
||||
//timer_res = '0';
|
||||
socket.emit('submit_answer', {
|
||||
question_index: question_index,
|
||||
answer: answer
|
||||
@@ -132,15 +118,14 @@ SPDX-License-Identifier: MPL-2.0
|
||||
_arr[b] = temp;
|
||||
return _arr;
|
||||
};
|
||||
run(() => {
|
||||
$effect(() => {
|
||||
set_answer_if_not_set_range(timer_res);
|
||||
});
|
||||
let circular_progress = $state(0);
|
||||
run(() => {
|
||||
let circular_progress = $derived.by(() => {
|
||||
try {
|
||||
circular_progress = 1 - ((100 / question.time) * parseInt(timer_res)) / 100;
|
||||
return 1 - ((100 / question.time) * parseInt(timer_res)) / 100;
|
||||
} catch {
|
||||
circular_progress = 0;
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -155,9 +140,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
return '100';
|
||||
}
|
||||
};
|
||||
run(() => {
|
||||
console.log(slider_value, 'values');
|
||||
});
|
||||
const default_colors = ['#D6EDC9', '#B07156', '#7F7057', '#4E6E58'];
|
||||
</script>
|
||||
|
||||
@@ -189,11 +171,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div
|
||||
class="absolute top-0 bottom-0 left-0 right-0 m-auto rounded-full h-fit w-fit border-2 border-black shadow-2xl z-40"
|
||||
>
|
||||
<CircularTimer
|
||||
bind:text={timer_res}
|
||||
bind:progress={circular_progress}
|
||||
color="#ef4444"
|
||||
/>
|
||||
<CircularTimer text={timer_res} progress={circular_progress} color="#ef4444" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-rows-2 grid-flow-col auto-cols-auto gap-2 w-full p-4 h-full">
|
||||
@@ -241,9 +219,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<div class="w-1/2">
|
||||
<BrownButton
|
||||
disabled={selected_answer !== undefined}
|
||||
on:click={() => selectAnswer(slider_value[0])}
|
||||
<BrownButton onclick={() => selectAnswer(slider_value[0])}
|
||||
>{$t('words.submit')}
|
||||
</BrownButton>
|
||||
</div>
|
||||
@@ -262,7 +238,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<input
|
||||
type="text"
|
||||
bind:value={text_input}
|
||||
disabled={selected_answer}
|
||||
disabled={selected_answer !== undefined}
|
||||
class="bg-gray-50 focus:ring text-gray-900 rounded-lg focus:ring-blue-500 block w-full p-2 dark:bg-gray-700 dark:text-white dark:focus:ring-blue-500 outline-hidden transition text-center disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
@@ -271,8 +247,8 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div class="w-1/3">
|
||||
<BrownButton
|
||||
type="button"
|
||||
disabled={selected_answer}
|
||||
on:click={() => {
|
||||
disabled={!text_input || text_input.length === 0}
|
||||
onclick={() => {
|
||||
selectAnswer(text_input);
|
||||
}}
|
||||
>
|
||||
@@ -281,20 +257,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if question.type === QuizQuestionType.RANGE}
|
||||
{#if solution === undefined}
|
||||
<Spinner />
|
||||
{:else}
|
||||
<p class="text-center">
|
||||
Every number between {solution.answers.min_correct} and {solution.answers
|
||||
.max_correct} was correct. You got {selected_answer}, so you have been
|
||||
{#if solution.answers.min_correct <= parseInt(selected_answer) && parseInt(selected_answer) <= solution.answers.max_correct}
|
||||
correct
|
||||
{:else}
|
||||
wrong.
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{:else if question.type === QuizQuestionType.ORDER}
|
||||
<!-- {#if solution === undefined}
|
||||
<Spinner />
|
||||
@@ -316,7 +278,8 @@ SPDX-License-Identifier: MPL-2.0
|
||||
}}
|
||||
class="disabled:opacity-50 shadow-lg bg-black/30 w-full flex justify-center rounded-lg p-2 hover:bg-black/20 transition"
|
||||
type="button"
|
||||
disabled={i === 0 || selected_answer}
|
||||
aria-label="Move item up"
|
||||
disabled={i === 0 || Boolean(selected_answer)}
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8"
|
||||
@@ -343,7 +306,8 @@ SPDX-License-Identifier: MPL-2.0
|
||||
}}
|
||||
class="disabled:opacity-50 shadow-lg bg-black/30 w-full flex justify-center rounded-lg p-2 hover:bg-black/20 transition"
|
||||
type="button"
|
||||
disabled={i === question.answers.length - 1 || selected_answer}
|
||||
aria-label="Move item down"
|
||||
disabled={i === question.answers.length - 1 || Boolean(selected_answer)}
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8"
|
||||
@@ -367,8 +331,8 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div class="w-full mt-2">
|
||||
<BrownButton
|
||||
type="button"
|
||||
disabled={selected_answer}
|
||||
on:click={() => {
|
||||
disabled={Boolean(selected_answer)}
|
||||
onclick={() => {
|
||||
select_complex_answer(question.answers);
|
||||
}}>{$t('words.submit')}</BrownButton
|
||||
>
|
||||
@@ -380,30 +344,23 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<Spinner />
|
||||
{:then c}
|
||||
<c.default
|
||||
bind:question
|
||||
{question}
|
||||
bind:selected_answer
|
||||
bind:game_mode
|
||||
{game_mode}
|
||||
{timer_res}
|
||||
{circular_progress}
|
||||
/>
|
||||
<div class="flex justify-center h-[5%]">
|
||||
<div class="w-1/2">
|
||||
<BrownButton
|
||||
disabled={!selected_answer}
|
||||
on:click={() => selectAnswer(selected_answer)}
|
||||
type="button"
|
||||
disabled={selected_answer === undefined}
|
||||
onclick={() => selectAnswer(selected_answer)}
|
||||
>{$t('words.submit')}
|
||||
</BrownButton>
|
||||
</div>
|
||||
</div>
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
<!--{:else if question.type === QuizQuestionType.VOTING}
|
||||
{#await import('$lib/play/admin/voting_results.svelte')}
|
||||
<Spinner />
|
||||
{:then c}
|
||||
<svelte:component this={c.default} bind:data={question_results}
|
||||
bind:question={quiz_data.questions[selected_question]} />
|
||||
{/await}-->
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
// import CircularTimer from '$lib/play/circular_progress.svelte';
|
||||
const default_colors = ['#D6EDC9', '#B07156', '#7F7057', '#4E6E58'];
|
||||
|
||||
|
||||
interface Props {
|
||||
question: Question;
|
||||
selected_answer?: string;
|
||||
@@ -23,10 +22,10 @@ SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
let {
|
||||
question,
|
||||
selected_answer = $bindable(''),
|
||||
selected_answer = $bindable(),
|
||||
game_mode,
|
||||
timer_res = $bindable(),
|
||||
circular_progress = $bindable()
|
||||
timer_res,
|
||||
circular_progress
|
||||
}: Props = $props();
|
||||
let _selected_answers = $state([false, false, false, false]);
|
||||
|
||||
@@ -58,7 +57,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div
|
||||
class="absolute top-0 bottom-0 left-0 right-0 m-auto rounded-full h-fit w-fit border-2 border-black shadow-2xl z-40"
|
||||
>
|
||||
<CircularTimer bind:text={timer_res} bind:progress={circular_progress} color="#ef4444" />
|
||||
<CircularTimer text={timer_res} progress={circular_progress} color="#ef4444" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-rows-2 grid-flow-col auto-cols-auto gap-2 w-full p-4 h-full">
|
||||
|
||||
@@ -5,10 +5,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy';
|
||||
|
||||
|
||||
|
||||
function sortObjectbyValue(obj) {
|
||||
const ret = {};
|
||||
Object.keys(obj)
|
||||
@@ -20,12 +16,12 @@ SPDX-License-Identifier: MPL-2.0
|
||||
interface Props {
|
||||
scores: any;
|
||||
question_results: Array<{
|
||||
username: string;
|
||||
answer: string;
|
||||
right: boolean;
|
||||
time_taken: number;
|
||||
score: number;
|
||||
}>;
|
||||
username: string;
|
||||
answer: string;
|
||||
right: boolean;
|
||||
time_taken: number;
|
||||
score: number;
|
||||
}>;
|
||||
username: any;
|
||||
}
|
||||
|
||||
@@ -37,26 +33,14 @@ SPDX-License-Identifier: MPL-2.0
|
||||
scores[i.username] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
run(() => {
|
||||
console.log(score_by_username, scores, 'dieter');
|
||||
});
|
||||
for (const i of question_results) {
|
||||
score_by_username[i.username] = i.score;
|
||||
}
|
||||
|
||||
run(() => {
|
||||
scores = sortObjectbyValue(scores);
|
||||
});
|
||||
|
||||
const do_sth = () => {
|
||||
for (const i of Object.keys(score_by_username)) {
|
||||
scores[i] = (score_by_username[i] ?? 0) + (scores[i] ?? 0);
|
||||
}
|
||||
scores = scores;
|
||||
};
|
||||
|
||||
do_sth();
|
||||
for (const username of Object.keys(score_by_username)) {
|
||||
scores[username] = (score_by_username[username] ?? 0) + (scores[username] ?? 0);
|
||||
}
|
||||
scores = scores;
|
||||
let sorted_scores = $derived(sortObjectbyValue(scores));
|
||||
</script>
|
||||
|
||||
<div>
|
||||
@@ -65,7 +49,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<p class="p-4 bg-black/40 rounded-lg text-2xl">
|
||||
+{score_by_username[username] ?? '0'}
|
||||
</p>
|
||||
<p>Total score: {scores[username] ?? '0'}</p>
|
||||
<p>Total score: {sorted_scores[username] ?? '0'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
|
||||
|
||||
SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import type { Answer, Question, QuizData } from '$lib/quiz_types';
|
||||
import { getLocalization } from '$lib/i18n';
|
||||
import { QuizQuestionType } from '../quiz_types.js';
|
||||
import Spinner from '$lib/Spinner.svelte';
|
||||
|
||||
const { t } = getLocalization();
|
||||
interface Props {
|
||||
results: Array<Answer>;
|
||||
game_data: QuizData;
|
||||
solution: Question;
|
||||
question_index: string;
|
||||
}
|
||||
|
||||
let {
|
||||
results,
|
||||
game_data,
|
||||
solution = $bindable(),
|
||||
question_index
|
||||
}: Props = $props();
|
||||
let data_store = $state({});
|
||||
|
||||
const question = solution.answers;
|
||||
for (let i = 0; i < question.length; i++) {
|
||||
data_store[question[i].answer] = 0;
|
||||
}
|
||||
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
data_store[results[i].answer] += 1;
|
||||
}
|
||||
|
||||
let slider_values = $state([solution.answers.min_correct ?? 0, solution.answers.max_correct ?? 0]);
|
||||
console.log(slider_values, solution.answers);
|
||||
</script>
|
||||
|
||||
<!-- Show the results from the results object -->
|
||||
<!-- Language: SvelteHTML -->
|
||||
<!-- Path: frontend/src/lib/play/show_results.svelte -->
|
||||
|
||||
<div>
|
||||
<h2 class="text-center text-3xl mb-8">{$t('words.result', { count: 2 })}</h2>
|
||||
<div class="w-screen flex justify-center">
|
||||
{#if solution.type === QuizQuestionType.ABCD}
|
||||
<div class="relative overflow-x-auto shadow-md rounded-lg">
|
||||
<table class="w-fit text-sm text-left text-gray-500 dark:text-gray-400">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="py-3 px-6 text-xs font-medium tracking-wider text-left text-gray-700 uppercase dark:text-gray-400"
|
||||
>
|
||||
{$t('words.answer')}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="py-3 px-6 text-xs font-medium tracking-wider text-left text-gray-700 uppercase dark:text-gray-400"
|
||||
>
|
||||
{$t('words.count')}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="py-3 px-6 text-xs font-medium tracking-wider text-left text-gray-700 uppercase dark:text-gray-400"
|
||||
>
|
||||
{$t('words.correct')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each solution.answers as answer}
|
||||
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700">
|
||||
<td
|
||||
class="py-4 px-6 text-sm font-medium text-gray-900 whitespace-nowrap dark:text-white"
|
||||
>
|
||||
{answer.answer}
|
||||
</td>
|
||||
<td
|
||||
class="py-4 px-6 text-sm text-gray-500 whitespace-nowrap dark:text-gray-400"
|
||||
>
|
||||
{data_store[answer.answer]}
|
||||
</td>
|
||||
<td
|
||||
class="py-4 px-6 text-sm text-gray-500 whitespace-nowrap dark:text-gray-400"
|
||||
>
|
||||
{#if answer.right}
|
||||
✅
|
||||
{:else}
|
||||
❌
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else if solution.type === QuizQuestionType.RANGE}
|
||||
<!--<p class="text-center">
|
||||
Every number between {solution.answers
|
||||
.min_correct} and {solution.answers
|
||||
.max_correct} was correct.
|
||||
</p>-->
|
||||
{#await import('svelte-range-slider-pips')}
|
||||
<Spinner />
|
||||
{:then c}
|
||||
<div class="grayscale pointer-events-none w-full">
|
||||
<c.default
|
||||
bind:values={slider_values}
|
||||
bind:min={solution.answers.min}
|
||||
bind:max={solution.answers.max}
|
||||
pips
|
||||
float
|
||||
all="label"
|
||||
/>
|
||||
</div>
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,7 +45,10 @@ SPDX-License-Identifier: MPL-2.0
|
||||
if (question.type === QuizQuestionType.RANGE) {
|
||||
slider_value[0] = (question.answers.max - question.answers.min) / 2 + question.answers.min;
|
||||
}
|
||||
let slider_values = $state([question.answers.min_correct ?? 0, question.answers.max_correct ?? 0]);
|
||||
let slider_values = $state([
|
||||
question.answers.min_correct ?? 0,
|
||||
question.answers.max_correct ?? 0
|
||||
]);
|
||||
|
||||
let text_input = $state();
|
||||
timer(question.time);
|
||||
@@ -347,7 +350,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
{/each}
|
||||
<BrownButton
|
||||
type="button"
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
timer_res = '0';
|
||||
}}>{$t('words.submit')}</BrownButton
|
||||
>
|
||||
|
||||
@@ -5,8 +5,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { preventDefault } from 'svelte/legacy';
|
||||
|
||||
import { getLocalization } from '$lib/i18n';
|
||||
|
||||
import { fly } from 'svelte/transition';
|
||||
@@ -15,7 +13,11 @@ SPDX-License-Identifier: MPL-2.0
|
||||
const { t } = getLocalization();
|
||||
</script>
|
||||
|
||||
<div class="w-full px-6 lg:px-20 h-[80vh] absolute" in:fly|global={{ x: 100 }} out:fly|global={{ x: -100 }}>
|
||||
<div
|
||||
class="w-full px-6 lg:px-20 h-[80vh] absolute"
|
||||
in:fly|global={{ x: 100 }}
|
||||
out:fly|global={{ x: -100 }}
|
||||
>
|
||||
<div class="rounded-lg bg-white w-full h-full border-gray-500 dark:bg-gray-700">
|
||||
<div class="h-fit bg-gray-300 rounded-t-lg dark:bg-gray-500">
|
||||
<div class="flex align-middle p-4 gap-3">
|
||||
@@ -52,9 +54,10 @@ SPDX-License-Identifier: MPL-2.0
|
||||
src="/api/v1/storage/download/{data.cover_image}"
|
||||
alt="not available"
|
||||
class="max-h-72 h-auto w-auto"
|
||||
oncontextmenu={preventDefault(() => {
|
||||
oncontextmenu={(e) => {
|
||||
e.preventDefault();
|
||||
data.cover_image = '';
|
||||
})}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
|
||||
|
||||
SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
@@ -84,7 +84,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
{#if data.answers.length < 4}
|
||||
<div class="rounded-sm p-6 bg-gray-700">
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
data.answers = [...data.answers, { ...{ answer: '', correct: false } }];
|
||||
}}>{$t('editor_page.add_an_answer')}</BrownButton
|
||||
>
|
||||
|
||||
@@ -5,8 +5,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy';
|
||||
|
||||
import type { Markdown } from '$lib/quiztivity/types';
|
||||
import { marked } from 'marked';
|
||||
import { browser } from '$app/environment';
|
||||
@@ -23,11 +21,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
};
|
||||
}
|
||||
|
||||
let rendered_html = $state('');
|
||||
|
||||
run(() => {
|
||||
rendered_html = browser ? marked.parse(data.markdown) : '';
|
||||
});
|
||||
let rendered_html = $derived(browser ? marked.parse(data.markdown) : '');
|
||||
</script>
|
||||
|
||||
<div class="w-full h-[70vh] flex flex-row p-4 gap-4">
|
||||
|
||||
@@ -5,8 +5,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy';
|
||||
|
||||
import type { Markdown } from '$lib/quiztivity/types';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { marked } from 'marked';
|
||||
@@ -18,11 +16,9 @@ SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
let rendered_html = $state('');
|
||||
|
||||
run(() => {
|
||||
rendered_html = browser ? DOMPurify.sanitize(marked.parse(data.markdown ?? '')) : '';
|
||||
});
|
||||
let rendered_html = $derived(
|
||||
browser ? DOMPurify.sanitize(marked.parse(data.markdown ?? '')) : ''
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="prose dark:prose-invert">
|
||||
|
||||
@@ -96,7 +96,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center p-2">
|
||||
<BrownButton on:click={add_card}
|
||||
<BrownButton onclick={add_card}
|
||||
>{$t('quiztivity.memory.editor.add_pair')}</BrownButton
|
||||
>
|
||||
</div>
|
||||
@@ -115,13 +115,13 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</div>
|
||||
<div class="grid grid-cols-2 p-2 gap-2">
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
move_card_left(i);
|
||||
}}
|
||||
disabled={i === 0}>{$t('quiztivity.editor.move_left')}</BrownButton
|
||||
>
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
move_card_right(i);
|
||||
}}
|
||||
disabled={i + 1 === data.cards.length}
|
||||
|
||||
@@ -5,8 +5,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy';
|
||||
|
||||
import type { Data } from './types';
|
||||
import { getLocalization } from '$lib/i18n';
|
||||
import BrownButton from '$lib/components/buttons/brown.svelte';
|
||||
@@ -48,7 +46,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
data.pages.push({ title: undefined, data: undefined, type, id });
|
||||
opened_slide = data.pages.length - 1;
|
||||
};
|
||||
run(() => {
|
||||
$effect(() => {
|
||||
handle_slide_add(selected_type);
|
||||
});
|
||||
|
||||
@@ -83,7 +81,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
{#if data.id}
|
||||
<div class="mr-auto w-fit pl-2">
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
shares_menu_open = true;
|
||||
}}>{$t('quiztivity.editor.open_shares_menu')}</BrownButton
|
||||
>
|
||||
@@ -99,7 +97,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div class="self-end pr-2 w-full">
|
||||
<div class="ml-auto w-fit">
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
dispatch('save');
|
||||
}}
|
||||
disabled={!data.title}>{$t('words.save')}</BrownButton
|
||||
@@ -109,20 +107,20 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</div>
|
||||
<div class="flex flex-row gap-2 w-full p-2">
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
selected_type = null;
|
||||
}}>{$t('quiztivity.editor.add_new')}</BrownButton
|
||||
>
|
||||
<BrownButton on:click={delete_slide} disabled={selected_slide === null}
|
||||
<BrownButton onclick={delete_slide} disabled={selected_slide === null}
|
||||
>{$t('quiztivity.editor.delete')}</BrownButton
|
||||
>
|
||||
<BrownButton
|
||||
on:click={move_slide_left}
|
||||
onclick={move_slide_left}
|
||||
disabled={selected_slide === null || selected_slide === 0}
|
||||
>{$t('quiztivity.editor.move_left')}</BrownButton
|
||||
>
|
||||
<BrownButton
|
||||
on:click={move_slide_right}
|
||||
onclick={move_slide_right}
|
||||
disabled={selected_slide === null || selected_slide === data.pages.length - 1}
|
||||
>{$t('quiztivity.editor.move_right')}</BrownButton
|
||||
>
|
||||
@@ -140,7 +138,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
class:opacity-0={selected_slide !== i}
|
||||
>
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
selected_slide = selected_slide === i ? null : i;
|
||||
}}
|
||||
>
|
||||
@@ -149,7 +147,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
)}{/if}
|
||||
</BrownButton>
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
opened_slide = i;
|
||||
}}>{$t('words.edit')}</BrownButton
|
||||
>
|
||||
@@ -164,7 +162,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div class="h-full">
|
||||
<div class="mb-2">
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
opened_slide = null;
|
||||
}}>{$t('words.back')}</BrownButton
|
||||
>
|
||||
|
||||
@@ -5,8 +5,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { preventDefault } from 'svelte/legacy';
|
||||
|
||||
import BrownButton from '$lib/components/buttons/brown.svelte';
|
||||
import { getLocalization } from '$lib/i18n';
|
||||
import Spinner from '$lib/Spinner.svelte';
|
||||
@@ -81,7 +79,8 @@ SPDX-License-Identifier: MPL-2.0
|
||||
});
|
||||
let never_expires_checked = $state(true);
|
||||
let selected_date = $state(undefined);
|
||||
const create_share = async () => {
|
||||
const create_share = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
if (!selected_date && !never_expires_checked) {
|
||||
return;
|
||||
}
|
||||
@@ -117,7 +116,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
? !navigator.canShare({
|
||||
title: 'title',
|
||||
url: `${window.location.origin}/quiztivity`
|
||||
})
|
||||
})
|
||||
: false;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -137,7 +136,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
>
|
||||
<div class="flex justify-center flex-col">
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
add_shares_open = !add_shares_open;
|
||||
}}>{$t('quiztivity.editor.shares.add_new_share')}</BrownButton
|
||||
>
|
||||
@@ -145,7 +144,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<form
|
||||
class="flex justify-center p-2 border-b-2 border-l-2 border-r-2 border-[#B07156] flex-col gap-2"
|
||||
transition:fly={{ duration: 100, y: -10 }}
|
||||
onsubmit={preventDefault(create_share)}
|
||||
onsubmit={create_share}
|
||||
>
|
||||
<div class="grid grid-cols-2">
|
||||
<input
|
||||
@@ -175,7 +174,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<BrownButton
|
||||
flex={true}
|
||||
disabled={share_available()}
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
navigator.share({
|
||||
title: 'Quiztivity on ClassQuiz',
|
||||
text: 'Play this Quiztivity now on ClassQuiz!',
|
||||
@@ -204,7 +203,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div class="w-full mx-auto">
|
||||
<BrownButton
|
||||
flex={true}
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
copyToClipboard(
|
||||
`${window.location.origin}/quiztivity/share/${share.id}?ref=copy`
|
||||
);
|
||||
@@ -242,7 +241,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
</p>
|
||||
<div class="w-fit my-auto ml-auto">
|
||||
<BrownButton
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
delete_share(share.id);
|
||||
}}>{$t('words.delete')}</BrownButton
|
||||
>
|
||||
|
||||
@@ -15,10 +15,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
<div class="flex justify-center">
|
||||
<a href="/view/{quiz.id}" class="h-max w-fit">
|
||||
<div class="max-w-md py-4 px-8 bg-white shadow-lg rounded-lg my-20 dark:bg-slate-800">
|
||||
<!-- <div class='flex justify-center md:justify-end -mt-16'>
|
||||
<img class='w-20 h-20 object-cover rounded-full border-2 border-indigo-500'
|
||||
src='https://images.unsplash.com/photo-1499714608240-22fc6ad53fb2?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=334&q=80'>
|
||||
</div>-->
|
||||
<div>
|
||||
<div class="flex w-full items-center">
|
||||
<h2
|
||||
|
||||
@@ -9,8 +9,7 @@ SPDX-License-Identifier: MPL-2.0
|
||||
import Navbar from '$lib/navbar.svelte';
|
||||
import { pathname } from '$lib/stores';
|
||||
import { navbarVisible } from '$lib/stores.svelte';
|
||||
// import * as Sentry from '@sentry/browser';
|
||||
// import { BrowserTracing } from '@sentry/tracing';
|
||||
|
||||
import { initLocalizationContext } from '$lib/i18n';
|
||||
import { browser } from '$app/environment';
|
||||
import CommandPalette from '$lib/components/commandpalette.svelte';
|
||||
@@ -19,19 +18,6 @@ SPDX-License-Identifier: MPL-2.0
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
// import Alert from '$lib/modals/alert.svelte';
|
||||
|
||||
/* afterNavigate(() => {
|
||||
if (browser) {
|
||||
if (latestPageVisitURl === window.location.href) {
|
||||
return;
|
||||
} else {
|
||||
latestPageVisitURl = window.location.href;
|
||||
plausible.trackPageview();
|
||||
}
|
||||
console.log('After nav');
|
||||
}
|
||||
});*/
|
||||
const plausible_data_url = import.meta.env.VITE_PLAUSIBLE_DATA_URL;
|
||||
|
||||
if (browser) {
|
||||
@@ -45,34 +31,12 @@ SPDX-License-Identifier: MPL-2.0
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
|
||||
// Whenever the user explicitly chooses light mode
|
||||
// localStorage.theme = 'light';
|
||||
//
|
||||
// // Whenever the user explicitly chooses dark mode
|
||||
// localStorage.theme = 'dark';
|
||||
//
|
||||
// // Whenever the user explicitly chooses to respect the OS preference
|
||||
// localStorage.removeItem('theme');
|
||||
}
|
||||
let start_language = 'en';
|
||||
if (browser) {
|
||||
start_language = localStorage.getItem('language') ?? 'en';
|
||||
}
|
||||
initLocalizationContext(start_language);
|
||||
/*
|
||||
if (import.meta.env.VITE_SENTRY !== undefined && import.meta.env.PROD) {
|
||||
Sentry.init({
|
||||
dsn: String(import.meta.env.VITE_SENTRY),
|
||||
integrations: [new BrowserTracing()],
|
||||
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
// of transactions for performance monitoring.
|
||||
// We recommend adjusting this value in production
|
||||
tracesSampleRate: 0.5
|
||||
});
|
||||
}
|
||||
*/
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -92,40 +56,15 @@ SPDX-License-Identifier: MPL-2.0
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<!-- {#if navbarVisible.visible = true.visible}
|
||||
<Navbar />
|
||||
<div class="pt-16 h-screen">
|
||||
<div class="z-40"></div>
|
||||
<slot />
|
||||
</div>
|
||||
{:else}
|
||||
<slot />
|
||||
{/if} -->
|
||||
{#if navbarVisible.visible}
|
||||
<Navbar />
|
||||
<div class="pt-16">
|
||||
<div class="z-40"></div>
|
||||
<!-- extra content above slot -->
|
||||
</div>
|
||||
{/if}
|
||||
<slot />
|
||||
{@render children?.()}
|
||||
<CommandPalette />
|
||||
|
||||
<!--{#if $alertModal.open ?? false}
|
||||
<div
|
||||
class="fixed inset-0 h-screen w-screen bg-black z-30 bg-opacity-60 items-center justify-center content-center"
|
||||
class:hidden={!$alertModal.open}
|
||||
class:flex={$alertModal.open}
|
||||
class:visible={$alertModal.open}
|
||||
>
|
||||
<Alert
|
||||
bind:title={$alertModal.title}
|
||||
bind:body={$alertModal.body}
|
||||
bind:open={$alertModal.open}
|
||||
/>
|
||||
</div>
|
||||
{/if}-->
|
||||
|
||||
<style lang="scss">
|
||||
:global(html:not(.dark)) {
|
||||
// height: 100%;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
// import { redirect } from '@sveltejs/kit';
|
||||
import { signedIn } from '$lib/stores';
|
||||
import type { LayoutLoad } from './$types';
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user