Merge branch 'mawoka-myblock:master' into master

This commit is contained in:
Felix Kleinsteuber
2025-12-13 15:09:33 +01:00
committed by GitHub
141 changed files with 4762 additions and 5132 deletions
+2 -2
View File
@@ -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
View File
@@ -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/
+3 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+33 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
+51 -62
View File
@@ -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
+3 -3
View File
@@ -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")
+22 -23
View File
@@ -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
+3 -3
View File
@@ -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:
+4 -2
View File
@@ -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),
+2 -4
View File
@@ -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
+3 -3
View File
@@ -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:
+11 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+24 -2
View File
@@ -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",
+3 -3
View File
@@ -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)}
-13
View File
@@ -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 -5
View File
@@ -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
+17 -5
View File
@@ -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 = []
+2 -2
View File
@@ -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:
+8 -2
View File
@@ -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
+18 -73
View File
@@ -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")
+74 -225
View File
@@ -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()
+139
View File
@@ -0,0 +1,139 @@
# SPDX-FileCopyrightText: 2025 Marlon W (Mawoka)
#
# SPDX-License-Identifier: MPL-2.0
import aiohttp
from classquiz.config import settings
from classquiz.db.models import (
PlayGame,
QuizQuestionType,
TextQuizAnswer,
ABCDQuizAnswer,
VotingQuizAnswer,
RangeQuizAnswer,
AnswerDataList,
)
from classquiz.socket_server.models import SubmitAnswerData
from .models import SubmitAnswerDataOrderType
async def check_captcha(captcha_data: str) -> bool:
async with aiohttp.ClientSession() as session:
try:
if settings.hcaptcha_key is not None:
try:
async with session.post(
"https://hcaptcha.com/siteverify",
data={
"response": captcha_data,
"secret": settings.hcaptcha_key,
},
) as resp:
resp_data = await resp.model_dump_json()
if not resp_data["success"]:
return
except KeyError:
return False
elif settings.recaptcha_key is not None:
async with session.post(
"https://www.google.com/recaptcha/api/siteverify",
data={
"secret": settings.recaptcha_key,
"response": captcha_data,
},
) as resp:
try:
resp_data = await resp.model_dump_json()
if not resp_data["success"]:
return False
except KeyError:
return False
except TypeError:
pass
return False
return True
def check_answer(game_data: PlayGame, data: SubmitAnswerData) -> (bool, str):
q_i = int(float(data.question_index))
q_type = game_data.questions[q_i].type
q_answers = game_data.questions[q_i].answers
q_answer = data.answer
if q_type == QuizQuestionType.ABCD:
return (check_abcd_question(q_answer, q_answers), data.answer)
elif q_type == QuizQuestionType.RANGE:
return (
check_range_question(q_answer, q_answers),
q_answer,
)
elif q_type == QuizQuestionType.VOTING:
return (False, q_answer)
elif q_type == QuizQuestionType.ORDER:
return check_order_question(data.complex_answer, q_answer, q_answers)
elif q_type == QuizQuestionType.TEXT:
return (
check_text_question(q_answer, q_answers),
q_answer,
)
elif q_type == QuizQuestionType.CHECK:
return (
check_check_question(q_answer, q_answers),
q_answer,
)
else:
return (False, q_answer)
return (False, q_answer)
def check_abcd_question(answer: str, answers: ABCDQuizAnswer) -> bool:
for a in answers:
if a.answer == answer and a.right:
return True
return False
def check_range_question(answer: str, answers: RangeQuizAnswer) -> bool:
if answers.min_correct <= int(float(answer)) <= answers.max_correct:
return answers.min_correct <= int(float(answer)) <= answers.max_correct
def check_order_question(
complex_answer: list[SubmitAnswerDataOrderType] | None,
answer: str,
answers: list[VotingQuizAnswer],
) -> (bool, str):
if complex_answer is None:
return (False, answer)
correct_answers = [{"answer": a.answer} for a in answers]
submitted_answers = [{"answer": a.answer} for a in complex_answer]
answer_str = ", ".join(a["answer"] for a in submitted_answers)
is_correct = submitted_answers == correct_answers
return is_correct, answer_str
def check_text_question(answer: str, answers: list[TextQuizAnswer]) -> bool:
for q in answers:
if q.case_sensitive:
if answer == q.answer:
return True
else:
if answer.lower() == q.answer.lower():
return True
return False
def check_check_question(answer: str, answers: list[ABCDQuizAnswer]) -> bool:
correct_string = ""
for i, a in enumerate(answers):
if a.right:
correct_string += str(i)
return bool(correct_string == answer)
async def has_already_answered(game_pin: str, q_index: int, username: str) -> bool:
answers = await AnswerDataList.get_redis_or_empty(game_pin, q_index)
if answers is None:
return False
else:
answers = list(filter(lambda a: a.username == username, answers.root))
return len(answers) > 0
+68
View File
@@ -0,0 +1,68 @@
# SPDX-FileCopyrightText: 2025 Marlon W (Mawoka)
#
# SPDX-License-Identifier: MPL-2.0
from pydantic import BaseModel, field_validator, ValidationInfo
from classquiz.db.models import QuizQuestion, QuizQuestionType, VotingQuizAnswer
class JoinGameData(BaseModel):
username: str
game_pin: str
captcha: str | None = None
custom_field: str | None = None
class RejoinGameData(BaseModel):
old_sid: str
game_pin: str
username: str
class RegisterAsAdminData(BaseModel):
game_pin: str
game_id: str
class ABCDQuizAnswerWithoutSolution(BaseModel):
answer: str
color: str | None = None
class RangeQuizAnswerWithoutSolution(BaseModel):
min: int
max: int
class ReturnQuestion(QuizQuestion):
answers: list[ABCDQuizAnswerWithoutSolution] | RangeQuizAnswerWithoutSolution | list[VotingQuizAnswer]
type: QuizQuestionType = QuizQuestionType.ABCD
@field_validator("answers")
def answers_not_none_if_abcd_type(cls, v, info: ValidationInfo):
if info.data["type"] == QuizQuestionType.ABCD and type(v[0]) is not ABCDQuizAnswerWithoutSolution:
raise ValueError("Answers can't be none if type is ABCD")
if info.data["type"] == QuizQuestionType.RANGE and type(v) is not RangeQuizAnswerWithoutSolution:
raise ValueError("Answer must be from type RangeQuizAnswer if type is RANGE")
# skipcq: PTC-W0047
if info.data["type"] == QuizQuestionType.VOTING and type(v[0]) is not VotingQuizAnswer:
pass
return v
class SubmitAnswerDataOrderType(BaseModel):
answer: str
class SubmitAnswerData(BaseModel):
question_index: int
answer: str | int
complex_answer: list[SubmitAnswerDataOrderType] | None = None
class KickPlayerInput(BaseModel):
username: str
class ConnectSessionIdEvent(BaseModel):
session_id: str
+93 -58
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
+1516 -1571
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -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 =
+9 -2
View File
@@ -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 {
+8 -11
View File
@@ -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)
};
};*/
+5 -11
View File
@@ -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}
+7 -37
View File
@@ -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 />
+2 -11
View File
@@ -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}
+7 -5
View File
@@ -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>
+8 -22
View File
@@ -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}
/>
+1 -1
View File
@@ -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) {
+13 -11
View File
@@ -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
+6 -12
View File
@@ -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 {
+5 -2
View File
@@ -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": "",
+3 -3
View File
@@ -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": "",
+2 -1
View File
@@ -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"
+2 -1
View File
@@ -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",
+3 -2
View File
@@ -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": {
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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
+5 -2
View File
@@ -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
+19 -18
View File
@@ -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": {
-35
View File
@@ -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 = '&#xfeff;';
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 = '&lt;- 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} />
+91 -121
View File
@@ -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"
+6 -13
View File
@@ -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 })}
+10 -26
View File
@@ -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 }}
+1 -1
View File
@@ -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>
+2 -4
View File
@@ -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>
+3 -18
View File
@@ -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
View File
@@ -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>
+17 -20
View File
@@ -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>
+21 -64
View File
@@ -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>
+4 -5
View File
@@ -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">
+12 -28
View File
@@ -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>
-123
View File
@@ -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>
+5 -2
View File
@@ -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}
+10 -12
View File
@@ -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
>
-4
View File
@@ -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
+2 -63
View File
@@ -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%;
-1
View File
@@ -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