🔀 Merged ClassQuizController
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
||||
codecov:
|
||||
require_ci_to_pass: true
|
||||
require_ci_to_pass: false
|
||||
|
||||
coverage:
|
||||
precision: 2
|
||||
|
||||
@@ -7,3 +7,4 @@ node_modules/
|
||||
*.rdb
|
||||
survey.json
|
||||
.coverage
|
||||
export_deta.py
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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 +1,2 @@
|
||||
engine-strict=true
|
||||
strict-peer-dependencies=false
|
||||
|
||||
+2
-2
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1536
-1226
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
{<span class="text-indigo-400">{arg}</span
|
||||
>}{/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
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
];
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}">
|
||||
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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ı!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "兩步驟驗證已啟用"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
})
|
||||
.then((newEditor) => {
|
||||
editor = newEditor;
|
||||
console.log(editor);
|
||||
editor.setData(text);
|
||||
editor.model.document.on('change:data', () => {
|
||||
triggerChange();
|
||||
|
||||
@@ -117,6 +117,11 @@
|
||||
code: 'uk',
|
||||
name: 'Українська',
|
||||
flag: '🇺🇦'
|
||||
},
|
||||
{
|
||||
code: 'nl',
|
||||
name: 'Nederlands',
|
||||
flag: '🇳🇱'
|
||||
}
|
||||
];
|
||||
const get_selected_language = (): string => {
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user