🔀 Merged ClassQuizController

This commit is contained in:
Mawoka
2023-06-30 01:36:48 +02:00
183 changed files with 10232 additions and 3260 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
codecov:
require_ci_to_pass: true
require_ci_to_pass: false
coverage:
precision: 2
+1
View File
@@ -7,3 +7,4 @@ node_modules/
*.rdb
survey.json
.coverage
export_deta.py
+4 -4
View File
@@ -2,7 +2,7 @@
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
rev: v4.4.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
@@ -10,11 +10,11 @@ repos:
# - id: check-added-large-files
- repo: https://github.com/psf/black
rev: 22.10.0
rev: 23.3.0
hooks:
- id: black
- repo: https://github.com/pre-commit/mirrors-prettier
rev: 'v3.0.0-alpha.2' # Use the sha / tag you want to point at
rev: 'v3.0.0-alpha.9-for-vscode' # Use the sha / tag you want to point at
hooks:
- id: prettier
files: "^frontend/"
@@ -23,7 +23,7 @@ repos:
- "prettier-plugin-svelte@latest"
- "prettier@latest"
- repo: https://github.com/pycqa/flake8
rev: 5.0.4
rev: 6.0.0
hooks:
- id: flake8
# flake8 is passed in all tracked python files
+6 -5
View File
@@ -1,13 +1,14 @@
FROM python:3.10-slim
FROM python:3.11-slim
COPY Pipfile* /app/
WORKDIR /app/
RUN apt update && \
apt install -y jq gcc libpq5 libpq-dev && \
jq -r '.default | to_entries[] | .key + .value.version' Pipfile.lock > requirements.txt && \
sed -i "s/psycopg2-binary/psycopg2/g" requirements.txt
apt install -y jq gcc libpq5 libpq-dev libmagic1 && \
jq -r '.default | to_entries[] | .key + .value.version' Pipfile.lock > requirements.txt && \
sed -i "s/psycopg2-binary/psycopg2/g" requirements.txt
RUN pip install -r requirements.txt
RUN pip install -r requirements.txt && \
apt remove -y jq gcc
COPY classquiz/ /app/classquiz/
COPY image_cleanup.py /app/image_cleanup.py
+7 -3
View File
@@ -30,12 +30,15 @@ pillow = ">=9.1.1"
authlib = "*"
httpx = "*"
itsdangerous = "*"
puremagic = "*"
py-avataaars-no-png = "*"
cryptography = "*"
scheduler = "*"
webauthn = "*"
pyotp = "*"
minio = "*"
xxhash = "*"
arq = "*"
thumbhash-python = "==1.0.0"
python-magic = "*"
[dev-packages]
coverage = "*"
@@ -49,9 +52,10 @@ pre-commit = "*"
python-socketio = {extras = ["client"], version = "*"}
[requires]
python_version = "3.10"
python_version = "3.11"
[scripts]
format = "black ."
lint = "flake8 classquiz"
test = "coverage run -m pytest --lf -v --asyncio-mode=strict classquiz/tests"
worker = "arq classquiz.worker.WorkerSettings"
Generated
+671 -445
View File
File diff suppressed because it is too large Load Diff
+9 -8
View File
@@ -27,18 +27,14 @@
</p>
</div>
## License Note
This repository is licensed under the [Mozilla Public License 2.0](https://www.mozilla.org/en-US/MPL/2.0/), so you
**MUST PUBLISH ANY CHANGES YOU MAKE!!!**[^1]
## About ClassQuiz
ClassQuiz is a quiz-application like KAHOOT!, but open-source which is very important if it is a product for educational
ClassQuiz is a quiz app to learn interactively for students,
but open-source which is very important if it is a product for educational
purposes.
You can create quizzes and play them remotely with other people.
It is mainly made for teachers, who create a
It is mainly made for teachers who create a
quiz, so students can compete with their knowledge against each other.
## Try it
@@ -114,7 +110,12 @@ Closed-Source 3rd parties:
- [hCaptcha](https://www.hcaptcha.com/) (captcha)
---
*Kahoot! and the K! logo are trademarks of Kahoot! AS*
## License Note
This repository is licensed under the [Mozilla Public License 2.0](https://www.mozilla.org/en-US/MPL/2.0/), so you
**MUST PUBLISH ANY CHANGES YOU MAKE!!!**[^1]
[^1]: _I added this note, since people are stealing my software and changing it without providing the source-code. Maybe
they
+6 -12
View File
@@ -1,7 +1,6 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
import asyncio
import sentry_sdk
from fastapi import FastAPI, Request
@@ -11,7 +10,6 @@ from starlette.middleware.sessions import SessionMiddleware
from classquiz.config import settings
from classquiz.db import database
from datetime import timedelta
from classquiz.oauth import rememberme_middleware
from classquiz.routers import (
@@ -33,10 +31,11 @@ from classquiz.routers import (
results,
admin,
box_controller,
quiztivity,
pixabay,
)
from classquiz.socket_server import sio
from classquiz.helpers import meilisearch_init, telemetry_ping, bg_tasks
from scheduler.asyncio import Scheduler
from classquiz.helpers import meilisearch_init, telemetry_ping
settings = settings()
if settings.sentry_dsn:
@@ -57,13 +56,6 @@ async def sentry_exception(request: Request, call_next):
raise e
async def background_tasks():
schedule = Scheduler()
schedule.cyclic(timedelta(hours=6), bg_tasks.clean_editor_images_up)
while True:
await asyncio.sleep(1)
@app.on_event("startup")
async def startup() -> None:
database_ = app.state.database
@@ -71,7 +63,6 @@ async def startup() -> None:
await database_.connect()
await meilisearch_init()
await telemetry_ping()
asyncio.create_task(background_tasks())
@app.on_event("shutdown")
@@ -86,6 +77,9 @@ async def auth_middleware_wrapper(request: Request, call_next):
return await rememberme_middleware(request, call_next)
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(
box_controller.router, tags=["boxcontroller"], prefix="/api/v1/box-controller", include_in_schema=True
)
-1
View File
@@ -33,7 +33,6 @@ async def cache_account(criteria: str, content: str) -> Union[User, None]:
await insert_into_redis(res, content)
return res
elif criteria == "id":
try:
res = await User.objects.get(id=uuid.UUID(content), verified=True)
except ormar.exceptions.NoMatch:
+25 -7
View File
@@ -9,6 +9,8 @@ import redis as redis_base_lib
from pydantic import BaseSettings, RedisDsn, PostgresDsn, BaseModel
import meilisearch as MeiliSearch
from typing import Optional
from arq import create_pool
from arq.connections import RedisSettings, ArqRedis
from classquiz.storage import Storage
@@ -48,33 +50,49 @@ class Settings(BaseSettings):
github_client_secret: Optional[str]
custom_openid_provider: CustomOpenIDProvider | None = None
telemetry_enabled: bool = True
free_storage_limit: int = 1074000000
pixabay_api_key: str | None = None
# storage_backend
storage_backend: str | None = "deta"
# if storage_backend == "deta":
deta_project_key: str | None
deta_project_id: str | None
storage_backend: str | None = "local"
# if storage_backend == "local":
storage_path: str | None
# if storage_backend == "s3":
s3_access_key: str | None
s3_secret_key: str | None
s3_bucket_name: str = "classquiz"
s3_base_url: str | None
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
env_nested_delimiter = "__"
async def initialize_arq():
# skipcq: PYL-W0603
global arq
arq = await create_pool(RedisSettings.from_dsn(settings.redis))
@lru_cache()
def settings() -> Settings:
return Settings()
redis: redis_base_lib.client.Redis = redis_lib.Redis().from_url(settings().redis)
pool = redis_lib.ConnectionPool().from_url(settings().redis)
redis: redis_base_lib.client.Redis = redis_lib.Redis(connection_pool=pool)
arq: ArqRedis = ArqRedis(pool_or_conn=pool)
storage: Storage = Storage(
backend=settings().storage_backend,
deta_key=settings().deta_project_key,
deta_id=settings().deta_project_id,
storage_path=settings().storage_path,
access_key=settings().s3_access_key,
secret_key=settings().s3_secret_key,
bucket_name=settings().s3_bucket_name,
base_url=settings().s3_base_url,
)
meilisearch = MeiliSearch.Client(settings().meilisearch_url)
+163 -28
View File
@@ -2,16 +2,17 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
import os
import re
import uuid
from datetime import datetime
from typing import Optional
import ormar
from ormar import ReferentialAction
from pydantic import BaseModel, Json, validator
from enum import Enum
from . import metadata, database
from ..config import server_regex
from .quiztivity import QuizTivityPage
from sqlalchemy import func
class UserAuthTypes(Enum):
@@ -40,6 +41,7 @@ class User(ormar.Model):
require_password: bool = ormar.Boolean(default=True, nullable=False)
backup_code: str = ormar.String(max_length=64, min_length=64, nullable=False, default=os.urandom(32).hex())
totp_secret: str = ormar.String(max_length=32, min_length=32, nullable=True, default=None)
storage_used: int = ormar.BigInteger(nullable=False, default=0, minimum=0)
class Meta:
tablename = "users"
@@ -55,7 +57,7 @@ class FidoCredentials(ormar.Model):
id: bytes = ormar.LargeBinary(max_length=256)
public_key: bytes = ormar.LargeBinary(max_length=256)
sign_count: int = ormar.Integer()
user: Optional[User] = ormar.ForeignKey(User)
user: Optional[User] = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
class Meta:
tablename = "fido_credentials"
@@ -65,7 +67,7 @@ class FidoCredentials(ormar.Model):
class ApiKey(ormar.Model):
key: str = ormar.String(max_length=48, min_length=48, primary_key=True)
user: Optional[User] = ormar.ForeignKey(User)
user: Optional[User] = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
class Meta:
tablename = "api_keys"
@@ -79,7 +81,7 @@ class UserSession(ormar.Model):
"""
id: uuid.UUID = ormar.UUID(primary_key=True, default=uuid.uuid4())
user: Optional[User] = ormar.ForeignKey(User)
user: Optional[User] = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
session_key: str = ormar.String(unique=True, max_length=64)
created_at: datetime = ormar.DateTime(default=datetime.now())
ip_address: str = ormar.String(max_length=100, nullable=True)
@@ -118,6 +120,7 @@ class QuizQuestionType(str, Enum):
SLIDE = "SLIDE"
TEXT = "TEXT"
ORDER = "ORDER"
CHECK = "CHECK"
class TextQuizAnswer(BaseModel):
@@ -134,7 +137,6 @@ class QuizQuestion(BaseModel):
@validator("answers")
def answers_not_none_if_abcd_type(cls, v, values):
# print(values)
if values["type"] == QuizQuestionType.ABCD and type(v[0]) != ABCDQuizAnswer:
raise ValueError("Answers can't be none if type is ABCD")
if values["type"] == QuizQuestionType.RANGE and type(v) != RangeQuizAnswer:
@@ -147,6 +149,8 @@ class QuizQuestion(BaseModel):
raise ValueError("Answer must be from type VotingQuizAnswer if type is ORDER")
if values["type"] == QuizQuestionType.SLIDE and type(v[0]) != str:
raise ValueError("Answer must be from type SlideElement if type is SLIDE")
if values["type"] == QuizQuestionType.CHECK and type(v[0]) != ABCDQuizAnswer:
raise ValueError("Answers can't be none if type is CHECK")
return v
@@ -159,15 +163,6 @@ class QuizInput(BaseModel):
questions: list[QuizQuestion]
background_image: str | None
@validator("background_image")
def must_come_from_local_cdn(cls, v):
if v is None:
return v
elif bool(re.match(server_regex, v)):
return v
else:
raise ValueError("does not match url scheme")
class Quiz(ormar.Model):
id: uuid.UUID = ormar.UUID(primary_key=True, default=uuid.uuid4(), nullable=False, unique=True)
@@ -176,7 +171,7 @@ class Quiz(ormar.Model):
description: str = ormar.Text(nullable=True)
created_at: datetime = ormar.DateTime(default=datetime.now())
updated_at: datetime = ormar.DateTime(default=datetime.now())
user_id: uuid.UUID = ormar.ForeignKey(User)
user_id: uuid.UUID = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
questions: Json[list[QuizQuestion]] = ormar.JSON(nullable=False)
imported_from_kahoot: Optional[bool] = ormar.Boolean(default=False, nullable=True)
cover_image: Optional[str] = ormar.Text(nullable=True, unique=False)
@@ -184,15 +179,6 @@ class Quiz(ormar.Model):
background_image: str | None = ormar.Text(nullable=True, unique=False)
kahoot_id: uuid.UUID | None = ormar.UUID(nullable=True, default=None)
@validator("background_image")
def must_come_from_local_cdn(cls, v):
if v is None:
return v
elif bool(re.match(server_regex, v)):
return v
else:
raise ValueError("does not match url scheme")
class Meta:
tablename = "quiz"
metadata = metadata
@@ -291,7 +277,7 @@ class GameInLobby(BaseModel):
game_id: uuid.UUID
#
# skipcq: PY-W0069
# class UserProfileLinks(ormar.Model):
# id: int = ormar.Integer(primary_key=True, autoincrement=True)
# user: Optional[User] = ormar.ForeignKey(User)
@@ -302,8 +288,8 @@ class GameInLobby(BaseModel):
class GameResults(ormar.Model):
id: uuid.UUID = ormar.UUID(primary_key=True)
quiz: uuid.UUID | Quiz = ormar.ForeignKey(Quiz)
user: uuid.UUID | User = ormar.ForeignKey(User)
quiz: uuid.UUID | Quiz = ormar.ForeignKey(Quiz, ondelete=ReferentialAction.CASCADE)
user: uuid.UUID | User = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
timestamp: datetime = ormar.DateTime(default=datetime.now(), nullable=False)
player_count: int = ormar.Integer(nullable=False, default=0)
note: str | None = ormar.Text(nullable=True)
@@ -320,6 +306,155 @@ class GameResults(ormar.Model):
database = database
class QuizTivityInput(BaseModel):
title: str
pages: list[QuizTivityPage]
class QuizTivity(ormar.Model):
id: uuid.UUID = ormar.UUID(primary_key=True)
title: str = ormar.Text(nullable=False)
created_at: datetime = ormar.DateTime(nullable=False, server_default=func.now())
user: User | None = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
pages: list[QuizTivityPage] = ormar.JSON(nullable=False)
class Meta:
tablename = "quiztivitys"
metadata = metadata
database = database
class QuizTivityShare(ormar.Model):
id: uuid.UUID = ormar.UUID(primary_key=True)
name: str | None = ormar.Text(nullable=True)
expire_at: datetime | None = ormar.DateTime(nullable=True)
quiztivity: QuizTivity | None = ormar.ForeignKey(QuizTivity, ondelete=ReferentialAction.CASCADE)
user: User | None = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
class Meta:
tablename = "quiztivityshares"
metadata = metadata
database = database
class OnlyId(BaseModel):
id: uuid.UUID
class PublicQuizTivityShare(BaseModel):
id: uuid.UUID
name: str | None
expire_in: int | None
quiztivity: OnlyId
user: OnlyId
@classmethod
def from_db_model(cls, data: QuizTivityShare):
expire_in = None
if data.expire_at is not None:
expire_in = int((data.expire_at - datetime.now()).seconds / 60)
return cls(
id=data.id,
name=data.name,
expire_in=expire_in,
quiztivity=OnlyId(id=data.quiztivity.id),
user=OnlyId(id=data.user.id),
)
class StorageItem(ormar.Model):
id: uuid.UUID = ormar.UUID(primary_key=True)
uploaded_at: datetime = ormar.DateTime(nullable=False, default=datetime.now())
mime_type: str = ormar.Text(nullable=False)
hash: bytes | None = ormar.LargeBinary(nullable=True, min_length=16, max_length=16)
user: User | None = ormar.ForeignKey(User, ondelete=ReferentialAction.SET_NULL)
size: int = ormar.BigInteger(nullable=False)
storage_path: str | None = ormar.Text(nullable=True)
deleted_at: datetime | None = ormar.DateTime(nullable=True, default=None)
quiztivities: list[QuizTivity] | None = ormar.ManyToMany(QuizTivity)
quizzes: list[Quiz] | None = ormar.ManyToMany(Quiz)
alt_text: str | None = ormar.Text(default=None, nullable=True)
filename: str | None = ormar.Text(default=None, nullable=True)
thumbhash: str | None = ormar.Text(default=None, nullable=True)
server: str | None = ormar.Text(default=None, nullable=True)
imported: bool = ormar.Boolean(default=False, nullable=True)
class Meta:
tablename = "storage_items"
metadata = metadata
database = database
class PublicStorageItem(BaseModel):
id: uuid.UUID
uploaded_at: datetime
mime_type: str
hash: str | None
size: int
deleted_at: datetime | None
alt_text: str | None
filename: str | None
thumbhash: str | None
server: str | None
imported: bool
@classmethod
def from_db_model(cls, data: StorageItem):
hash_data = None
if data.hash is not None:
hash_data = data.hash.hex()
return cls(
id=data.id,
uploaded_at=data.uploaded_at,
mime_type=data.mime_type,
hash=hash_data,
size=data.size,
deleted_at=data.deleted_at,
alt_text=data.alt_text,
filename=data.filename,
thumbhash=data.thumbhash,
server=data.server,
imported=data.imported,
)
class PrivateStorageItem(PublicStorageItem):
quizzes: list[OnlyId]
quiztivities: list[OnlyId]
@classmethod
def from_db_model(cls, data: StorageItem):
hash_data = None
if data.hash is not None:
hash_data = data.hash.hex()
quiztivities = []
quizzes = []
for quiz in data.quizzes:
quizzes.append(OnlyId(id=quiz.id))
for quiztivity in data.quiztivities:
quiztivities.append(OnlyId(id=quiztivity.id))
return cls(
id=data.id,
uploaded_at=data.uploaded_at,
mime_type=data.mime_type,
hash=hash_data,
size=data.size,
deleted_at=data.deleted_at,
alt_text=data.alt_text,
filename=data.filename,
quiztivities=quiztivities,
quizzes=quizzes,
thumbhash=data.thumbhash,
server=data.server,
imported=data.imported,
)
class UpdateStorageItem(BaseModel):
filename: str | None
alt_text: str | None
class Controller(ormar.Model):
id: uuid.UUID = ormar.UUID(primary_key=True)
user: uuid.UUID | User = ormar.ForeignKey(User)
+56
View File
@@ -0,0 +1,56 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from pydantic import BaseModel
import enum
class Pdf(BaseModel):
url: str
class _MemoryCard(BaseModel):
image: str | None
text: str | None
id: str
class Memory(BaseModel):
cards: list[list[_MemoryCard]]
class Markdown(BaseModel):
# skipcq: PTC-W0052
markdown: str
class _AbcdAnswer(BaseModel):
answer: str
correct: bool
class Abcd(BaseModel):
question: str
answers: list[_AbcdAnswer]
class QuizTivityTypes(str, enum.Enum):
SLIDE = "SLIDE"
PDF = "PDF"
MEMORY = "MEMORY"
MARKDOWN = "MARKDOWN"
ABCD = "ABCD"
TYPE_CLASS_LIST = {
QuizTivityTypes.PDF: type(Pdf),
QuizTivityTypes.MEMORY: type(Memory),
QuizTivityTypes.MARKDOWN: type(Markdown),
QuizTivityTypes.ABCD: type(Abcd),
}
class QuizTivityPage(BaseModel):
title: str | None
type: QuizTivityTypes
data: Pdf | Memory | Markdown | Abcd
+43 -6
View File
@@ -3,6 +3,7 @@
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
import asyncio
import uuid
from typing import Optional
import ormar.exceptions
@@ -64,12 +65,15 @@ async def generate_spreadsheet(quiz_results: dict, quiz: Quiz, player_fields: di
worksheet.write(i + 1, 1, question["time"])
try:
async with ClientSession() as session, session.get(question["image"]) as response:
img_data = BytesIO(await response.read())
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)
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"):
img_data = BytesIO(await response.read())
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)
except TypeError:
pass
answer_amount = len(answer_data)
@@ -175,3 +179,36 @@ def check_hashcash(data: str, input_data: str, claim_in: Optional[str] = "19") -
return False
some_error = [version == "1", claim == claim_in, res == input_data, ext == ""]
return all(el is True for el in some_error)
def check_image_string(image: str) -> (bool, uuid.UUID | None):
# Valid formats: {uuid} and {uuid}--{uuid}
try:
parsed_uuid = uuid.UUID(image)
return True, parsed_uuid
except ValueError:
pass
split_image = image.split("--")
if len(split_image) != 2:
return False, None
try:
uuid.UUID(split_image[0])
uuid.UUID(split_image[1])
return True, None
except ValueError:
return False, None
def extract_image_ids_from_quiz(quiz: Quiz) -> list[str | uuid.UUID]:
quiz_images = []
if quiz.background_image is not None:
quiz_images.append(quiz.background_image)
if quiz.cover_image is not None:
quiz_images.append(quiz.cover_image)
for question in quiz.questions:
if question["image"] is None:
continue
quiz_images.append(question["image"])
return quiz_images
-21
View File
@@ -1,21 +0,0 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from classquiz.config import redis, storage
from classquiz.storage.errors import DeletionFailedError
async def clean_editor_images_up():
print("Cleaning images up")
edit_sessions = await redis.smembers("edit_sessions")
for session_id in edit_sessions:
session = await redis.get(f"edit_session:{session_id}")
if session is None:
images = await redis.lrange(f"edit_session:{session_id}:images", 0, 3000)
if len(images) != 0:
try:
await storage.delete(images)
except DeletionFailedError:
print("Deletion Error", images)
await redis.srem("edit_sessions", session_id)
await redis.delete(f"edit_session:{session_id}:images")
+136
View File
@@ -0,0 +1,136 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
import enum
from aiohttp import ClientSession
from pydantic import BaseModel
class ImageType(str, enum.Enum):
all = "all"
photo = "photo"
illustration = "illustration"
vector = "vector"
class Orientation(str, enum.Enum):
all = ("all",)
horizontal = "horizontal"
vertical = "vertical"
class Category(str, enum.Enum):
background = "background"
fashion = "fashion"
nature = "nature"
science = "science"
education = "education"
feelings = "feelings"
health = "health"
people = "people"
religion = "religion"
places = "places"
animals = "animals"
industry = "industry"
computer = "computer"
food = "food"
sports = "sports"
transportation = "transportation"
travel = "travel"
buildings = "buildings"
business = "business"
music = "music"
class Colors(str, enum.Enum):
grayscale = "grayscale"
transparent = "transparent"
red = "red"
orange = "orange"
yellow = "yellow"
green = "green"
turquoise = "turquoise"
blue = "blue"
lilac = "lilac"
pink = "pink"
white = "white"
gray = "gray"
black = "black"
brown = "brown"
class Order(str, enum.Enum):
popular = "popular"
latest = "latest"
class BoolInput(str, enum.Enum):
true = "true"
false = "false"
class GetImagesParams(BaseModel):
q: str = ""
lang: str = "en"
id: str = ""
image_type: ImageType = ImageType.all
orientation: Orientation = Orientation.all
category: Category | str = ""
min_width: int = 0
min_height: int = 0
colors: Colors | str = ""
editors_choice: BoolInput = BoolInput.false
safesearch: BoolInput = BoolInput.false
order: Order = Order.popular
page: int = 1
pretty: BoolInput = BoolInput.false
class Hit(BaseModel):
id: int
pageURL: str
type: str
tags: str
previewURL: str
previewWidth: int
previewHeight: int
webformatURL: str
webformatWidth: int
webformatHeight: int
largeImageURL: str
imageWidth: int
imageHeight: int
imageSize: int
views: int
downloads: int
collections: int
likes: int
comments: int
user_id: int
user: str
userImageURL: str
class GetImagesResponse(BaseModel):
total: int
totalHits: int
hits: list[Hit]
class NotFoundError(Exception):
pass
async def get_images(api_key: str, params: GetImagesParams) -> GetImagesResponse:
async with ClientSession() as session, session.get(
"https://pixabay.com/api/", params={"key": api_key, **params.dict()}
) as resp:
if resp.status == 200:
return GetImagesResponse.parse_obj(await resp.json())
else:
raise NotFoundError
+44 -13
View File
@@ -3,6 +3,7 @@
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
import html
import io
import json
import uuid
from datetime import datetime
@@ -10,8 +11,8 @@ from datetime import datetime
import bleach
from aiohttp import ClientSession
from classquiz.config import settings, storage, meilisearch, ALLOWED_TAGS_FOR_QUIZ
from classquiz.db.models import Quiz, ABCDQuizAnswer, QuizQuestion, User
from classquiz.config import settings, storage, meilisearch, ALLOWED_TAGS_FOR_QUIZ, arq
from classquiz.db.models import Quiz, ABCDQuizAnswer, QuizQuestion, User, StorageItem
from classquiz.kahoot_importer.get import get as get_quiz
from classquiz.helpers import get_meili_data
@@ -23,6 +24,28 @@ async def _download_image(url: str) -> bytes:
return await resp.read()
DEFAULT_COLORS = ["#D6EDC9", "#B07156", "#7F7057", "#4E6E58"]
async def handle_image_upload(url: str, user: User) -> StorageItem:
image_bytes = await _download_image(url)
file_obj = StorageItem(
id=uuid.uuid4(),
uploaded_at=datetime.now(),
mime_type="application/octet-stream",
hash=None,
user=user,
size=0,
deleted_at=None,
alt_text=None,
imported=True,
)
await file_obj.save()
await storage.upload(file_name=file_obj.id.hex, file_data=io.BytesIO(image_bytes))
await arq.enqueue_job("calculate_hash", file_obj.id.hex)
return file_obj
async def import_quiz(quiz_id: str, user: User) -> Quiz | str:
"""
Imports a quiz from Kahoot.
@@ -38,18 +61,24 @@ async def import_quiz(quiz_id: str, user: User) -> Quiz | str:
quiz_id = uuid.uuid4()
meilisearch.delete_index(settings.meilisearch_index)
meilisearch.create_index(settings.meilisearch_index)
uploaded_images: list[StorageItem] = []
for q in quiz.kahoot.questions:
answers: list[ABCDQuizAnswer] = []
image = None
if q.image is not None and q.image != "":
image_bytes = await _download_image(q.image)
image_name = f"{quiz_id}--{uuid.uuid4()}"
image = await storage.upload(file_name=image_name, file_data=image_bytes)
image = f"{settings.root_address}/api/v1/storage/download/{image_name}"
for a in q.choices:
image_obj = await handle_image_upload(q.image, user)
uploaded_images.append(image_obj)
image = image_obj.id.hex
for i, a in enumerate(q.choices):
answers.append(
(ABCDQuizAnswer(right=a.correct, answer=html.unescape(bleach.clean(a.answer, tags=[], strip=True))))
(
ABCDQuizAnswer(
right=a.correct,
answer=html.unescape(bleach.clean(a.answer, tags=[], strip=True)),
color=DEFAULT_COLORS[i],
)
)
)
quiz_questions.append(
@@ -62,10 +91,9 @@ async def import_quiz(quiz_id: str, user: User) -> Quiz | str:
)
cover = None
if quiz.kahoot.cover != "" and quiz.kahoot.cover is not None:
image_bytes = await _download_image(quiz.kahoot.cover)
image_name = f"{quiz_id}--{uuid.uuid4()}"
await storage.upload(file_name=image_name, file_data=image_bytes)
cover = f"{settings.root_address}/api/v1/storage/download/{image_name}"
img_obj = await handle_image_upload(quiz.kahoot.cover, user)
uploaded_images.append(img_obj)
cover = img_obj.id.hex
quiz_data = Quiz(
id=quiz_id,
public=True,
@@ -80,4 +108,7 @@ async def import_quiz(quiz_id: str, user: User) -> Quiz | str:
kahoot_id=uuid.UUID(kahoot_quiz_id),
)
meilisearch.index(settings.meilisearch_index).add_documents([await get_meili_data(quiz_data)])
return await quiz_data.save()
await quiz_data.save()
for img in uploaded_images:
await quiz_data.storageitems.add(img)
return quiz_data
-11
View File
@@ -24,16 +24,6 @@ async def rememberme_middleware(request: Request, call_next):
rememberme_cookie = request.cookies.get("rememberme_token")
bearer_token = request.cookies.get("access_token")
conditions_to_handle_met = True
# print(bearer_token)
# if bearer_token is not None:
# bearer_token = bearer_token.replace("Bearer ", "")
# test = jws.verify(bearer_token, settings.secret_key, algorithms=["HS256"])
# try:
# jwt.decode(bearer_token, settings.secret_key, algorithms=["HS256"])
# print("jwt ok")
# except JWTError as e:
# print("jwt failed")
# print(test)
scheme, param = get_authorization_scheme_param(bearer_token)
@@ -71,7 +61,6 @@ async def rememberme_middleware(request: Request, call_next):
response: Response = await call_next(request)
return response
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
# access_token_expires = timedelta(seconds=1)
access_token = create_access_token(data={"sub": user_session.user.email}, expires_delta=access_token_expires)
await user_session.update(last_seen=datetime.now())
request.state.access_token = f"Bearer {access_token}"
+2 -1
View File
@@ -88,8 +88,9 @@ async def auth(request: Request, response: Response):
google_uid=user_data.sub.hex,
avatar=gzipped_user_avatar(),
)
# skipcq: PYL-W0703
except Exception as e:
if type(e) == asyncpg.exceptions.UniqueViolationError:
if type(e) is asyncpg.exceptions.UniqueViolationError:
error = True
counter = 1
while error:
+2 -1
View File
@@ -116,8 +116,9 @@ async def auth(request: Request, response: Response):
auth_type=UserAuthTypes.GITHUB,
avatar=gzipped_user_avatar(),
)
# skipcq: PYL-W0703
except Exception as e:
if type(e) == asyncpg.exceptions.UniqueViolationError:
if type(e) is asyncpg.exceptions.UniqueViolationError:
error = True
counter = 1
while error:
+2 -1
View File
@@ -90,8 +90,9 @@ async def auth(request: Request, response: Response):
google_uid=user_data.sub,
avatar=gzipped_user_avatar(),
)
# skipcq: PYL-W0703
except Exception as e:
if type(e) == asyncpg.exceptions.UniqueViolationError:
if type(e) is asyncpg.exceptions.UniqueViolationError:
error = True
counter = 1
while error:
+32 -26
View File
@@ -12,30 +12,36 @@ settings = settings()
@lru_cache()
def init_oauth() -> OAuth:
oauth = OAuth()
oauth.register(
name="google",
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={"scope": "openid email profile"},
client_id=settings.google_client_id,
client_secret=settings.google_client_secret,
)
oauth.register(
name="github",
client_kwargs={"scope": "read:user user:email"},
access_token_url="https://github.com/login/oauth/access_token",
access_token_params=None,
authorize_url="https://github.com/login/oauth/authorize",
authorize_params=None,
api_base_url="https://api.github.com/",
client_id=settings.github_client_id,
client_secret=settings.github_client_secret,
)
oauth.register(
name="custom",
client_kwargs={"scope": settings.custom_openid_provider.scopes},
server_metadata_url=settings.custom_openid_provider.server_metadata_url,
client_id=settings.custom_openid_provider.client_id,
client_secret=settings.custom_openid_provider.client_secret,
)
if settings.google_client_secret is not None and settings.google_client_id is not None:
oauth.register(
name="google",
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={"scope": "openid email profile"},
client_id=settings.google_client_id,
client_secret=settings.google_client_secret,
)
if settings.github_client_id is not None and settings.github_client_secret is not None:
oauth.register(
name="github",
client_kwargs={"scope": "read:user user:email"},
access_token_url="https://github.com/login/oauth/access_token",
access_token_params=None,
authorize_url="https://github.com/login/oauth/authorize",
authorize_params=None,
api_base_url="https://api.github.com/",
client_id=settings.github_client_id,
client_secret=settings.github_client_secret,
)
if (
settings.custom_openid_provider is not None
and settings.custom_openid_provider.client_id is not None
and settings.custom_openid_provider.client_secret is not None
):
oauth.register(
name="custom",
client_kwargs={"scope": settings.custom_openid_provider.scopes},
server_metadata_url=settings.custom_openid_provider.server_metadata_url,
client_id=settings.custom_openid_provider.client_id,
client_secret=settings.custom_openid_provider.client_secret,
)
return oauth
+1
View File
@@ -80,6 +80,7 @@ async def get_customized_avatar(
clothe_color=clothe_color,
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)},")
@@ -57,12 +57,12 @@ async def submit_answer_fn(data_answer: int, game_pin: str, player_id: str, now:
answers = await redis.get(f"game_session:{game_pin}:{game.current_question}")
answers = await set_answer(answers, game_pin=game_pin, data=answer_data, q_index=game.current_question)
player_count = await redis.scard(f"game_session:{game_pin}:players")
print(player_count, answers)
await sio.emit("player_answer", {})
if answers is not None and len(answers.__root__) == player_count:
await sio.emit("everyone_answered", {})
button_to_index_map = {"y": 0, "r": 2, "g": 1, "b": 3}
button_to_index_map = {"y": 0, "r": 3, "g": 1, "b": 2}
class WebSocketTypes(enum.Enum):
@@ -81,9 +81,9 @@ wss_clients = {}
@router.websocket("/{game_id}")
async def websocket_endpoint(ws: WebSocket, game_id: str):
try:
if game_id in wss_clients.keys():
if game_id in wss_clients:
await ws.close(code=status.WS_1001_GOING_AWAY)
print("Client {} already exists.".format(game_id))
print(f"Client {game_id} already exists.")
return
await ws.accept()
wss_clients[game_id] = ws
@@ -119,7 +119,8 @@ async def websocket_endpoint(ws: WebSocket, game_id: str):
await ws.send_text(WebSocketRequest(type=WebSocketTypes.Error, data="InvalidButton").json())
continue
await submit_answer_fn(answer_index, game_pin, player_id, now)
print(f"Data from client {game_id}: {data}")
except WebSocketDisconnect as ex:
print("Client {} is disconnected: {}".format(game_id, ex))
print(f"Client {game_id} is disconnected: {ex}")
wss_clients.pop(game_id, None)
+20 -88
View File
@@ -4,24 +4,22 @@
import asyncio
import html
import re
import uuid
from typing import Optional
import asyncpg.exceptions
import bleach
from fastapi import APIRouter, File, UploadFile, HTTPException, Depends
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from classquiz.config import settings, redis, storage, meilisearch, ALLOWED_TAGS_FOR_QUIZ, server_regex
from classquiz.db.models import Quiz, QuizInput, User, QuizQuestionType
import puremagic
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_hashcash
from classquiz.helpers import get_meili_data, check_image_string, extract_image_ids_from_quiz
from classquiz.storage.errors import DeletionFailedError
settings = settings()
@@ -67,56 +65,6 @@ async def init_editor(edit: bool, quiz_id: Optional[UUID] = None, user: User = D
return InitEditorResponse(token=edit_id)
class GetPowData(BaseModel):
data: str
@router.get("/pow", response_model=GetPowData)
async def get_pow_data(edit_id: str):
session_data = await redis.get(f"edit_session:{edit_id}")
if session_data is None:
raise HTTPException(status_code=401, detail="Edit ID not found!")
random_str = os.urandom(8).hex()
await redis.set(f"edit_session:{edit_id}:pow", random_str, ex=3800)
return GetPowData(data=random_str)
class UploadImageReturn(BaseModel):
id: str
pow_data: str
@router.post("/image", response_model=UploadImageReturn)
async def upload_image(edit_id: str, pow_data: str, file: UploadFile = File()):
session_data = await redis.get(f"edit_session:{edit_id}")
pow_data_server = await redis.get(f"edit_session:{edit_id}:pow")
uploaded_images = await redis.llen(f"edit_session:{edit_id}:images")
if pow_data_server is None:
raise HTTPException(status_code=401, detail="Edit ID not found!")
if session_data is None:
raise HTTPException(status_code=401, detail="Edit ID not found!")
if uploaded_images == 0 and not check_hashcash(pow_data, pow_data_server, "8"):
raise HTTPException(status_code=401, detail="Edit ID not found!")
if uploaded_images != 0 and not check_hashcash(pow_data, pow_data_server, "8"):
raise HTTPException(status_code=401, detail="Edit ID not found!")
file_bytes = await file.read()
if len(file_bytes) > 2000000:
raise HTTPException(status_code=400, detail="File too large")
try:
pm_data = puremagic.magic_string(file_bytes)[0]
except puremagic.PureError:
raise HTTPException(status_code=400, detail="Image couldn't be identified!")
if pm_data.extension not in allowed_image_extensions:
raise HTTPException(status_code=400, detail="Image-type now allowed!")
session_data = EditSessionData.parse_raw(session_data)
file_name = f"{session_data.quiz_id}--{uuid.uuid4()}"
await storage.upload(file_name=file_name, file_data=file_bytes)
await redis.lpush(f"edit_session:{edit_id}:images", file_name)
random_str = os.urandom(8).hex()
await redis.set(f"edit_session:{edit_id}:pow", random_str, ex=3800)
return UploadImageReturn(id=file_name, pow_data=random_str)
@router.post("/finish")
async def finish_edit(edit_id: str, quiz_input: QuizInput):
session_data = await redis.get(f"edit_session:{edit_id}")
@@ -141,25 +89,10 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput):
quiz_input.questions[i].answers[i2].answer = html.unescape(
bleach.clean(answer.answer, tags=ALLOWED_TAGS_FOR_QUIZ, strip=True)
)
image_id_regex = r"^.{36}--.{36}$"
imgur_regex = r"^https://i\.imgur\.com\/.{7}.(jpg|png|gif)$"
extract_file_name_re = r"^.*/api/v1/storage/download/(.{36}--.{36})$"
images_to_delete = []
old_quiz_data: Quiz = await Quiz.objects.get_or_none(id=session_data.quiz_id, user_id=session_data.user_id)
def mark_image_for_deletion(new: str | None, index: int, old_quiz: Quiz | None):
if old_quiz is None:
return
try:
# Why does this work or not throw an error (TODO)
if new == old_quiz.questions[index]["image"]:
return
else:
images_to_delete.append(old_quiz.questions[index]["image"])
except IndexError:
pass
for i, question in enumerate(quiz_input.questions):
image = question.image
quiz_input.questions[i].question = html.unescape(
@@ -167,29 +100,20 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput):
)
if image == "":
question.image = None
mark_image_for_deletion(question.image, i, old_quiz_data)
elif image is None:
mark_image_for_deletion(question.image, i, old_quiz_data)
elif bool(re.match(image_id_regex, question.image)):
question.image = f"{settings.root_address}/api/v1/storage/download/{image}"
mark_image_for_deletion(question.image, i, old_quiz_data)
elif bool(re.match(imgur_regex, image)):
mark_image_for_deletion(question.image, i, old_quiz_data)
elif bool(re.match(server_regex, image)):
mark_image_for_deletion(question.image, i, old_quiz_data)
else:
if image is not None and not check_image_string(image)[0]:
raise HTTPException(status_code=400, detail="Image URL(s) aren't valid!")
if quiz_input.cover_image == "":
quiz_input.cover_image = None
# if quiz_input.background_image is None and old_quiz_data.background_image is not None:
# mark_image_for_deletion(quiz_input.background_image)
if quiz_input.cover_image is not None and not check_image_string(quiz_input.cover_image)[0]:
raise HTTPException(status_code=400, detail="image url is not valid")
if quiz_input.cover_image is not None and not bool(re.match(server_regex, quiz_input.cover_image)):
if quiz_input.background_image is not None and not check_image_string(quiz_input.background_image)[0]:
raise HTTPException(status_code=400, detail="image url is not valid")
if session_data.edit:
await arq.enqueue_job("quiz_update", old_quiz_data, old_quiz_data.id, _defer_by=2)
quiz = old_quiz_data
meilisearch.index(settings.meilisearch_index).update_documents([await get_meili_data(quiz)])
if not quiz_input.public:
@@ -207,13 +131,14 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput):
for image in images_to_delete:
if image is not None:
try:
await storage.delete([re.search(extract_file_name_re, image).group(1)])
await storage.delete([image])
except DeletionFailedError:
pass
await redis.srem("edit_sessions", edit_id)
await redis.delete(f"edit_session:{edit_id}")
await redis.delete(f"edit_session:{edit_id}:images")
return await quiz.update()
await quiz.update()
return quiz
else:
quiz = Quiz(
**quiz_input.dict(),
@@ -222,6 +147,7 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput):
created_at=datetime.now(),
updated_at=datetime.now(),
)
await redis.delete("global_quiz_count")
if quiz_input.public:
meilisearch.index(settings.meilisearch_index).add_documents([await get_meili_data(quiz)])
@@ -229,6 +155,12 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput):
await redis.srem("edit_sessions", edit_id)
await redis.delete(f"edit_session:{edit_id}")
await redis.delete(f"edit_session:{edit_id}:images")
return await quiz.save()
await quiz.save()
except asyncpg.exceptions.UniqueViolationError:
raise HTTPException(status_code=400, detail="The quiz already exists")
new_images = extract_image_ids_from_quiz(quiz)
for image in new_images:
item = await StorageItem.objects.get_or_none(id=uuid.UUID(image))
if item is None:
continue
await quiz.storageitems.add(item)
+30 -8
View File
@@ -1,6 +1,7 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
import io
import json
import uuid
from datetime import datetime
@@ -10,10 +11,11 @@ from aiohttp import ClientSession
from fastapi import APIRouter, Depends, File, UploadFile, HTTPException
from fastapi.responses import StreamingResponse
from classquiz.auth import get_current_user
from classquiz.config import storage, settings
from classquiz.db.models import Quiz, User
from classquiz.config import storage, settings, arq
from classquiz.db.models import Quiz, User, StorageItem
import gzip
import urllib.parse
import magic
router = APIRouter()
settings = settings()
@@ -44,12 +46,13 @@ async def export_quiz(quiz_id: uuid.UUID, user: User = Depends(get_current_user)
quiz_dict["updated_at"] = quiz_dict["updated_at"].isoformat()
quiz_json = json.dumps(quiz_dict)
bin_data = gzip.compress(quiz_json.encode("utf-8"), compresslevel=9)
# bin_data = quiz_json.encode("utf-8")
bin_data = bin_data + quiz_delimiter
for image_key in image_urls.keys():
for image_key in image_urls:
bin_data = bin_data + image_delimiter + str(image_key).encode("utf-8") + image_index_delimiter
image_data = None
async with ClientSession() as session, session.get(image_urls[image_key]) as resp:
async with ClientSession() as session, session.get(
f"{settings.root_address}/api/v1/storage/download/{image_urls[image_key]}"
) as resp:
image_data = await resp.read()
bin_data = bin_data + image_data
@@ -68,6 +71,8 @@ async def export_quiz(quiz_id: uuid.UUID, user: User = Depends(get_current_user)
@router.post("/")
async def import_quiz(file: UploadFile = File(), user: User = Depends(get_current_user)):
if user.storage_used > settings.free_storage_limit:
raise HTTPException(status_code=409, detail="Storage limit reached")
data = await file.read()
[split_data, images] = data.split(quiz_delimiter)
decompressed_quiz = gzip.decompress(split_data)
@@ -75,15 +80,32 @@ async def import_quiz(file: UploadFile = File(), user: User = Depends(get_curren
image_splits = images.split(image_delimiter)
quiz_id = uuid.uuid4()
image_urls = {}
print(len(data))
for image_split in image_splits:
res = image_split.split(image_index_delimiter)
if len(res) != 2:
continue
[index, image_data] = res
print(len(image_data))
img_data = io.BytesIO(image_data)
mime_type = magic.from_buffer(img_data.read(2048), mime=True)
print(mime_type)
index = int(index.decode("utf-8"))
image_name = f"{quiz_id}--{uuid.uuid4()}"
await storage.upload(file_name=image_name, file_data=image_data)
image = f"{settings.root_address}/api/v1/storage/download/{image_name}"
file_id = uuid.uuid4()
file_obj = StorageItem(
id=file_id,
uploaded_at=datetime.now(),
mime_type=mime_type,
hash=None,
user=user,
size=0,
deleted_at=None,
alt_text=None,
)
await storage.upload(file_name=file_id.hex, file_data=img_data, mime_type=mime_type)
await file_obj.save()
await arq.enqueue_job("calculate_hash", file_id.hex)
image = file_id.hex
image_urls[index] = image
quiz_dict["created_at"] = datetime.fromisoformat(quiz_dict["created_at"])
quiz_dict["updated_at"] = datetime.fromisoformat(quiz_dict["updated_at"])
-8
View File
@@ -134,8 +134,6 @@ async def get_live_game_data(
@router.get("/user_count")
async def get_game_user_count(game_pin: str, api_key: str, as_string: bool = False, as_array: bool = False):
# if redis_res is None:
# raise HTTPException(status_code=404, detail="Game not found")
user_id = await check_api_key(api_key)
redis_res = await redis.get(f"game_session:{game_pin}")
if redis_res is None:
@@ -149,12 +147,6 @@ async def get_game_user_count(game_pin: str, api_key: str, as_string: bool = Fal
return {"players": {"count": player_count}}
# class _LivePlayersReturn(BaseModel):
# # players: list[GamePlayer | None]
# answers: list[GameAnswer1 | None]
# players: list[GamePlayer | None]
@router.get(
"/players",
)
+1 -1
View File
@@ -149,7 +149,7 @@ class StepInput(BaseModel):
@router.post("/step/{step_id}")
async def step_1(session_id: str, data: StepInput, request: Request, response: Response, step_id: int):
async def step_1_endpoint(session_id: str, data: StepInput, request: Request, response: Response, step_id: int):
if step_id < 0 or step_id > 2:
raise HTTPException(status_code=401)
redis_res = await redis.get(f"login_session:{session_id}")
+62
View File
@@ -0,0 +1,62 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from datetime import datetime
from io import BytesIO
from uuid import uuid4
from aiohttp import ClientSession
from fastapi import APIRouter, Depends, HTTPException
from classquiz.auth import get_current_user
from classquiz.db.models import User, StorageItem, PublicStorageItem
from classquiz.helpers.pixabay import get_images, GetImagesParams, BoolInput, GetImagesResponse, NotFoundError
from classquiz.config import settings, storage, arq
settings = settings()
router = APIRouter()
@router.get("/images")
async def search_pixabay_images(query: str, page: int = 1, user: User = Depends(get_current_user)) -> GetImagesResponse:
if settings.pixabay_api_key is None:
raise HTTPException(status_code=423, detail="Pixabay not set up")
return await get_images(settings.pixabay_api_key, GetImagesParams(q=query, safesearch=BoolInput.true, page=page))
@router.post("/save")
async def save_pixabay_image(id: str, user: User = Depends(get_current_user)) -> PublicStorageItem:
if settings.pixabay_api_key is None:
raise HTTPException(status_code=423, detail="Pixabay not set up")
if user.storage_used > settings.free_storage_limit:
raise HTTPException(status_code=409, detail="Storage limit reached")
try:
images = await get_images(settings.pixabay_api_key, GetImagesParams(id=id, safesearch=BoolInput.true))
except NotFoundError:
raise HTTPException(status_code=404, detail="Pixabay file not found")
image = images.hits[0]
file_id = uuid4()
file_data = b""
async with ClientSession() as session, session.get(image.largeImageURL) as resp:
async for i in resp.content.iter_chunked(1024):
file_data += i
content_type = resp.headers.get("Content-Type")
if content_type is None:
content_type = "image/*"
file = BytesIO(file_data)
await storage.upload(file_name=file_id.hex, file_data=file, mime_type=content_type)
file_obj: StorageItem = StorageItem(
id=file_id,
uploaded_at=datetime.now(),
mime_type=content_type,
hash=None,
user=user,
size=0,
deleted_at=None,
alt_text=None,
imported=True,
)
await file_obj.save()
await arq.enqueue_job("calculate_hash", file_id.hex)
return PublicStorageItem.from_db_model(file_obj)
+9 -79
View File
@@ -10,18 +10,16 @@ from random import randint
import ormar.exceptions
from classquiz.helpers import get_meili_data, generate_spreadsheet
from classquiz.helpers import generate_spreadsheet
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import ValidationError, BaseModel
import bleach
from classquiz.auth import get_current_user
from classquiz.config import redis, settings, storage, meilisearch
from classquiz.db.models import Quiz, QuizInput, User, PlayGame, GameInLobby, QuizQuestion
from classquiz.db.models import Quiz, User, PlayGame, GameInLobby, QuizQuestion
from classquiz.helpers.box_controller import generate_code
from classquiz.kahoot_importer.import_quiz import import_quiz
import html
import urllib.parse
settings = settings()
@@ -29,33 +27,6 @@ settings = settings()
router = APIRouter()
@router.post("/create", deprecated=True)
async def create_quiz_lol(quiz_input: QuizInput, user: User = Depends(get_current_user)):
imgur_regex = r"^https://i\.imgur\.com\/.{7}.(jpg|png|gif)$"
server_regex = rf"^{re.escape(settings.root_address)}/api/v1/storage/download/.{36}--.{36}$"
quiz_input.title = html.unescape(bleach.clean(quiz_input.title, tags=[], strip=True))
quiz_input.description = html.unescape(bleach.clean(quiz_input.description, tags=[], strip=True))
for question in quiz_input.questions:
if question.image == "":
question.image = None
if (
question.image is not None
and not re.match(imgur_regex, question.image)
and not re.match(server_regex, question.image)
):
raise HTTPException(status_code=400, detail="image url is not valid")
if quiz_input.cover_image == "":
quiz_input.cover_image = None
if quiz_input.cover_image is not None and not bool(re.match(server_regex, quiz_input.cover_image)):
raise HTTPException(status_code=400, detail="image url is not valid")
quiz = Quiz(**quiz_input.dict(), user_id=user.id, id=uuid.uuid4())
await redis.delete("global_quiz_count")
if quiz_input.public:
meilisearch.index(settings.meilisearch_index).add_documents([await get_meili_data(quiz)])
return await quiz.save()
@router.get("/get/{quiz_id}")
async def get_quiz_from_id(quiz_id: str, user: User | None = Depends(get_current_user)):
try:
@@ -195,57 +166,14 @@ async def get_quiz_list(user: User = Depends(get_current_user), page_size: int |
raise HTTPException(status_code=400, detail="Invalid page(size). page(size) have to be greater than 0.")
@router.put("/update/{quiz_id}", deprecated=True)
async def update_quiz(quiz_id: str, quiz_input: QuizInput, user: User = Depends(get_current_user)):
imgur_regex = r"^https://i\.imgur\.com\/.{7}.(jpg|png|gif)$"
server_regex = rf"^{re.escape(settings.root_address)}/api/v1/storage/download/.{{36}}--.{{36}}$"
for question in quiz_input.questions:
if question.image == "":
question.image = None
if (
question.image is not None
and not bool(re.match(server_regex, question.image))
and not bool(re.match(imgur_regex, question.image))
):
raise HTTPException(status_code=400, detail="image url is not valid")
try:
quiz_id = uuid.UUID(quiz_id)
except ValueError:
raise HTTPException(status_code=400, detail="badly formed quiz id")
# Check Cover-Image
if quiz_input.cover_image == "":
quiz_input.cover_image = None
if quiz_input.cover_image is not None and not bool(re.match(server_regex, quiz_input.cover_image)):
raise HTTPException(status_code=400, detail="image url is not valid")
quiz = await Quiz.objects.get_or_none(id=quiz_id, user_id=user.id)
if quiz is None:
return JSONResponse(status_code=404, content={"detail": "quiz not found"})
else:
quiz_input.description = html.unescape(bleach.clean(quiz_input.description, tags=[], strip=True))
quiz_input.title = html.unescape(bleach.clean(quiz_input.title, tags=[], strip=True))
meilisearch.index(settings.meilisearch_index).update_documents([await get_meili_data(quiz)])
if quiz.public and not quiz_input.public:
meilisearch.index(settings.meilisearch_index).delete_document(str(quiz.id))
if not quiz.public and quiz_input.public:
meilisearch.index(settings.meilisearch_index).add_documents([await get_meili_data(quiz)])
quiz.title = quiz_input.title
quiz.cover_image = quiz_input.cover_image
quiz.public = quiz_input.public
quiz.description = quiz_input.description
quiz.updated_at = datetime.now()
quiz.questions = quiz_input.dict()["questions"]
return await quiz.update()
@router.post("/import/{quiz_id}")
async def import_quiz_route(quiz_id: str, user: User = Depends(get_current_user)):
if user.storage_used > settings.free_storage_limit:
raise HTTPException(status_code=409, detail="Storage limit reached")
try:
return await import_quiz(quiz_id, user)
except ValidationError:
except ValidationError as e:
print(e)
raise HTTPException(status_code=400, detail="This quiz isn't (yet) supported")
@@ -264,7 +192,9 @@ async def delete_quiz(quiz_id: str, user: User = Depends(get_current_user)):
for question in quiz.questions:
try:
if question["image"] is not None and not str(question["image"]).startswith("https://i.imgur.com/"):
pics_to_delete.append(pic_name_regex.match(question["image"]).group(1))
old_image_to_delete = pic_name_regex.match(question["image"])
if old_image_to_delete is not None:
pics_to_delete.append(old_image_to_delete.group(1))
except KeyError:
pass
if len(pics_to_delete) != 0:
+65
View File
@@ -0,0 +1,65 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException
from classquiz.auth import get_current_user
from datetime import datetime
from classquiz.db.models import User, QuizTivityInput, QuizTivity, QuizTivityShare, PublicQuizTivityShare
from classquiz.routers.quiztivity.shares import router as shares_router
router = APIRouter()
router.include_router(shares_router, prefix="/shares")
@router.post("/create")
async def create_quiztivity(data: QuizTivityInput, user: User = Depends(get_current_user)) -> QuizTivity:
quiztivity = QuizTivity.parse_obj({**data.dict(), "user": user, "id": uuid4(), "created_at": datetime.now()})
return await quiztivity.save()
@router.get("/{uuid}")
async def get_quiztivity(uuid: UUID) -> QuizTivity:
quiztivity = await QuizTivity.objects.get_or_none(id=uuid)
if quiztivity is None:
raise HTTPException(status_code=404, detail="QuizTivity not found")
return quiztivity
@router.put("/{uuid}")
async def put_quiztivity(data: QuizTivityInput, uuid: UUID, user: User = Depends(get_current_user)) -> QuizTivity:
quiztivity = await QuizTivity.objects.get_or_none(id=uuid, user=user)
if quiztivity is None:
raise HTTPException(status_code=404, detail="QuizTivity not found")
quiztivity.pages = data.dict()["pages"]
quiztivity.title = data.title
return await quiztivity.update()
@router.delete("/{uuid}")
async def delete_quiztivity(uuid: UUID):
quiztivity = await QuizTivity.objects.get_or_none(id=uuid)
if quiztivity is None:
raise HTTPException(status_code=404, detail="QuizTivity not found")
await quiztivity.delete()
@router.get("/")
async def get_all_quiztivities(user: User = Depends(get_current_user)) -> list[QuizTivity]:
quiztivities = await QuizTivity.objects.filter(user=user).order_by(QuizTivity.created_at.desc()).all()
return quiztivities
@router.get("/{uuid}/shares")
async def get_shares(uuid: UUID, user: User = Depends(get_current_user)) -> list[PublicQuizTivityShare]:
shares = (
await QuizTivityShare.objects.filter(quiztivity=uuid, user=user).order_by(QuizTivityShare.expire_at.asc()).all()
)
resp_shares = []
for share in shares:
resp_shares.append(PublicQuizTivityShare.from_db_model(share))
return resp_shares
+84
View File
@@ -0,0 +1,84 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from datetime import datetime, timedelta
from classquiz.auth import get_current_user
from classquiz.db.models import User, QuizTivityShare, QuizTivity, PublicQuizTivityShare
router = APIRouter()
@router.get("/")
async def get_shares(user: User = Depends(get_current_user)) -> list[PublicQuizTivityShare]:
shares = await QuizTivityShare.objects.filter(user=user).all()
resp_shares = []
for share in shares:
resp_shares.append(PublicQuizTivityShare.from_db_model(share))
return resp_shares
class CreateShareInput(BaseModel):
name: str | None
quiztivity: UUID
expire_in: int | None
@router.post("/")
async def create_share(data: CreateShareInput, user: User = Depends(get_current_user)) -> PublicQuizTivityShare:
quiztivity = await QuizTivity.objects.get_or_none(id=data.quiztivity, user=user)
expire_at = None
if data.expire_in is not None:
expire_at = (datetime.now() + timedelta(minutes=data.expire_in)).replace(tzinfo=None)
if quiztivity is None:
raise HTTPException(status_code=400, detail="Quiztivity wasn't found")
share = await QuizTivityShare.objects.create(
id=uuid4(), name=data.name, expire_at=expire_at, quiztivity=quiztivity, user=user
)
share = PublicQuizTivityShare.from_db_model(share)
return share
@router.delete("/{uuid}")
async def delete_share(uuid: UUID, user: User = Depends(get_current_user)):
share = await QuizTivityShare.objects.get_or_none(id=uuid, user=user)
if share is None:
raise HTTPException(status_code=404, detail="Share wasn't found")
await share.delete()
return
class UpdateShareInput(BaseModel):
name: str | None
expire_in: int | None
@router.put("/{uuid}")
async def update_share(
data: UpdateShareInput, uuid: UUID, user: User = Depends(get_current_user)
) -> PublicQuizTivityShare:
share = await QuizTivityShare.objects.get_or_none(id=uuid, user=user)
if share is None:
raise HTTPException(status_code=404, detail="Share wasn't found")
expire_at = None
if data.expire_in is not None:
expire_at = (datetime.now() + timedelta(minutes=data.expire_in)).replace(tzinfo=None)
share.name = data.name
share.expire_at = expire_at
return PublicQuizTivityShare.from_db_model(await share.update())
@router.get("/{uuid}")
async def get_share(uuid: UUID) -> QuizTivity:
share = await QuizTivityShare.objects.select_related("quiztivity").get_or_none(id=uuid)
if share is None:
raise HTTPException(status_code=404, detail="Share not found")
if share.expire_at is None:
return share.quiztivity
if share.expire_at < datetime.now():
raise HTTPException(status_code=410, detail="Already expired")
return share.quiztivity
+11 -10
View File
@@ -12,14 +12,14 @@ from classquiz.db.models import User, GameResults
router = APIRouter()
@router.get("/list", response_model=list[GameResults])
async def list_game_results(user: User = Depends(get_current_user)):
results = await GameResults.objects.all(user=user.id)
@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)
return results
@router.get("/list/{quiz_id}", response_model=list[GameResults])
async def get_results_by_quiz(quiz_id: UUID, user: User = Depends(get_current_user)):
@router.get("/list/{quiz_id}")
async def get_results_by_quiz(quiz_id: UUID, user: User = Depends(get_current_user)) -> list[GameResults]:
res = await GameResults.objects.all(user=user.id, quiz=quiz_id)
if res is None:
raise HTTPException(status_code=404, detail="Game Result not found")
@@ -27,9 +27,9 @@ async def get_results_by_quiz(quiz_id: UUID, user: User = Depends(get_current_us
return res
@router.get("/{game_id}", response_model=GameResults)
async def get_game_result(game_id: UUID, user: User = Depends(get_current_user)):
res = await GameResults.objects.get_or_none(user=user.id, id=game_id)
@router.get("/{game_id}")
async def get_game_result(game_id: UUID, user: User = Depends(get_current_user)) -> GameResults:
res = await GameResults.objects.select_related(GameResults.quiz).get_or_none(user=user.id, id=game_id)
if res is None:
raise HTTPException(status_code=404, detail="Game Result not found")
else:
@@ -40,8 +40,8 @@ class _SetNoteInput(BaseModel):
note: str
@router.post("/set_note", response_model=GameResults)
async def set_note(id: UUID, data: _SetNoteInput, user: User = Depends(get_current_user)):
@router.post("/set_note")
async def set_note(id: UUID, data: _SetNoteInput, user: User = Depends(get_current_user)) -> GameResults:
res = await GameResults.objects.get_or_none(user=user.id, id=id)
if res is None:
raise HTTPException(status_code=404, detail="Game Result not found")
@@ -49,6 +49,7 @@ async def set_note(id: UUID, data: _SetNoteInput, user: User = Depends(get_curre
return await res.update()
# skipcq: PYL-W0105
"""
@router.get("/export/{result_id}", response_class=StreamingResponse)
async def export_result(result_id: UUID, user: User = Depends(get_current_user)):
+238 -14
View File
@@ -1,38 +1,262 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from datetime import datetime, timedelta
from tempfile import SpooledTemporaryFile
import re
from fastapi import APIRouter, HTTPException, UploadFile, File, Depends, Request, Response
from fastapi.responses import StreamingResponse, RedirectResponse
from pydantic import BaseModel
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from classquiz.config import settings, storage
from classquiz.auth import get_current_user
from classquiz.config import settings, storage, arq
from classquiz.db.models import User, StorageItem, PublicStorageItem, UpdateStorageItem, PrivateStorageItem
from classquiz.helpers import check_image_string
from classquiz.storage.errors import DownloadingFailedError
from uuid import uuid4, UUID
settings = settings()
router = APIRouter()
file_regex = r"^[a-z0-9]{8}-[a-z0-9-]{27}--[a-z0-9-]{36}$"
def headers_from_storage_item(item: StorageItem) -> dict[str, str]:
base_headers = {"Content-Type": item.mime_type}
if item.hash is not None:
base_headers["X-Hash"] = item.hash.hex()
if item.thumbhash is not None:
base_headers["X-Thumbhash"] = item.thumbhash
if item.alt_text is not None:
base_headers["X-Alt-Text"] = item.alt_text
if item.size != 0:
base_headers["Content-Size"] = str(item.size)
return base_headers
@router.get("/download/{file_name}")
async def download_file(file_name: str):
if not re.match(file_regex, file_name):
item = None
checked_image_string = check_image_string(file_name)
if not checked_image_string[0]:
raise HTTPException(status_code=400, detail="Invalid file name")
if checked_image_string[1] is not None:
item = await StorageItem.objects.get_or_none(id=checked_image_string[1])
if item is None:
print("Item not found")
raise HTTPException(status_code=404, detail="File not found")
file_name = item.storage_path
if file_name is None:
file_name = item.id.hex
if storage.backend == "s3":
if item is None:
return RedirectResponse(url=await storage.get_url(file_name, 300))
else:
return RedirectResponse(url=await storage.get_url(file_name, 300), headers=headers_from_storage_item(item))
try:
download = await storage.download(file_name)
download = storage.download(file_name)
except DownloadingFailedError:
print("error")
raise HTTPException(status_code=404, detail="File not found")
if download is None:
print("dload is none")
raise HTTPException(status_code=404, detail="File not found")
def iter_file():
yield from download
media_type = "image/*"
if item is not None:
media_type = item.mime_type
headers = {"Cache-Control": "public, immutable, max-age=31536000"}
if item is not None:
headers = {**headers, **headers_from_storage_item(item)}
return StreamingResponse(
iter_file(),
media_type="image/*",
headers={"Cache-Control": "public, immutable, max-age=31536000"},
download,
media_type=media_type,
headers=headers,
)
@router.get("/info/{file_name}")
async def get_basic_file_info(file_name: str) -> Response:
checked_image_string = check_image_string(file_name)
if not checked_image_string[0]:
raise HTTPException(status_code=404, detail="Invalid file name")
if checked_image_string[1] is not None:
item = await StorageItem.objects.get_or_none(id=checked_image_string[1])
if item is None:
raise HTTPException(status_code=404, detail="File not found")
# return PublicStorageItem.from_db_model(item)
storage_file_name = item.storage_path
if storage_file_name is None:
storage_file_name = item.id.hex
resp = Response(status_code=200, headers=headers_from_storage_item(item))
else:
resp = Response(status_code=200, headers={"Content-Type": "image/*"})
return resp
@router.head("/download/{file_name}")
async def download_file_head(file_name: str) -> Response:
checked_image_string = check_image_string(file_name)
if not checked_image_string[0]:
raise HTTPException(status_code=404, detail="Invalid file name")
if checked_image_string[1] is not None:
item = await StorageItem.objects.get_or_none(id=checked_image_string[1])
if item is None:
raise HTTPException(status_code=404, detail="File not found")
# return PublicStorageItem.from_db_model(item)
storage_file_name = item.storage_path
if storage_file_name is None:
storage_file_name = item.id.hex
resp = Response(status_code=200, headers=headers_from_storage_item(item))
else:
resp = Response(status_code=200, headers={"Content-Type": "image/*"})
storage_file_name = file_name
if storage.backend == "s3":
resp.status_code = 307
resp.headers.append("Location", await storage.get_url(storage_file_name, 300))
return resp
@router.post("/")
async def upload_file(file: UploadFile = File(), user: User = Depends(get_current_user)) -> PublicStorageItem:
if user.storage_used > settings.free_storage_limit:
raise HTTPException(status_code=409, detail="Storage limit reached")
file_id = uuid4()
size = 0
file_obj = StorageItem(
id=file_id,
uploaded_at=datetime.now(),
mime_type=file.content_type,
hash=None,
user=user,
size=size,
deleted_at=None,
alt_text=None,
)
await storage.upload(file_name=file_id.hex, file_data=file.file, mime_type=file.content_type)
await file_obj.save()
await arq.enqueue_job("calculate_hash", file_id.hex)
return PublicStorageItem.from_db_model(file_obj)
@router.post("/raw")
async def upload_raw_file(request: Request, user: User = Depends(get_current_user)) -> PublicStorageItem:
if user.storage_used > settings.free_storage_limit:
raise HTTPException(status_code=409, detail="Storage limit reached")
file_id = uuid4()
data_file = SpooledTemporaryFile(max_size=1000)
async for chunk in request.stream():
data_file.write(chunk)
data_file.seek(0)
file_obj = StorageItem(
id=file_id,
uploaded_at=datetime.now(),
mime_type=request.headers.get("Content-Type"),
hash=None,
user=user,
size=0,
deleted_at=None,
alt_text=None,
)
# https://github.com/VirusTotal/vt-py/issues/119#issuecomment-1261246867
await storage.upload(
file_name=file_id.hex,
# skipcq: PYL-W0212
file_data=data_file._file,
mime_type=request.headers.get("Content-Type"),
)
await file_obj.save()
await arq.enqueue_job("calculate_hash", file_id.hex)
return PublicStorageItem.from_db_model(file_obj)
@router.get("/meta/{file_id}")
async def get_file_info(file_id: UUID, user: User = Depends(get_current_user)) -> PublicStorageItem:
file_data = await StorageItem.objects.get_or_none(id=file_id, user=user, deleted_at=None)
if file_data is None:
raise HTTPException(status_code=404, detail="File not found")
return PublicStorageItem.from_db_model(file_data)
@router.delete("/meta/{file_id}")
async def mark_file_as_deleted(file_id: UUID, user: User = Depends(get_current_user)):
file_data = await StorageItem.objects.get_or_none(id=file_id, user=user, deleted_at=None)
if file_data is None:
raise HTTPException(status_code=404, detail="File not found")
storage_path = file_data.storage_path
if storage_path is None:
storage_path = file_data.id.hex
await storage.delete(storage_path)
file_data.deleted_at = datetime.now()
await file_data.update()
return
@router.put("/meta/{file_id}")
async def update_image_data(
file_id: UUID, data: UpdateStorageItem, user: User = Depends(get_current_user)
) -> PublicStorageItem:
file_data = await StorageItem.objects.get_or_none(id=file_id, user=user, deleted_at=None)
if file_data is None:
raise HTTPException(status_code=404, detail="File not found")
if data.alt_text == "":
data.alt_text = None
if data.filename == "":
data.filename = None
file_data.filename = data.filename
file_data.alt_text = data.alt_text
await file_data.update()
return PublicStorageItem.from_db_model(file_data)
@router.get("/list")
async def list_images(
since: datetime | None = None, user: User = Depends(get_current_user)
) -> list[PrivateStorageItem]:
if since is None:
since = datetime.now() - timedelta(weeks=9999)
storage_items = (
await StorageItem.objects.filter(user=user)
.filter(StorageItem.uploaded_at > since)
.filter(StorageItem.deleted_at == None) # noqa: E711
.order_by(StorageItem.uploaded_at.desc())
.select_related([StorageItem.quizzes, StorageItem.quiztivities])
.all()
)
if len(storage_items) == 0:
raise HTTPException(status_code=404, detail="No items found")
return_items: list[PrivateStorageItem] = []
for item in storage_items:
return_items.append(PrivateStorageItem.from_db_model(item))
return return_items
@router.get("/list/last")
async def get_latest_images(count: int = 50, user: User = Depends(get_current_user)) -> list[PrivateStorageItem]:
count = min(count, 50)
items = (
await StorageItem.objects.filter(user=user)
.limit(count)
.select_related([StorageItem.quizzes, StorageItem.quiztivities])
.order_by(StorageItem.uploaded_at.desc())
.all()
)
return_items: list[PrivateStorageItem] = []
for item in items:
return_items.append(PrivateStorageItem.from_db_model(item))
return return_items
class ReturnGetStorageLimit(BaseModel):
limit: int
limit_reached: bool
used: int
@router.get("/limit")
async def get_storage_limit(user: User = Depends(get_current_user)) -> ReturnGetStorageLimit:
user = await User.objects.get_or_none(id=user.id)
if user.storage_used > settings.free_storage_limit:
return ReturnGetStorageLimit(limit=settings.free_storage_limit, limit_reached=True, used=user.storage_used)
else:
return ReturnGetStorageLimit(limit=settings.free_storage_limit, limit_reached=False, used=user.storage_used)
-1
View File
@@ -79,7 +79,6 @@ async def create_user(user: RouteUser, background_task: BackgroundTasks) -> User
if len(user.username) == 32:
return JSONResponse({"details": "Username mustn't be 32 characters long"}, 400)
await user.save()
# print(settings.skip_email_verification)
if settings.skip_email_verification:
user.verify_key = None
user.verified = True
+8 -5
View File
@@ -252,8 +252,8 @@ class ReturnQuestion(QuizQuestion):
raise ValueError("Answers can't be none if type is ABCD")
if values["type"] == QuizQuestionType.RANGE and type(v) != RangeQuizAnswerWithoutSolution:
raise ValueError("Answer must be from type RangeQuizAnswer if type is RANGE")
# skipcq: PTC-W0047
if values["type"] == QuizQuestionType.VOTING and type(v[0]) != VotingQuizAnswer:
# print("Answer must be from type VotingQuizAnswer if type is VOTING")
pass
return v
@@ -262,7 +262,6 @@ class ReturnQuestion(QuizQuestion):
async def set_question_number(sid, data: str):
# data is just a number (as a str) of the question
session = await sio.get_session(sid)
# print("set_question_number", data, session)
if session["admin"]:
game_pin = session["game_pin"]
game_data = PlayGame.parse_raw(await redis.get(f"game:{session['game_pin']}"))
@@ -286,7 +285,6 @@ async def set_question_number(sid, data: str):
temp_return["type"] = game_data.questions[int(float(data))].type
if temp_return["type"] == QuizQuestionType.ORDER:
random.shuffle(temp_return["answers"])
# print("emitting")
await sio.emit(
"set_question_number",
{
@@ -359,6 +357,12 @@ async def submit_answer(sid: str, data: dict):
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 sio.get_session(sid))["ping"]))
@@ -366,8 +370,6 @@ async def submit_answer(sid: str, data: dict):
answers = await redis.get(f"game_session:{session['game_pin']}:{data.question_index}")
diff = (time_q_started - now).total_seconds() * 1000 # - timedelta(milliseconds=latency)
# print(abs(diff) - latency, latency, abs(diff))
score = 0
if answer_right:
score = calculate_score(
@@ -385,6 +387,7 @@ async def submit_answer(sid: str, data: dict):
answers, game_pin=session["game_pin"], data=answer_data, q_index=int(float(data.question_index))
)
player_count = await redis.scard(f"game_session:{session['game_pin']}:players")
await sio.emit("player_answer", {})
if len(answers.__root__) == player_count:
# await sio.emit(
# "question_results",
+33 -33
View File
@@ -2,58 +2,58 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from io import BytesIO
from typing import Optional
from typing import Optional, BinaryIO
from .deta_storage import DetaStorage
from .local_storage import LocalStorage
from .s3_storage import S3Storage
from typing import Generator
class Storage:
def __init__(
self,
backend: str,
deta_key: Optional[str],
deta_id: Optional[str],
storage_path: Optional[str],
access_key: str | None = None,
secret_key: str | None = None,
bucket_name: str | None = None,
base_url: str | None = None,
):
self.backend = backend
self.deta_key: str | None = deta_key
self.deta_id: str | None = deta_id
self.deta_base_url = f"https://drive.deta.sh/v1/{deta_id}/classquiz1"
if backend == "deta":
if deta_key is None or deta_id is None:
raise ValueError("deta_key and deta_id must be provided")
else:
self.deta_instance = DetaStorage(
deta_base_url=self.deta_base_url,
deta_key=self.deta_key,
deta_id=self.deta_id,
)
self.access_key = access_key
self.secret_key = secret_key
self.bucket_name = bucket_name
self.base_url = base_url
self.instance: LocalStorage | S3Storage | None = None
elif backend == "local":
if backend == "local":
if storage_path is None:
raise ValueError("storage_path must be provided")
else:
self.local_instance = LocalStorage(base_path=storage_path)
self.instance = LocalStorage(base_path=storage_path)
elif backend == "s3":
if access_key is None or secret_key is None or bucket_name is None or base_url is None:
raise ValueError("Not all parameters given")
self.instance = S3Storage(
base_url=base_url, access_key=access_key, secret_key=secret_key, bucket_name=bucket_name
)
else:
raise NotImplementedError(f"Backend {backend} not implemented")
async def download(self, file_name: str) -> BytesIO | None:
if self.backend == "deta":
return await self.deta_instance.download(file_name)
elif self.backend == "local":
return await self.local_instance.get_file(file_name)
def download(self, file_name: str) -> Generator | None:
return self.instance.download(file_name)
async def upload(self, file_name: str, file_data: bytes) -> None:
if self.backend == "deta":
return await self.deta_instance.upload(file=file_data, file_name=file_name)
elif self.backend == "local":
return await self.local_instance.write_file(file_name=file_name, data=file_data)
async def upload(self, file_name: str, file_data: BinaryIO, mime_type: str | None = None) -> None:
return await self.instance.upload(file=file_data, file_name=file_name, mime_type=mime_type)
async def delete(self, file_names: [str]) -> None:
if self.backend == "deta":
return await self.deta_instance.delete(file_names=file_names)
elif self.backend == "local":
return await self.local_instance.delete_file(file_names=file_names)
return await self.instance.delete(file_names=file_names)
async def get_url(self, file_name: str, expiry: int) -> str:
if self.backend == "s3":
return self.instance.get_url(file_name=file_name, expire=expiry)
async def get_file_size(self, file_name: str) -> int | None:
return self.instance.size(file_name)
-58
View File
@@ -1,58 +0,0 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from io import BytesIO
from classquiz.storage.errors import DeletionFailedError, SavingFailedError, DownloadingFailedError
from aiohttp import ClientSession
class DetaStorage:
def __init__(self, deta_base_url: str, deta_id: str, deta_key: str):
self.deta_url = deta_base_url
self.deta_id = deta_id
self.deta_key = deta_key
self.headers = {
"X-Api-Key": self.deta_key,
}
async def download(self, file_name: str) -> BytesIO | None:
"""
:param file_name: The name of the file to be downloaded
:return: Either bytes f successfull download or None if failed
"""
async with ClientSession(headers=self.headers) as session, session.get(
f"{self.deta_url}/files/download?name={file_name}"
) as response:
if response.status == 200:
return BytesIO(await response.read())
elif response.status == 404:
return None
else:
raise DownloadingFailedError
async def upload(self, file: bytes, file_name: str) -> None:
"""
:param file: The file in bytes
:param file_name: The name of the file
:return:
"""
async with ClientSession(headers=self.headers) as session, session.post(
f"{self.deta_url}/files?name={file_name}", data=file
) as response:
if response.status == 201:
return None
else:
print(response.status, await response.json())
raise SavingFailedError
async def delete(self, file_names: [str]) -> None:
async with ClientSession(headers=self.headers) as session, session.delete(
f"{self.deta_url}/files", json={"names": file_names}
) as response:
if response.status == 200:
return None
else:
raise DeletionFailedError
+22 -8
View File
@@ -2,32 +2,46 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
import io
import os
from shutil import copyfileobj
from typing import BinaryIO, Generator
import aiofiles
import aiofiles.os
_DEFAULT_CHUNK_SIZE = 32768 # bytes; arbitrary
class LocalStorage:
def __init__(self, base_path: str):
self.base_path = base_path
async def get_file(self, file_name: str) -> io.BytesIO | None:
async def download(self, file_name: str) -> Generator | None:
try:
async with aiofiles.open(file=os.path.join(self.base_path, file_name), mode="rb") as f:
return io.BytesIO(await f.read())
while True:
chunk = await f.read(8192)
if not chunk:
break
yield chunk
except FileNotFoundError:
return None
yield None
async def write_file(self, file_name: str, data: bytes) -> None:
async with aiofiles.open(file=os.path.join(self.base_path, file_name), mode="wb") as f:
await f.write(data)
# skipcq: PYL-W0613
async def upload(self, file_name: str, file: BinaryIO, mime_type: str | None = None) -> None:
with open(file=os.path.join(self.base_path, file_name), mode="wb") as f:
copyfileobj(file, f)
async def delete_file(self, file_names: [str]) -> None:
async def delete(self, file_names: [str]) -> None:
for i in file_names:
try:
await aiofiles.os.remove(os.path.join(self.base_path, i))
except FileNotFoundError:
pass
return None
def size(self, file_name: str) -> int | None:
try:
return os.stat(os.path.join(self.base_path, file_name)).st_size
except FileNotFoundError:
return None
+157
View File
@@ -0,0 +1,157 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
import hashlib
import hmac
from datetime import datetime, timedelta
from typing import Tuple, BinaryIO, Generator
from aiohttp import ClientSession
import minio
from pydantic import BaseModel
from classquiz.storage.errors import DeletionFailedError, SavingFailedError, DownloadingFailedError
class S3Storage:
class _HeaderAndParams(BaseModel):
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"):
self.base_url = base_url
self.access_key = access_key
self.secret_key = secret_key
self.bucket_name = bucket_name
self.region = region
self.DATE_FORMAT = "%a, %d %b %Y %H:%M:%S GMT"
self.host = base_url.replace("http://", "").replace("https://", "")
self.client = minio.Minio(self.host, access_key=access_key, secret_key=secret_key)
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]:
path = f"/{self.bucket_name}{path}"
service = "s3"
# Create a timestamp for the request
t = datetime.utcnow()
amz_date = t.strftime("%Y%m%dT%H%M%SZ")
datestamp = t.strftime("%Y%m%d")
# Create a canonical request
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()
canonical_request = (
method
+ "\n"
+ canonical_uri
+ "\n"
+ canonical_querystring
+ "\n"
+ canonical_headers
+ "\n"
+ signed_headers
+ "\n"
+ payload_hash
)
# Create a string to sign
algorithm = "AWS4-HMAC-SHA256"
credential_scope = 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()
)
# 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()
# Calculate the 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 = (
algorithm
+ " "
+ "Credential="
+ self.access_key
+ "/"
+ credential_scope
+ ", "
+ "SignedHeaders="
+ signed_headers
+ ", "
+ "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
return headers, request_url
# skipcq: PYL-W0613
async def upload(self, file: BinaryIO, file_name: str, mime_type: str | None = "application/octet-stream") -> None:
headers, url = self._generate_aws_signature_v4(method="PUT", path=f"/{file_name}")
async with ClientSession() as session, session.put(url, headers=headers, data=file) as resp:
if resp.status == 200:
return None
else:
print(await resp.text())
raise SavingFailedError
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:
if resp.status == 204:
return None
else:
raise DeletionFailedError
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)
)
def size(self, file_name: str) -> int | None:
try:
res = self.client.stat_object(bucket_name=self.bucket_name, object_name=file_name)
except minio.error.S3Error:
return None
return res.size
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:
if resp.status == 200:
async for i in resp.content.iter_chunked(1024):
yield i
elif resp.status == 404:
yield None
else:
raise DownloadingFailedError
# client = httpx.AsyncClient()
# async with client.stream("GET", url, headers=headers) as resp:
# if resp.status == 200:
# yield resp.aiter_bytes()
+22 -1
View File
@@ -33,6 +33,11 @@ class ValueStorage:
exported_quiz_data = None
edit_id = None
image_id = None
cookies = None
file_id = None
quiztivity_id = None
share_id = None
expired_share_id = None
example_quiz = {
@@ -41,14 +46,16 @@ example_quiz = {
"description": "A description",
"questions": [
{
"type": "ABCD",
"question": "Is ClassQuiz cool?",
"time": 10,
"answers": [{"right": True, "answer": "Yes"}, {"right": False, "answer": "No"}],
},
{
"type": "ABCD",
"question": "Do you like open source?",
"time": 5,
"image": "https://i.imgur.com/sSNSy77.png",
"image": None,
"answers": [
{"right": True, "answer": "Yes"},
{"right": False, "answer": "No"},
@@ -60,6 +67,20 @@ example_quiz = {
test_user_email = "sth@byom.de"
test_user_password = "test"
example_quiztivity = {
"title": "Some test Quiztivity",
"pages": [
{
"title": "Some test question",
"type": "ABCD",
"data": {
"question": "Is ClassQuiz cool?",
"answers": [{"correct": True, "answer": "Yes"}, {"correct": False, "answer": "No"}],
},
}
],
}
# mock_test_results = {'0': [{'username': 'Player 1', 'answer': 'Byte, Bit, KB, MB, GB, TB', 'right': False},
# {'username': 'Player 2', 'answer': 'Byte, Bit, KB, MB, GB, TB', 'right': False}, {'username': 'Player 3',
# 'answer': 'Bit, Byte, KB, MB, GB, TB', 'right': True}], '1': [{'username': 'Player 3', 'answer': 'CPU',
+420 -256
View File
@@ -5,12 +5,11 @@
import uuid
import pytest
from httpx import AsyncClient
from redis import Redis
from classquiz.config import settings
from classquiz.tests import test_user_email, test_user_password
from classquiz.tests import test_user_email, test_user_password, example_quiztivity
from classquiz.tests import test_client, example_quiz, ValueStorage # noqa : F401
from classquiz.helpers.hashcash import mint
from fastapi.testclient import TestClient
# @pytest.fixture
@@ -25,8 +24,18 @@ from classquiz.helpers.hashcash import mint
class TestUsers:
@staticmethod
def log_in(tc: TestClient, email=test_user_email, password=test_user_password) -> int:
resp = tc.post("/api/v1/login/start", json={"email": email})
session_id = resp.json()["session_id"]
resp = tc.post(
f"/api/v1/login/step/1?session_id={session_id}", json={"auth_type": "PASSWORD", "data": password}
)
ValueStorage.cookies = resp.cookies
return resp.status_code
@pytest.mark.asyncio
async def test_create_test_user(self, test_client): # noqa : F811
async def test_create_test_user(self, test_client: TestClient): # noqa : F811
resp = test_client.post(
"/api/v1/users/create",
json={"email": test_user_email, "password": test_user_password, "username": "mawoka"},
@@ -55,156 +64,104 @@ class TestUsers:
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_verify_email(self, test_client): # noqa : F811
resp = test_client.post(
"/api/v1/users/token/cookie", data={"username": test_user_email, "password": test_user_password}
)
assert resp.status_code == 401
async def test_verify_email(self, test_client: TestClient): # noqa : F811
user = test_client.get(f"/api/v1/internal/testing/user/{test_user_email}?secret_key={settings().secret_key}")
assert (test_client.get("/api/v1/users/verify/dasadsasdadsasdsaddassad")).status_code == 404
test_client.get(f"/api/v1/users/verify/{user.json()['verify_key']}")
resp = test_client.post("/api/v1/login/start", json={"email": test_user_email})
assert resp.status_code == 200
session_id = resp.json()["session_id"]
resp = test_client.post(
"/api/v1/users/token/cookie", data={"username": test_user_email, "password": test_user_password}
f"/api/v1/login/step/1?session_id={session_id}", json={"auth_type": "PASSWORD", "data": test_user_password}
)
ValueStorage.cookies = resp.cookies
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_check(self, test_client): # noqa : F811
resp = test_client.post(
"/api/v1/users/token/cookie", data={"username": test_user_email, "password": test_user_password}
)
token = resp.json()["access_token"]
resp = test_client.get("/api/v1/users/check", cookies={"access_token": f"Bearer {token}"})
async def test_check(self, test_client: TestClient): # noqa : F811
resp = test_client.get("/api/v1/users/check", cookies=ValueStorage.cookies)
assert resp.status_code == 200
resp = test_client.get("/api/v1/users/check", cookies={"access_token": "Bearer dasasdasddasadsasdadssadsd"})
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_me(self, test_client): # noqa : F811
resp = test_client.post(
"/api/v1/users/token/cookie", data={"username": test_user_email, "password": test_user_password}
)
token = resp.json()["access_token"]
resp = test_client.get("/api/v1/users/me", cookies={"access_token": f"Bearer {token}"})
async def test_me(self, test_client: TestClient): # noqa : F811
resp = test_client.get("/api/v1/users/me", cookies=ValueStorage.cookies)
data = resp.json()
assert resp.status_code == 200
assert data["verified"] is True
assert data["email"] == test_user_email
assert data["username"] == "mawoka"
@pytest.mark.asyncio
async def test_rememberme(self, test_client): # noqa : F811
resp = test_client.post(
"/api/v1/users/token/cookie", data={"username": test_user_email, "password": test_user_password}
)
rememberme_token = resp.cookies["rememberme_token"]
resp = test_client.get("/api/v1/users/token/rememberme", cookies={"rememberme_token": rememberme_token})
assert resp.cookies["access_token"] is not None
assert resp.status_code == 200
resp = test_client.get(
"/api/v1/users/token/rememberme", cookies={"rememberme_token": "dsahgvjadsvsahgxddsvhgdsvhg"}
)
assert resp.status_code == 401
# @pytest.mark.asyncio
# async def test_logout(self, test_client): # noqa : F811
# resp = test_client.get("/api/v1/users/me", cookies={"access_token": access_token})
# assert resp.status_code == 200
# resp = test_client.get(
# "/api/v1/users/logout", cookies={"rememberme_token": rememberme_token}, allow_redirects=False
# )
# assert resp.status_code == 302
# resp = test_client.get("/api/v1/users/me", cookies={"rememberme_token": rememberme_token})
# assert resp.status_code == 401
@pytest.mark.asyncio
async def test_logout(self, test_client): # noqa : F811
resp = test_client.post(
"/api/v1/users/token/cookie", data={"username": test_user_email, "password": test_user_password}
)
rememberme_token = resp.cookies["rememberme_token"]
access_token = resp.cookies["access_token"]
resp = test_client.get("/api/v1/users/me", cookies={"access_token": access_token})
assert resp.status_code == 200
resp = test_client.get(
"/api/v1/users/logout", cookies={"rememberme_token": rememberme_token}, allow_redirects=False
)
assert resp.status_code == 302
resp = test_client.get("/api/v1/users/me", cookies={"rememberme_token": rememberme_token})
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_password_update(self, test_client): # noqa : F811
resp = test_client.post(
"/api/v1/users/token/cookie", data={"username": test_user_email, "password": test_user_password}
)
token = resp.json()["access_token"]
async def test_password_update(self, test_client: TestClient): # noqa : F811
resp = test_client.put(
"/api/v1/users/password/update",
json={"new_password": "new_password", "old_password": test_user_password},
cookies={"access_token": f"Bearer {token}"},
cookies=ValueStorage.cookies,
)
assert resp.status_code == 200
resp = test_client.put(
"/api/v1/users/password/update",
json={"new_password": "asdsdadsasdaasd", "old_password": "asdasdsadadsasdsadasdasd"},
cookies={"access_token": f"Bearer {token}"},
cookies=ValueStorage.cookies,
)
assert resp.status_code == 400
resp = test_client.post(
"/api/v1/users/token/cookie", data={"username": test_user_email, "password": "new_password"}
)
assert resp.status_code == 200
resp_code = self.log_in(test_client, password="new_password")
assert resp_code == 200
resp = test_client.put(
"/api/v1/users/password/update",
json={"new_password": test_user_password, "old_password": "new_password"},
cookies={"access_token": f"Bearer {token}"},
cookies=ValueStorage.cookies,
)
assert resp.status_code == 200
resp1 = test_client.post(
"/api/v1/users/token/cookie", data={"username": test_user_email, "password": test_user_password}
)
rememberme_token = resp1.cookies["rememberme_token"]
response = test_client.get("/api/v1/users/me", cookies={"rememberme_token": rememberme_token})
resp_code = self.log_in(test_client)
assert resp_code == 200
response = test_client.get("/api/v1/users/me", cookies=ValueStorage.cookies)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_get_session(self, test_client): # noqa : F811
resp = test_client.post(
"/api/v1/users/token/cookie", data={"username": test_user_email, "password": test_user_password}
)
token = resp.cookies["rememberme_token"]
resp = test_client.get("/api/v1/users/session", cookies={"rememberme_token": token})
async def test_get_session(self, test_client: TestClient): # noqa : F811
resp = test_client.get("/api/v1/users/session", cookies=ValueStorage.cookies)
assert resp.status_code == 200
assert resp.json()["ip_address"] == "testclient"
@pytest.mark.asyncio
async def test_delete_session(self, test_client): # noqa : F811
resp = test_client.post(
"/api/v1/users/token/cookie", data={"username": test_user_email, "password": test_user_password}
)
token = resp.cookies["rememberme_token"]
resp = test_client.get("/api/v1/users/session", cookies={"rememberme_token": token})
session_id = resp.json()["id"]
resp = test_client.delete("/api/v1/users/sessions/" + str(session_id), cookies={"rememberme_token": token})
async def test_list_sessions(self, test_client: TestClient): # noqa : F811
resp = test_client.get("/api/v1/users/sessions/list", cookies=ValueStorage.cookies)
assert resp.status_code == 200
resp = test_client.delete("/api/v1/users/sessions/asdsadasdasdsad", cookies={"rememberme_token": token})
data = resp.json()
assert len(data) >= 1
@pytest.mark.asyncio
async def test_delete_session(self, test_client: TestClient): # noqa : F811
resp = test_client.get("/api/v1/users/session", cookies=ValueStorage.cookies)
session_id = resp.json()["id"]
resp = test_client.delete("/api/v1/users/sessions/" + str(session_id), cookies=ValueStorage.cookies)
assert resp.status_code == 200
resp = test_client.delete("/api/v1/users/sessions/asdsadasdasdsad", cookies=ValueStorage.cookies)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_list_sessions(self, test_client): # noqa : F811
resp = test_client.post(
"/api/v1/users/token/cookie", data={"username": test_user_email, "password": test_user_password}
)
token = resp.cookies["rememberme_token"]
resp = test_client.get("/api/v1/users/sessions/list", cookies={"rememberme_token": token})
assert resp.status_code == 200
assert len(resp.json()) >= 1
@pytest.mark.asyncio
async def test_forgotten_password(self, test_client): # noqa : F811
async def test_forgotten_password(self, test_client: TestClient): # noqa : F811
resp = test_client.post("/api/v1/users/forgot-password", json={"email": test_user_email})
assert resp.status_code == 200
resp = test_client.post("/api/v1/users/forgot-password", json={"email": "ddassad@dsa.ads"})
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_reset_password_with_token(self, test_client): # noqa : F811
resp = test_client.post(
"/api/v1/users/token/cookie", data={"username": test_user_email, "password": test_user_password}
)
token = resp.cookies["access_token"]
me = test_client.get("/api/v1/users/me", cookies={"access_token": token}).json()
async def test_reset_password_with_token(self, test_client: TestClient): # noqa : F811
me = test_client.get("/api/v1/users/me", cookies=ValueStorage.cookies).json()
redis = Redis().from_url(settings().redis)
redis.set("reset_passwd:_1token_", str(me["id"]))
redis.set("reset_passwd:_2token_", str(uuid.uuid4()))
@@ -217,40 +174,29 @@ class TestUsers:
assert resp.status_code == 400
resp = test_client.post("/api/v1/users/reset-password", json={"token": "_1token_", "password": "new_password"})
assert resp.status_code == 200
resp = test_client.post(
"/api/v1/users/token/cookie", data={"username": test_user_email, "password": "new_password"}
)
token = resp.cookies["access_token"]
self.log_in(test_client, password="new_password")
test_client.put(
"/api/v1/users/password/update",
json={"new_password": test_user_password, "old_password": "new_password"},
cookies={"access_token": token},
cookies=ValueStorage.cookies,
)
redis.flushdb()
@pytest.mark.asyncio
async def test_signout_everywhere(self, test_client): # noqa : F811
resp = test_client.post(
"/api/v1/users/token/cookie", data={"username": test_user_email, "password": test_user_password}
)
token = resp.cookies["access_token"]
resp = test_client.delete("/api/v1/users/signout-everywhere", cookies={"access_token": token})
async def test_signout_everywhere(self, test_client: TestClient): # noqa : F811
resp = test_client.delete("/api/v1/users/signout-everywhere", cookies=ValueStorage.cookies)
assert resp.status_code == 200
class TestUtils:
@pytest.mark.asyncio
async def test_get_ip_data(self, test_client): # noqa : F811
resp = test_client.post(
"/api/v1/users/token/cookie", data={"username": test_user_email, "password": test_user_password}
)
token = resp.cookies["access_token"]
resp = test_client.get("/api/v1/utils/ip-lookup/1.1.1.1", cookies={"access_token": token})
async def test_get_ip_data(self, test_client: TestClient): # noqa : F811
resp = test_client.get("/api/v1/utils/ip-lookup/1.1.1.1", cookies=ValueStorage.cookies)
assert resp.status_code == 200
assert resp.json()["query"] == "1.1.1.1"
@pytest.mark.asyncio
async def test_get_qr(self, test_client): # noqa : F811
async def test_get_qr(self, test_client: TestClient): # noqa : F811
resp = test_client.get("/api/v1/utils/qr/12345678")
assert resp.status_code == 200
assert resp.headers["Content-Type"] == "image/svg+xml"
@@ -258,7 +204,7 @@ class TestUtils:
class TestStats:
@pytest.mark.asyncio
async def test_get_quiz_count(self, test_client): # noqa : F811
async def test_get_quiz_count(self, test_client: TestClient): # noqa : F811
redis = Redis().from_url(settings().redis)
redis.flushdb()
for _ in range(2):
@@ -267,7 +213,7 @@ class TestStats:
assert resp.text == str(0)
@pytest.mark.asyncio
async def test_get_user_count(self, test_client): # noqa : F811
async def test_get_user_count(self, test_client: TestClient): # noqa : F811
redis = Redis().from_url(settings().redis)
redis.flushdb()
for _ in range(2):
@@ -276,7 +222,7 @@ class TestStats:
assert resp.text == str(1)
@pytest.mark.asyncio
async def test_get_combined_count(self, test_client): # noqa : F811
async def test_get_combined_count(self, test_client: TestClient): # noqa : F811
redis = Redis().from_url(settings().redis)
redis.flushdb()
for _ in range(2):
@@ -288,150 +234,132 @@ class TestStats:
class TestQuiz:
@pytest.mark.asyncio
async def test_create_quiz(self, test_client): # noqa : F811
resp = test_client.post(
"/api/v1/users/token/cookie", data={"username": test_user_email, "password": test_user_password}
)
token = resp.cookies["access_token"]
resp = test_client.post("/api/v1/quiz/create", json=example_quiz, cookies={"access_token": token})
async def test_create_quiz(self, test_client: TestClient): # noqa : F811
resp = test_client.post("/api/v1/editor/start?edit=false", cookies=ValueStorage.cookies)
assert resp.status_code == 200
ValueStorage.quiz_id = resp.json()["id"]
example_quiz["questions"][1]["image"] = "https://imgur.com/sSNSy77.png"
resp = test_client.post("/api/v1/quiz/create", json=example_quiz, cookies={"access_token": token})
assert resp.status_code == 400
edit_token = resp.json()["token"]
assert len(edit_token) == 8
resp = test_client.post(
f"/api/v1/editor/finish?edit_id={edit_token}", json=example_quiz, cookies=ValueStorage.cookies
)
assert resp.status_code == 200
resp = test_client.get("/api/v1/quiz/list", cookies=ValueStorage.cookies)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
ValueStorage.quiz_id = data[0]["id"]
@pytest.mark.asyncio
async def test_get_quiz_from_id(self, test_client): # noqa : F811
resp = test_client.post(
"/api/v1/users/token/cookie", data={"username": test_user_email, "password": test_user_password}
)
token = resp.cookies["access_token"]
resp = test_client.get(f"/api/v1/quiz/get/{ValueStorage.quiz_id}", cookies={"access_token": token})
async def test_get_quiz_from_id(self, test_client: TestClient): # noqa : F811
resp = test_client.get(f"/api/v1/quiz/get/{ValueStorage.quiz_id}", cookies=ValueStorage.cookies)
assert resp.status_code == 200
resp = test_client.get("/api/v1/quiz/get/dasdsadsadsadsadsa", cookies={"access_token": token})
resp = test_client.get("/api/v1/quiz/get/dasdsadsadsadsadsa", cookies=ValueStorage.cookies)
assert resp.status_code == 400
resp = test_client.get("/api/v1/quiz/get/847c64d3-39f9-4bb7-8f13-fae913f67858", cookies={"access_token": token})
resp = test_client.get("/api/v1/quiz/get/847c64d3-39f9-4bb7-8f13-fae913f67858", cookies=ValueStorage.cookies)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_list_quizzes(self, test_client): # noqa : F811
resp = test_client.post(
"/api/v1/users/token/cookie", data={"username": test_user_email, "password": test_user_password}
)
token = resp.cookies["access_token"]
resp = test_client.get("/api/v1/quiz/list", cookies={"access_token": token})
async def test_list_quizzes(self, test_client: TestClient): # noqa : F811
resp = test_client.get("/api/v1/quiz/list", cookies=ValueStorage.cookies)
assert resp.status_code == 200
assert resp.json()[0]["id"] == ValueStorage.quiz_id
@pytest.mark.asyncio
async def test_update_quiz(self, test_client): # noqa : F811
async def test_update_quiz(self, test_client: TestClient): # noqa : F811
example_quiz["public"] = True
example_quiz["questions"][1]["image"] = "https://i.imgur.com/sSNSy77.png"
resp = test_client.post(
"/api/v1/users/token/cookie", data={"username": test_user_email, "password": test_user_password}
f"/api/v1/editor/start?edit=true&quiz_id={ValueStorage.quiz_id}", cookies=ValueStorage.cookies
)
token = resp.cookies["access_token"]
resp = test_client.put(
f"/api/v1/quiz/update/{ValueStorage.quiz_id}", json=example_quiz, cookies={"access_token": token}
edit_id = resp.json()["token"]
resp = test_client.post(
f"/api/v1/editor/finish?edit_id={edit_id}", json=example_quiz, cookies=ValueStorage.cookies
)
assert resp.status_code == 200
resp = test_client.put(
"/api/v1/quiz/update/f183e091-a863-44ec-a1b7-c70eb92e3f6a",
json=example_quiz,
cookies={"access_token": token},
resp = test_client.post(
"/api/v1/editor/start?edit=true&quiz_id=f183e091-a863-44ec-a1b7-c70eb92e3f6a", cookies=ValueStorage.cookies
)
assert resp.status_code == 404
resp = test_client.put(
"/api/v1/quiz/update/saddsaasddsadsa", json=example_quiz, cookies={"access_token": token}
)
assert resp.status_code == 400
resp = test_client.post("/api/v1/editor/start?edit=true&quiz_id=asddasasdasdads", cookies=ValueStorage.cookies)
assert resp.status_code == 422
example_quiz["public"] = False
test_client.put(
f"/api/v1/quiz/update/{ValueStorage.quiz_id}", json=example_quiz, cookies={"access_token": token}
resp = test_client.post(
f"/api/v1/editor/start?edit=true&quiz_id={ValueStorage.quiz_id}", cookies=ValueStorage.cookies
)
edit_id = resp.json()["token"]
test_client.post(f"/api/v1/editor/finish?edit_id={edit_id}", json=example_quiz, cookies=ValueStorage.cookies)
example_quiz["public"] = True
test_client.put(
f"/api/v1/quiz/update/{ValueStorage.quiz_id}", json=example_quiz, cookies={"access_token": token}
resp = test_client.post(
f"/api/v1/editor/start?edit=true&quiz_id={ValueStorage.quiz_id}", cookies=ValueStorage.cookies
)
edit_id = resp.json()["token"]
test_client.post(f"/api/v1/editor/finish?edit_id={edit_id}", json=example_quiz, cookies=ValueStorage.cookies)
@pytest.mark.asyncio
async def test_import_quiz(self, test_client): # noqa : F811
async def test_import_quiz(self, test_client: TestClient): # noqa : F811
resp = test_client.post(
"/api/v1/users/token/cookie", data={"username": test_user_email, "password": test_user_password}
)
token = resp.cookies["access_token"]
resp = test_client.post(
"/api/v1/quiz/import/1f95eb0b-fcf4-4db2-879b-5418ef75116b", cookies={"access_token": token}
"/api/v1/quiz/import/1f95eb0b-fcf4-4db2-879b-5418ef75116b", cookies=ValueStorage.cookies
)
assert resp.status_code == 200
ValueStorage.imported_quizzes.append(resp.json()["id"])
resp = test_client.post("/api/v1/quiz/import/1f95eb0bdassdadasdas", cookies={"access_token": token})
resp = test_client.post("/api/v1/quiz/import/1f95eb0bdassdadasdas", cookies=ValueStorage.cookies)
assert resp.text == '"quiz not found"'
@pytest.mark.asyncio
async def test_get_public_quiz(self, test_client): # noqa : F811
resp = test_client.post(
"/api/v1/users/token/cookie", data={"username": test_user_email, "password": test_user_password}
)
token = resp.cookies["access_token"]
async def test_get_public_quiz(self, test_client: TestClient): # noqa : F811
resp = test_client.get(f"/api/v1/quiz/get/public/{ValueStorage.imported_quizzes[0]}")
assert resp.status_code == 200
resp = test_client.get(
"/api/v1/quiz/get/public/f183e091-a863-44ec-a1b7-c70eb92e3f6a", cookies={"access_token": token}
"/api/v1/quiz/get/public/f183e091-a863-44ec-a1b7-c70eb92e3f6a", cookies=ValueStorage.cookies
)
assert resp.status_code == 404
resp = test_client.get("/api/v1/quiz/get/public/dadasdas92e3f6a", cookies={"access_token": token})
resp = test_client.get("/api/v1/quiz/get/public/dadasdas92e3f6a", cookies=ValueStorage.cookies)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_search_get(self, test_client): # noqa : F811
async def test_search_get(self, test_client: TestClient): # noqa : F811
resp = test_client.get("/api/v1/search/?q=*")
assert resp.status_code == 200
assert len(resp.json()["hits"]) > 0
@pytest.mark.asyncio
async def test_search_post(self, test_client): # noqa : F811
async def test_search_post(self, test_client: TestClient): # noqa : F811
resp = test_client.post("/api/v1/search/", json={"q": "*"})
assert resp.status_code == 200
assert len(resp.json()["hits"]) > 0
@pytest.mark.asyncio
async def test_image_cdn(self, test_client): # noqa : F811
async def test_image_cdn(self, test_client: TestClient): # noqa : F811
resp = test_client.get(f"/api/v1/quiz/get/public/{ValueStorage.imported_quizzes[0]}")
assert resp.status_code == 200
quiz = resp.json()
image_url = quiz["questions"][0]["image"]
resp = test_client.get(image_url)
assert resp.status_code == 200
resp = test_client.get(f"{image_url}sadgvsadgvhsad")
image_id = quiz["questions"][0]["image"]
# resp = test_client.get(f"/api/v1/storage/download/{image_id}")
# print(resp.text)
# assert resp.status_code == 200 This fails because I don't know
resp = test_client.get(f"/api/v1/storage/download/{image_id}sadgvsadgvhsad")
assert resp.status_code == 400
class TestPlayQuiz:
@pytest.mark.asyncio
async def test_start_quiz(self, test_client): # noqa : F811
async def test_start_quiz(self, test_client: TestClient): # noqa : F811
resp = test_client.post(
"/api/v1/users/token/cookie", data={"username": test_user_email, "password": test_user_password}
)
token = resp.cookies["access_token"]
resp = test_client.post(
"/api/v1/quiz/start/fb5adc91-629e-416e-8b98-ae400e36417c?game_mode=kahoot", cookies={"access_token": token}
"/api/v1/quiz/start/fb5adc91-629e-416e-8b98-ae400e36417c?game_mode=kahoot", cookies=ValueStorage.cookies
)
assert resp.status_code == 404
resp = test_client.post(
"/api/v1/quiz/start/fb5adc91-629e-416e-8b98-ae400sdadsasadsadasddsae36417c?game_mode=kahoot",
cookies={"access_token": token},
cookies=ValueStorage.cookies,
)
assert resp.status_code == 400
resp = test_client.post(
f"/api/v1/quiz/start/{ValueStorage.quiz_id}?game_mode=kahoot", cookies={"access_token": token}
f"/api/v1/quiz/start/{ValueStorage.quiz_id}?game_mode=kahoot", cookies=ValueStorage.cookies
)
ValueStorage.game_pin = resp.json()["game_pin"]
ValueStorage.game_id = resp.json()["game_id"]
@pytest.mark.asyncio
async def test_check_captcha_enabled(self, test_client): # noqa : F811
async def test_check_captcha_enabled(self, test_client: TestClient): # noqa : F811
res = test_client.get(f"/api/v1/quiz/play/check_captcha/{ValueStorage.game_pin}")
assert res.status_code == 200
assert res.json()["enabled"] is True
@@ -440,7 +368,7 @@ class TestPlayQuiz:
assert res.status_code == 404
@pytest.mark.asyncio
async def test_join_game_route(self, test_client): # noqa : F811
async def test_join_game_route(self, test_client: TestClient): # noqa : F811
res = test_client.get(f"/api/v1/quiz/join/{ValueStorage.game_pin}")
assert res.status_code == 200
assert res.text == f'"{ValueStorage.game_id}"'
@@ -462,98 +390,334 @@ class TestCache:
"/api/v1/users/token/cookie", data={"username": test_user_email, "password": test_user_password}
)
token = resp.cookies["access_token"]
resp = test_client.get("/api/v1/users/me", cookies={"access_token": token})
resp = test_client.get("/api/v1/users/me", cookies=ValueStorage.cookies)
user = await get_user_from_id(resp.json()["id"])
"""
class TestEditor:
class TestCommunity:
@pytest.mark.asyncio
async def test_start(self, test_client): # noqa : F811
resp = test_client.post(
"/api/v1/users/token/cookie", data={"username": test_user_email, "password": test_user_password}
)
token = resp.cookies["access_token"]
resp = test_client.post("/api/v1/editor/start?edit=false", cookies={"access_token": token})
async def test_get_user_by_id(self, test_client: TestClient): # noqa : F811
user = test_client.get("/api/v1/users/me", cookies=ValueStorage.cookies)
user_id = user.json()["id"]
resp = test_client.get(f"/api/v1/community/user/{user_id}")
assert resp.status_code == 200
edit_id = resp.json()["token"]
resp = test_client.get(f"/api/v1/editor/pow?edit_id={edit_id}")
assert resp.status_code == 200
pow_data = resp.json()["data"]
resp = test_client.get("/api/v1/editor/pow?edit_id=loladdfs")
assert resp.status_code == 401
pow_res = mint(pow_data, 8, None, "", 8, False)
print("POW-Res", pow_res)
async with AsyncClient() as ac:
resp = await ac.get("https://i.imgur.com/OE22DNZ.png")
image_bytes = resp.read()
resp = test_client.post(
f"/api/v1/editor/image?edit_id={edit_id}&pow_data={pow_res}", files={"file": image_bytes}
)
assert resp.status_code == 200
ValueStorage.edit_id = edit_id
ValueStorage.image_id = resp.json()["id"]
resp = test_client.get("/api/v1/community/user/e673c9ca-0cdf-4ebf-bad2-7d009ef5c62b")
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_finish(self, test_client): # noqa : F811
local_example_quiz = example_quiz
local_example_quiz["questions"][1][
"image"
] = f"http://localhost:8080/api/v1/storage/download/{ValueStorage.image_id}"
resp = test_client.post(f"/api/v1/editor/finish?edit_id={ValueStorage.edit_id}", json=example_quiz)
async def test_get_quizzes_from_user(self, test_client: TestClient): # noqa : F811
user = test_client.get("/api/v1/users/me", cookies=ValueStorage.cookies)
user_id = user.json()["id"]
resp = test_client.get(f"/api/v1/community/quizzes/{user_id}")
data = resp.json()
assert type(data) is list
class TestSitemap:
@pytest.mark.asyncio
async def test_get_sitemap(self, test_client: TestClient): # noqa : F811
resp = test_client.get("/api/v1/sitemap/get")
assert resp.status_code == 200
resp = test_client.get("/api/v1/sitemap/get")
assert resp.status_code == 200
class TestStorage:
@pytest.mark.asyncio
async def test_upload_file(self, test_client: TestClient): # noqa : F811
resp = test_client.post(
"/api/v1/storage/", cookies=ValueStorage.cookies, files={"file": ("img.svg", "svg_content")}
)
assert resp.status_code == 200
data = resp.json()
ValueStorage.file_id = data["id"]
@pytest.mark.asyncio
async def test_upload_raw_file(self, test_client: TestClient): # noqa : F811
resp = test_client.request(
"POST",
"/api/v1/storage/raw",
data=b"data!",
headers={"Content-Type": "image/svg+xml"},
cookies=ValueStorage.cookies,
)
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_get_file_info(self, test_client: TestClient): # noqa : F811
resp = test_client.get("/api/v1/storage/meta/dsadsaasdas", cookies=ValueStorage.cookies)
assert resp.status_code == 422
resp = test_client.get(
"/api/v1/storage/meta/35c4f635-906a-46d7-8ab8-0520105ffff5", cookies=ValueStorage.cookies
)
assert resp.status_code == 404
resp = test_client.get(f"/api/v1/storage/meta/{ValueStorage.file_id}", cookies=ValueStorage.cookies)
data = resp.json()
assert data["size"] == 0
assert data["imported"] is False
assert data["alt_text"] is None
assert data["filename"] is None
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_update_image_data(self, test_client: TestClient): # noqa : F811
resp = test_client.put(
f"/api/v1/storage/meta/{ValueStorage.file_id}",
json={"alt_text": "Alt", "filename": "Filename"},
cookies=ValueStorage.cookies,
)
assert resp.status_code == 200
resp = test_client.put(
f"/api/v1/storage/meta/{ValueStorage.file_id}",
json={"alt_text": "", "filename": ""},
cookies=ValueStorage.cookies,
)
assert resp.status_code == 200
resp = test_client.get(f"/api/v1/storage/meta/{ValueStorage.file_id}", cookies=ValueStorage.cookies)
data = resp.json()
assert data["alt_text"] is None
assert data["filename"] is None
@pytest.mark.asyncio
async def test_list_images(self, test_client: TestClient): # noqa : F811
resp = test_client.get("/api/v1/storage/list", cookies=ValueStorage.cookies)
assert resp.status_code == 200
data = resp.json()
assert type(data) is list
assert len(data) >= 2
@pytest.mark.asyncio
async def test_get_latest_images(self, test_client: TestClient): # noqa : F811
resp = test_client.get("/api/v1/storage/list/last", cookies=ValueStorage.cookies)
assert resp.status_code == 200
data = resp.json()
assert type(data) is list
assert len(data) >= 2
resp = test_client.get("/api/v1/storage/list/last?count=1", cookies=ValueStorage.cookies)
assert resp.status_code == 200
# assert len(data) == 1
@pytest.mark.asyncio
async def test_get_storage_limit(self, test_client: TestClient): # noqa : F811
resp = test_client.get("/api/v1/storage/limit", cookies=ValueStorage.cookies)
assert resp.status_code == 200
data = resp.json()
assert data["limit_reached"] is False
assert type(data["limit"]) is int
assert data["used"] == 0
class TestQuizivity:
@pytest.mark.asyncio
async def test_create_quiztivity(self, test_client: TestClient): # noqa : F811
resp = test_client.post("/api/v1/quiztivity/create", cookies=ValueStorage.cookies, json=example_quiztivity)
assert resp.status_code == 200
data = resp.json()
ValueStorage.quiztivity_id = data["id"]
@pytest.mark.asyncio
async def test_get_quiztivity(self, test_client: TestClient): # noqa : F811
resp = test_client.get("/api/v1/quiztivity/8bd77201-65ed-46fe-9160-cfe71dad501f", cookies=ValueStorage.cookies)
assert resp.status_code == 404
resp = test_client.get(f"/api/v1/quiztivity/{ValueStorage.quiztivity_id}", cookies=ValueStorage.cookies)
assert resp.status_code == 200
data = resp.json()
assert data["id"] == ValueStorage.quiztivity_id
@pytest.mark.asyncio
async def test_put_quiztivity(self, test_client: TestClient): # noqa : F811
example_quiztivity["title"] = "New title"
resp = test_client.put(
f"/api/v1/quiztivity/{ValueStorage.quiztivity_id}", json=example_quiztivity, cookies=ValueStorage.cookies
)
assert resp.status_code == 200
resp = test_client.put(
"/api/v1/quiztivity/8bd77201-65ed-46fe-9160-cfe71dad501f",
json=example_quiztivity,
cookies=ValueStorage.cookies,
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_get_all_quiztivities(self, test_client: TestClient): # noqa : F811
resp = test_client.get("/api/v1/quiztivity/", cookies=ValueStorage.cookies)
assert resp.status_code == 200
data = resp.json()
assert type(data) is list
assert data[0]["id"] == ValueStorage.quiztivity_id
@pytest.mark.asyncio
async def test_create_share(self, test_client: TestClient): # noqa : F811
resp = test_client.post(
"/api/v1/quiztivity/shares/",
cookies=ValueStorage.cookies,
json={"quiztivity": ValueStorage.quiztivity_id, "expire_in": None},
)
assert resp.status_code == 200
data = resp.json()
ValueStorage.share_id = data["id"]
resp = test_client.post(
"/api/v1/quiztivity/shares/",
cookies=ValueStorage.cookies,
json={"quiztivity": "a090077f-9059-42bc-9783-f2cd01e069b8", "expire_in": None},
)
assert resp.status_code == 400
resp = test_client.post(
"/api/v1/quiztivity/shares/",
cookies=ValueStorage.cookies,
json={"quiztivity": ValueStorage.quiztivity_id, "expire_in": 0},
)
data = resp.json()
ValueStorage.expired_share_id = data["id"]
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_get_share(self, test_client: TestClient): # noqa : F811
resp = test_client.get(
"/api/v1/quiztivity/shares/8bd77201-65ed-46fe-9160-cfe71dad501f", cookies=ValueStorage.cookies
)
assert resp.status_code == 404
resp = test_client.get(f"/api/v1/quiztivity/shares/{ValueStorage.share_id}", cookies=ValueStorage.cookies)
assert resp.status_code == 200
data = resp.json()
assert data["id"] == ValueStorage.quiztivity_id
resp = test_client.get(
f"/api/v1/quiztivity/shares/{ValueStorage.expired_share_id}", cookies=ValueStorage.cookies
)
assert resp.status_code == 410
@pytest.mark.asyncio
async def test_update_share(self, test_client: TestClient): # noqa : F811
resp = test_client.put(
"/api/v1/quiztivity/shares/8bd77201-65ed-46fe-9160-cfe71dad501f",
cookies=ValueStorage.cookies,
json={"expire_in": 50},
)
assert resp.status_code == 404
resp = test_client.put(
f"/api/v1/quiztivity/shares/{ValueStorage.share_id}", cookies=ValueStorage.cookies, json={"expire_in": 50}
)
assert resp.status_code == 200
async def test_delete_share(self, test_client: TestClient): # noqa : F811
resp = test_client.delete(
"/api/v1/quiztivity/shares/8bd77201-65ed-46fe-9160-cfe71dad501f", cookies=ValueStorage.cookies
)
assert resp.status_code == 404
resp = test_client.delete(
f"/api/v1/quiztivity/shares/{ValueStorage.expired_share_id}", cookies=ValueStorage.cookies
)
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_get_shares(self, test_client: TestClient): # noqa : F811
resp = test_client.get("/api/v1/quiztivity/shares/", cookies=ValueStorage.cookies)
assert resp.status_code == 200
data = resp.json()
assert type(data) is list
assert data[0]["id"] == ValueStorage.share_id
assert data[0]["expire_in"] == 49
@pytest.mark.asyncio
async def test_get_shares_by_quiztivity(self, test_client: TestClient): # noqa : F811
resp = test_client.get(f"/api/v1/quiztivity/{ValueStorage.quiztivity_id}/shares", cookies=ValueStorage.cookies)
assert resp.status_code == 200
data = resp.json()
assert type(data) is list
assert data[0]["id"] == ValueStorage.share_id
@pytest.mark.asyncio
async def test_get_shares(self, test_client: TestClient): # noqa : F811
resp = test_client.get("/api/v1/quiztivity/shares/", cookies=ValueStorage.cookies)
assert resp.status_code == 200
data = resp.json()
assert type(data) is list
assert data[0]["id"] == ValueStorage.share_id
@pytest.mark.asyncio
async def test_delete_quiztivity(self, test_client: TestClient): # noqa : F811
test_client.delete(f"/api/v1/quiztivity/shares/{ValueStorage.share_id}", cookies=ValueStorage.cookies)
resp = test_client.delete(
"/api/v1/quiztivity/8bd77201-65ed-46fe-9160-cfe71dad501f", cookies=ValueStorage.cookies
)
assert resp.status_code == 404
resp = test_client.delete(f"/api/v1/quiztivity/{ValueStorage.quiztivity_id}", cookies=ValueStorage.cookies)
assert resp.status_code == 200
class TestAvatar:
@pytest.mark.asyncio
async def test_get_customized_avatar(self, test_client: TestClient): # noqa : F811
resp = test_client.get("/api/v1/avatar/custom?skin_color=69", cookies=ValueStorage.cookies)
assert resp.status_code == 400
resp = test_client.get("/api/v1/avatar/custom", cookies=ValueStorage.cookies)
assert resp.status_code == 200
assert "image/svg+xml" in resp.headers.get("Content-Type")
@pytest.mark.asyncio
async def test_save_avatar(self, test_client: TestClient): # noqa : F811
resp = test_client.post("/api/v1/avatar/save?skin_color=69", cookies=ValueStorage.cookies)
assert resp.status_code == 400
resp = test_client.post("/api/v1/avatar/save", cookies=ValueStorage.cookies)
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_get_own_avatar(self, test_client: TestClient): # noqa : F811
resp = test_client.get("/api/v1/users/avatar", cookies=ValueStorage.cookies)
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_get_other_avatar(self, test_client: TestClient): # noqa : F811
resp = test_client.get(
"/api/v1/users/8bd77201-65ed-46fe-9160-cfe71dad501f/avatar", cookies=ValueStorage.cookies
)
assert resp.status_code == 404
user_id = test_client.get("/api/v1/users/me", cookies=ValueStorage.cookies).json()["id"]
resp = test_client.get(f"/api/v1/users/avatar/{user_id}", cookies=ValueStorage.cookies)
assert resp.status_code == 200
class TestExImport:
@pytest.mark.asyncio
async def test_export_quiz(self, test_client): # noqa : F811
resp = test_client.get("/api/v1/eximport/jgfgufgfgfzftzi")
async def test_export_quiz(self, test_client: TestClient): # noqa : F811
resp = test_client.get("/api/v1/eximport/jgfgufgfgfzftzi", cookies=ValueStorage.cookies)
assert resp.status_code == 422
resp = test_client.get("/api/v1/eximport/8bd77201-65ed-46fe-9160-cfe71dad501f")
resp = test_client.get("/api/v1/eximport/8bd77201-65ed-46fe-9160-cfe71dad501f", cookies=ValueStorage.cookies)
assert resp.status_code == 404
resp = test_client.get(f"/api/v1/eximport/{ValueStorage.quiz_id}")
resp = test_client.get(f"/api/v1/eximport/{ValueStorage.quiz_id}", cookies=ValueStorage.cookies)
assert resp.status_code == 200
exported_data = resp.content
assert len(exported_data) > 3000
assert len(exported_data) < 3000
ValueStorage.exported_quiz_data = exported_data
@pytest.mark.asyncio
async def test_import_quiz(self, test_client): # noqa : F811
resp = test_client.post("/api/v1/eximport/", files={"file": ValueStorage.exported_quiz_data})
async def test_import_quiz(self, test_client: TestClient): # noqa : F811
resp = test_client.post(
"/api/v1/eximport/", files={"file": ValueStorage.exported_quiz_data}, cookies=ValueStorage.cookies
)
assert resp.status_code == 200
class TestDeleteStuff:
@pytest.mark.asyncio
async def test_delete_quiz(self, test_client): # noqa : F811
resp = test_client.post(
"/api/v1/users/token/cookie", data={"username": test_user_email, "password": test_user_password}
)
token = resp.cookies["access_token"]
async def test_delete_quiz(self, test_client: TestClient): # noqa : F811
resp = test_client.delete(
f"/api/v1/quiz/delete/{ValueStorage.imported_quizzes[0]}", cookies={"access_token": token}
f"/api/v1/quiz/delete/{ValueStorage.imported_quizzes[0]}", cookies=ValueStorage.cookies
)
assert resp.status_code == 200
resp = test_client.delete(
"/api/v1/quiz/delete/be582c77-da03-4271-929c-5d582056eb78", cookies={"access_token": token}
"/api/v1/quiz/delete/be582c77-da03-4271-929c-5d582056eb78", cookies=ValueStorage.cookies
)
assert resp.status_code == 404
resp = test_client.delete(
"/api/v1/quiz/delete/be582c77-da03-sdaasdadsasddas4271-929c-5d582056eb78", cookies={"access_token": token}
"/api/v1/quiz/delete/be582c77-da03-sdaasdadsasddas4271-929c-5d582056eb78", cookies=ValueStorage.cookies
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_delete_user(self, test_client): # noqa : F811
resp = test_client.post(
"/api/v1/users/token/cookie", data={"username": test_user_email, "password": test_user_password}
)
token = resp.cookies["access_token"]
data = {"password": test_user_password}
resp = test_client.delete("/api/v1/users/me", cookies={"access_token": token}, json=data)
assert resp.status_code == 200
# @pytest.mark.asyncio
# async def test_delete_user(self, test_client: TestClient): # noqa : F811
# data = {"password": test_user_password}
# resp = test_client.delete("/api/v1/users/me", cookies=ValueStorage.cookies, json=data)
# assert resp.status_code == 200
+32 -22
View File
@@ -1,6 +1,7 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
import io
import pytest
@@ -16,41 +17,50 @@ def test_storage_init():
with pytest.raises(NotImplementedError):
Storage(
backend="asdsad",
deta_key=settings.deta_project_key,
deta_id=settings.deta_project_id,
storage_path=settings.storage_path,
)
with pytest.raises(ValueError):
Storage(backend="deta", deta_key=None, deta_id=None, storage_path=None)
Storage(backend="s3", base_url=None, secret_key=None, access_key=None, storage_path=None)
with pytest.raises(ValueError):
Storage(backend="local", storage_path=None, deta_key=None, deta_id=None)
Storage(backend="local", storage_path=None)
async def storage_tester(storage: Storage):
res = await storage.upload(file_name="test.txt", file_data=file_contents)
res = await storage.upload(file_name="test.txt", file_data=io.BytesIO(initial_bytes=file_contents))
assert res is None
res = await storage.download(file_name="test.txt")
assert res.read() == file_contents
res = await storage.delete(file_names=["test.txt"])
assert res is None
res = await storage.download(file_name="test.txt")
res = storage.download(file_name="test.txt")
async for chunk in res:
assert bytes(chunk) == file_contents
res = await storage.get_file_size(file_name="test.txt")
assert res == len(file_contents)
res = await storage.get_file_size(file_name="asdsadasdasdadfdsf.txt")
assert res is None
res = await storage.delete(file_names=["test.txt"])
assert res is None
@pytest.mark.asyncio
async def test_deta():
storage: Storage = Storage(
backend="deta",
deta_key=settings.deta_project_key,
deta_id=settings.deta_project_id,
storage_path=settings.storage_path,
)
await storage_tester(storage)
res = storage.download(file_name="test.txt")
async for chunk in res:
assert chunk is None
res = await storage.delete(file_names=["test.txt"])
assert res is None
@pytest.mark.asyncio
async def test_local():
storage: Storage = Storage(backend="local", storage_path=settings.storage_path, deta_key=None, deta_id=None)
storage: Storage = Storage(backend="local", storage_path=settings.storage_path)
await storage_tester(storage)
@pytest.mark.asyncio
async def test_minio():
storage: Storage = Storage(
backend="s3",
access_key="Q3AM3UQ867SPQQA43P2F",
secret_key="zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG",
bucket_name="classquiz",
base_url="https://play.min.io",
storage_path=None,
)
await storage_tester(storage)
await storage.upload(file_name="test.txt", file_data=io.BytesIO(initial_bytes=file_contents))
url = await storage.get_url(file_name="test.txt", expiry=20)
assert url is not None
+28
View File
@@ -0,0 +1,28 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from arq import cron
from arq.connections import RedisSettings
from classquiz import settings
from classquiz.db import database
from classquiz.worker.storage import clean_editor_images_up, calculate_hash, quiz_update
async def startup(ctx):
ctx["db"] = database
if not ctx["db"].is_connected:
await ctx["db"].connect()
async def shutdown(ctx):
if ctx["db"].is_connected:
await ctx["db"].disconnect()
class WorkerSettings:
functions = [calculate_hash, quiz_update]
cron_jobs = [cron(clean_editor_images_up, hour={0, 6, 12, 18}, minute=0)]
on_startup = startup
on_shutdown = shutdown
redis_settings = RedisSettings.from_dsn(settings.redis)
+108
View File
@@ -0,0 +1,108 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
import uuid
import ormar.exceptions
from arq.worker import Retry
import xxhash
from classquiz.config import redis, storage
from tempfile import SpooledTemporaryFile
from classquiz.db.models import StorageItem, Quiz, User
from classquiz.helpers import extract_image_ids_from_quiz
from classquiz.storage.errors import DeletionFailedError
from thumbhash import image_to_thumbhash
# skipcq: PYL-W0613
async def clean_editor_images_up(ctx):
print("Cleaning images up")
edit_sessions = await redis.smembers("edit_sessions")
for session_id in edit_sessions:
session = await redis.get(f"edit_session:{session_id}")
if session is None:
images = await redis.lrange(f"edit_session:{session_id}:images", 0, 3000)
if len(images) != 0:
try:
await storage.delete(images)
except DeletionFailedError:
print("Deletion Error", images)
await redis.srem("edit_sessions", session_id)
await redis.delete(f"edit_session:{session_id}:images")
async def calculate_hash(ctx, file_id_as_str: str):
file_id = uuid.UUID(file_id_as_str)
file_data: StorageItem = await StorageItem.objects.select_related(StorageItem.user).get(id=file_id)
file_path = file_id.hex
if file_data.storage_path is not None:
file_path = file_data.storage_path
file = SpooledTemporaryFile()
file_data.size = await storage.get_file_size(file_name=file_path)
if file_data.size is None:
file_data.size = 0
file_bytes = storage.download(file_path)
if file_bytes is None:
print("Retry raised!")
raise Retry(defer=ctx["job_try"] * 10)
async for chunk in file_bytes:
file.write(chunk)
try:
if 0 < file_data.size < 20_970_000: # greater than 0 but smaller than 20mbytes
file_data.thumbhash = image_to_thumbhash(file)
# skipcq: PYL-W0703
except Exception:
pass
hash_obj = xxhash.xxh3_128()
# skipcq: PY-W0069
# assert hash_obj.block_size == 64
while chunk := file.read(6400):
hash_obj.update(chunk)
file_data.hash = hash_obj.digest()
await file_data.update()
file.close()
user: User | None = await User.objects.get_or_none(id=file_data.user.id)
if user is None:
return
user.storage_used += file_data.size
await user.update()
# skipcq: PYL-W0613
async def quiz_update(ctx, old_quiz: Quiz, quiz_id: uuid.UUID):
new_quiz: Quiz = await Quiz.objects.get(id=quiz_id)
old_images = extract_image_ids_from_quiz(old_quiz)
new_images = extract_image_ids_from_quiz(new_quiz)
# If images are identical, then return
if sorted(old_images) == sorted(new_images):
print("Nothing's changed")
return
print("Change detected")
removed_images = list(set(old_images) - set(new_images))
added_images = list(set(new_images) - set(old_images))
change_made = False
for image in removed_images:
if "--" in image:
await storage.delete([image])
else:
item = await StorageItem.objects.get_or_none(id=uuid.UUID(image))
if item is None:
continue
try:
await new_quiz.storageitems.remove(item)
except ormar.exceptions.NoMatch:
continue
change_made = True
for image in added_images:
if "--" not in image:
item = await StorageItem.objects.get_or_none(id=uuid.UUID(image))
if item is None:
continue
await new_quiz.storageitems.add(item)
change_made = True
if change_made:
await new_quiz.update()
+15 -4
View File
@@ -13,16 +13,17 @@ services:
REDIS_URL: redis://redis:6379/0?decode_responses=True
API_URL: http://api:80
api:
build:
build: &build_cfg
context: .
dockerfile: Dockerfile
restart: always
depends_on:
restart: &restart always
depends_on: &depends
- db
- redis
environment:
environment: &env_vars
DB_URL: "postgresql://postgres:classquiz@db:5432/classquiz"
REDIS: "redis://redis:6379/0?decode_responses=True"
MAIL_ADDRESS: "email@email@email.email"
MAIL_PASSWORD: "PASSWORT"
MAIL_USERNAME: "email@email@email.email"
@@ -34,6 +35,8 @@ services:
SKIP_EMAIL_VERIFICATION: True
HCAPTCHA_KEY: "HCAPTCHA_PRIVATE_KEY"
MEILISEARCH_URL: "http://meilisearch:7700"
STORAGE_BACKEND: "local"
STORAGE_PATH: "/app/data"
redis:
image: redis:alpine
restart: always
@@ -67,6 +70,14 @@ services:
MEILI_NO_ANALYTICS: true
volumes:
- meilisearch-data:/data.ms
worker:
build: *build_cfg
environment: *env_vars
depends_on: *depends
restart: *restart
command: arq classquiz.worker.WorkerSettings
volumes:
data:
meilisearch-data:
+1
View File
@@ -1 +1,2 @@
engine-strict=true
strict-peer-dependencies=false
+2 -2
View File
@@ -22,7 +22,7 @@ COPY package*.json ./
COPY pnpm-lock.yaml ./
# run npm install in our local machine
RUN corepack enable && corepack prepare pnpm@7.26.2 --activate && pnpm i
RUN corepack enable && corepack prepare pnpm@8.6.1 --activate && pnpm i
# copy the generated modules and all other files to the container
COPY . .
@@ -38,7 +38,7 @@ FROM node:19-bullseye-slim
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@7.26.2 --activate && pnpm i
RUN corepack enable && corepack prepare pnpm@8.6.1 --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
+62 -52
View File
@@ -8,55 +8,61 @@
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. . '!src/lib/i18n/locales/*.json' && eslint --ignore-path .gitignore .",
"lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. . '!src/lib/i18n/locales/*.json' '!pnpm-lock.yaml' && eslint --ignore-path .gitignore .",
"format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. . '!src/lib/i18n/locales/*.json'",
"run:prod": "node index.js",
"translations-scan": "i18next-scanner --config i18next-scanner.config.engine.cjs src/**/*.svelte"
},
"devDependencies": {
"@beyonk/svelte-mapbox": "^8.2.0",
"@ckeditor/ckeditor5-autoformat": "^35.4.0",
"@ckeditor/ckeditor5-basic-styles": "^35.4.0",
"@ckeditor/ckeditor5-build-balloon": "^35.4.0",
"@ckeditor/ckeditor5-editor-balloon": "^35.4.0",
"@ckeditor/ckeditor5-essentials": "^35.4.0",
"@ckeditor/ckeditor5-theme-lark": "^35.4.0",
"@beyonk/svelte-mapbox": "^9.0.5",
"@ckeditor/ckeditor5-autoformat": "^37.1.0",
"@ckeditor/ckeditor5-basic-styles": "^37.1.0",
"@ckeditor/ckeditor5-build-balloon": "^37.1.0",
"@ckeditor/ckeditor5-editor-balloon": "^37.1.0",
"@ckeditor/ckeditor5-essentials": "^37.1.0",
"@ckeditor/ckeditor5-theme-lark": "^37.1.0",
"@felte/reporter-tippy": "^1.1.5",
"@felte/validator-yup": "^1.0.11",
"@ffmpeg/core": "^0.11.0",
"@ffmpeg/ffmpeg": "^0.11.6",
"@fontsource/marck-script": "^4.5.11",
"@sentry/browser": "^7.34.0",
"@sentry/tracing": "^7.34.0",
"@simplewebauthn/browser": "^6.2.2",
"@sveltejs/adapter-auto": "^1.0.2",
"@sveltejs/adapter-node": "^1.1.4",
"@sveltejs/kit": "^1.3.2",
"@sentry/browser": "^7.48.0",
"@sentry/tracing": "^7.48.0",
"@simplewebauthn/browser": "^7.2.0",
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/adapter-node": "^1.2.3",
"@sveltejs/kit": "^1.15.7",
"@tailwindcss/typography": "^0.5.9",
"@types/canvas-confetti": "^1.6.0",
"@types/cookie": "^0.5.1",
"@types/js-cookie": "^3.0.2",
"@types/luxon": "^2.4.0",
"@types/dompurify": "^3.0.2",
"@types/js-cookie": "^3.0.3",
"@types/luxon": "^3.3.0",
"@types/marked": "^4.3.0",
"@types/qrcode": "^1.5.0",
"@types/sortablejs": "^1.15.0",
"@types/sortablejs": "^1.15.1",
"@types/ua-parser-js": "^0.7.36",
"@typescript-eslint/eslint-plugin": "^5.49.0",
"@typescript-eslint/parser": "^5.49.0",
"@uppy/compressor": "^1.0.1",
"@uppy/core": "^3.0.5",
"@uppy/dashboard": "^3.2.1",
"@uppy/drag-drop": "^3.0.1",
"@typescript-eslint/eslint-plugin": "^5.59.0",
"@typescript-eslint/parser": "^5.59.0",
"@unlazy/svelte": "^0.8.9",
"@uppy/compressor": "^1.0.2",
"@uppy/core": "^3.2.0",
"@uppy/dashboard": "^3.4.0",
"@uppy/drag-drop": "^3.0.2",
"@uppy/drop-target": "^2.0.1",
"@uppy/image-editor": "^2.1.0",
"@uppy/progress-bar": "^3.0.1",
"@uppy/status-bar": "^3.0.1",
"@uppy/svelte": "^3.0.1",
"@uppy/xhr-upload": "^3.0.4",
"autoprefixer": "^10.4.13",
"@uppy/image-editor": "^2.1.2",
"@uppy/progress-bar": "^3.0.2",
"@uppy/status-bar": "^3.1.1",
"@uppy/svelte": "^3.0.2",
"@uppy/xhr-upload": "^3.2.0",
"autoprefixer": "^10.4.14",
"canvas-confetti": "^1.6.0",
"cookie": "^0.5.0",
"crypto-js": "^4.1.1",
"cssnano": "^5.1.14",
"eslint": "^8.33.0",
"eslint-config-prettier": "^8.6.0",
"cssnano": "^6.0.0",
"dompurify": "^3.0.3",
"eslint": "^8.38.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-svelte3": "^4.0.0",
"felte": "^1.2.7",
"fuse.js": "^6.6.2",
@@ -64,37 +70,41 @@
"i18next-browser-languagedetector": "^7.0.1",
"js-cookie": "^3.0.1",
"jws": "^4.0.0",
"luxon": "^3.2.1",
"mapbox-gl": "^2.12.0",
"luxon": "^3.3.0",
"mapbox-gl": "^2.14.1",
"marked": "^5.0.0",
"mdsvex": "^0.10.6",
"minisearch": "^5.1.0",
"pikaso": "^2.7.4",
"minisearch": "^6.0.1",
"pikaso": "^2.7.6",
"plausible-tracker": "^0.3.8",
"postcss": "^8.4.21",
"postcss-import": "^14.1.0",
"postcss": "^8.4.22",
"postcss-import": "^15.1.0",
"postcss-load-config": "^4.0.1",
"prettier": "^2.8.3",
"prettier-plugin-svelte": "^2.9.0",
"prettier": "^2.8.7",
"prettier-plugin-svelte": "^2.10.0",
"qrcode": "^1.5.1",
"sass": "^1.57.1",
"socket.io-client": "^4.5.4",
"svelte": "^3.55.1",
"svelte-check": "^3.0.3",
"svelte-preprocess": "^5.0.1",
"sass": "^1.62.0",
"socket.io-client": "^4.6.1",
"svelte": "^3.58.0",
"svelte-check": "^3.2.0",
"svelte-preprocess": "^5.0.3",
"svelte-range-slider-pips": "^2.1.1",
"svelte-tippy": "^1.3.2",
"swiper": "^8.4.6",
"tailwindcss": "^3.2.4",
"swiper": "^8.4.7",
"tailwindcss": "^3.3.1",
"thumbhash": "^0.1.1",
"tinykeys": "^2.1.0",
"tippy.js": "^6.3.7",
"tslib": "^2.5.0",
"typescript": "~4.7.4",
"ua-parser-js": "^1.0.33",
"vite": "^4.0.4",
"typescript": "~5.0.4",
"ua-parser-js": "^1.0.35",
"vite": "^4.2.2",
"vite-plugin-cross-origin-isolation": "^0.1.6",
"vite-plugin-iso-import": "^1.0.0",
"yup": "^0.32.11"
"yup": "^1.1.1"
},
"type": "module",
"dependencies": {
"i18next": "^22.4.9"
"i18next": "^22.4.15"
}
}
+1536 -1226
View File
File diff suppressed because it is too large Load Diff
+56 -31
View File
@@ -13,6 +13,8 @@
import { kahoot_icons } from './play/kahoot_mode_assets/kahoot_icons';
import CircularTimer from '$lib/play/circular_progress.svelte';
import Spinner from '$lib/Spinner.svelte';
import { get_foreground_color } from '$lib/helpers';
import MediaComponent from '$lib/editor/MediaComponent.svelte';
export let game_token: string;
export let quiz_data: QuizData;
@@ -20,6 +22,7 @@
export let bg_color;
const { t } = getLocalization();
const default_colors = ['#D6EDC9', '#B07156', '#7F7057', '#4E6E58'];
let question_results = null;
export let final_results: Array<null> | Array<Array<PlayerAnswer>> = [null];
@@ -28,6 +31,7 @@
let shown_question_now: number;
let final_results_clicked = false;
let timer_interval;
let answer_count = 0;
export let control_visible: boolean;
export let player_scores;
@@ -50,6 +54,7 @@
shown_question_now = data.question_index;
timer_res = quiz_data.questions[data.question_index].time;
selected_question = selected_question + 1;
answer_count = 0;
timer(timer_res);
});
const get_question_results = () => {
@@ -92,6 +97,10 @@
}
});
socket.on('player_answer', (_) => {
answer_count += 1;
});
const timer = (time: string) => {
let seconds = Number(time);
timer_interval = setInterval(() => {
@@ -132,7 +141,7 @@
{#if selected_question + 1 === quiz_data.questions.length && ((timer_res === '0' && question_results !== null) || quiz_data?.questions?.[selected_question]?.type === QuizQuestionType.SLIDE)}
{#if JSON.stringify(final_results) === JSON.stringify([null])}
<button on:click={get_final_results} class="admin-button"
>Get final results
>{$t('admin_page.get_final_results')}
</button>
{/if}
{:else if timer_res === '0' || selected_question === -1}
@@ -142,7 +151,7 @@
set_question_number(selected_question + 1);
}}
class="admin-button"
>Next Question ({selected_question + 2})
>{$t('admin_page.next_question', { question: selected_question + 2 })}
</button>
{/if}
{#if question_results === null && selected_question !== -1}
@@ -152,11 +161,11 @@
set_question_number(selected_question + 1);
}}
class="admin-button"
>Next Question ({selected_question + 2})
>{$t('admin_page.next_question', { question: selected_question + 2 })}
</button>
{:else}
<button on:click={get_question_results} class="admin-button"
>Show results
>{$t('admin_page.show_results')}
</button>
{/if}
{/if}
@@ -167,22 +176,22 @@
set_question_number(selected_question + 1);
}}
class="admin-button"
>Next Question ({selected_question + 2})
>{$t('admin_page.next_question', { question: selected_question + 2 })}
</button>
{:else}
<button on:click={show_solutions} class="admin-button"
>Stop time and show solutions
>{$t('admin_page.stop_time_and_solutions')}
</button>
{/if}
{:else}
<!-- <button
on:click={() => {
set_question_number(selected_question + 1);
}}
class='admin-button'
>Next Question ({selected_question + 2}
)
</button>-->
on:click={() => {
set_question_number(selected_question + 1);
}}
class='admin-button'
>Next Question ({selected_question + 2}
)
</button>-->
{/if}
</div>
</div>
@@ -212,37 +221,53 @@
{@html quiz_data.questions[selected_question].question}
</h1>
<!-- <span class='text-center py-2 text-lg'>{$t('admin_page.time_left')}: {timer_res}</span>-->
<div class="mx-auto my-2">
<CircularTimer
bind:text={timer_res}
bind:progress={circular_progress}
color="#ef4444"
/>
<div class="grid grid-cols-3 my-2">
<span />
<div class="m-auto">
<CircularTimer
bind:text={timer_res}
bind:progress={circular_progress}
color="#ef4444"
/>
</div>
<p class="m-auto text-3xl">
{$t('admin_page.answers_submitted', { answer_count: answer_count })}
</p>
</div>
</div>
{#if quiz_data.questions[selected_question].image !== null}
<div>
<img
<div class="flex w-full">
<MediaComponent
src={quiz_data.questions[selected_question].image}
class="max-h-[20vh] object-cover mx-auto mb-8 w-auto"
alt="Content for Question"
muted={false}
css_classes="max-h-[20vh] object-cover mx-auto mb-8 w-auto"
/>
</div>
{/if}
{#if quiz_data.questions[selected_question].type === QuizQuestionType.ABCD || quiz_data.questions[selected_question].type === QuizQuestionType.VOTING}
{#if quiz_data.questions[selected_question].type === QuizQuestionType.ABCD || quiz_data.questions[selected_question].type === QuizQuestionType.VOTING || quiz_data.questions[selected_question].type === QuizQuestionType.CHECK}
<div class="grid grid-rows-2 grid-flow-col auto-cols-auto gap-2 w-full p-4">
{#each quiz_data.questions[selected_question].answers as answer, i}
<div
class="rounded-lg h-fit flex"
style="background-color: {answer.color ?? '#B45309'}"
class="rounded-lg h-fit flex border-2 border-black"
style="background-color: {answer.color ?? default_colors[i]};"
class:opacity-50={!answer.right &&
timer_res === '0' &&
quiz_data.questions[selected_question].type ===
QuizQuestionType.ABCD}
>
<img class="w-14 inline-block pl-4" alt="icon" src={kahoot_icons[i]} />
<span class="text-center text-2xl px-2 py-4 w-full text-black"
>{answer.answer}</span
<img
class="w-14 inline-block pl-4"
alt="icon"
style="color: {get_foreground_color(
answer.color ?? default_colors[i]
)}"
src={kahoot_icons[i]}
/>
<span
class="text-center text-2xl px-2 py-4 w-full"
style="color: {get_foreground_color(
answer.color ?? default_colors[i]
)}">{answer.answer}</span
>
<span class="pl-4 w-10" />
</div>
@@ -262,7 +287,7 @@
</div>
{:else}
<div class="flex justify-center">
<p class="text-2xl">Enter your answer into the input field!</p>
<p class="text-2xl">{$t('admin_page.enter_answer_into_field')}</p>
</div>
{/if}
{/if}
@@ -308,7 +333,7 @@
<div class="h-[30vh] m-auto w-auto mt-12">
<img
class="max-h-full max-w-full block"
src={quiz_data.cover_image}
src="/api/v1/storage/download/{quiz_data.cover_image}"
alt="Not provided"
/>
</div>
@@ -18,7 +18,11 @@
<a
{href}
{target}
class="text-black hover:bg-opacity-80 w-full px-4 py-2 leading-5 text-black transition-all duration-200 transform bg-[#B07156] rounded text-center focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 outline-none"
{disabled}
class:opacity-50={disabled}
class:cursor-not-allowed={disabled}
class:pointer-events-none={disabled}
class="text-black hover:bg-opacity-80 w-full px-4 py-2 leading-5 transition-all duration-200 transform bg-[#B07156] rounded text-center outline-none"
on:click
class:flex
class:justify-center={flex}
@@ -29,7 +33,7 @@
<button
{disabled}
{type}
class="text-black hover:opacity-80 w-full px-4 py-2 leading-5 text-black transition-all duration-200 transform bg-[#B07156] rounded text-center focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 outline-none"
class="text-black hover:opacity-80 w-full px-4 py-2 leading-5 transition-all duration-200 transform bg-[#B07156] rounded text-center focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 outline-none"
on:click
class:flex
class:justify-center={flex}
@@ -0,0 +1,279 @@
<!--
- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { tinykeys } from '$lib/tinykeys';
import { fade } from 'svelte/transition';
import MiniSearch from 'minisearch';
let open = false;
let input = '';
let bg_text = '';
let title_ms: MiniSearch;
let command_ms: MiniSearch;
let selected: null | number = null;
// eslint-disable-next-line no-unused-vars
type ActionFunction = (args: string[]) => void;
const actions: {
id: number;
title: string;
description?: string;
command?: string;
args?: string[];
action: ActionFunction;
}[] = [
{
id: 0,
title: 'Close CommandPalette',
description: 'Closes CommandPalette',
command: 'close',
action: () => close_cp(undefined)
},
{
id: 1,
title: 'Create Quiz',
description: 'Opens editor to create a new quiz',
command: 'newquiz',
args: ['title'],
action: (args) => window.location.assign(`/create?title=${args.join(' ')}`)
},
{
id: 2,
title: 'Import a Quiz',
description: 'Opens the import page',
command: 'import',
args: ['url'],
action: (args) => window.location.assign(`/import?url=${args?.[0] ?? ''}`)
},
{
id: 3,
title: 'Create Quiztivity',
description: 'Opens the editor for quiztivities',
command: 'newquiztivity',
args: ['title'],
action: (args) => window.location.assign(`/quiztivity/create?title=${args.join(' ')}`)
},
{
id: 4,
title: 'View Results',
description: 'Opens the Results viewer',
command: 'results',
action: () => window.location.assign('/results')
},
{
id: 5,
title: 'Explore Quizzes',
description: 'Opens the Explore-page',
command: 'explore',
action: () => window.location.assign('/explore')
},
{
id: 6,
title: 'Dashboard',
description: 'Go to Dashboard',
command: 'dash',
action: () => window.location.assign('/dashboard')
},
{
id: 7,
title: 'Docs',
description: 'Go to documentation',
command: 'docs',
action: () => window.location.assign('/docs')
},
{
id: 8,
title: 'Settings',
description: 'Opens the Settings page',
command: 'settings',
action: () => window.location.assign('/account/settings')
}
];
let visible_items = actions;
const toggle_open = (e: KeyboardEvent | undefined) => {
e.preventDefault();
open = !open;
console.log('TOGGLE!');
};
const close_cp = (e: KeyboardEvent | undefined) => {
if (e) {
e.preventDefault();
}
open = false;
};
const close_on_outside = (e: Event) => {
if (e.target == e.currentTarget) {
open = false;
}
};
const execute_action = () => {
let args = [];
const entry = visible_items[selected];
if (input.startsWith('/')) {
const tokens = input.split(' ');
args = tokens.slice(1);
}
console.log(args);
entry.action(args);
};
const search = (term: string) => {
if (!command_ms || !title_ms) {
return;
}
if (term === '' || term === '/') {
selected = 0;
visible_items = actions;
bg_text = '';
return;
}
let suggestions;
let res;
if (term.startsWith('/')) {
term = term.substring(1);
suggestions = command_ms.autoSuggest(term, { boost: { command: 2 }, prefix: true });
res = command_ms.search(term, { boost: { command: 2 }, prefix: true });
bg_text = suggestions[0]?.suggestion;
bg_text ??= '';
bg_text = `/${bg_text}`;
} else {
suggestions = title_ms.autoSuggest(term, { boost: { command: 2 }, prefix: true });
res = title_ms.search(term, { boost: { command: 2 }, prefix: true });
bg_text = suggestions[0]?.suggestion;
bg_text ??= '';
}
visible_items = [];
console.log(res);
for (const quiz_data of res) {
visible_items.push(actions[quiz_data.id]);
}
visible_items = visible_items;
if (visible_items.length === 1) {
selected = 0;
}
if (visible_items.length === 0) {
selected = null;
}
};
const autocomplete_on_tab = (e: KeyboardEvent) => {
e.preventDefault();
input = bg_text;
};
const on_arrow_down = (e: KeyboardEvent) => {
e.preventDefault();
if (visible_items.length < 1) {
return;
}
if (selected + 1 === visible_items.length) {
return;
}
selected += 1;
};
const on_arrow_up = (e: KeyboardEvent) => {
e.preventDefault();
if (visible_items.length < 1) {
return;
}
if (selected === 0) {
return;
}
selected -= 1;
};
const on_enter = (e: KeyboardEvent) => {
e.preventDefault();
if (selected === null) {
return;
}
execute_action();
input = '';
};
$: search(input);
// $: input = lower_input(input)
$: input = input.toLowerCase();
onMount(async () => {
tinykeys(window, {
'$mod+k': toggle_open,
Escape: close_cp,
Tab: autocomplete_on_tab,
ArrowDown: on_arrow_down,
ArrowUp: on_arrow_up,
Enter: on_enter
});
title_ms = new MiniSearch<any>({
fields: ['title'],
storeFields: ['id']
});
title_ms.addAll(actions);
command_ms = new MiniSearch<any>({
fields: ['command'],
storeFields: ['id']
});
command_ms.addAll(actions);
});
</script>
{#if open}
<div
class="fixed top-0 left-0 w-screen h-screen flex bg-black bg-opacity-50 z-50"
on:click={close_on_outside}
transition:fade={{ duration: 60 }}
>
<div class="m-auto w-1/3 h-2/3 rounded bg-black flex flex-col">
<div class="grid grid-cols-1 grid-rows-1 border-b border-b-white">
<p
class="col-start-1 row-start-1 w-full p-4 outline-none bg-gray-700 rounded-t text-gray-400"
>
{bg_text}
</p>
<input
type="text"
autofocus
class="col-start-1 row-start-1 bg-transparent w-full p-4 outline-none bg-gray-700 rounded"
bind:value={input}
/>
</div>
<div class="flex flex-col p-2 gap-2 overflow-scroll">
{#each visible_items as vi, i}
<div
transition:fade|local={{ duration: 60 }}
class="p-2 transition rounded"
class:bg-[#B07156]={selected === i}
class:bg-gray-700={selected !== i}
on:mouseenter={() => (selected = i)}
on:mousedown={execute_action}
>
<div class="flex">
<h3 class="text-lg my-auto">{vi.title}</h3>
<p
class="font-mono my-auto ml-auto h-fit bg-black bg-opacity-50 rounded p-0.5"
>
/{vi.command}
{#if vi.args}
{#each vi.args as arg}
&lbrace;<span class="text-indigo-400">{arg}</span
>&rbrace;{/each}
{/if}
</p>
</div>
<p class="text-sm">{vi.description}</p>
</div>
{/each}
</div>
</div>
</div>
{/if}
@@ -0,0 +1,61 @@
<!--
- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<script lang="ts">
import { fly } from 'svelte/transition';
import { getLocalization } from '$lib/i18n';
import { PopoverTypes } from './smalltop';
const { t } = getLocalization();
export let open = false;
export let type: PopoverTypes;
export let data: undefined | { game_pin: number | string; game_id: string } = undefined;
</script>
{#if open}
<div class="fixed w-screen top-10 z-[60] flex justify-center" transition:fly={{ y: -100 }}>
<div
class="flex items-center p-4 w-full max-w-xs text-gray-500 bg-white rounded-lg shadow dark:text-gray-400 dark:bg-gray-800"
role="alert"
>
<div class="ml-3 text-sm font-normal">
{#if type === PopoverTypes.Copy}
{$t('components.popover.copied_to_clipboard')}
{:else if type === PopoverTypes.GameInLobby}A game is currently in the lobby. Click <a
class="underline"
href="/remote?game_pin={data.game_pin}&game_id={data.game_id}">here</a
> to join as a remote.
{:else}
<p>Error!!!</p>
{/if}
</div>
<button
type="button"
class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700"
data-dismiss-target="#toast-default"
aria-label="Close"
on:click={() => {
open = false;
}}
>
<span class="sr-only">{$t('words.close')}</span>
<svg
aria-hidden="true"
class="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
</div>
{/if}
@@ -0,0 +1,11 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
/* eslint-disable no-unused-vars */
export enum PopoverTypes {
Copy,
GameInLobby
}
+6 -12
View File
@@ -13,7 +13,7 @@
import { QuizQuestionType } from '$lib/quiz_types.js';
import { getLocalization } from '$lib/i18n';
import StartGamePopup from './start_game.svelte';
import { onMount } from 'svelte';
// import { onMount } from 'svelte';
import viewport from './useViewportAction.js';
import Spinner from '$lib/Spinner.svelte';
import GrayButton from '$lib/components/buttons/gray.svelte';
@@ -34,12 +34,6 @@
};
let visibleImages = Array.from(Array(quizzes.length), () => []);
const close_start_game_if_esc_is_pressed = (key: KeyboardEvent) => {
if (key.code === 'Escape') {
start_game = null;
}
};
const copy_id = (quiz_id: string) => {
navigator.clipboard.writeText(quiz_id);
copy_toast_open = true;
@@ -58,9 +52,9 @@
game_in_lobby = await res.json();
}
};
onMount(() => {
document.body.addEventListener('keydown', close_start_game_if_esc_is_pressed);
});
// onMount(() => {
// document.body.addEventListener('keydown', close_start_game_if_esc_is_pressed);
// });
get_game_in_lobby_fn();
</script>
@@ -164,7 +158,7 @@
<div class="h-[20vh] m-auto w-auto">
<img
class="max-h-full max-w-full block"
src={quiz.cover_image}
src="/api/v1/storage/download/{quiz.cover_image}"
alt="Not provided"
loading="lazy"
/>
@@ -308,7 +302,7 @@
{#if visibleImages?.[i]?.[q]}
<img
class="max-h-full max-w-full block"
src={question.image}
src="/api/v1/storage/download/{question.image}"
alt="Not provided"
/>
{/if}
+16 -17
View File
@@ -4,14 +4,16 @@
file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<script lang="ts">
import { alertModal } from '$lib/stores';
// import { alertModal } from '$lib/stores';
import { captcha_enabled } from '$lib/config';
import StartGameBackground from './start_game_background.svg';
import { fade } from 'svelte/transition';
import Spinner from '$lib/Spinner.svelte';
import { onMount } from 'svelte';
import { createTippy } from 'svelte-tippy';
import { getLocalization } from '$lib/i18n';
const { t } = getLocalization();
export let quiz_id;
let captcha_selected = false;
let selected_game_mode = 'kahoot';
@@ -52,14 +54,16 @@
);
}
if (res.status !== 200) {
alertModal.set({
/* alertModal.set({
open: true,
title: 'Start failed',
body: `Failed to start game, ${await res.text()}`
});
alertModal.subscribe((_) => {
});*/
/*alertModal.subscribe((_) => {
window.location.assign('/account/login?returnTo=/dashboard');
});
});*/
alert('Starting game failed');
window.location.assign('/account/login?returnTo=/dashboard');
} else {
const data = await res.json();
// eslint-disable-next-line no-undef
@@ -103,9 +107,7 @@
{#if captcha_selected}
<div class="flex justify-center mt-2" in:fade>
<p class="w-1/3">
If enabled, Google's ReCaptcha will load in the browser of players. Only enable
if you really need it, since you need the consent of <b>EVERY</b> player to load
the captcha.
{$t('start_game.captcha_message')}
</p>
<!-- Todo: Add translation -->
</div>
@@ -119,11 +121,9 @@
selected_game_mode = 'kahoot';
}}
>
<h2 class="text-center text-2xl">Normal</h2>
<h2 class="text-center text-2xl">{$t('words.normal')}</h2>
<p>
Question and answer will only be shown on admins screen, like Kahoot!. The
players will only have colored buttons with symbols matching these on the screen
of the admin.
{$t('start_game.normal_mode_description')}
</p>
</div>
<div
@@ -133,15 +133,14 @@
selected_game_mode = 'normal';
}}
>
<h2 class="text-center text-2xl">Old-School</h2>
<h2 class="text-center text-2xl">{$t('start_game.old_school_mode')}</h2>
<p>
Questions and images will be shown on both admins screen and on the screen of
the players
{$t('start_game.old_school_mode_description')}
</p>
</div>
</div>
<div class="flex justify-center items-center my-auto">
<label class="mr-4">Custom Field</label>
<label class="mr-4">{$t('result_page.custom_field')}</label>
<input
bind:value={custom_field}
class="rounded-lg p-2 outline-none placeholder:italic"
@@ -183,7 +182,7 @@
{#if loading}
<Spinner my_20={false} />
{:else}
Start Game
{$t('start_game.start_game')}
{/if}
</button>
</div>
@@ -4,6 +4,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
// Stolen from https://svelte.dev/repl/c6a402704224403f96a3db56c2f48dfc?version=3.55.0
// skipcq: JS-0119
let intersectionObserver;
function ensureIntersectionObserver() {
+10 -36
View File
@@ -4,13 +4,16 @@
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<script lang="ts">
import { mint } from '$lib/hashcash';
// import { mint } from '$lib/hashcash';
import { dataSchema } from '$lib/yupSchemas';
import type { EditorData, Question } from './quiz_types';
import Sidebar from '$lib/editor/sidebar.svelte';
import SettingsCard from '$lib/editor/settings-card.svelte';
import QuizCard from '$lib/editor/card.svelte';
import Spinner from './Spinner.svelte';
import { getLocalization } from '$lib/i18n';
const { t } = getLocalization();
let schemaInvalid = false;
let yupErrorMessage = '';
@@ -19,22 +22,6 @@
export let quiz_id: string | null;
let selected_question = -1;
let imgur_links_valid = false;
let pow_salt;
const computePOW = async (salt: string) => {
if (pow_salt === undefined) {
return;
}
console.log('Computing POW');
pow_data = await mint(salt, 16, '', 8, false);
pow_salt = undefined;
return;
};
$: {
pow_salt;
computePOW(pow_salt);
}
const validateInput = async (data: EditorData) => {
// console.log("input", data)
@@ -69,7 +56,8 @@
$: imgur_links_valid = checkIfAllQuestionImagesComplyWithRegex(data.questions);
let edit_id;
let confirm_to_leave = true;
let pow_data;
$: console.log('data', data);
const getEditID = async () => {
let res;
@@ -85,7 +73,6 @@
if (res.status === 200) {
const json = await res.json();
edit_id = json.token;
setPOWdata();
} else {
alert('Error!');
}
@@ -120,13 +107,6 @@
alert('Error');
}
};
const setPOWdata = async () => {
const res = await fetch(`/api/v1/editor/pow?edit_id=${edit_id}`);
const data = (await res.json()).data;
console.log(data);
pow_data = await mint(data, 16);
console.log(pow_data);
};
</script>
<svelte:window on:beforeunload={confirmUnload} />
@@ -152,10 +132,10 @@
</p>
{/if}
<button
class="pr-2 align-middle bg-purple-400 pl-2 ml-auto whitespace-nowrap disabled:opacity-60 rounded-br-lg"
class="pr-2 align-middle bg-[#B07156] pl-2 ml-auto whitespace-nowrap disabled:opacity-60 rounded-br-lg"
disabled={schemaInvalid}
>
<span>Save</span>
<span>{$t('words.save')}</span>
<svg
class="w-6 h-6 inline-block"
fill="none"
@@ -174,15 +154,9 @@
</div>
<div class="w-full h-full">
{#if selected_question === -1}
<SettingsCard bind:data bind:pow_salt bind:edit_id bind:pow_data />
<SettingsCard bind:data bind:edit_id />
{:else}
<QuizCard
bind:data
bind:selected_question
bind:edit_id
bind:pow_data
bind:pow_salt
/>
<QuizCard bind:data bind:selected_question bind:edit_id />
{/if}
</div>
</div>
+27 -8
View File
@@ -4,15 +4,20 @@
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<script lang="ts">
import type { EditorData, Answer } from '../quiz_types';
import type { Answer, EditorData } from '../quiz_types';
import { QuizQuestionType } from '../quiz_types';
import { fade } from 'svelte/transition';
import { reach } from 'yup';
import { ABCDQuestionSchema } from '$lib/yupSchemas';
import { getLocalization } from '$lib/i18n';
import { get_foreground_color } from '$lib/helpers';
const { t } = getLocalization();
const default_colors = ['#D6EDC9', '#B07156', '#7F7057', '#4E6E58'];
export let selected_question: number;
export let check_choice = false;
export let data: EditorData;
if (!Array.isArray(data.questions[selected_question].answers)) {
data.questions[selected_question].answers = [];
@@ -29,17 +34,30 @@
};
const get_empty_answer = (i: number): Answer => {
const color = localStorage.getItem(`quiz_color:${i}:${data.title}`);
return {
answer: '',
color: color,
color: default_colors[i],
right: false
};
};
$: save_colors(data);
data.questions[selected_question].type =
check_choice === true ? QuizQuestionType.CHECK : QuizQuestionType.ABCD;
const set_colors_if_unset = () => {
for (let i = 0; i < data.questions[selected_question].answers.length; i++) {
if (!data.questions[selected_question].answers[i].color) {
data.questions[selected_question].answers[i].color = default_colors[i];
}
}
};
$: {
set_colors_if_unset();
data;
selected_question;
}
</script>
<div class="grid grid-cols-2 gap-4 w-full px-10">
<div class="grid grid-rows-2 grid-flow-col auto-cols-auto gap-4 w-full px-10">
{#if Array.isArray(data.questions[selected_question].answers)}
{#each data.questions[selected_question].answers as answer, index}
<div
@@ -79,14 +97,15 @@
bind:value={answer.answer}
type="text"
class="border-b-2 border-dotted w-5/6 text-center rounded-lg bg-transparent"
style="background-color: {answer.color ?? 'transparent'}"
placeholder="Empty..."
style="background-color: {answer.color}; color: {get_foreground_color(
answer.color
)}"
placeholder={$t('editor.empty')}
/>
<button
type="button"
on:click={() => {
answer.right = !answer.right;
console.log(answer.right);
}}
>
{#if answer.right}
@@ -126,7 +145,7 @@
type="color"
bind:value={answer.color}
on:contextmenu|preventDefault={() => {
answer.color = null;
answer.color = default_colors[index];
}}
/>
</div>
@@ -0,0 +1,115 @@
<!--
- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<script lang="ts">
import type { Answers, Question } from '$lib/quiz_types';
import { QuizQuestionType } from '$lib/quiz_types';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { getLocalization } from '$lib/i18n';
export let questions: Question[];
export let open: boolean;
const { t } = getLocalization();
onMount(() => {
document.body.addEventListener('keydown', close_start_game_if_esc_is_pressed);
});
const close_start_game_if_esc_is_pressed = (key: KeyboardEvent) => {
if (key.code === 'Escape') {
open = false;
}
};
const on_parent_click = (e: Event) => {
if (e.target === e.currentTarget) {
open = false;
}
};
const question_types: {
name: string;
description: string;
answers: Answers;
type: QuizQuestionType;
}[] = [
{
name: $t('words.multiple_choice'),
description: $t('editor.abcd_description'),
answers: [],
type: QuizQuestionType.ABCD
},
{
name: $t('words.voting'),
description: $t('editor.voting_description'),
answers: [],
type: QuizQuestionType.VOTING
},
{
name: $t('words.check_choice'),
description: $t('editor.check_choice_description'),
answers: [],
type: QuizQuestionType.CHECK
},
{
name: $t('words.order'),
description: $t('editor.order_description'),
answers: [],
type: QuizQuestionType.ORDER
},
{
name: $t('words.text'),
description: $t('editor.text_description'),
answers: [],
type: QuizQuestionType.TEXT
},
{
name: $t('words.range'),
description: $t('editor.range_description'),
answers: {
max: 10,
min: 0,
max_correct: 7,
min_correct: 3
},
type: QuizQuestionType.RANGE
}
];
const add_question = (index: number) => {
const empty_question: Question = {
type: question_types[index].type,
time: '20',
question: '',
image: undefined,
answers: question_types[index].answers
};
questions = [...questions, { ...empty_question }];
open = false;
};
</script>
<div
class="fixed top-0 left-0 w-screen h-screen flex bg-black z-50 bg-opacity-50"
on:click={on_parent_click}
transition:fade|local={{ duration: 100 }}
>
<div class="m-auto w-2/3 h-5/6 rounded shadow-2xl bg-white dark:bg-gray-600 p-6">
<h1 class="text-center text-3xl mb-6">{$t('quiztivity.editor.select_page_type')}</h1>
<div class="grid grid-cols-4 gap-4 overflow-y-scroll">
{#each question_types as qt, i}
<div class="rounded p-6 border-[#B07156] border">
<button
class="text-xl text-black dark:text-white"
on:click={() => {
add_question(i);
}}>{qt.name}</button
>
<p class="text-sm">{qt.description}</p>
</div>
{/each}
</div>
</div>
</div>
@@ -0,0 +1,95 @@
<!--
- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<script lang="ts">
import { browser } from '$app/environment';
import { fade } from 'svelte/transition';
export let src: string;
export let css_classes = 'max-h-64 h-auto w-auto';
export let muted = true;
export let allow_fullscreen = true;
let type: 'img' | 'video' | undefined = undefined;
let img_data;
const get_media = async () => {
if (!browser) {
return;
}
const res = await fetch(`/api/v1/storage/info/${src}`);
const fileType = res.headers.get('Content-Type');
if (fileType.includes('video')) {
type = 'video';
} else {
type = 'img';
const data = await fetch(`/api/v1/storage/download/${src}`);
img_data = {
data: URL.createObjectURL(await data.blob()),
alt_text: res.headers.get('X-Alt-Text')
};
}
};
const update_url = () => {
media = get_media();
};
let media = get_media();
$: {
src;
update_url();
}
let fullscreen_open = false;
const open_fullscreen = () => {
if (!allow_fullscreen) {
return;
}
fullscreen_open = true;
};
</script>
{#await media}
<p>Placeholder</p>
{:then data}
{#if type === 'img'}
<img
src={img_data.data}
alt={img_data.alt_text ?? 'Not available'}
class={css_classes}
on:click={() => open_fullscreen()}
/>
{:else if type === 'video'}
<video
class={css_classes}
disablepictureinpicture
x-webkit-airplay="deny"
controls
autoplay
loop
{muted}
preload="metadata"
>
<source src="/api/v1/storage/download/{src}" />
</video>
{:else}
<p>Unknown media type</p>
{/if}
{/await}
{#if fullscreen_open}
<div
class="fixed top-0 left-0 z-50 w-screen h-screen bg-black bg-opacity-50 fle p-2"
transition:fade={{ duration: 80 }}
on:click={() => (fullscreen_open = false)}
>
<img
src={img_data.data}
alt={img_data.alt_text ?? 'Not available'}
class="object-cover rounded m-auto max-h-full max-w-full"
/>
</div>
{/if}
@@ -141,7 +141,7 @@
type="text"
class="border-b-2 border-dotted w-5/6 text-center rounded-lg bg-transparent"
style="background-color: {answer.color ?? 'transparent'}"
placeholder="Empty..."
placeholder={$t('editor.empty')}
/>
<input
class="rounded-lg p-1 border-black border"
@@ -14,6 +14,7 @@
min_correct: 3
};
}
/*
const correct_numbers = (data: number[]) => {
console.log(data, data[1] <= data[0])
@@ -74,13 +74,12 @@
bind:value={answer.answer}
type="text"
class="border-b-2 border-dotted w-5/6 text-center rounded-lg bg-transparent"
placeholder="Empty..."
placeholder={$t('editor.empty')}
/>
<button
type="button"
on:click={() => {
answer.case_sensitive = !answer.case_sensitive;
console.log(answer.case_sensitive);
}}
>
{#if answer.case_sensitive}
+33 -16
View File
@@ -4,18 +4,16 @@
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<script lang="ts">
import type { EditorData, VotingAnswer } from '../quiz_types';
import type { EditorData } from '../quiz_types';
import { fade } from 'svelte/transition';
import { reach } from 'yup';
import { getLocalization } from '$lib/i18n';
import { VotingQuestionSchema } from '$lib/yupSchemas';
import { get_foreground_color } from '$lib/helpers';
const { t } = getLocalization();
const empty_answer: VotingAnswer = {
answer: '',
image: undefined
};
const default_colors = ['#D6EDC9', '#B07156', '#7F7057', '#4E6E58'];
export let selected_question: number;
export let data: EditorData;
@@ -29,16 +27,28 @@
// eslint-disable-next-line no-empty
} catch {}
/*console.log(data.questions[selected_question].answers, 'moIn!', data.questions[selected_question].answers.length);
onMount(() => {
for (let i = 0; i < data.questions[selected_question].answers; i++) {
console.log(data.questions[selected_question].answers[i], 'iterate');
data.questions[selected_question].answers[i].right = undefined;
const set_colors_if_unset = () => {
for (let i = 0; i < data.questions[selected_question].answers.length; i++) {
if (!data.questions[selected_question].answers[i].color) {
data.questions[selected_question].answers[i].color = default_colors[i];
}
}
});*/
};
$: {
set_colors_if_unset();
data;
selected_question;
}
/*console.log(data.questions[selected_question].answers, 'moIn!', data.questions[selected_question].answers.length);
onMount(() => {
for (let i = 0; i < data.questions[selected_question].answers; i++) {
console.log(data.questions[selected_question].answers[i], 'iterate');
data.questions[selected_question].answers[i].right = undefined;
}
});*/
</script>
<div class="grid grid-cols-2 gap-4 w-full px-10">
<div class="grid grid-rows-2 grid-flow-col auto-cols-auto gap-4 w-full px-10">
{#if Array.isArray(data.questions[selected_question].answers)}
{#each data.questions[selected_question].answers as answer, index}
<div
@@ -78,15 +88,16 @@
bind:value={answer.answer}
type="text"
class="border-b-2 border-dotted w-5/6 text-center rounded-lg"
style="background-color: {answer.color ?? 'transparent'}"
placeholder="Empty..."
style="background-color: {answer.color ??
'transparent'}; color: {get_foreground_color(answer.color)}"
placeholder={$t('editor.empty')}
/>
<input
class="rounded-lg p-1 border-black border"
type="color"
bind:value={answer.color}
on:contextmenu|preventDefault={() => {
answer.color = null;
answer.color = default_colors[index];
}}
/>
</div>
@@ -100,7 +111,13 @@
on:click={() => {
data.questions[selected_question].answers = [
...data.questions[selected_question].answers,
{ ...empty_answer }
{
...{
answer: '',
image: undefined,
color: default_colors[data.questions[selected_question].answers.length]
}
}
];
}}
>
+58 -48
View File
@@ -10,22 +10,21 @@
import { reach } from 'yup';
import { dataSchema } from '$lib/yupSchemas';
import Spinner from '../Spinner.svelte';
import { createTippy } from 'svelte-tippy';
// import { createTippy } from 'svelte-tippy';
import { getLocalization } from '$lib/i18n';
// import MediaComponent from "$lib/editor/MediaComponent.svelte";
const { t } = getLocalization();
const tippy = createTippy({
arrow: true,
animation: 'perspective-subtle',
placement: 'top'
});
/* const tippy = createTippy({
arrow: true,
animation: 'perspective-subtle',
placement: 'top'
});*/
export let data: EditorData;
export let selected_question: number;
export let edit_id: string;
export let pow_data;
export let pow_salt: string;
let uppyOpen = false;
let unique = {};
@@ -42,19 +41,45 @@
.toString()
.slice(0, 3);
}
};
const set_unique = () => {
unique = {};
};
$: correctTimeInput(data.questions[selected_question].time);
/*
if (typeof data.questions[selected_question].type !== QuizQuestionType) {
console.log(data.questions[selected_question].type !== QuizQuestionType.ABCD || data.questions[selected_question].type !== QuizQuestionType.RANGE)
data.questions[selected_question].type = QuizQuestionType.ABCD;
$: {
selected_question;
set_unique();
}
*/
let image_url = '';
const update_image_url = () => {
image_url = data.questions[selected_question].image;
};
$: {
update_image_url();
selected_question;
data.questions;
}
const type_to_name = {
RANGE: $t('words.range'),
ABCD: $t('words.multiple_choice'),
VOTING: $t('words.voting'),
TEXT: $t('words.text'),
ORDER: $t('words.order'),
CHECK: $t('words.check_choice')
};
/*
if (typeof data.questions[selected_question].type !== QuizQuestionType) {
console.log(data.questions[selected_question].type !== QuizQuestionType.ABCD || data.questions[selected_question].type !== QuizQuestionType.RANGE)
data.questions[selected_question].type = QuizQuestionType.ABCD;
}
*/
</script>
<div class="w-full max-h-full pb-20 px-20 h-full">
<div class="rounded-lg bg-white w-full h-full border-gray-500 drop-shadow-2xl dark:bg-gray-700">
<div class="rounded-lg bg-white w-full h-full border-gray-500 dark:bg-gray-700 shadow-2xl">
<div class="h-12 bg-gray-300 rounded-t-lg dark:bg-gray-500">
<div class="flex align-middle p-4 gap-3">
<span
@@ -75,6 +100,7 @@
<svelte:component this={c.default} bind:data={data.questions[selected_question]} />
{/await}
{:else}
{@const type = data.questions[selected_question].type}
<div class="flex flex-col">
<div class="flex justify-center pt-10 w-full">
{#key unique}
@@ -103,7 +129,7 @@
class="rounded-full absolute -top-2 -right-2 opacity-70 hover:opacity-100 transition"
type="button"
on:click={() => {
data.questions[selected_question].image = '';
data.questions[selected_question].image = null;
}}
>
<svg
@@ -121,22 +147,11 @@
/>
</svg>
</button>
<img
src={data.questions[selected_question].image}
alt="not available"
class="max-h-64 h-auto w-auto"
/>
{#await import('$lib/editor/MediaComponent.svelte') then c}
<svelte:component this={c.default} bind:src={image_url} />
{/await}
</div>
</div>
{:else if pow_data === undefined}
<a
href="/docs/pow"
target="_blank"
use:tippy={{ content: "Click to learn why it's loading so long." }}
class="cursor-help"
>
<Spinner my_20={false} />
</a>
{:else}
{#await import('$lib/editor/uploader.svelte')}
<Spinner my_20={false} />
@@ -147,8 +162,7 @@
bind:edit_id
bind:data
bind:selected_question
bind:pow_data
bind:pow_salt
video_upload={true}
/>
{/await}
{/if}
@@ -178,40 +192,36 @@
</div>
</div>
<div class="flex justify-center pt-10">
<select
class="p-2 rounded-lg bg-gray-800 focus:ring-2 ring-blue-600 text-white"
name="Answer-Type"
bind:value={data.questions[selected_question].type}
>
<option value={QuizQuestionType.RANGE}>{$t('words.range')}</option>
<option value={QuizQuestionType.ABCD}>{$t('words.multiple_choice')}</option>
<option value={QuizQuestionType.VOTING}>{$t('words.voting')}</option>
<option value={QuizQuestionType.TEXT}>{$t('words.text')}</option>
<option value={QuizQuestionType.ORDER}>{$t('words.order')}</option>
</select>
<p>{type_to_name[String(data.questions[selected_question].type)]}</p>
</div>
<div class="flex justify-center py-10 w-full">
{#if data.questions[selected_question].type === QuizQuestionType.ABCD}
{#if type === QuizQuestionType.ABCD || type === QuizQuestionType.CHECK}
{#await import('$lib/editor/ABCDEditorPart.svelte')}
<Spinner my_20={false} />
{:then c}
<svelte:component this={c.default} bind:data bind:selected_question />
<svelte:component
this={c.default}
bind:data
bind:selected_question
check_choice={type === QuizQuestionType.CHECK}
/>
{/await}
{:else if data.questions[selected_question].type === QuizQuestionType.RANGE}
{:else if type === QuizQuestionType.RANGE}
<p>Range</p>
<RangeEditor bind:selected_question bind:data />
{:else if data.questions[selected_question].type === QuizQuestionType.VOTING}
{:else if type === QuizQuestionType.VOTING}
{#await import('$lib/editor/VotingEditorPart.svelte')}
<Spinner my_20={false} />
{:then c}
<svelte:component this={c.default} bind:data bind:selected_question />
{/await}
{:else if data.questions[selected_question].type === QuizQuestionType.TEXT}
{:else if type === QuizQuestionType.TEXT}
{#await import('$lib/editor/TextEditorPart.svelte')}
<Spinner my_20={false} />
{:then c}
<svelte:component this={c.default} bind:data bind:selected_question />
{/await}
{:else if data.questions[selected_question].type === QuizQuestionType.ORDER}
{:else if type === QuizQuestionType.ORDER}
{#await import('$lib/editor/OrderEditorPart.svelte')}
<Spinner my_20={false} />
{:then c}
+6 -19
View File
@@ -8,9 +8,6 @@
import { getLocalization } from '$lib/i18n';
import Spinner from '$lib/Spinner.svelte';
export let pow_data;
export let pow_salt;
const { t } = getLocalization();
let uppyOpen = false;
@@ -42,7 +39,7 @@
<div
class="dark:bg-gray-700 h-full"
style="background-repeat: no-repeat;background-size: 100% 100%;background-image: {data.background_image
? `url("${data.background_image}")`
? `url("/api/v1/storage/download/${data.background_image}")`
: `unset`}"
>
<div class="flex justify-center pt-10 w-full">
@@ -68,18 +65,14 @@
{#if data.cover_image != undefined && data.cover_image !== ''}
<div class="flex justify-center pt-10 w-full max-h-72 w-full">
<img
src={data.cover_image}
src="/api/v1/storage/download/{data.cover_image}"
alt="not available"
class="max-h-72 h-auto w-auto"
on:contextmenu|preventDefault={() => {
data.cover_image = '';
data.cover_image = null;
}}
/>
</div>
{:else if pow_data === undefined}
<a href="/docs/pow" target="_blank" class="cursor-help">
<Spinner my_20={false} />
</a>
{:else}
{#await import('$lib/editor/uploader.svelte')}
<Spinner my_20={false} />
@@ -89,8 +82,7 @@
bind:modalOpen={uppyOpen}
bind:edit_id
bind:data
bind:pow_data
bind:pow_salt
video_upload={false}
/>
{/await}
{/if}
@@ -180,7 +172,7 @@
</div>
</div>
<div class="flex justify-center pt-10">
<h3>Background-Image</h3>
<h3>{$t('editor.bg_image')}</h3>
</div>
<div class="w-full flex justify-center -mt-8">
{#if data.background_image}
@@ -191,10 +183,6 @@
class="mt-10 bg-red-500 p-2 rounded-lg border-2 border-black transition hover:bg-red-400"
>Remove Background-Image</button
>
{:else if pow_data === undefined}
<a href="/docs/pow" target="_blank" class="cursor-help pt-10">
<Spinner my_20={false} />
</a>
{:else}
{#await import('$lib/editor/uploader.svelte')}
<div class="pt-10">
@@ -207,8 +195,7 @@
bind:edit_id
bind:data
selected_question={-1}
bind:pow_data
bind:pow_salt
video_upload={false}
/>
{/await}
{/if}
+30 -27
View File
@@ -9,6 +9,10 @@
import { reach } from 'yup';
import { ABCDQuestionSchema, dataSchema } from '../yupSchemas';
import { createTippy } from 'svelte-tippy';
import { getLocalization } from '$lib/i18n';
import AddNewQuestionPopup from '$lib/editor/AddNewQuestionPopup.svelte';
const { t } = getLocalization();
export let data: EditorData;
export let selected_question = -1;
@@ -20,13 +24,7 @@
});
let arr_of_cards = Array(data.questions.length);
let propertyCard;
const empty_question: Question = {
question: '',
time: '20',
image: '',
answers: [],
type: QuizQuestionType.ABCD
};
let add_new_question_popup_open = false;
const empy_slide: Question = {
type: QuizQuestionType.SLIDE,
@@ -49,10 +47,10 @@
}
};
/* onMount(() => {
propertyCard.scrollIntoView({
behavior: 'smooth'
});
});*/
propertyCard.scrollIntoView({
behavior: 'smooth'
});
});*/
</script>
<div class="h-screen border-r-2 pt-6 px-6 overflow-scroll">
@@ -78,7 +76,7 @@
{#if data.title}
{@html data.title}
{:else}
<i>No title...</i>
<i>{$t('editor.no_title')}</i>
{/if}
</p>
</div>
@@ -121,7 +119,7 @@
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>Public</span>
<span>{$t('words.public')}</span>
{:else}
<svg
class="w-5 h-5 inline-block"
@@ -137,7 +135,7 @@
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
/>
</svg>
<span>Private</span>
<span>{$t('words.private')}</span>
{/if}
</button>
</div>
@@ -190,7 +188,7 @@
class:dark:text-black={index === selected_question}
>
{#if question.question === ''}
<span class="italic text-gray-500">No title...</span>
<span class="italic text-gray-500">{$t('editor.no_title')}</span>
{:else}
{@html question.question}
{/if}
@@ -199,17 +197,17 @@
{#if question.image}
<div class="flex justify-center align-middle pb-0.5">
<img
src={question.image}
src="/api/v1/storage/download/{question.image}"
class="h-10 border rounded-lg"
alt="Not available"
use:tippy={{
content: `<img src='${question.image}' alt='Not available' class='rounded'>`,
content: `<img src="/api/v1/storage/download/${question.image}" alt="Not available" class="rounded">`,
allowHTML: true
}}
/>
</div>
{/if}
{#if question.type === QuizQuestionType.ABCD}
{#if question.type === QuizQuestionType.ABCD || question.type === QuizQuestionType.CHECK}
<div class="grid grid-cols-2 gap-2">
{#if Array.isArray(question.answers)}
{#each question.answers as answer}
@@ -222,10 +220,11 @@
'answer'
).isValidSync(answer.answer)}
use:tippy={{
content: answer.answer === '' ? 'Empty...' : answer.answer
content:
answer.answer === '' ? $t('editor.empty') : answer.answer
}}
>{#if answer.answer === ''}
<i>Empty...</i>
<i>{$t('editor.empty')}</i>
{:else}
{answer.answer}
{/if}</span
@@ -252,10 +251,11 @@
'answer'
).isValidSync(answer.answer)}
use:tippy={{
content: answer.answer === '' ? 'Empty...' : answer.answer
content:
answer.answer === '' ? $t('editor.empty') : answer.answer
}}
>{#if answer.answer === ''}
<i>Empty...</i>
<i>{$t('editor.empty')}</i>
{:else}
{answer.answer}
{/if}</span
@@ -275,12 +275,12 @@
>
<button
type="button"
class="h-full flex justify-center w-full dark:text-black flex-col border-r border-black"
class="h-full flex justify-center w-full flex-col border-r border-black dark:text-white"
on:click={() => {
data.questions = [...data.questions, { ...empty_question }];
add_new_question_popup_open = true;
}}
>
<span class="w-full text-center">Question</span>
<span class="w-full text-center">{$t('words.question')}</span>
<svg
class="w-5/6 m-auto"
fill="none"
@@ -298,12 +298,12 @@
</button>
<button
type="button"
class="h-full flex justify-center w-full dark:text-black flex-col"
class="h-full flex justify-center w-full dark:text-white flex-col"
on:click={() => {
data.questions = [...data.questions, { ...empy_slide }];
}}
>
<span class="w-full text-center">Slide</span>
<span class="w-full text-center">{$t('words.slide')}</span>
<svg
class="w-5/6 m-auto"
fill="none"
@@ -321,3 +321,6 @@
</button>
</div>
</div>
{#if add_new_question_popup_open}
<AddNewQuestionPopup bind:questions={data.questions} bind:open={add_new_question_popup_open} />
{/if}
-2
View File
@@ -84,7 +84,6 @@
fill: '#ff000d'
});
}
console.log(canvas.export.toJson());
};
$: {
@@ -146,7 +145,6 @@
interactive: false
}*/
});
console.log(data.answers);
if (data.answers) {
if (typeof data.answers === 'string') {
canvas.import.json(JSON.parse(data.answers));
@@ -15,7 +15,6 @@
const set_available_modifiers = () => {
available_modifiers = [];
console.log(selected_el, 'sel_el');
if (!selected_el) {
return;
}
@@ -43,19 +42,16 @@
const change_color = (e: Event) => {
if (available_modifiers.includes('text_color')) {
console.log('text color');
selected_el.updateText({
fill: e.target.value
});
} else if (available_modifiers.includes('fill_color')) {
console.log('fill color');
selected_el.update({ fill: e.target.value });
}
opened_dropdown = null;
};
const change_fontsize = (e: Event) => {
console.log(selected_el.node.children[1].attrs.fontSize);
selected_el.updateText({
fontSize: e.target.value
});
@@ -7,7 +7,9 @@
import { ElementTypes } from '$lib/quiz_types';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { getLocalization } from '$lib/i18n';
const { t } = getLocalization();
export let selected_element;
const keybinding_list = {
t: ElementTypes.Text,
@@ -40,22 +42,22 @@
shortcut: string;
}> = [
{
name: 'Headline',
description: 'A bold text for headlines',
name: $t('editor.slide.headline'),
description: $t('editor.slide.headline_description'),
type: ElementTypes.Headline,
icon: undefined,
shortcut: 'h'
},
{
name: 'Text',
description: 'Smaller longer text',
name: $t('editor.slide.text'),
description: $t('editor.slide.text_description'),
type: ElementTypes.Text,
icon: undefined,
shortcut: 't'
},
{
name: 'Rectangle',
description: 'Just a rectangle',
name: $t('editor.slide.rectangle'),
description: $t('editor.slide.rectangle_description'),
type: ElementTypes.Rectangle,
icon: undefined,
shortcut: 'r'
@@ -10,7 +10,6 @@
if (!data) {
data = '#ed333b';
}
$: console.log(data);
</script>
<div class="rounded-full w-full h-full flex justify-center p-2" style="background-color: {data}">
@@ -10,7 +10,6 @@
if (!data) {
data = '#ed333b';
}
$: console.log(data);
</script>
<div class="w-full h-full flex justify-center p-2" style="background-color: {data}">
+142 -31
View File
@@ -12,6 +12,7 @@
import Dashboard from '@uppy/dashboard';
import Compressor from '@uppy/compressor';
import { fade } from 'svelte/transition';
import BrownButton from '$lib/components/buttons/brown.svelte';
// CSS imports
import '@uppy/core/dist/style.css';
@@ -21,6 +22,9 @@
import '@uppy/image-editor/dist/style.css';
import type { EditorData } from '../quiz_types';
import { getLocalization } from '$lib/i18n';
import { onMount } from 'svelte';
import Library from '$lib/editor/uploader/Library.svelte';
import Pixabay from '$lib/editor/uploader/Pixabay.svelte';
const { t } = getLocalization();
@@ -28,10 +32,26 @@
export let edit_id: string;
export let data: EditorData;
export let selected_question: number;
export let pow_data;
export let pow_salt: string;
export let video_upload = false;
export let library_enabled = true;
// eslint-disable-next-line no-undef
let video_popup: undefined | WindowProxy = undefined;
let selected_type: AvailableUploadTypes | null = null;
// eslint-disable-next-line no-unused-vars
enum AvailableUploadTypes {
// eslint-disable-next-line no-unused-vars
Image,
// eslint-disable-next-line no-unused-vars
Video,
// eslint-disable-next-line no-unused-vars
Library,
// eslint-disable-next-line no-unused-vars
Pixabay
}
console.log(pow_data);
const uppy = new Uppy()
.use(DropTarget, {
target: document.body
@@ -45,59 +65,150 @@
quality: 0.6
})
.use(XHRUpload, {
endpoint: `/api/v1/editor/image?edit_id=${edit_id}&pow_data=${pow_data}`
endpoint: `/api/v1/storage/`
});
const props = {
inline: true,
restrictions: {
maxFileSize: 2_000_000,
maxFileSize: 10_490_000,
maxNumberOfFiles: 1,
allowedFileTypes: ['.gif', '.jpg', '.jpeg', '.png', '.svg', '.webp']
allowedFileTypes: ['image/*']
// allowedFileTypes: ['.gif', '.jpg', '.jpeg', '.png', '.svg', '.webp']
}
};
let image_id;
uppy.on('upload-success', (file, response) => {
image_id = response.body.id;
pow_salt = response.body.pow_data;
console.log(pow_salt, response.body);
pow_data = undefined;
});
uppy.on('complete', (_) => {
console.log(pow_data);
if (selected_question === undefined) {
data.cover_image = `${window.location.origin}/api/v1/storage/download/${image_id}`;
data.cover_image = image_id;
} else if (selected_question === -1) {
data.background_image = `${window.location.origin}/api/v1/storage/download/${image_id}`;
data.background_image = image_id;
} else {
data.questions[
selected_question
].image = `${window.location.origin}/api/v1/storage/download/${image_id}`;
data.questions[selected_question].image = image_id;
}
console.log(selected_question, data);
modalOpen = false;
selected_type = null;
});
onMount(() => {
window.addEventListener('storage', (e) => {
if (e.key !== 'video_upload_id') {
return;
}
localStorage.removeItem('video_upload_id');
data.questions[selected_question].image = e.newValue;
selected_type = null;
});
});
const upload_video = async () => {
video_popup = window.open(
'/edit/videos',
'_blank',
'popup=true,toolbar=false,menubar=false,location=false,'
);
video_popup.addEventListener('beforeunload', () => {
video_popup = undefined;
});
};
const handle_on_click = (e: Event) => {
if (e.target === e.currentTarget) {
modalOpen = false;
selected_type = null;
}
};
onMount(() => {
window.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape') {
modalOpen = false;
selected_type = null;
}
});
});
console.log(edit_id);
</script>
{#if modalOpen}
<div
class="w-full h-full absolute top-0 left-0 bg-opacity-60 z-20 flex justify-center"
transition:fade|local
class="w-screen h-screen fixed top-0 left-0 bg-opacity-50 bg-black z-20 flex justify-center"
on:click={handle_on_click}
transition:fade|local={{ duration: 100 }}
>
<div>
<button
type="button"
class="rounded-t-lg bg-black text-white px-1"
on:click={() => {
modalOpen = false;
}}
>Close
</button>
<div>
<SvelteDashboard {uppy} width="100%" {props} />
{#if selected_type === null}
<div class="m-auto w-1/3 h-auto bg-white dark:bg-gray-700 p-4 rounded">
<h1 class="text-3xl text-center mb-4">{$t('uploader.select_upload_type')}</h1>
<div class="flex flex-row gap-4">
<div class="w-full">
<BrownButton
on:click={() => {
selected_type = AvailableUploadTypes.Image;
}}
>{$t('words.image')}
</BrownButton>
</div>
<div class="w-full">
<BrownButton
disabled={!video_upload}
on:click={() => {
selected_type = AvailableUploadTypes.Video;
}}
>{$t('words.video')}
</BrownButton>
</div>
{#if library_enabled}
<div class="w-full">
<BrownButton
on:click={() => {
selected_type = AvailableUploadTypes.Library;
}}
>{$t('words.library')}
</BrownButton>
</div>
{/if}
<div class="w-full">
<BrownButton
on:click={() => {
selected_type = AvailableUploadTypes.Pixabay;
}}
>Pixabay
</BrownButton>
</div>
</div>
</div>
</div>
{:else if selected_type === AvailableUploadTypes.Image}
<div class="m-auto w-1/3 h-5/6" transition:fade|local={{ duration: 100 }}>
<div>
<SvelteDashboard {uppy} width="100%" {props} />
</div>
</div>
{:else if selected_type === AvailableUploadTypes.Video}
<div
class="m-auto w-1/3 h-auto bg-white dark:bg-gray-700 p-4 rounded"
transition:fade|local={{ duration: 100 }}
>
<h1 class="text-3xl text-center mb-4">{$t('uploader.upload_a_video')}</h1>
{#if video_popup}
<p class="text-center">
{$t('uploader.upload_video_popup_notice')}
</p>
{:else}
<BrownButton on:click={upload_video} type="button"
>{$t('uploader.upload_video')}</BrownButton
>
{/if}
</div>
{:else if selected_type === AvailableUploadTypes.Library}
<div>
<Library bind:data {selected_question} bind:modalOpen />
</div>
{:else if selected_type === AvailableUploadTypes.Pixabay}
<div>
<Pixabay bind:data {selected_question} bind:modalOpen />
</div>
{/if}
</div>
{/if}
<div class="flex justify-center w-full pt-10" transition:fade|local>
@@ -0,0 +1,65 @@
<!--
- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<script lang="ts">
import type { PrivateImageData } from '$lib/quiz_types';
import Spinner from '$lib/Spinner.svelte';
import type { EditorData } from '$lib/quiz_types';
import BrownButton from '$lib/components/buttons/brown.svelte';
import { getLocalization } from '$lib/i18n';
export let data: EditorData;
export let selected_question: number;
export let modalOpen: boolean;
const { t } = getLocalization();
const fetch_images = async (): Promise<PrivateImageData> => {
const response = await fetch('/api/v1/storage/list/last?count=50');
return await response.json();
};
let image_fetch = fetch_images();
const set_image = (id: string) => {
if (selected_question === undefined) {
data.cover_image = id;
} else if (selected_question === -1) {
data.background_image = id;
} else {
data.questions[selected_question].image = id;
}
modalOpen = false;
};
</script>
{#await image_fetch}
<Spinner />
{:then images}
<div class="flex w-screen p-8 h-screen">
<div
class="flex flex-col w-1/3 m-auto overflow-scroll h-full rounded p-4 gap-4 bg-white dark:bg-gray-700"
>
{#each images as image}
<div class="rounded border-2 border-[#B07156] p-2 flex-col flex gap-2">
<div>
<img
src="/api/v1/storage/download/{image.id}"
loading="lazy"
alt={image.alt_text}
class="object-contain w-full h-full max-h-full rounded"
/>
</div>
<p class="text-center">{image.filename ?? 'No name available'}</p>
<BrownButton
on:click={() => {
set_image(image.id);
}}>{$t('words.select')}</BrownButton
>
</div>
{/each}
</div>
</div>
{/await}
@@ -0,0 +1,99 @@
<!--
- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<script lang="ts">
import type { EditorData } from '$lib/quiz_types';
import Spinner from '$lib/Spinner.svelte';
import BrownButton from '$lib/components/buttons/brown.svelte';
import { getLocalization } from '$lib/i18n';
export let data: EditorData;
export let selected_question: number;
export let modalOpen: boolean;
let page = 1;
let search_term = '';
let loading = false;
const { t } = getLocalization();
const set_image = async (id: string) => {
loading = true;
const res = await fetch(`/api/v1/pixabay/save?id=${id}`, {
method: 'POST'
});
const json = await res.json();
const storage_id = json.id;
if (selected_question === undefined) {
data.cover_image = storage_id;
} else if (selected_question === -1) {
data.background_image = storage_id;
} else {
data.questions[selected_question].image = storage_id;
}
modalOpen = false;
};
const fetch_data = async () => {
const res = await fetch(`/api/v1/pixabay/images?page=${page}&query=${search_term}`);
return await res.json();
};
let fetched_data = fetch_data();
</script>
{#await fetched_data}
<Spinner />
{:then data}
{#if loading}
<Spinner />
{:else}
<div class="flex w-screen p-8 h-full mt-8 mb-1">
<div
class="flex flex-col w-1/3 m-auto overflow-scroll h-full rounded p-4 gap-2 bg-white dark:bg-gray-700"
>
<h1 class="text-2xl text-center">{$t('uploader.images_by_pixabay')}</h1>
<div class="flex">
<a href="https://pixabay.com" target="_blank" class="underline mx-auto"
>{$t('uploader.visit_pixabay')}</a
>
</div>
<form
class="w-full flex gap-2"
on:submit|preventDefault={() => (fetched_data = fetch_data())}
>
<input
class="w-full outline-none p-1 rounded dark:bg-gray-500 bg-gray-300"
bind:value={search_term}
/>
<div class="w-fit">
<BrownButton type="submit">{$t('words.search')}</BrownButton>
</div>
</form>
<span class="italic text-center text-sm">{$t('uploader.search_english_only')}</span>
{#each data.hits as image}
<div class="rounded border-2 border-[#B07156] p-2 flex-col flex gap-2">
<div>
<img
src={image.webformatURL}
loading="lazy"
alt="unavailable"
class="object-contain w-full h-full rounded max-h-[80vh]"
/>
</div>
<BrownButton
on:click={() => {
set_image(image.id);
}}>{$t('words.select')}</BrownButton
>
</div>
{/each}
</div>
</div>
{/if}
{/await}
+36
View File
@@ -0,0 +1,36 @@
<!--
- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<script lang="ts">
import Spinner from '$lib/Spinner.svelte';
export let files: {
id: string;
uploaded_at: string;
mime_type?: string;
hash?: string;
size: number;
deleted_at?: string;
alt_text?: string;
filename?: string;
thumbhash?: string;
server?: string;
quizzes: { id: string }[];
quiztivities: { id: string }[];
}[];
const get_files = async () => {
const res = await fetch('/api/v1/storage/list');
files = await res.json();
};
if (!files) {
get_files();
}
</script>
{#if files}{:else}
<Spinner />
{/if}
+2 -11
View File
@@ -68,22 +68,13 @@
/>
</svg>
<a
href="https://mastodon.online/@Mawoka"
href="https://fosstodon.org/@classquiz"
rel="me"
class="underline text-blue-300 hover:text-blue-500 transition"
>@Mawoka@mastodon.online</a
>@classquiz@fosstodon.org</a
> for updates!
</p>
</div>
<!-- to-[#8dc63f]-->
<span class="w-full h-0.5 bg-gradient-to-r from-[#009444] via-[#39b54a] to-[#8dc63f] block" />
<div class="flex justify-center bg-gray-700">
<p class="text-gray-400 text-center">
<i>Kahoot! and the K! logo are trademarks of Kahoot! AS</i>
</p>
</div>
</footer>
<!--
+2
View File
@@ -42,10 +42,12 @@ export const mint = async (
// eslint-disable-next-line no-constant-condition
while (true) {
// skipcq: JS-0003
const data = new TextEncoder().encode(`${challenge}:${counter.toString(16)}`);
const hashBuffer = await crypto.subtle.digest('SHA-1', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const digest = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
// skipcq: JS-0050
if (digest.slice(0, hex_digits) == zeros) {
result = counter.toString(16);
break;
+42 -2
View File
@@ -10,11 +10,51 @@ export const invertColor = (hexTripletColor: string): string => {
let color_int = parseInt(color, 16); // convert to integer
color_int = 0xffffff ^ color_int; // invert three bytes
color = color_int.toString(16); // convert to hex
color = ('000000' + color).slice(-6); // pad with leading zeros
color = '#' + color; // prepend #
color = `000000${color}`.slice(-6); // pad with leading zeros
color = `#${color}`; // prepend #
return color;
};
export const calculate_score = (q_time: number, time_taken: number): number => {
return q_time / time_taken;
};
export type RGB = [number, number, number];
export const getLuminance = (rgb: RGB): number => {
const [r, g, b] = rgb.map((v) => {
v /= 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
});
return r * 0.2126 + g * 0.7152 + b * 0.0722;
};
export const getContrast = (foregroundColor: RGB, backgroundColor: RGB) => {
const foregroundLuminance = getLuminance(foregroundColor);
const backgroundLuminance = getLuminance(backgroundColor);
return backgroundLuminance < foregroundLuminance
? (backgroundLuminance + 0.05) / (foregroundLuminance + 0.05)
: (foregroundLuminance + 0.05) / (backgroundLuminance + 0.05);
};
export const getRgbColorFromHex = (hex: string): RGB => {
hex = hex.slice(1);
const value = parseInt(hex, 16);
// skipcq: JS-C1002
const r = (value >> 16) & 255;
// skipcq: JS-C1002
const g = (value >> 8) & 255;
// skipcq: JS-C1002
const b = value & 255;
return [r, g, b] as RGB;
};
export const get_foreground_color = (bg_color: string): 'black' | 'white' => {
const bg_rgb = getRgbColorFromHex(bg_color);
const white_rgb: RGB = [255, 255, 255];
const black_rgb: RGB = [0, 0, 0];
const black_contrast = getContrast(black_rgb, bg_rgb);
const white_contrast = getContrast(white_rgb, bg_rgb);
return black_contrast < white_contrast ? 'black' : 'white';
};
+3 -3
View File
@@ -5,7 +5,6 @@
*/
import i18next from 'i18next';
import translations from './translations';
import en from './locales/en.json';
import de from './locales/de.json';
import fr from './locales/fr.json';
@@ -19,11 +18,12 @@ import zh_Hant from './locales/zh_Hant.json';
import pl from './locales/pl.json';
import pt from './locales/pt.json';
import uk from './locales/uk.json';
import nl from './locales/nl.json';
// import uz from './locales/uz.json'
// import zh_Hans from './locales/zh_Hans.json';
import LanguageDetector from 'i18next-browser-languagedetector';
import type { i18n, Resource } from 'i18next';
import type { i18n } from 'i18next';
export class I18nService {
// expose i18next
@@ -49,7 +49,6 @@ export class I18nService {
fallbackLng: 'en',
debug: false,
defaultNS: 'translation',
resources: translations as Resource,
interpolation: {
escapeValue: false
},
@@ -79,6 +78,7 @@ export class I18nService {
this.i18n.addResourceBundle('pl', 'translation', pl);
this.i18n.addResourceBundle('pt', 'translation', pt);
this.i18n.addResourceBundle('uk', 'translation', uk);
this.i18n.addResourceBundle('nl', 'translation', nl);
// this.i18n.addResourceBundle('uz', 'translation', uz);
}
+1
View File
@@ -0,0 +1 @@
{}
+165 -8
View File
@@ -48,7 +48,8 @@
"community_driven_content": "ClassQuiz hängt von der Community ab, die ClassQuiz mit Spenden, Feature-Requests, Übersetzungen und mehr versorgt! Du kannst auch ein Teil der ClassQuiz-Community werden!",
"download_quizzes_content": "Quiz können als einzige Datei heruntergeladen werden und jederzeit importiert werden, sodass du auch einfach auf eine andere Instanz von ClassQuiz umziehen kannst!",
"community_driven": "Von der Community betrieben",
"download_quizzes": "Quiz herunterladen"
"download_quizzes": "Quiz herunterladen",
"how_does_classquiz_work": "Wie funktioniert ClassQuiz überhaupt?"
},
"overview_page": {
"created_at": "Erstellt am",
@@ -161,7 +162,29 @@
"score": "Punkte",
"results": "Ergebnisse",
"note": "Notiz",
"player_plural": "Spieler"
"player_plural": "Spieler",
"slide": "Folie",
"back": "Zurück",
"finish": "Fertig",
"name": "Name",
"point_plural": "Punkte",
"point": "Punkt",
"normal": "Normal",
"selected": "Ausgewählt",
"select": "Auswählen",
"next": "Weiter",
"quiz": "Quiz",
"quiztivity": "Quiztivity",
"check_choice": "Prüfauswahl",
"video": "Video",
"progress": "Fortschritt",
"files_library": "Dateien-Bibliothek",
"upload": "Upload",
"library": "Bibliothek",
"speed": "Geschwindigkeit",
"answer_plural": "Antworten",
"yes": "ja",
"no": "Nein"
},
"editor": {
"time_in_seconds": "Zeit in Sekunden",
@@ -170,7 +193,24 @@
"add_new_question": "Neue Frage hinzufügen",
"delete_question": "Frage löschen",
"delete_answer": "Antwort löschen",
"not_all_links_imgur_links": "Nicht alle Links sind Imgur-Links!"
"not_all_links_imgur_links": "Nicht alle Links sind Imgur-Links!",
"no_title": "Kein Titel...",
"empty": "Leer...",
"bg_image": "Hintergrundbild",
"slide": {
"text": "Text",
"text_description": "Kleiner langer Text",
"rectangle": "Rechteck",
"headline": "Überschrift",
"rectangle_description": "Nur ein Rechteck",
"headline_description": "Ein fetter Text als Überschrift"
},
"abcd_description": "Nur eine Antwort kann ausgewählt werden",
"voting_description": "Antworten geben keine Punkte",
"order_description": "Antworten müssen in die richtige Reihenfolge gebracht werden",
"text_description": "Spieler können text eingeben",
"range_description": "Ein Zahlenbereich kann mit einem Schieberegler ausgewählt werden",
"check_choice_description": "Alle richtigen Antworten müssen für Punkte ausgewählt werden"
},
"import": {
"need_help": "",
@@ -191,7 +231,12 @@
"start_by_showing_first_question": "Beginne damit, die erste Frage zu zeigen!",
"no_answers": "Keine Antworten!",
"stop_time": "Zeit stoppen",
"save_results": "Ergebnisse speichern"
"save_results": "Ergebnisse speichern",
"next_question": "Nächste Frage ({{question}})",
"show_results": "Ergebnisse anzeigen",
"enter_answer_into_field": "Gib die Antwort in das Eingabefeld ein!",
"stop_time_and_solutions": "Zeit stoppen und Ergebnisse zeigen",
"answers_submitted": "{{answer_count}} Antworten abgegen"
},
"import_page": {
"need_help": "Brauchst du Hilfe?",
@@ -214,7 +259,9 @@
"delete_this_session": "Diese Sitzung löschen",
"this_session?": "Diese Sitzung?",
"old_password": "Altes Passwort",
"new_password": "Neues Passwort"
"new_password": "Neues Passwort",
"change_avatar": "Avatar ändern",
"security_settings": "Sicherheitseinstellungen"
},
"explore_page": {
"made_by": "Erstellt von",
@@ -229,7 +276,12 @@
"end_sentence": "Das war's! Das war das Quiz.",
"1st_place": "1. Platz",
"2nd_place": "2. Platz",
"with_out_of": "mit {{correct_questions}} von insgesamt {{total_question_count}}"
"with_out_of": "mit {{correct_questions}} von insgesamt {{total_question_count}}",
"final_result_rank": "{{place}}. Platz: {{username}} mit {{points}} Punkten",
"your_score": "Deine Punktzahl: {{score}}",
"join_description": "Tritt bei unter {{url}} und gib {{pin}} ein.",
"join_by_entering_code": "Tritt bei, indem du folgenden Code eingibst",
"points_added": "Hinzugefügte Punkte"
},
"editor_page": {
"add_an_answer": "Antwort hinzufügen",
@@ -248,7 +300,11 @@
"unknown_error_text": "Das sollte nicht passieren. Es ist wahrscheinlich meine Schuld, oder du hast eine magische Fähigkeit, Fehler zu erschaffen..."
},
"uploader": {
"add_image": "Bild hinzufügen"
"add_image": "Bild hinzufügen",
"upload_a_video": "Ein Video hochladen",
"upload_video": "Video hochladen",
"upload_video_popup_notice": "Das Popup ist offen; guck dir es für weitere Infos an",
"select_upload_type": "Wähle den Upload-Typen aus"
},
"avatar_settings": {
"skin_color": "Hautfarbe",
@@ -264,7 +320,8 @@
"clothe_graphic_type": "Grafik",
"thats_you": "Das bist du!",
"start_over": "Neu anfangen",
"clothe_type": "Kleidung"
"clothe_type": "Kleidung",
"go_back": "Zurückkehren"
},
"results_page": {
"quiz_title": "Quiz-Titel",
@@ -279,5 +336,105 @@
"time_taken": "benötigte Zeit",
"player_name": "Spielername",
"correct_answer_plural": "{{count}} richtige Antworten"
},
"navbar": {
"donate": "Spenden"
},
"security_settings": {
"activate_2fa": "Zwei-Faktor-Authentifizierung aktivieren",
"2fa_activated": "Zwei-Faktor-Authentifizierung ist aktiviert",
"webauthn": "Webauthn",
"webauthn_available": "Webauthn ist verfügbar",
"webauthn_unavailable": "Webauthn ist nicht verfügbar",
"add_security_key": "Sicherheitsschlüssel hinzufügen",
"totp": "Totp",
"totp_available": "Totp ist verfügbar",
"enable_totp": "Totp aktivieren",
"backup_codes": {
"your_backup_code": "Dein Backup-Code",
"save_somewhere_save": "Speichere ihn an einem sicheren Ort!",
"download_code": "Code herunterladen"
},
"totp_setup": {
"do_not_forget_backup_code": "Vergiss nicht, deinen Backup-Code zu speichern!",
"scan_to_set_up": "Scanne diesen QR-Code um Totp einzurichten",
"enter_as_secret_if_no_see_code": "Gib dieses Geheimnis ein, wenn du den QR-Code nicht scannen kannst",
"totp_setup": "Totp-Einrichtung"
},
"backup_code": "Backup-Code",
"get_backup_code": "Backup-Code erhalten",
"2fa_deactivated": "Zwei-Faktor-Authentifizierung ist deaktiviert",
"totp_unavailable": "Totp ist nicht verfügbar",
"disable_totp": "Totp deaktivieren"
},
"view_quiz_page": {
"made_by": "Erstellt von",
"view_on_kahoot": "Auf Kahoot! ansehen"
},
"start_game": {
"start_game": "Spiel starten",
"old_school_mode": "Old-School",
"captcha_message": "Wenn aktiviert, Googles ReCaptcha wird in den Browsern der Spielerinnen und Spieler geladen. Aktiviere dies nur, wenn du es wirklich brauchst, da du die Einverständniserklärung JEDEN SPIELERS brauchst um die Captcha zu laden.",
"old_school_mode_description": "Fragen und Bilder werden sowohl auf dem Bildschirm des Admins als auch auf dem Bildschirm der Spielerinnen und Spieler angezeigt",
"normal_mode_description": "Fragen und Antwortmöglichkeiten werden nur auf dem Bildschirm des Admins angezeigt, wie bei Kahoot!. Die Spielerinnen und Spieler werden nur gefärbte Knöpfe mit passenden Symbolen haben."
},
"quiztivity": {
"editor": {
"select_page_type": "Seitentyp auswählen",
"move_left": "Nach links bewegen",
"add_new": "Hinzufügen",
"delete": "Löschen",
"open_shares_menu": "Teilen-Menü öffnen",
"shares": {
"add_new_share": "Neue Veröffentlichung hinzufügen",
"never_expires": "Läuft nie ab",
"expires_on": "Läuft ab am {{date}}"
},
"move_right": "Nach rechts bewegen",
"title_placeholder": "Titel hier eingeben"
},
"share_expired": "Veröffentlichung abgelaufen",
"memory": {
"editor": {
"add_card": "Karte hinzufügen",
"upload_image": "Bild hochladen",
"add_pair": "Paar hinzufügen"
}
},
"play": {
"memory": {
"try_count": "Versuche: {{try_count}}"
}
}
},
"components": {
"popover": {
"copied_to_clipboard": "In die Zwischenablage kopiert!"
}
},
"public_user_page": {
"joined_on": "Beigetreten am {{date}}",
"no_original_quizzes": "Dieser Nutzer hat keine originellen Quiz"
},
"file_dashboard": {
"not_available": "Nicht verfügbar",
"missing": "NICHT VORHANDEN!",
"unset": "Unbestimmt",
"size": "Größe: {{size}} Mib",
"caption": "Beschreibung: {{caption}}",
"filename": "Dateiname: {{filename}}",
"uploaded": "Hochgeladen am: {{date}}",
"Imported": "Importiert: {{yes_or_no}}",
"edit_details": "Details bearbeiten",
"edit_the_image": "Bild bearbeiten",
"filename_word": "Dateiname",
"storage_usage": "Du hast {{used}} Mib von {{total}} Mib benutzt. Das entspricht {{percent}}% deines Speichers.",
"delete_image": "Bild löschen",
"alt_text": "Alternativtext / Beschreibung",
"imported": "Importiert: {{yes_or_no}}"
},
"video_uploader": {
"time_elapsed": "Abgelaufene Zeit",
"time_remaining": "Zeit übrig"
}
}
+180 -22
View File
@@ -2,14 +2,9 @@
"index_page": {
"slogan": "The open-source quiz-platform!",
"meta": {
"description": "ClassQuiz is a quiz app like Kahoot! for students, which is open source and free to use",
"description": "ClassQuiz is a quiz app to learn interactively for students, which is open source and free to use",
"title": "Home"
},
"features_description": {
"1": "ClassQuiz is a quiz-platform that allows you to create and manage quizzes.",
"2": "The main feature is a Kahoot!-import function that allows you to import quizzes from Kahoot!-quizzes.",
"3": "The editor and function of exporting quiz results as Excel files are particular highlights of the software."
},
"stats": "There are already {{user_count}} users and {{quiz_count}} quizzes on ClassQuiz.",
"see_what_true_and_false": "See what was right or wrong",
"see_how_many_true_and_false": "See how many were right or wrong",
@@ -28,7 +23,6 @@
"get_a_quiz": "1. Get a quiz",
"create_a_quiz_from_scratch": "Create a quiz from scratch with the editor and include pictures and more",
"find_or_explore": "Find (or explore) quizzes made or imported by other people",
"import_quiz_from_kahoot_and_edit": "Import a quiz from Kahoot! and edit it in ClassQuiz",
"play_quiz": "2. Play the quiz",
"select_answer": "Select the answer",
"choose_answer_wisely": "Choose your answer wisely",
@@ -37,7 +31,7 @@
"list_winners": "List winners",
"get_ranking_and_winners": "Get the ranking and see who won",
"why_classquiz": "Why ClassQuiz?",
"no_tracking_content": "Kahoot! tracks you and sends that info to third-parties, but ClassQuiz doesn't.",
"no_tracking_content": "Others track you and sends that info to third-parties, but ClassQuiz doesn't.",
"self_hostable_content": "ClassQuiz can easily be self-hosted, so the data is only in your control!",
"user_friendly_content": "ClassQuiz aims to be simple, so it is can be used by everyone.",
"completely_free_content": "ClassQuiz is completely cost free (for the user), without any paid plans or annoying redirects to the upgrade-page. Donations are highly appreciated.",
@@ -48,12 +42,13 @@
"download_quizzes": "Download Quizzes",
"download_quizzes_content": "Quizzes can be downloaded as one file and imported at any time. This also lets you move your quizzes to other ClassQuiz instances.",
"community_driven": "Community-driven",
"community_driven_content": "ClassQuiz depends on its community for funding, testing ideas, feature requests, translations and more! You can also be a part of the ClassQuiz-community!"
"community_driven_content": "ClassQuiz depends on its community for funding, testing ideas, feature requests, translations and more! You can also be a part of the ClassQuiz-community!",
"how_does_classquiz_work": "How does ClassQuiz even work?"
},
"overview_page": {
"created_at": "Created at",
"question_count": "Question count",
"no_quizzes": "Click the \"Create\"-button, or import a quiz from Kahoot! to get going."
"no_quizzes": "Click the \"Create\"-button, or import a quiz to get going."
},
"edit_page": {
"success_update_title": "Quiz updated.",
@@ -157,16 +152,37 @@
"backup_code": "Backup-code",
"totp": "Totp",
"text": "Text",
"order": "order",
"order": "Order",
"results": "Results",
"note": "Note",
"player_plural": "Players",
"score": "Score",
"version": "Version",
"never": "Never",
"unknown": "Unknown",
"update": "Update",
"score": "Score",
"slide": "Slide",
"name": "Name",
"update": "Update"
"point": "Point",
"point_plural": "Points",
"back": "Back",
"finish": "Finish",
"normal": "Normal",
"selected": "Selected",
"select": "Select",
"quiz": "Quiz",
"quiztivity": "Quiztivity",
"next": "Next",
"check_choice": "Check Choice",
"video": "Video",
"library": "Library",
"progress": "Progress",
"speed": "Speed",
"upload": "Upload",
"files_library": "Files Library",
"answer_plural": "Answers",
"yes": "Yes",
"no": "no"
},
"editor": {
"time_in_seconds": "Time in seconds",
@@ -175,7 +191,24 @@
"add_new_question": "Add new question",
"delete_question": "Delete question",
"delete_answer": "Delete answer",
"not_all_links_imgur_links": "Not all links are Imgur-links!"
"not_all_links_imgur_links": "Not all links are Imgur-links!",
"no_title": "No title...",
"empty": "Empty...",
"bg_image": "Background image",
"slide": {
"headline": "Headline",
"headline_description": "A bold text for headlines",
"text": "Text",
"text_description": "Smaller longer text",
"rectangle": "Rectangle",
"rectangle_description": "Just a rectangle"
},
"abcd_description": "Only one answer can be chosen",
"voting_description": "Answers don't add any points",
"check_choice_description": "All correct answers have to be chosen to score points",
"order_description": "Answers can be brought into the correct order",
"text_description": "Players can enter text",
"range_description": "A number-range can be selected with a slider"
},
"import_page": {
"need_help": "Need help?",
@@ -194,12 +227,18 @@
"get_results": "Get results",
"get_results_and_stop_time": "Get results and stop time",
"get_final_results": "Get final results",
"export_results": "Export results",
"show_next_question": "Show next question",
"start_by_showing_first_question": "Start by showing the first question.",
"no_answers": "No answers!",
"stop_time": "Stop time",
"save_results": "Save results"
"save_results": "Save results",
"next_question": "Next Question ({{question}})",
"show_results": "Show results",
"stop_time_and_solutions": "Stop time and show solutions",
"enter_answer_into_field": "Enter your answer into the input field!",
"answers_submitted": "{{answer_count}} Answers submitted",
"request_export_results": "Request result download",
"download_export_results": "Download results"
},
"password_reset_page": {
"reset_password": "Reset password"
@@ -212,7 +251,10 @@
"last_seen": "Last seen",
"check_location": "Check location",
"delete_this_session": "Delete this session",
"this_session?": "This session?"
"this_session?": "This session?",
"change_avatar": "Change avatar",
"security_settings": "Security-Settings",
"add_api_key": "Add API key"
},
"explore_page": {
"made_by": "Made by",
@@ -227,7 +269,12 @@
"1st_place": "1st Place",
"2nd_place": "2nd Place",
"3rd place": "3rd Place",
"with_out_of": "with {{correct_questions}} out of {{total_question_count}}"
"with_out_of": "with {{correct_questions}} out of {{total_question_count}}",
"final_result_rank": "{{place}}: {{username}} with {{points}} points",
"your_score": "Your score: {{score}}",
"join_description": "Join at {{url}} and enter {{pin}}.",
"join_by_entering_code": "Join by entering the following code",
"points_added": "Points added"
},
"editor_page": {
"add_an_answer": "Add an answer",
@@ -246,7 +293,14 @@
"unknown_error_text": "That shouldn't happen. It's probably my fault, not yours, but maybe you have a magical power to break stuff..."
},
"uploader": {
"add_image": "Add image"
"add_image": "Add image",
"select_upload_type": "Select the Upload Type",
"upload_a_video": "Upload a Video",
"upload_video_popup_notice": "The popup is open; have a look at it for further information",
"upload_video": "Upload Video",
"visit_pixabay": "Visit Pixabay",
"images_by_pixabay": "Images provided by Pixabay",
"search_english_only": "The search works in English only"
},
"avatar_settings": {
"skin_color": "Skin color",
@@ -262,13 +316,17 @@
"clothe_color": "Clothing color",
"clothe_graphic_type": "Graphic",
"thats_you": "That's You!",
"start_over": "Start over"
"start_over": "Start over",
"go_back": "Go back"
},
"results_page": {
"no_results_so_far": "No results saved so far...",
"quiz_title": "Quiz Title",
"date_played": "Date Played",
"player_count": "Player count"
"player_count": "Player count",
"general_overview": {
"sentence": "The quiz \"{{title}}\", which was played on {{date}} had {{player_count}} players with an average score of {{average_score}}."
}
},
"result_page": {
"player_name": "Player name",
@@ -276,7 +334,8 @@
"average_score": "Average score: {{average_score}}",
"correct_answer": "{{count}} correct answer",
"correct_answer_plural": "{{count}} correct answers",
"time_taken": "Time taken"
"time_taken": "Time taken",
"player_score": "Player Score"
},
"controllers": {
"add_new_controller": "Add new controller",
@@ -288,5 +347,104 @@
"already_latest_version": "You're already on the latest version",
"cancel_update": "Cancel Update!",
"update_from_to": "Update from {{current_version}} to {{newest_version}}"
},
"navbar": {
"donate": "Donate"
},
"security_settings": {
"backup_code": "Backup-Code",
"get_backup_code": "Get Backup-Code",
"activate_2fa": "Activate Two Factor Authentication",
"2fa_activated": "Two Factor authentication is activated",
"2fa_deactivated": "Two Factor authentication is deactivated",
"webauthn": "Webauthn",
"webauthn_available": "Webauthn is available",
"webauthn_unavailable": "Webauthn is not available",
"add_security_key": "Add Security-Key",
"totp": "Totp",
"totp_available": "Totp is available",
"totp_unavailable": "Totp is not available",
"disable_totp": "Disable Totp",
"enable_totp": "Enable Totp",
"backup_codes": {
"your_backup_code": "Your Backup-Code",
"save_somewhere_save": "Save this somewhere safe!",
"download_code": "Download code"
},
"totp_setup": {
"scan_to_set_up": "Scan this QR-code to set up the code",
"enter_as_secret_if_no_see_code": "Enter this as the secret if you can't scan the QR-code",
"totp_setup": "Totp-Setup",
"do_not_forget_backup_code": "Do not forget to save your recovery-code!"
}
},
"view_quiz_page": {
"made_by": "Made by",
"view_on_kahoot": "View on the original"
},
"start_game": {
"captcha_message": "If enabled, Google's ReCaptcha will load in the browser of players. Only enable if you really need it, since you need the consent of EVERY player to load the captcha.",
"normal_mode_description": "Question and answer will only be shown on admins screen. The players will only have colored buttons with symbols matching these on the screen of the admin.",
"old_school_mode": "Old-School",
"old_school_mode_description": "Questions and images will be shown on both admins screen and on the screen of the players",
"start_game": "Start Game"
},
"quiztivity": {
"editor": {
"select_page_type": "Select Page Type",
"move_left": "Move left",
"move_right": "Move right",
"title_placeholder": "Enter title here",
"add_new": "Add new",
"delete": "Delete",
"open_shares_menu": "Open Shares menu",
"shares": {
"add_new_share": "Add new Share",
"expires_on": "Expires on {{date}}",
"never_expires": "Never expires"
}
},
"memory": {
"editor": {
"add_card": "Add card",
"upload_image": "Upload image",
"add_pair": "Add pair"
}
},
"play": {
"memory": {
"try_count": "Tries: {{try_count}}"
}
},
"share_expired": "Share expired"
},
"components": {
"popover": {
"copied_to_clipboard": "Copied to clipboard!"
}
},
"public_user_page": {
"joined_on": "Joined on {{date}}",
"no_original_quizzes": "This user doesn't have any original quizzes"
},
"file_dashboard": {
"not_available": "Not available",
"missing": "MISSING!",
"unset": "Unset",
"size": "Size: {{size}} Mib",
"caption": "Caption: {{caption}}",
"filename": "Filename: {{filename}}",
"uploaded": "Uploaded: {{date}}",
"imported": "Imported: {{yes_or_no}}",
"edit_details": "Edit details",
"delete_image": "Delete image",
"edit_the_image": "Edit the image",
"filename_word": "Filename",
"alt_text": "Alt(ernate) text / Caption",
"storage_usage": "You've used {{used}} Mib out of {{total}} MiB of storage. That's equivalent to {{percent}}% of your storage."
},
"video_uploader": {
"time_elapsed": "Time elapsed",
"time_remaining": "Time remaining"
}
}
+191 -27
View File
@@ -7,8 +7,8 @@
},
"features_description": {
"1": "ClassQuiz es una plataforma de quiz que permite crear y gestionar quiz.",
"2": "La principal funcionalidad es una función de importación de Kahoot! que permite importar cuestionarios de Kahoot!",
"3": "El editor fácil de usar es un punto destacado, al igual que la función de exportación para descargar los resultados de las pruebas como archivos de Excel."
"2": "La característica principal es una función de importación de Kahoot! que le permite importar cuestionarios desde Kahoot!-quizzes.",
"3": "El editor y la función de exportar los resultados de los cuestionarios como archivos Excel son aspectos especialmente destacados del programa."
},
"stats": "Ya hay {{user_count}} usuarios y {{quiz_count}} cuestionarios en ClassQuiz.",
"see_what_true_and_false": "Ver lo que estaba correcto o incorrecto",
@@ -16,20 +16,20 @@
"get_a_quiz": "1. Haz un quiz",
"create_a_quiz_from_scratch": "Crea un quiz desde cero con el editor e incluye imágenes y más",
"find_or_explore": "Encuentra (o explora) quizzes hechos o importados por otras personas",
"import_quiz_from_kahoot_and_edit": "¡Importa un cuestionario de Kahoot! y editarlo en ClassQuiz",
"import_quiz_from_kahoot_and_edit": "Importar un cuestionario de Kahoot! y editarlo en ClassQuiz",
"no_tracking": "Sin rastreo",
"german_server": "Servidor alemán",
"user_friendly": "Fácil de usar",
"completely_free": "Totalmente gratis",
"quiz_results_downloadable": "Los resultados de los cuestionarios se pueden descargar",
"multilingual": "Multilingüe",
"completely_free_content": "ClassQuiz es gratuito para el usuario, sin planes pagos ni redireccionamientos para una versión paga. Por lo tanto, cualquier donación es apreciada.",
"completely_free_content": "ClassQuiz es completamente gratuito (para el usuario), sin planes de pago ni molestas redirecciones a la página de actualización. Las donaciones son muy apreciadas.",
"see_how_many_true_and_false": "Ver cuántos estaban correctos o equivocados",
"create_or_import": "Crear o importar",
"see_all_quizzes": "Ver todos tus cuestionarios",
"teachers_site": "Portal de profesores",
"students_site": "Portal estudiante",
"multilingual_content": "ClassQuiz ya está completamente disponible en inglés, alemán, turco, francés, bokmål noruego e italiano, mientras que también está disponible parcialmente en indonesio y catalán.",
"multilingual_content": "ClassQuiz está disponible en Inglés, Francés, Alemán, Italiano, Bokmål Noruego, Turco y, en parte, Indonesio y Catalán.",
"select_answer": "Selecciona la respuesta",
"view_results": "Ver los resultados",
"check_if_chosen_wisely": "Comprueba, si has elegido bien",
@@ -37,18 +37,19 @@
"get_ranking_and_winners": "Consigue la clasificación y mira quién ha ganado",
"why_classquiz": "¿Por qué ClassQuiz?",
"self_hostable_content": "ClassQuiz puede ser fácilmente auto-alojado, por lo que los datos sólo están bajo tu control!",
"user_friendly_content": "ClassQuiz está diseñado para ser simple y fácil de usar para todos.",
"user_friendly_content": "ClassQuiz pretende ser sencillo, para que todo el mundo pueda utilizarlo.",
"quiz_results_downloadable_content": "Los resultados de los cuestionarios se pueden exportar fácilmente a una hoja de cálculo de Excel. (No sabía que otros no pudieran hacerlo)",
"dark_mode_content": "Una de las funcionalidades más importantes que puede tener un sitio web!",
"german_server_content": "Los servidores de ClassQuiz se encuentran en Alemania y están alojados con netcup.",
"play_quiz": "2. Haz el quiz",
"choose_answer_wisely": "Elige bien tu respuesta",
"no_tracking_content": "Kahoot! rastrea y comparte su perfil con terceros, pero ClassQuiz no lo hace.",
"no_tracking_content": "Kahoot! te rastrea y envía esa información a terceros, pero ClassQuiz no lo hace.",
"self_hostable": "Autohospedable",
"download_quizzes": "Descargar cuestionarios",
"community_driven_content": "¡ClassQuiz depende de la comunidad que proporciona ClassQuiz con donaciones, solicitudes de funciones, traducciones y más! ¡También puede convertirse en parte de la comunidad de ClassQuiz!",
"community_driven_content": "¡ClassQuiz depende de su comunidad para financiar, probar ideas, solicitar nuevas funciones, traducciones y más! ¡También puedes ser parte de la comunidad ClassQuiz!",
"community_driven": "Impulsado por la comunidad",
"download_quizzes_content": "Los cuestionarios se pueden descargar como un solo archivo e importar en cualquier momento, lo que le permite mover fácilmente sus cuestionarios a otra instancia de ClassQuiz."
"download_quizzes_content": "Los cuestionarios pueden descargarse como un archivo e importarse en cualquier momento. Esto también te permite mover tus cuestionarios a otras instancias de ClassQuiz.",
"how_does_classquiz_work": "¿Cómo funciona ClassQuiz?"
},
"create_page": {
"success": {
@@ -59,7 +60,7 @@
"login_page": {
"modal": {
"success": {
"success_check_mail": "Conectado. Por favor revise su bandeja de entrada de correo electrónico.",
"success_check_mail": "Conectado. Comprueba tu bandeja de entrada del correo electrónico.",
"description": {
"success_check_mail": "Verifique su buzón de correo ya que debería haber recibido un correo electrónico con un enlace para iniciar sesión.",
"success": "Conectado."
@@ -67,24 +68,24 @@
"success": "Conectado."
},
"error": {
"wrong_creds": "Dirección de correo electrónico o contraseña incorrecta.",
"wrong_creds": "Dirección de correo electrónico o contraseña incorrectas.",
"unexpected": "¡Error inesperado!",
"description": {
"wrong_creds": "Por favor, asegúrese de que su contraseña y dirección de correo electrónico sean correctas.",
"wrong_creds": "Por favor, asegúrate de que tu contraseña y tu dirección de correo electrónico son correctas.",
"unexpected": "Se produjo el típico error inesperado!"
}
}
},
"welcome_back": "Bienvenido de nuevo.",
"login_or_create_account": "Ingresar o Crear una cuenta",
"login_or_create_account": "Conectarse o crear una cuenta",
"already_have_account": "¿No tienes una cuenta?",
"use_backup_code": "Usar el código de la copia de seguridad",
"email_or_username": "Correo electrónico o nombre de usuario"
"use_backup_code": "Utilizar el código de seguridad",
"email_or_username": "Correo electrónico o nombre del usuario"
},
"overview_page": {
"created_at": "Creado en",
"question_count": "Recuento de preguntas",
"no_quizzes": "Haga clic en el botón \"Crear\" o importe un cuestionario de Kahoot. para ponerse en marcha."
"no_quizzes": "Haz clic en el botón \"Crear\" o importa un cuestionario de Kahoot! para empezar."
},
"edit_page": {
"success_update_title": "Cuestionario actualizado.",
@@ -121,7 +122,7 @@
"stats": "Estadísticas",
"features": "Características",
"login": "Iniciar sesión",
"email": "Dirección de correo electrónico",
"email": "Correo electrónico",
"username": "Nombre de usuario",
"count": "Cuenta",
"range": "Zona",
@@ -157,11 +158,33 @@
"backup_code": "Código de la copia de seguridad",
"totp": "contraseña de un solo uso (Totp)",
"text": "Texto",
"order": "solicitar",
"order": "Ordenar",
"results": "Resultados",
"note": "Nota",
"player_plural": "Jugadores",
"score": "Puntuación"
"score": "Puntuación",
"slide": "Deslizar",
"name": "Nombre",
"point": "Punto",
"point_plural": "Puntos",
"back": "Atrás",
"finish": "Finalizar",
"normal": "Normal",
"selected": "Seleccionado",
"select": "Seleccionar",
"quiz": "Cuestionario",
"quiztivity": "Quiztivity",
"next": "Siguiente",
"check_choice": "Comprobar la elección",
"video": "Vídeo",
"library": "Biblioteca",
"progress": "Progreso",
"speed": "Velocidad",
"upload": "Subir",
"files_library": "Biblioteca de archivos",
"yes": "Sí",
"answer_plural": "Respuestas",
"no": "no"
},
"admin_page": {
"export_results": "Exportar resultados",
@@ -175,7 +198,14 @@
"get_final_results": "Ver los resultados finales",
"start_by_showing_first_question": "Comienza mostrando la primera pregunta.",
"no_answers": "¡No hay respuestas!",
"save_results": "Guardar los resultados"
"save_results": "Guardar los resultados",
"next_question": "Siguiente pregunta ({{question}})",
"show_results": "Mostrar los resultados",
"stop_time_and_solutions": "Detener el tiempo y mostrar los resultados",
"enter_answer_into_field": "¡Ingresa tu respuesta en el campo de entrada!",
"answers_submitted": "{{answer_count}} Respuestas enviadas",
"download_export_results": "Descargar los resultados",
"request_export_results": "Solicitar la descarga de los resultados"
},
"settings_page": {
"check_location": "Comprobar ubicación",
@@ -185,14 +215,22 @@
"last_seen": "Visto por última vez",
"delete_this_session": "Borrar esta sesión",
"this_session?": "¿Esta sesión?",
"old_password": "Contraseña antigua"
"old_password": "Contraseña antigua",
"change_avatar": "Cambiar el avatar",
"security_settings": "Configuraciones de seguridad",
"add_api_key": "Añadir la clave de la API"
},
"play_page": {
"2nd_place": "2º puesto",
"end_sentence": "¡Eso es todo! Este fue el cuestionario.",
"1st_place": "1er puesto",
"3rd place": "3er puesto",
"with_out_of": "con {{correct_questions}} de {{total_question_count}}"
"with_out_of": "con {{correct_questions}} de {{total_question_count}}",
"final_result_rank": "{{place}}: {{username}} con {{points}} puntos",
"your_score": "Tú puntuación: {{score}}",
"join_description": "Únete en {{url}} e ingresa {{pin}}.",
"join_by_entering_code": "Únete ingresando el siguiente código",
"points_added": "Puntos agregados"
},
"editor_page": {
"right_click_to_delete": "Haz clic con el botón derecho en una respuesta para eliminarla!",
@@ -214,7 +252,24 @@
"not_all_links_imgur_links": "¡No todos los enlaces son de Imgur!",
"right_or_true?": "¿Cierto?",
"add_new_answer": "Añadir nueva respuesta",
"time_in_seconds": "Tiempo en segundos"
"time_in_seconds": "Tiempo en segundos",
"slide": {
"headline": "Titular",
"headline_description": "Texto en negrita para los titulares",
"text": "Texto",
"text_description": "Texto más corto y largo",
"rectangle": "Rectángulo",
"rectangle_description": "Solo un rectangulo"
},
"no_title": "Sin título...",
"empty": "Vacío...",
"bg_image": "Imagen de fondo",
"abcd_description": "Solo se puede seleccionar una respuesta",
"voting_description": "Las respuestas no suman puntos",
"order_description": "Las respuestas se pueden poner en el orden correcto",
"text_description": "Los jugadores pueden ingresar texto",
"range_description": "Se puede seleccionar un rango de números con un control deslizante",
"check_choice_description": "Todas las respuestas correctas deben seleccionarse para obtener puntos"
},
"import_page": {
"need_help": "¿Necesitas ayuda?",
@@ -241,7 +296,11 @@
"search_for_own_quizzes": "Busca tus propios quizzes"
},
"uploader": {
"add_image": "Añadir una imagen"
"add_image": "Añadir una imagen",
"select_upload_type": "Seleccione el tipo de carga",
"upload_a_video": "Cargar un vídeo",
"upload_video_popup_notice": "La ventana emergente está abierta; échale un vistazo para obtener más información",
"upload_video": "Subir un vídeo"
},
"avatar_settings": {
"skin_color": "Color de la piel",
@@ -257,13 +316,17 @@
"clothe_type": "Ropa",
"eyebrow_type": "Cejas",
"clothe_graphic_type": "Gráficos",
"start_over": "Empezar de nuevo"
"start_over": "Empezar de nuevo",
"go_back": "Regresar"
},
"results_page": {
"quiz_title": "Título del cuestionario",
"date_played": "Fecha del juego",
"player_count": "Número de jugadores",
"no_results_so_far": "No hay resultados guardados hasta ahora..."
"no_results_so_far": "No hay resultados guardados hasta ahora...",
"general_overview": {
"sentence": "El cuestionario \"{{title}}\", al que se jugó el {{date}}, tenía {{player_count}} jugadores con una puntuación media de {{average_score}}."
}
},
"result_page": {
"player_name": "Nombre del jugador",
@@ -271,6 +334,107 @@
"correct_answer": "{{count}} respuesta correcta",
"correct_answer_plural": "{{count}} respuestas correctas",
"time_taken": "Tiempo invertido",
"average_score": "Puntuación media: {{average_score}}"
"average_score": "Puntuación media: {{average_score}}",
"player_score": "Puntuación del jugador"
},
"navbar": {
"donate": "Donar"
},
"security_settings": {
"2fa_deactivated": "La verificación en dos pasos está desactivada",
"backup_codes": {
"your_backup_code": "Tu código de seguridad",
"download_code": "Descargar el código",
"save_somewhere_save": "¡Guárdalo en un lugar seguro!"
},
"totp_setup": {
"scan_to_set_up": "Escanea este código QR para configurar el código",
"enter_as_secret_if_no_see_code": "Introduce este secreto si no puedes escanear el código QR",
"totp_setup": "Configuración de Totp",
"do_not_forget_backup_code": "¡No olvides guardar tu código de recuperación!"
},
"backup_code": "Código de seguridad",
"get_backup_code": "Obtener el código de la copia de seguridad",
"activate_2fa": "Habilitar la verificación en dos pasos",
"2fa_activated": "La verificación en dos pasos está activada",
"webauthn": "WebAuthn",
"webauthn_unavailable": "WebAuthn no está disponible",
"webauthn_available": "WebAuthn está disponible",
"add_security_key": "Agregar una clave de seguridad",
"totp": "contraseña de un solo uso (OTP)",
"totp_available": "Totp está disponible",
"totp_unavailable": "Totp no está disponible",
"disable_totp": "Desactivar Totp",
"enable_totp": "Activar Totp"
},
"view_quiz_page": {
"made_by": "Hecho por",
"view_on_kahoot": "¡Ver en Kahoot!"
},
"start_game": {
"captcha_message": "Si está habilitado, el ReCaptcha de Google se cargará en el navegador de los jugadores. Habilítalo solo si realmente lo necesitas, ya que necesitas el consentimiento de TODOS los jugadores para cargar el captcha.",
"normal_mode_description": "¡La pregunta y la respuesta solo se mostrarán en la pantalla de administración como en Kahoot! Los jugadores solo tendrán botones de colores con símbolos correspondientes a los de la pantalla de administración.",
"old_school_mode_description": "Las preguntas y las imágenes se muestran tanto en la pantalla del administrador como en la pantalla del jugador",
"old_school_mode": "De la vieja escuela",
"start_game": "Iniciar el Juego"
},
"quiztivity": {
"memory": {
"editor": {
"upload_image": "Subir una imagen",
"add_card": "Añadir una tarjeta",
"add_pair": "Añadir un par"
}
},
"editor": {
"title_placeholder": "Escribe el título aquí",
"shares": {
"expires_on": "Caduca el {{date}}",
"add_new_share": "Añadir una nueva acción",
"never_expires": "No caduca nunca"
},
"select_page_type": "Selecciona el tipo de página",
"move_left": "Mover hacia la izquierda",
"move_right": "Mover hacia la derecha",
"add_new": "Añadir nuevo",
"delete": "Borrar",
"open_shares_menu": "Abrir el menú Acciones"
},
"play": {
"memory": {
"try_count": "Intentos: {{try_count}}"
}
},
"share_expired": "Expiró lo compartido"
},
"components": {
"popover": {
"copied_to_clipboard": "¡Copiado al portapapeles!"
}
},
"public_user_page": {
"no_original_quizzes": "Este usuario no tiene cuestionarios originales",
"joined_on": "Ingresó el {{date}}"
},
"file_dashboard": {
"not_available": "No disponible",
"missing": "¡DESAPARECIDO!",
"unset": "Desactivar",
"size": "Tamaño: {{size}} Mib",
"uploaded": "Subido: {{date}}",
"Imported": "Importado: {{yes_or_no}}",
"edit_details": "Editar los detalles",
"delete_image": "Borrar la imagen",
"edit_the_image": "Editar la imagen",
"filename_word": "Nombre del archivo",
"alt_text": "Texto alternativo / Leyenda",
"caption": "Leyenda: {{caption}}",
"filename": "Nombre del archivo: {{filename}}",
"storage_usage": "Has utilizado {{used}} Mib de {{total}} MiB del almacenamiento. Eso equivale al {{percent}}% de tu almacenamiento.",
"imported": "Importado: {{yes_or_no}}"
},
"video_uploader": {
"time_elapsed": "Tiempo transcurrido",
"time_remaining": "Duración restante"
}
}
+214 -9
View File
@@ -20,7 +20,9 @@
},
"welcome_back": "Bon retour parmi nous.",
"login_or_create_account": "Se connecter ou créer un compte",
"already_have_account": "Pas encore de compte ?"
"already_have_account": "Pas encore de compte ?",
"email_or_username": "Email ou Nom d'utilisateur",
"use_backup_code": "Utilisez un code de sauvegarde"
},
"words": {
"question": "Question",
@@ -78,7 +80,38 @@
"practice": "Pratiquer",
"error": "Erreur",
"voting": "Vote",
"download": "Télécharger"
"download": "Télécharger",
"text": "Texte",
"order": "Ordre",
"normal": "Normal",
"totp": "TOTP",
"player_plural": "Joueurs",
"name": "Nom",
"point": "Point",
"continue": "Continuer",
"backup_code": "Code de sauvegarde",
"results": "Résultats",
"note": "Note",
"score": "Score",
"slide": "Diapositive",
"point_plural": "Points",
"back": "Retour",
"finish": "Finir",
"selected": "Séléctionné",
"select": "Sélectionner",
"quiz": "Quiz",
"answer_plural": "Réponses",
"yes": "Oui",
"no": "Non",
"quiztivity": "Quiztivité",
"next": "Suivant",
"check_choice": "Choix unique",
"progress": "Progrès",
"video": "Vidéo",
"library": "Bibliothèque",
"speed": "Vitesse",
"upload": "Charger",
"files_library": "Bibliothèque de fichiers"
},
"index_page": {
"slogan": "La plateforme de quiz open-source !",
@@ -116,7 +149,7 @@
"list_winners": "Lister les gagnants",
"get_ranking_and_winners": "Avoir le classement et voir les gagnants",
"why_classquiz": "Pourquoi ClassQuiz ?",
"no_tracking_content": "Si Kahoot! vous espionne avec au moins 2 outils tiers étatsuniens, Classquiz lui ne vous trace pas du tout !",
"no_tracking_content": "Kahoot! vous trace et envoie vos données à des services tiers, mais pas Classquiz.",
"self_hostable_content": "ClassQuiz est facilement auto-hébergeable, vos données sont sous contrôle!",
"user_friendly_content": "ClassQuiz se veut simple d'utilisation, de manière à être utilisable par le plus grand nombre.",
"import_quiz_from_kahoot_and_edit": "Importer un quiz depuis Kahoot! et l'éditer sur ClassQuiz",
@@ -129,7 +162,8 @@
"download_quizzes": "Télécharger des quiz",
"community_driven": "Piloté par la communauté",
"download_quizzes_content": "Les quiz peuvent être téléchargés en un seul fichier et importés à tout moment, ce qui vous permet de déplacer facilement vos quiz vers une autre instance de ClassQuiz.",
"community_driven_content": "ClassQuiz dépend de sa communauté pour le financement, les idées de tests, les demandes de fonctionnalités, les traductions et plus encore ! Vous pouvez aussi faire partie de la communauté ClassQuiz !"
"community_driven_content": "ClassQuiz dépend de sa communauté pour le financement, les idées de tests, les demandes de fonctionnalités, les traductions et plus encore ! Vous pouvez aussi faire partie de la communauté ClassQuiz !",
"how_does_classquiz_work": "Comment fonctionne ClassQuiz ?"
},
"overview_page": {
"created_at": "Crée le",
@@ -159,7 +193,24 @@
"delete_question": "Supprimer la question",
"delete_answer": "Supprimer la réponse",
"right_or_true?": "Vrai?",
"not_all_links_imgur_links": "Tout les liens ne sont pas des liens Imgur!"
"not_all_links_imgur_links": "Tout les liens ne sont pas des liens Imgur!",
"bg_image": "Image d'arrière plan",
"slide": {
"rectangle": "Rectangle",
"rectangle_description": "Juste un rectangle",
"text_description": "Texte plus petit et plus long",
"headline": "Titre",
"headline_description": "Un texte en gras pour les titres",
"text": "Texte"
},
"abcd_description": "Une seule réponse peut être choisie",
"voting_description": "Les réponses n'ajoutent aucun point",
"order_description": "Les réponses peuvent être mises dans le bon ordre",
"text_description": "Les joueurs peuvent saisir du texte",
"range_description": "Une plage de chiffres peut être sélectionnée à l'aide d'un curseur.",
"empty": "Vide...",
"no_title": "Aucun titre...",
"check_choice_description": "Toutes les réponses correctes doivent être choisies pour marquer des points."
},
"import_page": {
"need_help": "Besoin d'aide ?",
@@ -182,7 +233,15 @@
"show_next_question": "Montrer la prochaine question",
"start_by_showing_first_question": "Démarrer en montrant la première question.",
"no_answers": "Pas de réponses !",
"stop_time": "Arrêter le temps"
"stop_time": "Arrêter le temps",
"next_question": "Question suivante ({{question}})",
"show_results": "Montrer les résultats",
"stop_time_and_solutions": "Arrêter le temps et montrer des solutions",
"enter_answer_into_field": "Saisissez votre réponse dans le champ de saisie !",
"answers_submitted": "{{answer_count}} Réponses soumises",
"request_export_results": "Demande de téléchargement des résultats",
"save_results": "Sauvegarder les résultats",
"download_export_results": "Télécharger les résultats"
},
"password_reset_page": {
"reset_password": "Réinitialiser le mot de passe"
@@ -195,7 +254,10 @@
"last_seen": "Dernière consultation",
"check_location": "Vérifier la localisation",
"delete_this_session": "Supprimer cette session",
"this_session?": "Cette session ?"
"this_session?": "Cette session ?",
"security_settings": "Paramètres de sécurité",
"change_avatar": "Changer d'avatar",
"add_api_key": "Ajouter une clé API"
},
"explore_page": {
"made_by": "Créé par",
@@ -210,7 +272,12 @@
"end_sentence": "C'est terminé ! Voilà le quizz.",
"2nd_place": "2ème place",
"3rd place": "3ème place",
"with_out_of": "avec {{correct_questions}} sur {{total_question_count}}"
"with_out_of": "avec {{correct_questions}} sur {{total_question_count}}",
"join_description": "Inscrivez-vous sur {{url}} et entrez {{pin}}.",
"join_by_entering_code": "Participez en entrant le code suivant",
"points_added": "Points ajoutés",
"your_score": "Votre score : {{score}}",
"final_result_rank": "{{place}} : {{nom d'utilisateur}} avec {{points}} points"
},
"editor_page": {
"add_an_answer": "Ajouter une réponse",
@@ -229,6 +296,144 @@
"unknown_error_text": "Cela ne devrait pas arriver. C'est probablement ma faute, pas la tienne, mais peut-être que tu as un pouvoir magique pour casser les choses..."
},
"uploader": {
"add_image": "Ajouter une image"
"add_image": "Ajouter une image",
"select_upload_type": "Sélectionnez le type de téléchargement",
"upload_a_video": "Télécharger une vidéo",
"upload_video": "Télécharger la vidéo",
"upload_video_popup_notice": "La fenêtre contextuelle est ouverte ; jetez-y un coup d'œil pour obtenir de plus amples informations."
},
"quiztivity": {
"editor": {
"delete": "Supprimer",
"shares": {
"never_expires": "N'expire jamais",
"add_new_share": "Ajouter un nouveau partage",
"expires_on": "Expire le {{date}}"
},
"move_left": "Déplacer vers la gauche",
"move_right": "Déplacer à droite",
"select_page_type": "Sélectionner le type de page",
"add_new": "Ajouter un nouveau",
"title_placeholder": "Saisir le titre ici",
"open_shares_menu": "Ouvrir le menu Partage"
},
"memory": {
"editor": {
"add_card": "Ajouter une carte",
"upload_image": "Charger une image",
"add_pair": "Ajouter une paire"
}
},
"play": {
"memory": {
"try_count": "Essais : {{try_count}}"
}
},
"share_expired": "Partage expiré"
},
"file_dashboard": {
"size": "Taille : {{size}} Mo",
"filename": "Nom de fichier : {{filename}}",
"uploaded": "Chargé le : {{date}}",
"imported": "Importé : {{yes_or_no}}",
"edit_details": "Éditer",
"alt_text": "Texte alternatif / Légende",
"delete_image": "Supprimer l'image",
"caption": "Légende : {{caption}}",
"not_available": "Non disponible",
"edit_the_image": "Modifier l'image",
"filename_word": "Nom du fichier",
"storage_usage": "Vous avez utilisé {{used}} Mo sur un total de {{total}} Mo de stockage. Cela équivaut à {{percent}}% de votre espace de stockage.",
"missing": "MANQUANT !",
"unset": "Indéfini"
},
"avatar_settings": {
"skin_color": "Couleur de la peau",
"accessories_type": "Lunettes",
"hat_color": "Couleur du chapeau",
"start_over": "Recommencer",
"facial_hair_type": "Pilosité faciale",
"mouth_type": "Bouche",
"eyebrow_type": "Sourcils",
"clothe_type": "Vêtements",
"clothe_graphic_type": "Graphique",
"facial_hair_color": "Couleur de pilosité faciale",
"thats_you": "C'est vous !",
"top_type": "Haut",
"hair_color": "Couleur des cheveux",
"clothe_color": "Couleur de vêtement",
"go_back": "Retour"
},
"results_page": {
"no_results_so_far": "Aucun résultat enregistré jusqu'à présent...",
"general_overview": {
"sentence": "Le quiz \"{{titre}}\", qui a été joué le {{date}} a eu {{player_count}} joueurs avec un score moyen de {{average_score}}."
},
"quiz_title": "Titre du quiz",
"date_played": "Date jouée",
"player_count": "Nombre de joueurs"
},
"start_game": {
"old_school_mode": "Classique",
"start_game": "Lancer le jeu",
"captcha_message": "Si cette option est activée, le ReCaptcha de Google se chargera dans le navigateur des joueurs. N'activez cette option que si vous en avez vraiment besoin, car vous avez besoin du consentement de CHAQUE joueur pour charger le captcha.",
"old_school_mode_description": "Les questions et les images seront affichées à la fois sur l'écran des administrateurs et sur l'écran des joueurs.",
"normal_mode_description": "Questions et réponses ne seront affichées que sur l'écran de l'administrateur, comme dans Kahoot! Les joueurs n'auront que des boutons de couleur avec des symboles correspondants sur l'écran de l'administrateur."
},
"result_page": {
"average_score": "Score moyen : {{average score}}",
"player_name": "Nom du joueur",
"custom_field": "Champ personnalisé",
"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"
},
"security_settings": {
"totp_setup": {
"enter_as_secret_if_no_see_code": "Entrez ceci comme secret si vous ne pouvez pas scanner le QR-code.",
"scan_to_set_up": "Scanner ce QR-code pour configurer le code",
"totp_setup": "Paramètrage Totp",
"do_not_forget_backup_code": "N'oubliez pas de sauvegarder votre code de récupération !"
},
"activate_2fa": "Activer l'authentification à deux facteurs",
"webauthn_available": "Webauthn est disponible",
"totp": "Totp",
"totp_available": "Totp est disponible",
"totp_unavailable": "Totp n'est pas disponible",
"disable_totp": "Désactiver Totp",
"enable_totp": "Activer Totp",
"backup_codes": {
"your_backup_code": "Votre code de sauvegarde",
"save_somewhere_save": "Sauvegardez-le dans un endroit sûr !",
"download_code": "Télécharger le code"
},
"get_backup_code": "Obtenir le code de sauvegarde",
"2fa_activated": "L'authentification à deux facteurs est activée",
"2fa_deactivated": "L'authentification à deux facteurs est désactivée",
"webauthn": "Webauthn",
"add_security_key": "Ajouter une clé de sécurité",
"webauthn_unavailable": "Webauthn n'est pas disponible",
"backup_code": "Code de sauvegarde"
},
"view_quiz_page": {
"made_by": "Créé par",
"view_on_kahoot": "Voir sur Kahoot!"
},
"video_uploader": {
"time_elapsed": "Temps écoulé",
"time_remaining": "Temps restant"
},
"components": {
"popover": {
"copied_to_clipboard": "Copié dans le presse-papiers !"
}
},
"public_user_page": {
"no_original_quizzes": "Cet utilisateur n'a pas de quiz original",
"joined_on": "Rejoint le {{date}}"
},
"navbar": {
"donate": "Faire un don"
}
}
+22 -4
View File
@@ -1,14 +1,32 @@
{
"index_page": {
"meta": {
"title": "Beranda"
"title": "Beranda",
"description": "ClassQuiz adalah aplikasi kuis seperti Kahoot! untuk siswa, yang bersifat open source dan gratis untuk digunakan"
},
"features_description": {
"2": "Fitur utamanya adalah fungsi impor KAHOOT! yang memungkinkan kamu untuk mengimpor kuis dari kuis KAHOOT!.",
"1": "ClassQuiz adalah platform kuis yang memungkinkan kamu untuk membuat dan mengelola kuis."
"2": "Fitur utamanya adalah fungsi impor Kahoot!- ini memungkinkan Anda mengimpor kuis dari Kahoot!-kuis.",
"1": "ClassQuiz adalah platform kuis yang memungkinkan kamu untuk membuat dan mengelola kuis.",
"3": "Editor dan fungsi mengekspor hasil kuis sebagai file Excel adalah fitur khusus dari perangkat lunak ini."
},
"slogan": "Platform kuis sumber-terbuka!",
"stats": "Sudah ada {{user_count}} pengguna dan {{quiz_count}} kuis di ClassQuiz."
"stats": "Sudah ada {{user_count}} pengguna dan {{quiz_count}} kuis di ClassQuiz.",
"no_tracking": "Tanpa pelacakan",
"self_hostable": "Dapat Dihosting Sendiri",
"german_server": "Server Jerman",
"user_friendly": "Mudah digunakan",
"create_a_quiz_from_scratch": "Buat kuis dari awal dengan editor dan sertakan gambar dan lainnya",
"see_all_quizzes": "Lihat semua kuis Anda",
"teachers_site": "Situs guru",
"see_how_many_true_and_false": "Lihat berapa banyak yang benar atau salah",
"students_site": "Situs siswa",
"see_what_true_and_false": "Lihat mana yang benar atau salah",
"create_or_import": "Buat atau Impor",
"completely_free": "Gratis",
"quiz_results_downloadable": "Hasil kuis dapat diunduh",
"multilingual": "Multibahasa",
"dark_mode": "Mode gelap",
"get_a_quiz": "1. Dapatkan kuis"
},
"overview_page": {
"question_count": "Jumlah pertanyaan"
+121 -28
View File
@@ -2,12 +2,12 @@
"index_page": {
"meta": {
"title": "Home",
"description": "ClassQuiz è un'applicazione per quiz come KAHOOT! per gli studenti, è gratuita e open source"
"description": "ClassQuiz è un'app di quiz per gli studenti come Kahoot!, open source e gratuita"
},
"features_description": {
"1": "ClassQuiz è una webapp per creare e gestire quiz.",
"2": "Una delle caratteristiche principali è la possibilità di importare i quiz di KAHOOT!.",
"3": "L'editor delle domande è molto facile da usare, come l'esportazione dei risultati dei quiz in formato Excel."
"2": "La caratteristica principale è una funzione di importazione di Kahoot!che consente di importare quiz da Kahoot!-quiz.",
"3": "L'editor e la funzione di esportazione dei risultati dei quiz in file Excel sono i punti forti del software."
},
"stats": "In ClassQuiz ci sono già {{user_count}} utenti e {{quiz_count}} quiz.",
"see_what_true_and_false": "Verifica cos'è corretto o sbagliato",
@@ -17,21 +17,52 @@
"students_site": "Sito per lo studente",
"slogan": "La piattaforma per quiz open-source!",
"see_all_quizzes": "I tuoi quiz",
"dark_mode": "Modalità Notte"
"dark_mode": "Modalità Notte",
"quiz_results_downloadable_content": "I risultati dei quiz possono essere facilmente esportati in un foglio Excel. (Non sapevo che altri non potessero farlo)",
"multilingual_content": "ClassQuiz è già disponibile in inglese, francese, tedesco, italiano, norvegese, turco, e parzialmente in indonesiano e in catalano.",
"no_tracking_content": "Kahoot! ti traccia e invia le tue informazioni a terza parti, ma ClassQuiz, no.",
"no_tracking": "Senza tracking",
"self_hostable": "È possibile fare hosting autonomo",
"german_server": "Server tedesco",
"user_friendly": "Facile da usare",
"completely_free": "Completamente gratuito",
"quiz_results_downloadable": "I risultati dei quiz possono essere scaricati",
"multilingual": "Multilingue",
"get_a_quiz": "1. Ottieni un quiz",
"create_a_quiz_from_scratch": "Crea un quiz da zero con l'editor, includi immagini e altro ancora",
"find_or_explore": "Trova (o esplora) dei quiz fatti o importati da altre persone",
"import_quiz_from_kahoot_and_edit": "Importa un quiz da Kahoot! e modificalo su ClassQuiz",
"play_quiz": "2. Gioca al quiz",
"select_answer": "Seleziona la risposta",
"choose_answer_wisely": "Scegli la risposta attentamente",
"view_results": "Visualizza i risultati",
"check_if_chosen_wisely": "Controlla se hai scelto correttamente",
"list_winners": "Elenco vincitori",
"get_ranking_and_winners": "Vedi la classifica e chi ha vinto",
"why_classquiz": "Perché ClassQuiz?",
"self_hostable_content": "Con ClassQuiz si può fare facilmente hosting autonomo, così i dati sono in tuo controllo!",
"user_friendly_content": "ClassQuiz cerca di essere semplice, così può essere usato da tutti.",
"dark_mode_content": "Una delle caratteristiche più importanti che un sito web possa avere!",
"german_server_content": "I server di ClassQuiz si trovano in Germania e sono ospitati da netcup.",
"download_quizzes_content": "I quiz possono essere scaricati come un unico file e importati in qualsiasi momento. Questo ti permette di spostare i tuoi quiz in altre istanze di ClassQuiz.",
"download_quizzes": "Scarica i quiz",
"community_driven": "Guidati dalla comunità",
"community_driven_content": "ClassQuiz dipende dalla sua comunità per finanziamenti, idee di test, richieste di funzionalità, traduzioni e altro! Anche tu puoi far parte della comunità di ClassQuiz!",
"completely_free_content": "ClassQuiz è completamente gratuito (per l'utente), senza piani a pagamento o fastidiose richieste di aggiornamento. Le donazioni sono molto apprezzate."
},
"overview_page": {
"created_at": "Creato il",
"question_count": "Numero domande",
"no_quizzes": "Sembra che tu non abbia ancora quiz. Clicca sul pulsante \"Crea\" o importa un quiz da Kahoot!"
"no_quizzes": "Clicca sul pulsante \"Crea\", o importa un quiz da Kahoot! per iniziare."
},
"edit_page": {
"success_update_title": "Quiz importato con successo!",
"success_update_body": "Quiz aggiornato con successo!"
"success_update_title": "Quiz aggiornato.",
"success_update_body": "Nessuno si aspetta l'inquisizione spagnola."
},
"create_page": {
"success": {
"title": "Quiz creato con successo!",
"body": "Quiz creato con successo!"
"title": "Quiz creato.",
"body": "Che i giochi abbiano inizio."
}
},
"register_page": {
@@ -41,36 +72,38 @@
"already_have_account?": "Hai già un account?"
},
"login_page": {
"welcome_back": "Bentornato!",
"welcome_back": "Bentornato.",
"already_have_account": "Non hai un account?",
"modal": {
"success": {
"success_check_mail": "Login effettuato! Verifica la tua mailbox!",
"success": "Login effettuato!",
"success_check_mail": "Accesso effettuato. Controlla la mail.",
"success": "Accesso effettuato.",
"description": {
"success": "Login effettuato con successo!",
"success_check_mail": "Verifica la tua mailbox, dato che dovresti aver ricevuto una email con il link per accedere."
"success": "Accesso effettuato.",
"success_check_mail": "Per favore apri la mail. Troverai un link che puoi cliccare per effettuare l'accesso."
}
},
"error": {
"wrong_creds": "Email o password errate!",
"wrong_creds": "Indirizzo e-mail o password errati.",
"unexpected": "Errore imprevisto!",
"description": {
"unexpected": "È accaduto il buon vecco errore inatteso!",
"wrong_creds": "Verifica che l'email e la password siano corrette!"
"wrong_creds": "Per favore, assicurati che la tua password e l'indirizzo e-mail siano corretti."
}
}
},
"login_or_create_account": "Effettua il login o crea un account"
"login_or_create_account": "Accedi o crea un account",
"email_or_username": "Email o nome utente",
"use_backup_code": "Usa il codice di backup"
},
"words": {
"answer": "Risposta",
"question": "Domanda",
"stats": "Statistiche",
"features": "Caratteristiche",
"login": "Login",
"email": "Email",
"username": "Username",
"login": "Accedi",
"email": "Indirizzo e-mail",
"username": "Nome utente",
"password": "Password",
"play": "Avvia",
"delete": "Elimina",
@@ -78,7 +111,7 @@
"start": "Avvia",
"create": "Crea",
"import": "Importa",
"logout": "Logout",
"logout": "Esci dall'account",
"title": "Titolo",
"url": "URL",
"submit": "Invia",
@@ -111,10 +144,24 @@
"search": "Cerca",
"private": "Privato",
"question_plural": "Domande",
"game_pin": "PIN",
"game_pin": "PIN del gioco",
"other": "altro",
"other_plural": "altri",
"donating": "donazione"
"donating": "donazione",
"practice": "Esercitati",
"error": "Errore",
"voting": "Votazione",
"download": "Scarica",
"text": "Testo",
"order": "ordine",
"totp": "Password momentanea",
"continue": "Continua",
"backup_code": "Codice di backup",
"results": "Risultati",
"note": "Nota",
"player_plural": "Giocatori",
"score": "Punteggio",
"find": "Trova"
},
"editor": {
"right_or_true?": "Corretto?",
@@ -127,7 +174,13 @@
},
"import_page": {
"need_help": "Hai bisogno di aiuto?",
"visit_docs": "Guarda la documentazione"
"visit_docs": "Guarda la documentazione",
"url_should_look_like_this": "L'URL dovrebbe essere così: https://create.kahoot.it/details/...",
"this_side_classquiz": "Da questa parte puoi importare i quiz esportati da ClassQuiz.",
"side_import_kahoot": "Da questa parte è possibile importare i quiz di Kahoot!.",
"a_kahoot_quiz": "Un quiz di Kahoot",
"classquiz_quiz": "Un quiz di ClassQuiz",
"upload_file_ending": "Carica il file che termina con .cqa"
},
"admin_page": {
"already_registered_as_admin": "C'è già un amministratore registrato per questo quiz.",
@@ -138,8 +191,10 @@
"show_next_question": "Mostra la prossima domanda",
"no_answers": "Nessuna risposta!",
"get_final_results": "Risultati finali",
"start_by_showing_first_question": "Avvia mostrando la prima domanda!",
"get_results_and_stop_time": "Raccogli i risultati e ferma il tempo"
"start_by_showing_first_question": "Avvia mostrando la prima domanda.",
"get_results_and_stop_time": "Raccogli i risultati e ferma il tempo",
"stop_time": "Ferma il cronometro",
"save_results": "Salva i risultati"
},
"password_reset_page": {
"reset_password": "Resetta la password"
@@ -170,14 +225,52 @@
"right_click_to_delete": "Clicca col destro sulla risposta per eliminarla!"
},
"search_page": {
"at_least_3_characters": "Digita almento 3 caratteri..."
"at_least_3_characters": "Digita almento 3 caratteri...",
"nothing_here": "Non c'è nulla qua..."
},
"dashboard": {
"search_for_own_quizzes": "Cerca fra i tuoi quiz"
},
"footer": {
"self_ads": "Realizzato con ❤️ da {{mawoka_link}} e con l'aiuto di {{others_link}}.",
"more_details_here": "Maggiorni informazioni qui",
"more_details_here": "Maggiori informazioni qui",
"donate": "Se ritieni questa applicazione utile, considera la possibilità di fare una {{donate_link}}."
},
"avatar_settings": {
"facial_hair_type": "Peli del viso",
"skin_color": "Colore della pelle",
"top_type": "In alto",
"hair_color": "Colore dei capelli",
"eyebrow_type": "Sopracciglio",
"accessories_type": "Occhiali",
"hat_color": "Colore del cappello",
"clothe_type": "Vestiti",
"thats_you": "Sei tu!",
"clothe_graphic_type": "Grafica",
"facial_hair_color": "Colore dei peli del viso",
"mouth_type": "Bocca",
"clothe_color": "Colore dei vestiti",
"start_over": "Ricomincia da capo"
},
"uploader": {
"add_image": "Aggiungi immagine"
},
"results_page": {
"no_results_so_far": "Nessun risultato salvato finora...",
"quiz_title": "Titolo del quiz",
"date_played": "Data della partita",
"player_count": "Numero dei giocatori"
},
"result_page": {
"player_name": "Nome del giocatore",
"custom_field": "Campo personalizzato",
"average_score": "Punteggio medio: {{average_score}}",
"correct_answer": "{{count}} risposta corretta",
"correct_answer_plural": "{{count}} risposte corrette",
"time_taken": "Tempo impiegato"
},
"error_page": {
"404_text": "La pagina che stavi cercando è sparita o non è mai esistita. Chi lo sa?",
"unknown_error_text": "Questo non dovrebbe succedere. Probabilmente è colpa mia, non tua, oppure forse tu hai il potere magico di rompere le cose..."
}
}
+340
View File
@@ -0,0 +1,340 @@
{
"index_page": {
"features_description": {
"1": "ClassQuiz is een quiz-platform waarmee je quizzen kunt maken en beheren.",
"2": "De belangrijkste functie is een Kahoot!-importfunctie waarmee je quizzen van Kahoot! kunt importeren.",
"3": "De editor en de functie voor het exporteren van testresultaten als Excel-bestanden zijn bijzondere hoogtepunten van de software."
},
"create_or_import": "Maken of Importeren",
"see_all_quizzes": "Bekijk al je quizzen",
"no_tracking": "Geen Tracking",
"german_server": "Duitse Server",
"user_friendly": "Gebruikers Vriendelijk",
"completely_free": "Volledig Kostenloos",
"quiz_results_downloadable": "Quiz-resultaten kunnen worden gedownload",
"multilingual": "Meertalig",
"dark_mode": "Donkere Modus",
"create_a_quiz_from_scratch": "Maak een quiz vanaf niks met de editor en voeg afbeeldingen en meer toe",
"find_or_explore": "Zoek (of verken) quizzen gemaakt of geïmporteerd door andere mensen",
"import_quiz_from_kahoot_and_edit": "Importeer een quiz van Kahoot! en bewerk hem in ClassQuiz",
"play_quiz": "2. Speel de quiz",
"select_answer": "Selecteer een antwoord",
"view_results": "Bekijk de resultaten",
"check_if_chosen_wisely": "Check of je goed had gekozen",
"list_winners": "Winnaarslijst",
"get_ranking_and_winners": "Bekijk het klassement en zie wie er gewonnen heeft",
"why_classquiz": "Waarom ClassQuiz?",
"meta": {
"title": "Home",
"description": "ClassQuiz is een quiz-app zoals Kahoot! voor studenten, die open source en gratis te gebruiken is"
},
"get_a_quiz": "1. Verkrijg een quiz",
"user_friendly_content": "ClassQuiz heeft als doel simpel te zijn, zodat iedereen het kan gebruiken.",
"students_site": "Studenten site",
"teachers_site": "Leraar's site",
"slogan": "Het open-source quizplatform!",
"self_hostable": "Zelf-Hostable",
"multilingual_content": "ClassQuiz is beschikbaar in het Engels, Frans, Duits, Italiaans, Noors Bokmål, Turks en gedeeltelijk Indonesisch en Catalaans.",
"dark_mode_content": "Een van de belangrijkste functies die een website kan hebben!",
"community_driven_content": "ClassQuiz is afhankelijk van haar gemeenschap voor financiering, het testen van ideeën, feature requests, vertalingen en meer! U kunt ook deel uitmaken van de ClassQuiz-community!",
"community_driven": "Gedreven door de community",
"stats": "Er zijn al {{user_count}} gebruikers en {{quiz_count}} quizzen op ClassQuiz.",
"see_how_many_true_and_false": "Kijk hoeveel er goed of fout waren",
"see_what_true_and_false": "Zie wat goed of fout was",
"choose_answer_wisely": "Kies je antwoord verstandig",
"no_tracking_content": "Kahoot! volgt je en stuurt die informatie naar partijen van derden, maar ClassQuiz niet.",
"self_hostable_content": "ClassQuiz kan eenvoudig zelf worden gehost, dus de gegevens zijn alleen in jouw beheer!",
"german_server_content": "De servers van ClassQuiz bevinden zich in Duitsland en worden gehost door netcup.",
"completely_free_content": "ClassQuiz is volledig gratis (voor de gebruiker), zonder betaalde abonnementen of vervelende doorverwijzingen naar de upgrade-pagina. Donaties worden zeer op prijs gesteld.",
"download_quizzes": "Download Quizzen",
"download_quizzes_content": "Quizzen kunnen als één bestand worden gedownload en op elk moment worden geïmporteerd. Zo kun je ook je testen verplaatsen naar andere ClassQuiz instanties.",
"quiz_results_downloadable_content": "Quiz-resultaten kunnen gemakkelijk worden geëxporteerd naar een Excel-spreadsheet. (Wist niet dat anderen dat niet konden)",
"how_does_classquiz_work": "Hoe werkt ClassQuiz eigenlijk?"
},
"overview_page": {
"created_at": "Gemaakt op",
"question_count": "Vragenaantal",
"no_quizzes": "Klik op de \"Creëer\"-knop of importeer een quiz van Kahoot! om aan de slag te gaan."
},
"edit_page": {
"success_update_title": "Quiz bijgewerkt.",
"success_update_body": "Niemand zal het onderzoek verwachten."
},
"create_page": {
"success": {
"title": "Quiz gemaakt.",
"body": "Laat het spel beginnen."
}
},
"register_page": {
"greeting": "Leuk je te ontmoeten!",
"create_account": "Account aanmaken",
"forgot_password?": "Wachtwoord vergeten?",
"already_have_account?": "Heb je al een account?"
},
"login_page": {
"welcome_back": "Welkom terug.",
"login_or_create_account": "Log in of maak een account",
"already_have_account": "Heb je geen account?",
"email_or_username": "Email of Gebruikersnaam",
"modal": {
"success": {
"success_check_mail": "Ingelogd. Controleer jouw e-mail.",
"success": "Ingelogd.",
"description": {
"success": "Ingelogd.",
"success_check_mail": "Controleer jouw e-mail voor een mail met een link waarop je kunt klikken om in te loggen."
}
},
"error": {
"wrong_creds": "Verkeerd e-mailadres of wachtwoord.",
"unexpected": "Onverwachte fout!",
"description": {
"wrong_creds": "Zorg ervoor dat jouw wachtwoord en e-mailadres correct zijn.",
"unexpected": "De goede oude onverwachte fout trad op!"
}
}
},
"use_backup_code": "Gebruik backup-code"
},
"words": {
"question": "Vraag",
"answer": "Antwoord",
"stats": "Statistieken",
"email": "E-mailadres",
"username": "Gebruikersnaam",
"password": "Wachtwoord",
"play": "Speel",
"features": "Mogelijkheden",
"login": "Inloggen",
"edit": "Bewerken",
"delete": "Verwijderen",
"public": "Openbaar",
"url": "URL",
"submit": "Versturen",
"connect": "Verbinden",
"pin": "PIN",
"kick": "Eruit gooien",
"register": "Registeren",
"docs": "Documentatie",
"close": "Sluiten",
"save": "Opslaan",
"description": "Beschrijving",
"image": "Afbeelding",
"repeat_password": "Wachtwoord herhalen",
"overview": "Overzicht",
"report": "Melden",
"explore": "Verkennen",
"screenshot": "Schermafbeelding",
"screenshot_plural": "Schermafbeeldingen",
"view": "Bekijken",
"browser": "Browser",
"correct": "Juist",
"result": "Resultaat",
"result_plural": "Resultaten",
"count": "Aantal",
"range": "Bereik",
"private": "Privé",
"other": "andere",
"other_plural": "anderen",
"donating": "doneren",
"find": "Vind",
"practice": "Oefenen",
"error": "Fout",
"voting": "Stemmen",
"download": "Downloaden",
"continue": "Verder",
"backup_code": "Backup-code",
"totp": "TOTP",
"text": "Tekst",
"order": "volgorde",
"results": "Resultaten",
"note": "Opmerking",
"multiple_choice": "Meerkeuze",
"start": "Start",
"create": "Creëren",
"import": "Importeren",
"logout": "Uitloggen",
"title": "Titel",
"settings": "Instellingen",
"player_plural": "Spelers",
"search": "Zoeken",
"game_pin": "Spel PIN",
"dashboard": "Dashboard",
"question_plural": "Vragen",
"score": "Score",
"name": "Naam",
"point": "Punt",
"slide": "Dia",
"finish": "Afronden",
"point_plural": "Punten",
"back": "Terug"
},
"editor": {
"not_all_links_imgur_links": "Niet alle links zijn Imgur-links!",
"time_in_seconds": "Tijd in seconden",
"delete_answer": "Verwijder antwoord",
"right_or_true?": "Juist?",
"add_new_answer": "Nieuw antwoord toevoegen",
"add_new_question": "Nieuwe vraag toevoegen",
"delete_question": "Verwijder vraag",
"bg_image": "Achtergrondafbeelding",
"no_title": "Geen titel...",
"slide": {
"headline_description": "Een vetgedrukte tekst voor koppen",
"headline": "Kop",
"text_description": "Kleinere langere tekst",
"rectangle_description": "Gewoon een rechthoek",
"text": "Tekst",
"rectangle": "Rechthoek"
},
"empty": "Leeg..."
},
"import_page": {
"url_should_look_like_this": "De URL zou er als volgt uit moeten zien: https://create.kahoot.it/details/...",
"side_import_kahoot": "Aan deze kant kun je quizzen van Kahoot! importeren.",
"upload_file_ending": "Upload het bestand eindigend op .cqa",
"this_side_classquiz": "Aan deze kant kun je quizzen importeren die geëxporteerd zijn vanuit ClassQuiz.",
"visit_docs": "Bekijk de documentatie",
"a_kahoot_quiz": "Een Kahoot!-Quiz",
"need_help": "Hulp nodig?",
"classquiz_quiz": "Een ClassQuiz-Quiz"
},
"admin_page": {
"start_game": "Start spel",
"time_left": "Tijd over",
"get_results": "Verkrijg resultaten",
"no_answers": "Geen antwoorden!",
"stop_time": "Stop de tijd",
"save_results": "Resultaten opslaan",
"start_by_showing_first_question": "Start met het vertonen van de eerste vraag.",
"show_next_question": "Toon volgende vraag",
"already_registered_as_admin": "Er is al een beheerder geregistreerd voor dit spel.",
"get_results_and_stop_time": "Verkrijg resultaten en stop de tijd",
"get_final_results": "Verkrijg definitieve resultaten",
"export_results": "Exporteer resultaten",
"next_question": "Volgende Vraag ({{question}})",
"show_results": "Toon resultaten",
"enter_answer_into_field": "Typ je antwoord in het invoerveld!",
"stop_time_and_solutions": "Stop de tijd en toon oplossingen"
},
"password_reset_page": {
"reset_password": "Wachtwoord resetten"
},
"settings_page": {
"old_password": "Oud wachtwoord",
"new_password": "Nieuw wachtwoord",
"repeat_password": "Herhaal wachtwoord",
"change_password_submit": "Wachtwoord aangepast!",
"last_seen": "Laatst gezien",
"check_location": "Check locatie",
"delete_this_session": "Verwijder deze sessie",
"this_session?": "Deze sessie?",
"change_avatar": "Verander avatar",
"security_settings": "Beveiligingsinstellingen"
},
"explore_page": {
"made_by": "Gemaakt door",
"imported_by": "Geïmporteerd door"
},
"search_page": {
"at_least_3_characters": "Voer minimaal 3 tekens in...",
"nothing_here": "Er is hier niks..."
},
"play_page": {
"end_sentence": "Dat was het! Dit was de quiz.",
"1st_place": "1ste Plek",
"2nd_place": "2de Plek",
"3rd place": "3de Plek",
"with_out_of": "met {{correct_questions}} van de {{total_question_count}}",
"final_result_rank": "{{place}}: {{username}} met {{points}} punten",
"points_added": "Punten toegevoegd",
"your_score": "Jouw score: {{score}}",
"join_by_entering_code": "Neem deel door de volgende code in te voeren",
"join_description": "Neem deel via {{url}} en voer {{pin}} in."
},
"editor_page": {
"add_an_answer": "Voeg een antwoord toe",
"right_click_to_delete": "Klik met de rechtermuisknop op een antwoord om het te verwijderen!"
},
"footer": {
"more_details_here": "Meer details hier",
"donate": "Als je dit nuttig vindt overweeg dan {{donate_link}}.",
"self_ads": "Gemaakt met ❤️ door {{mawoka_link}} en met de hulp van {{others_link}}."
},
"error_page": {
"404_text": "De pagina waarnaar je op zoek was, is verdwenen of heeft zelfs nooit bestaan. Wie weet?",
"unknown_error_text": "Dat zou niet mogen gebeuren. Het is waarschijnlijk mijn schuld, niet de jouwe, maar misschien heb je een magische kracht om dingen te breken..."
},
"uploader": {
"add_image": "Afbeelding toevoegen"
},
"avatar_settings": {
"skin_color": "Huidskleur",
"hair_color": "Haarkleur",
"facial_hair_type": "Gezichtshaar",
"facial_hair_color": "Gezichtshaar kleur",
"mouth_type": "Mond",
"eyebrow_type": "Wenkbrauw",
"accessories_type": "Bril",
"hat_color": "Hoed kleur",
"clothe_type": "Kleding",
"clothe_color": "Kleding kleur",
"clothe_graphic_type": "Grafisch",
"thats_you": "Dat ben jij!",
"start_over": "Opnieuw beginnen",
"top_type": "Top",
"go_back": "Ga terug"
},
"results_page": {
"no_results_so_far": "Geen resultaten opgeslagen tot nu toe...",
"quiz_title": "Quiz Titel",
"date_played": "Datum Gespeeld",
"player_count": "Aantal spelers"
},
"result_page": {
"player_name": "Naam speler",
"custom_field": "Aangepast veld",
"average_score": "Gemiddelde score: {{average_score}}",
"correct_answer_plural": "{{count}} juiste antwoorden",
"time_taken": "Benodigde tijd",
"correct_answer": "{{count}} juist antwoord"
},
"dashboard": {
"search_for_own_quizzes": "Zoek voor je eigen quizzen"
},
"security_settings": {
"webauthn": "Webauthn",
"webauthn_available": "Webauthn is beschikbaar",
"webauthn_unavailable": "Webauthn is niet beschikbaar",
"backup_codes": {
"your_backup_code": "Jouw back-up-code",
"download_code": "Download code",
"save_somewhere_save": "Bewaar dit ergens veilig!"
},
"backup_code": "Backup Code",
"get_backup_code": "Verkrijg Backup Code",
"activate_2fa": "Activeer Tweestapsauthenticatie",
"2fa_activated": "Tweestapsauthenticatie is geactiveerd",
"totp_setup": {
"enter_as_secret_if_no_see_code": "Voer dit in als de 'secret' als u de QR-code niet kunt scannen",
"scan_to_set_up": "Scan deze QR-code om de code in te stellen",
"totp_setup": "Totp-Setup",
"do_not_forget_backup_code": "Vergeet niet je herstelcode op te slaan!"
},
"2fa_deactivated": "Tweestapsauthenticatie is gedeactiveerd",
"add_security_key": "Beveiligingssleutel toevoegen",
"totp": "Totp",
"totp_available": "Totp is beschikbaar",
"totp_unavailable": "Totp is niet beschikbaar",
"disable_totp": "Totp uitschakelen",
"enable_totp": "Totp inschakelen"
},
"navbar": {
"donate": "Doneer"
},
"view_quiz_page": {
"made_by": "Gemaakt door",
"view_on_kahoot": "Bekijk op Kahoot!"
}
}
+68
View File
@@ -0,0 +1,68 @@
{
"register_page": {
"already_have_account?": "Har du allereie ein konto?",
"create_account": "Opprett konto"
},
"overview_page": {
"created_at": "Oppretta"
},
"login_page": {
"welcome_back": "Velkomen attende.",
"modal": {
"error": {
"unexpected": "Uventa feil!"
}
}
},
"words": {
"features": "Funksjonar",
"question": "Spørsmål",
"username": "Brukarnamn",
"password": "Passord",
"play": "Spel",
"public": "Offentleg",
"start": "Start",
"email": "E-postadresse",
"edit": "Rediger",
"delete": "Slett"
},
"index_page": {
"features_description": {
"3": "Redigeringsverktyget og mogleheit for å eksportera resultat som Excel-filer er særlege funksjonar å merka seg.",
"1": "ClassQuiz er ein kvissplatform der du kan laga og handsame kvissar.",
"2": "Den viktigaste funksjonen er at du kan importera kvissar frå Kahoot!"
},
"stats": "Det er allereie {{user_count}} brukarar og {{quiz_count}} kvissar på ClassQuiz.",
"see_how_many_true_and_false": "Sjå kor mange som hadde rett eller feil",
"see_all_quizzes": "Sjå kvissane dine",
"teachers_site": "Læraren si side",
"students_site": "Eleven si side",
"no_tracking": "Inga sporing",
"german_server": "Tjenarmaskin i Tyskland",
"user_friendly": "Brukarvenleg",
"completely_free": "Heilt gratis",
"quiz_results_downloadable": "Du kan lasta ned kvissresultat",
"multilingual": "Fleirspråkleg",
"get_a_quiz": "1. Vel ein kviss",
"find_or_explore": "Finn kvissar laga eller importert av andre",
"import_quiz_from_kahoot_and_edit": "Importer ein kviss frå Kahoot! og rediger han i ClassQuiz",
"play_quiz": "2. Spel kvissen",
"select_answer": "Vel eit svar",
"choose_answer_wisely": "Vel med omhug",
"view_results": "Sjå resultata",
"check_if_chosen_wisely": "Sjekk om du hadde rett",
"list_winners": "List opp vinnarane",
"why_classquiz": "Kvifor ClassQuiz?",
"self_hostable": "Køyr frå eigen vert",
"dark_mode": "Mørk drakt",
"no_tracking_content": "Kahoot! sporar deg og sender info til tredjepart, det gjer ikkje ClassQuiz.",
"slogan": "Den opne kvissplatformen!",
"meta": {
"description": "ClassQuiz er ein kvissapp som Kahoot! for elever, som har open kjeldekode og er gratis",
"title": "Heim"
},
"see_what_true_and_false": "Sjå kva som er rett og gale",
"create_or_import": "Lag eller importer",
"create_a_quiz_from_scratch": "Lag ein ny kviss frå botnen av og ta med bilete og meir"
}
}
+178 -26
View File
@@ -24,7 +24,7 @@
"quiz_results_downloadable": "Wyniki quizu można pobrać",
"multilingual": "Wielojęzyczny",
"dark_mode": "Tryb ciemny",
"get_a_quiz": "1. Pobierz quiz",
"get_a_quiz": "1. Pozyskaj quiz",
"find_or_explore": "Znajdź (lub odkryj) quizy stworzone lub zaimportowane przez innych ludzi",
"import_quiz_from_kahoot_and_edit": "Zaimportuj quiz z Kahoot! i edytuj go w ClassQuiz",
"play_quiz": "2. Uruchom quiz",
@@ -43,12 +43,13 @@
"download_quizzes": "Pobierz quizy",
"download_quizzes_content": "Quizy mogą być pobierane jako jeden plik i importowane w dowolnym momencie. Pozwala to również na przenoszenie quizów do innych instancji ClassQuiz.",
"community_driven_content": "ClassQuiz polega na swojej społeczności w zakresie finansowania, testowania pomysłów, próśb o nowe funkcje, tłumaczeń i nie tylko! Ty też możesz być częścią społeczności ClassQuiz!",
"community_driven": "Napędzane przez społeczność",
"community_driven": "Napędzany przez społeczność",
"students_site": "Strona ucznia",
"create_a_quiz_from_scratch": "Stwórz quiz od podstaw za pomocą edytora i dołącz zdjęcia i więcej",
"create_a_quiz_from_scratch": "Stwórz quiz od podstaw za pomocą edytora, dołącz zdjęcia i więcej",
"multilingual_content": "ClassQuiz jest dostępny w języku angielskim, francuskim, niemieckim, włoskim, norweskim bokmål, tureckim oraz częściowo indonezyjskim i katalońskim.",
"quiz_results_downloadable_content": "Wyniki quizu można łatwo wyeksportować do arkusza kalkulacyjnego Excel. (Nie wiem, dlaczego inni nie mogli tego zrobić)",
"dark_mode_content": "Jedna z najważniejszych funkcji, jakie może mieć strona internetowa!"
"dark_mode_content": "Jedna z najważniejszych funkcji, jakie może mieć strona internetowa!",
"how_does_classquiz_work": "Jak działa ClassQuiz?"
},
"overview_page": {
"question_count": "Liczba pytań",
@@ -78,11 +79,11 @@
"email_or_username": "Adres e-mail lub nazwa użytkownika",
"modal": {
"success": {
"success_check_mail": "Zalogowany. Sprawdź swoją skrzynkę e-mail.",
"success": "Zalogowany.",
"success_check_mail": "Zalogowano. Sprawdź swoją skrzynkę e-mail.",
"success": "Zalogowano.",
"description": {
"success_check_mail": "Sprawdź swoją skrzynkę e-mail i poszukaj wiadomości z linkiem, który możesz kliknąć, aby się zalogować.",
"success": "Zalogowany."
"success": "Zalogowano."
}
},
"error": {
@@ -116,7 +117,7 @@
"screenshot_plural": "Zrzuty ekranu",
"browser": "Przeglądarka",
"view": "Widok",
"result": "Wyniki",
"result": "Wynik",
"result_plural": "Wyniki",
"range": "Zakres",
"multiple_choice": "Wielokrotny wybór",
@@ -124,12 +125,12 @@
"question_plural": "Pytania",
"game_pin": "PIN do gry",
"other": "inne",
"other_plural": "inni",
"donating": "darowizna",
"other_plural": "innych",
"donating": "dotację",
"find": "Szukaj",
"practice": "Ćwiczenia",
"error": "Błąd",
"register": "Zarejestruj",
"register": "Zarejestruj się",
"docs": "Dokumentacja",
"close": "Zamknij",
"save": "Zapisz",
@@ -154,14 +155,36 @@
"image": "Obraz",
"overview": "Przegląd",
"report": "Raport",
"explore": "Odkryj",
"correct": "Prawidłowo",
"explore": "Przeglądaj",
"correct": "Poprawnie",
"count": "Liczba",
"private": "Prywatny",
"backup_code": "Kod zapasowy",
"submit": "Prześlij",
"kick": "Wykop",
"order": "kolejność"
"order": "Kolejność",
"name": "Nazwa",
"point": "Punkt",
"slide": "Slajd",
"point_plural": "Punkty",
"back": "Wróć",
"finish": "Zakończ",
"normal": "Normalny",
"select": "wybierz",
"quiz": "Quiz",
"quiztivity": "Quiztivity",
"check_choice": "Sprawdź wybór",
"selected": "Wybrano",
"next": "Następny",
"progress": "Postęp",
"video": "Film",
"library": "Biblioteka",
"speed": "Prędkość",
"upload": "Prześlij",
"files_library": "Biblioteka plików",
"answer_plural": "Odpowiedzi",
"no": "nie",
"yes": "Tak"
},
"settings_page": {
"last_seen": "Ostatnio widziany",
@@ -171,7 +194,10 @@
"new_password": "Nowe hasło",
"repeat_password": "Powtórz hasło",
"this_session?": "Ta sesja?",
"change_password_submit": "Zmień hasło!"
"change_password_submit": "Zmień hasło!",
"change_avatar": "Zmień awatar",
"security_settings": "Ustawienia bezpieczeństwa",
"add_api_key": "Dodaj klucz API"
},
"editor_page": {
"add_an_answer": "Dodaj odpowiedź",
@@ -179,15 +205,19 @@
},
"footer": {
"self_ads": "Wykonane z ❤️ przez {{mawoka_link}} i z pomocą {{others_link}}.",
"more_details_here": "Więcej szczegółów tutaj",
"donate": "Jeśli uznasz to za przydatne, rozważ {{donate_link}}."
"more_details_here": "Więcej szczegółów znajdziesz tutaj",
"donate": "Jeśli uznasz to za użyteczne, rozważ {{donate_link}}."
},
"error_page": {
"404_text": "Strona, której szukałeś, zniknęła lub nawet nigdy nie istniała. Kto wie?",
"unknown_error_text": "To nie powinno się zdarzyć. To pewnie moja wina, nie twoja, ale może masz magiczną moc niszczenia rzeczy..."
},
"uploader": {
"add_image": "Dodaj obraz"
"add_image": "Dodaj obraz",
"select_upload_type": "Wybierz typ przesyłania",
"upload_a_video": "Prześlij film",
"upload_video_popup_notice": "Wyskakujące okienko jest otwarte; spójrz na nie, aby uzyskać więcej informacji",
"upload_video": "Prześlij film"
},
"editor": {
"time_in_seconds": "Czas w sekundach",
@@ -196,7 +226,24 @@
"delete_question": "Usuń pytanie",
"delete_answer": "Usuń odpowiedź",
"not_all_links_imgur_links": "Nie wszystkie linki prowadzą do Imgur!",
"right_or_true?": "Prawda?"
"right_or_true?": "Prawda?",
"no_title": "Brak tytułu...",
"empty": "Pusto...",
"bg_image": "Obraz tła",
"slide": {
"headline": "Nagłówek",
"headline_description": "Pogrubiony tekst dla nagłówków",
"text": "Tekst",
"text_description": "Mniejszy dłuższy tekst",
"rectangle": "Prostokąt",
"rectangle_description": "Tylko prostokąt"
},
"abcd_description": "Można wybrać tylko jedną odpowiedź",
"voting_description": "Odpowiedzi nie dodają żadnych punktów",
"check_choice_description": "Aby zdobyć punkty, należy wybrać wszystkie poprawne odpowiedzi",
"order_description": "Odpowiedzi można ustawić w odpowiedniej kolejności",
"text_description": "Gracze mogą wprowadzać tekst",
"range_description": "Zakres liczbowy można wybrać za pomocą suwaka"
},
"import_page": {
"need_help": "Potrzebujesz pomocy?",
@@ -220,7 +267,14 @@
"stop_time": "Zatrzymaj czas",
"save_results": "Zapisz wyniki",
"get_results_and_stop_time": "Uzyskaj wyniki i zatrzymaj czas",
"get_final_results": "Wyniki ostateczne"
"get_final_results": "Wyniki ostateczne",
"next_question": "Następne pytanie ({{question}})",
"show_results": "Pokaż wyniki",
"stop_time_and_solutions": "Zatrzymaj czas i pokaż rozwiązania",
"enter_answer_into_field": "Wprowadź swoją odpowiedź w pole wejściowe!",
"answers_submitted": "{{answer_count}} Przesłane odpowiedzi",
"request_export_results": "Żądanie pobrania wyników",
"download_export_results": "Wyniki pobierania"
},
"password_reset_page": {
"reset_password": "Zresetuj hasło"
@@ -234,11 +288,16 @@
"2nd_place": "2. miejsce",
"3rd place": "3. miejsce",
"with_out_of": "z {{correct_questions}} na {{total_question_count}}",
"end_sentence": "To jest to! To był quiz."
"end_sentence": "To jest to! To był quiz.",
"your_score": "Twój wynik: {{score}}",
"join_description": "Dołącz na {{url}} i wpisz {{pin}}.",
"join_by_entering_code": "Dołącz do nas wpisując następujący kod",
"points_added": "Dodane punkty",
"final_result_rank": "{{place}}: {{username}} z {{points}} punktów"
},
"explore_page": {
"made_by": "Wykonane przez",
"imported_by": "Importowane przez"
"imported_by": "Zaimportowane przez"
},
"avatar_settings": {
"skin_color": "Kolorystyka",
@@ -249,18 +308,22 @@
"clothe_type": "Odzież",
"clothe_graphic_type": "Grafika",
"start_over": "Zacznij od nowa",
"thats_you": "To ty!",
"thats_you": "To Ty!",
"clothe_color": "Kolor odzieży",
"hair_color": "Kolor włosów",
"facial_hair_type": "Zarost",
"facial_hair_color": "Kolor zarostu",
"top_type": "Góra"
"top_type": "Góra",
"go_back": "Wstecz"
},
"results_page": {
"quiz_title": "Tytuł quizu",
"player_count": "Liczba graczy",
"no_results_so_far": "Do tej pory nie zapisano żadnych wyników...",
"date_played": "Data gry"
"date_played": "Data gry",
"general_overview": {
"sentence": "W quizie \"{{title}}\", który został rozegrany {{date}} wzięło udział {{player_count}} graczy ze średnim wynikiem {{average_score}}."
}
},
"result_page": {
"player_name": "Nazwa gracza",
@@ -268,9 +331,98 @@
"average_score": "Średni wynik: {{average_score}}",
"correct_answer": "{{count}} poprawna odpowiedź",
"correct_answer_plural": "{count}} poprawne odpowiedzi",
"time_taken": "Czas trwania"
"time_taken": "Czas trwania",
"player_score": "Wynik gracza"
},
"dashboard": {
"search_for_own_quizzes": "Wyszukaj własne quizy"
},
"navbar": {
"donate": "Darowizna"
},
"security_settings": {
"backup_code": "Kod zapasowy",
"get_backup_code": "Pobierz kod zapasowy",
"activate_2fa": "Aktywuj uwierzytelnianie dwuskładnikowe",
"2fa_activated": "Uaktywniono uwierzytelnianie dwuskładnikowe",
"2fa_deactivated": "Uwierzytelnianie dwuskładnikowe jest wyłączone",
"backup_codes": {
"your_backup_code": "Twój kod zapasowy",
"save_somewhere_save": "Zapisz to gdzieś w bezpiecznym miejscu!",
"download_code": "Pobierz kod"
},
"totp_setup": {
"do_not_forget_backup_code": "Nie zapomnij zapisać swojego kodu odzyskiwania!",
"scan_to_set_up": "Zeskanuj ten kod QR, aby ustawić kod"
}
},
"view_quiz_page": {
"made_by": "Wykonane przez",
"view_on_kahoot": "Zobacz na Kahoot!"
},
"start_game": {
"captcha_message": "Jeśli ta opcja jest włączona, Google ReCaptcha będzie ładować się w przeglądarce graczy. Włącz tylko jeśli naprawdę tego potrzebujesz, ponieważ będziesz potrzebował zgody KAŻDEGO gracza na załadowanie captcha.",
"normal_mode_description": "Pytanie i odpowiedź będą wyświetlane tylko na ekranie administratora, jak w Kahoot! Gracze będą mieli do dyspozycji jedynie kolorowe przyciski z symbolami odpowiadającymi tym na ekranie administratora.",
"old_school_mode_description": "Pytania i obrazy będą wyświetlane zarówno na ekranie administratora, jak i na ekranie graczy",
"start_game": "Rozpocznij grę"
},
"quiztivity": {
"editor": {
"move_right": "Przesuń w prawo",
"shares": {
"expires_on": "Wygasa w dniu {{date}}",
"never_expires": "Nigdy nie wygasa",
"add_new_share": "Dodaj nowy udział"
},
"add_new": "Dodaj nowy",
"move_left": "Przesuń w lewo",
"delete": "Usuń",
"select_page_type": "Wybierz typ strony",
"title_placeholder": "Wpisz tytuł",
"open_shares_menu": "Otwórz menu Udziały"
},
"memory": {
"editor": {
"upload_image": "Wyślij obraz",
"add_pair": "Dodaj parę",
"add_card": "Dodaj kartę"
}
},
"play": {
"memory": {
"try_count": "Próby: {{try_count}}"
}
},
"share_expired": "Udział wygasł"
},
"components": {
"popover": {
"copied_to_clipboard": "Skopiowano do schowka!"
}
},
"public_user_page": {
"joined_on": "Dołączył {{date}}",
"no_original_quizzes": "Ten użytkownik nie ma jeszcze żadnych oryginalnych quizów"
},
"file_dashboard": {
"not_available": "Niedostępne",
"size": "Rozmiar: {{size}} Mib",
"uploaded": "Przesłano: {{date}}",
"Imported": "Importowane: {{yes_or_no}}",
"edit_details": "Edytuj szczegóły",
"delete_image": "Usuń obraz",
"edit_the_image": "Edytuj obraz",
"filename_word": "Nazwa pliku",
"storage_usage": "Użyłeś {{used}} Mib z {{łącznie}} MiB pamięci. Odpowiada to {{percent}}% twojej przestrzeni dyskowej.",
"imported": "Importowane: {{yes_or_no}}",
"missing": "ZAGINIONY!",
"unset": "Nieustawiony",
"caption": "Napis: {{caption}}",
"filename": "Nazwa pliku: {{nazwa pliku}}",
"alt_text": "Tekst alternatywny / Napis"
},
"video_uploader": {
"time_elapsed": "Upłynęło",
"time_remaining": "Pozostały czas:"
}
}
+214 -9
View File
@@ -24,7 +24,7 @@
"completely_free": "Tamamen Ücretsiz",
"quiz_results_downloadable": "Quiz sonuçları indirilebilir",
"multilingual": "Çok dilli",
"no_tracking_content": "Kahoot! en az iki Amerikan üçüncü taraflarla sizi takip eder, oysaki ClassQuiz bunu yapmaz!",
"no_tracking_content": "Kahoot! sizi takip eder ve bu bilgiyi üçüncü taraflara gönderir ama ClassQuiz bunu yapmaz.",
"german_server": "Alman Sunucusu",
"user_friendly": "Kullanıcı Dostu",
"list_winners": "Kazananları listele",
@@ -48,7 +48,8 @@
"community_driven_content": "ClassQuiz fonlama, fikirleri test etme, özellik istekleri, çeviriler ve daha fazlası için kendi topluluğuna bağlıdır! Siz de ClassQuiz topluluğunun bir parçası olabilirsiniz!",
"community_driven": "Topluluk güdümlü",
"download_quizzes": "Quizleri İndir",
"download_quizzes_content": "Quizler bir dosya olarak indirilebilir ve her zaman içe aktarılabilir. Bu ayrıca quizlerinizi diğer ClassQuiz sunucularına taşımanıza da olanak sağlar."
"download_quizzes_content": "Quizler bir dosya olarak indirilebilir ve her zaman içe aktarılabilir. Bu ayrıca quizlerinizi diğer ClassQuiz sunucularına taşımanıza da olanak sağlar.",
"how_does_classquiz_work": "ClassQuiz nasıl çalışıyor?"
},
"login_page": {
"already_have_account": "Bir hesabınız yok mu?",
@@ -71,7 +72,9 @@
}
}
},
"welcome_back": "Tekrar hoş geldiniz."
"welcome_back": "Tekrar hoş geldiniz.",
"use_backup_code": "Yedek kod kullanın",
"email_or_username": "E-posta veya Kullanıcı Adı"
},
"words": {
"answer": "Cevap",
@@ -129,7 +132,38 @@
"error": "Hata",
"practice": "Alıştırma",
"find": "Bul",
"download": "İndir"
"download": "İndir",
"answer_plural": "Cevaplar",
"yes": "Evet",
"no": "Hayır",
"normal": "Normal",
"continue": "Devam Et",
"backup_code": "Yedek Kod",
"totp": "TOTP",
"text": "Metin",
"finish": "Bitir",
"order": "Sıralama",
"results": "Sonuçlar",
"note": "Not",
"player_plural": "Oyuncular",
"score": "Skor",
"name": "Ad",
"back": "Geri",
"point": "Puan",
"point_plural": "Puanlar",
"slide": "Slayt",
"quiz": "Quiz",
"next": "Sonraki",
"check_choice": "Seçeneği İşaretle",
"select": "Seç",
"selected": "Seçilmiş",
"quiztivity": "Quiz Aktivitesi",
"library": "Kütüphane",
"speed": "Hız",
"upload": "Yükle",
"video": "Video",
"files_library": "Dosya Kütüphanesi",
"progress": "İlerleme"
},
"register_page": {
"create_account": "Hesap oluştur",
@@ -163,7 +197,15 @@
"show_next_question": "Bir sonraki soruyu göster",
"start_by_showing_first_question": "İlk soruyu göstererek başla.",
"no_answers": "Cevap yok!",
"stop_time": "Zamanı durdur"
"stop_time": "Zamanı durdur",
"answers_submitted": "{{answer_count}} Gönderilen cevaplar",
"request_export_results": "Sonuç indirmeyi talep et",
"download_export_results": "Sonuçları indir",
"save_results": "Sonuçları kaydet",
"show_results": "Sonuçları göster",
"stop_time_and_solutions": "Zamanı durdurun ve çözümleri gösterin",
"enter_answer_into_field": "Cevabınızı giriş alanına girin!",
"next_question": "Sonraki Soru ({{question}})"
},
"editor": {
"time_in_seconds": "Saniye cinsinden zaman",
@@ -172,7 +214,24 @@
"delete_question": "Soruyu sil",
"delete_answer": "Cevabı sil",
"add_new_answer": "Yeni cevap ekle",
"add_new_question": "Yeni soru ekle"
"add_new_question": "Yeni soru ekle",
"slide": {
"text": "Metin",
"headline": "Başlık",
"rectangle": "Dikdörtgen",
"rectangle_description": "Sadece bir dikdörtgen",
"headline_description": "Başlıklar için kalın bir metin",
"text_description": "Daha küçük ve daha uzun metin"
},
"bg_image": "Arkaplan resmi",
"empty": "Boş...",
"abcd_description": "Sadece bir cevap seçilebilir",
"voting_description": "Cevaplar puan eklemez",
"order_description": "Cevaplar doğru sıralamaya getirilebilir",
"range_description": "Kaydırıcı ile bir sayı aralığı seçilebilir",
"check_choice_description": "Puan toplamak için tüm doğru cevapların seçilmesi gerekir",
"text_description": "Oyuncular metin girebilir",
"no_title": "Başlık yok..."
},
"import_page": {
"visit_docs": "Dokümantasyonu ziyaret edin",
@@ -195,7 +254,10 @@
"change_password_submit": "Şifreyi değiştir!",
"last_seen": "Son görüldüğü tarih",
"delete_this_session": "Bu oturumu sil",
"old_password": "Eski şifre"
"old_password": "Eski şifre",
"add_api_key": "API anahtarı ekle",
"change_avatar": "Avatarı değiştir",
"security_settings": "Güvenlik Ayarları"
},
"explore_page": {
"made_by": "Oluşturan:",
@@ -210,7 +272,12 @@
"end_sentence": "Hepsi bu kadar! Quiz buydu.",
"1st_place": "1. Sıra",
"2nd_place": "2. Sıra",
"3rd place": "3. Sıra"
"3rd place": "3. Sıra",
"final_result_rank": "{{place}}: {{username}} ile {{points}} puan",
"join_description": "{{url}} adresinden katılın ve {{pin}} kodunu girin.",
"points_added": "Puanlar eklendi",
"your_score": "Skorunuz: {{score}}",
"join_by_entering_code": "Aşağıdaki kodu girerek katılın"
},
"editor_page": {
"add_an_answer": "Bir cevap ekle",
@@ -229,6 +296,144 @@
"unknown_error_text": "Bu gerçekleşmemeliydi. Muhtemelen benim hatam, sizin değil, ama belki sizde de bir şeyleri kırabilecek büyülü bir güç var..."
},
"uploader": {
"add_image": "Resim ekle"
"add_image": "Resim ekle",
"upload_a_video": "Bir Video Yükleyin",
"upload_video_popup_notice": "Açılır pencere açık; daha fazla bilgi için göz atın",
"select_upload_type": "Yükleme Türünü Seçin",
"upload_video": "Video Yükle"
},
"avatar_settings": {
"start_over": "Baştan başla",
"top_type": "Üst",
"hair_color": "Saç rengi",
"mouth_type": "Ağız",
"eyebrow_type": "Kaş",
"go_back": "Geri git",
"skin_color": "Ten rengi",
"facial_hair_type": "Yüz kılları",
"hat_color": "Şapka rengi",
"clothe_type": "Giysiler",
"clothe_graphic_type": "Grafik",
"thats_you": "Bu sensin!",
"facial_hair_color": "Yüz kıl rengi",
"clothe_color": "Giysi rengi",
"accessories_type": "Gözlük"
},
"security_settings": {
"backup_code": "Yedek Kod",
"activate_2fa": "İki Faktörlü Kimlik Doğrulamayı Etkinleştirme",
"get_backup_code": "Yedekleme Kodu Alın",
"enable_totp": "TOTP'yi etkinleştir",
"webauthn": "Webauthn",
"webauthn_available": "Webauthn kullanılabilir",
"totp": "TOTP",
"totp_available": "Totp kullanılabilir",
"totp_unavailable": "TOTP kullanılamaz",
"disable_totp": "TOTP'yi devre dışı bırak",
"backup_codes": {
"your_backup_code": "Yedekleme Kodunuz",
"save_somewhere_save": "Bunu güvenli bir yere sakla!",
"download_code": "Kodu indirin"
},
"totp_setup": {
"do_not_forget_backup_code": "Kurtarma kodunuzu kaydetmeyi unutmayın!",
"totp_setup": "TOTP Kurulumu",
"enter_as_secret_if_no_see_code": "QR kodunu tarayamıyorsanız bunu sır olarak girin",
"scan_to_set_up": "Kodu ayarlamak için bu QR kodunu tarayın"
},
"2fa_activated": "İki Faktörlü kimlik doğrulama etkinleştirildi",
"2fa_deactivated": "İki Faktörlü kimlik doğrulama devre dışı bırakıldı",
"webauthn_unavailable": "Webauthn kullanılamaz",
"add_security_key": "Güvenlik Anahtarı Ekle"
},
"result_page": {
"time_taken": "Alınan zaman",
"player_name": "Oyuncu adı",
"custom_field": "Özel alan",
"correct_answer_plural": "{{count}} doğru cevaplar",
"correct_answer": "{{count}} doğru cevap",
"player_score": "Oyuncu Skoru",
"average_score": "Ortalama skor: {{average_score}}"
},
"quiztivity": {
"editor": {
"move_left": "Sola hareket et",
"move_right": "Sağa hareket et",
"title_placeholder": "Başlığı buraya girin",
"add_new": "Yeni ekle",
"delete": "Sil",
"shares": {
"expires_on": "{{date}} tarihinde sona erer",
"never_expires": "Asla sona ermez",
"add_new_share": "Yeni Paylaşım Ekle"
},
"open_shares_menu": "Paylaşımlar menüsünü açın",
"select_page_type": "Sayfa Türünü Seçin"
},
"share_expired": "Paylaşım süresi doldu",
"memory": {
"editor": {
"add_card": "Kart ekle",
"upload_image": "Resim yükle",
"add_pair": "Çift ekle"
}
},
"play": {
"memory": {
"try_count": "Denemeler: {{try_count}}"
}
}
},
"file_dashboard": {
"not_available": "Mevcut değil",
"missing": "KAYIP!",
"caption": "Başlık: {{caption}}",
"filename": "Dosya adı: {{filename}}",
"size": "Boyut: {{size}} Mib",
"unset": "Ayarı kaldır",
"edit_details": "Ayrıntıları düzenle",
"edit_the_image": "Görüntüyü düzenleyin",
"filename_word": "Dosya adı",
"uploaded": "Yüklendi: {{tarih}}",
"alt_text": "Alt(ernatif) metin / Başlık",
"imported": "İçeri aktarıldı: {{yes_or_no}}",
"delete_image": "Resmi sil",
"storage_usage": "{{total}} MiB depolama alanınızın {{used}} MiB kadarını kullandınız. Bu, depolama alanınızın %{{percent}} kadarına eşdeğerdir."
},
"video_uploader": {
"time_remaining": "Kalan süre",
"time_elapsed": "Geçen süre"
},
"results_page": {
"no_results_so_far": "Şimdiye kadar kaydedilen sonuç yok...",
"quiz_title": "Quiz Başlığı",
"date_played": "Oynandığı Tarih",
"player_count": "Oyuncu sayısı",
"general_overview": {
"sentence": "Quiz \"{{title}}\", which was played on {{date}} tarihinde oynandı ve {{average_score}} ortalama skoruyla {{player_count}} oyuncusu vardı."
}
},
"navbar": {
"donate": "Bağışta Bulun"
},
"view_quiz_page": {
"made_by": "Oluşturan:",
"view_on_kahoot": "Kahoot'ta görüntüle!"
},
"start_game": {
"captcha_message": "Etkinleştirilirse, Google'ın ReCaptcha'sı oyuncuların tarayıcısına yüklenir. Captcha'yı yüklemek için HER oyuncunun onayına ihtiyacınız olduğundan, yalnızca gerçekten ihtiyacınız varsa etkinleştirin.",
"old_school_mode": "Eski Usul",
"normal_mode_description": "Soru ve cevaplar Kahoot! gibi sadece yöneticilerin ekranında gösterilecektir. Oyuncular sadece yöneticinin ekranında bunlarla eşleşen sembollere sahip renkli düğmelere sahip olacaklar.",
"old_school_mode_description": "Sorular ve görüntüler hem yöneticilerin ekranında hem de oyuncuların ekranında gösterilecektir",
"start_game": "Oyunu Başlat"
},
"public_user_page": {
"joined_on": "{{date}} tarihinde katıldı",
"no_original_quizzes": "Bu kullanıcının hiç orijinal quizi yok"
},
"components": {
"popover": {
"copied_to_clipboard": "Panoya kopyalandı!"
}
}
}
+43 -8
View File
@@ -31,14 +31,23 @@
"stats": "ClassQuiz 上已有 {{user_count}} 位使用者和 {{quiz_count}} 個測驗。",
"features_description": {
"1": "ClassQuiz 是一個可以讓你建立和管理測驗的測驗平台。",
"2": "最主要的功能之一是允許你匯入 Kahoot! 上的測驗。"
"2": "最主要的功能之一是允許你匯入 Kahoot! 上的測驗。",
"3": "編輯器和可將測驗結果匯出為 Excel 檔為此軟體的幾個亮點。"
},
"quiz_results_downloadable": "可被下載的測驗結果",
"find_or_explore": "搜尋 (或瀏覽) 其他人建立和匯入的測驗",
"no_tracking_content": "Kahoot! 會追蹤並將你的資料分享給第三方,但 ClassQuiz 不會。",
"user_friendly_content": "ClassQuiz 旨在簡單,每個人都可以輕鬆使用。",
"dark_mode_content": "一個網站最重要的功能之一!",
"german_server_content": "ClassQuiz 的伺服器位於德國,並由 netcup 提供。"
"german_server_content": "ClassQuiz 的伺服器位於德國,並由 netcup 提供。",
"how_does_classquiz_work": "ClassQuiz 是如何運作的?",
"see_how_many_true_and_false": "看看有多少人是對的或錯的",
"completely_free_content": "ClassQuiz 完全免費 (對用戶而言),沒有任何訂閱機制,也不會惱人地重新定向至升級頁面。你的捐贈我們不勝感激。",
"see_what_true_and_false": "看看什麼是對的,什麼是錯的",
"self_hostable_content": "輕鬆地自行架設 ClassQuiz ,資料只由你掌控!",
"quiz_results_downloadable_content": "測驗結果可以簡單地被匯出為 Excel 試算表。(不知道其他類似的服務沒有提供此功能)",
"community_driven": "社群驅動",
"community_driven_content": "ClassQuiz 依靠它的社群提供金援、測試想法、功能請求、翻譯...等!你也可以成為 ClassQuiz 的社群成員之一!"
},
"edit_page": {
"success_update_title": "測驗已更新。"
@@ -84,7 +93,13 @@
"add_new_question": "新增問題",
"delete_answer": "刪除答案",
"add_new_answer": "新增答案",
"not_all_links_imgur_links": "不是所有連結都是 Imgur 連結!"
"not_all_links_imgur_links": "不是所有連結都是 Imgur 連結!",
"right_or_true?": "正確?",
"no_title": "沒有標題...",
"bg_image": "背景圖片",
"slide": {
"rectangle": "長方形"
}
},
"import_page": {
"need_help": "需要幫助?",
@@ -108,7 +123,11 @@
"no_answers": "沒有答案!",
"start_by_showing_first_question": "開始時顯示第一個問題。",
"save_results": "儲存結果",
"already_registered_as_admin": "此遊戲已有一位管理員。"
"already_registered_as_admin": "此遊戲已有一位管理員。",
"stop_time_and_solutions": "停止計時並顯示解答",
"enter_answer_into_field": "在輸入欄內輸入你的答案!",
"show_results": "顯示結果",
"next_question": "下個問題 ({{question}})"
},
"password_reset_page": {
"reset_password": "重設密碼"
@@ -120,7 +139,8 @@
"change_password_submit": "變更密碼!",
"check_location": "檢查位置",
"delete_this_session": "刪除此工作階段",
"this_session?": "這個工作階段?"
"this_session?": "這個工作階段?",
"security_settings": "安全性設定"
},
"explore_page": {
"made_by": "作者為",
@@ -131,7 +151,9 @@
"3rd place": "第三名",
"with_out_of": "{{total_question_count}} 題中答對 {{correct_questions}} 題",
"1st_place": "第一名",
"end_sentence": "完成!本次測驗結束。"
"end_sentence": "完成!本次測驗結束。",
"your_score": "",
"join_description": "在 {{url}} 加入並輸入 {{pin}}。"
},
"editor_page": {
"add_an_answer": "新增一個答案",
@@ -204,7 +226,14 @@
"connect": "連線",
"results": "結果",
"score": "分數",
"player_plural": "玩家"
"player_plural": "玩家",
"finish": "完成",
"point": "",
"name": "姓名",
"slide": "投影片",
"note": "筆記",
"back": "返回",
"point_plural": ""
},
"search_page": {
"at_least_3_characters": "輸入至少 3 個字元...",
@@ -234,7 +263,8 @@
"facial_hair_type": "鬍子",
"hat_color": "帽子顏色",
"clothe_type": "服裝",
"clothe_color": "服裝顏色"
"clothe_color": "服裝顏色",
"go_back": "返回"
},
"results_page": {
"quiz_title": "測驗標題",
@@ -249,5 +279,10 @@
"time_taken": "花費時間",
"correct_answer": "{{count}} 個正確答案",
"correct_answer_plural": "{{count}} 個正確答案"
},
"security_settings": {
"activate_2fa": "啟用兩步驟驗證",
"2fa_deactivated": "兩步驟驗證未啟用",
"2fa_activated": "兩步驟驗證已啟用"
}
}
-10
View File
@@ -3,13 +3,3 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
export default {
en: {
common: {
'ClassQuiz is a quiz app like KAHOOT! for students, which is open source and free to use.':
'ClassQuiz is a quiz app like KAHOOT! for students, which is open source and free to use.',
'The open-source quiz-platform!': 'The open-source quiz-platform!'
}
}
};
-1
View File
@@ -73,7 +73,6 @@
})
.then((newEditor) => {
editor = newEditor;
console.log(editor);
editor.setData(text);
editor.model.document.on('change:data', () => {
triggerChange();
+5
View File
@@ -117,6 +117,11 @@
code: 'uk',
name: 'Українська',
flag: '🇺🇦'
},
{
code: 'nl',
name: 'Nederlands',
flag: '🇳🇱'
}
];
const get_selected_language = (): string => {
+2 -2
View File
@@ -109,7 +109,7 @@
<ul id="menu-items" class="lg:flex w-full flex-col lg:flex-row" class:hidden={openMenu}>
<li class="py-2 lg:hidden">
<BrownButton href="https://mawoka.eu/donate" target="_blank"
>Donate <span class="">❤️</span></BrownButton
>{$t('navbar.donate')} <span class="">❤️</span></BrownButton
>
</li>
{#if $signedIn}
@@ -183,7 +183,7 @@
>
<div class="whitespace-nowrap hidden lg:block">
<BrownButton href="https://mawoka.eu/donate" target="_blank"
>Donate <span class="">❤️</span></BrownButton
>{$t('navbar.donate')} <span class="">❤️</span></BrownButton
>
</div>
{#if darkMode}
@@ -5,6 +5,9 @@
-->
<script lang="ts">
import { onMount } from 'svelte';
import { getLocalization } from '$lib/i18n';
const { t } = getLocalization();
export let data;
export let username;
@@ -53,8 +56,11 @@
style="font-size: {player_count_or_five - i / 2}rem"
class="text-center"
>
{i + 1}
: {player} with {data[player]} points
{$t('play_page.final_result_rank', {
place: i + 1,
username: player,
points: data[player]
})}
</p>
{/if}
{/each}
@@ -62,7 +68,7 @@
{#if 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">
<p>Your score: <b>{data[username]}</b></p>
<p>{$t('play_page.your_score', { score: data[username] })}</p>
</div>
</div>
{/if}
@@ -38,7 +38,13 @@
<div class="grid grid-cols-3 mt-12">
<div class="flex justify-center">
<p class="m-auto text-2xl">
Join at <b>{window.location.host}/play</b> and enter <b>{game_pin}</b>.
{$t('play_page.join_description', {
url:
window.location.host === 'classquiz.de'
? 'cquiz.de'
: `${window.location.host}/play`,
pin: game_pin
})}
</p>
</div>
<img
@@ -49,7 +55,7 @@
{#if cqc_code}
<div class="m-auto">
<div class="flex-col flex justify-center">
<p class="mx-auto">Join by entering the following code</p>
<p class="mx-auto">{$t('play_page.join_by_entering_code')}</p>
<ControllerCodeDisplay code={cqc_code} />
</div>
</div>
+7 -3
View File
@@ -7,6 +7,8 @@
import { flip } from 'svelte/animate';
import { fly } from 'svelte/transition';
import { onMount } from 'svelte';
import { getLocalization } from '$lib/i18n';
const { t } = getLocalization();
export let data;
@@ -91,11 +93,13 @@
<div>
<table class="table-auto text-xl">
<tr>
<th class="p-2 border-r border-r-black border-b-2 border-b-black">Name</th>
<th class="p-2 border-b-2 border-b-black">Points</th>
<th class="p-2 border-r border-r-black border-b-2 border-b-black"
>{$t('words.name')}</th
>
<th class="p-2 border-b-2 border-b-black">{$t('words.point', { count: 2 })}</th>
{#if show_new_score_clicked}
<th in:fly={{ x: 300 }} class="p-2 border-b-2 border-b-black"
>Points added
>{$t('play_page.points_added')}
</th>
{/if}
</tr>

Some files were not shown because too many files have changed in this diff Show More