Merge branch 'master' into order

This commit is contained in:
Mawoka
2023-01-26 21:13:48 +01:00
committed by GitHub
21 changed files with 1183 additions and 664 deletions
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v3
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
@@ -43,7 +43,7 @@ jobs:
# Workaround: https://github.com/docker/build-push-action/issues/461
- name: Setup Docker buildx
uses: docker/setup-buildx-action@79abd3f86f79a9d68a23c75a09a9a85889262adf
uses: docker/setup-buildx-action@v2.2.1
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
+3 -2
View File
@@ -8,6 +8,7 @@ on:
branches: [ master ]
paths:
- "frontend/**"
workflow_dispatch:
pull_request:
jobs:
@@ -15,11 +16,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2.0.0
- uses: actions/checkout@v3.3.0
with:
fetch-depth: 1
- uses: pnpm/action-setup@v2.2.2
- uses: pnpm/action-setup@v2.2.4
with:
version: 7.9.3
working-directory: ./frontend
+2
View File
@@ -28,6 +28,7 @@ from classquiz.routers import (
login,
sitemap,
remote,
community,
)
from classquiz.socket_server import sio
from classquiz.helpers import meilisearch_init, telemetry_ping, bg_tasks
@@ -98,5 +99,6 @@ app.include_router(
app.include_router(editor.router, tags=["editor"], prefix="/api/v1/editor", include_in_schema=True)
app.include_router(eximport.router, tags=["export", "import"], prefix="/api/v1/eximport", include_in_schema=True)
app.include_router(sitemap.router, tags=["sitemap"], prefix="/api/v1/sitemap", include_in_schema=True)
app.include_router(community.router, tags=["community"], prefix="/api/v1/community", include_in_schema=True)
app.mount("/", ASGIApp(sio))
+6 -6
View File
@@ -43,12 +43,12 @@ class OAuth2PasswordBearerWithCookie(OAuth2):
super().__init__(flows=flows, scheme_name=scheme_name, auto_error=auto_error)
async def __call__(self, request: Request) -> Optional[str]:
authorization: str = request.cookies.get("access_token") # changed to accept access token from httpOnly Cookie
if authorization is None:
try:
authorization = request.state.access_token
except AttributeError:
pass
try:
authorization = request.state.access_token
except AttributeError:
authorization: str = request.cookies.get(
"access_token"
) # changed to accept access token from httpOnly Cookie
scheme, param = get_authorization_scheme_param(authorization)
if not authorization or scheme.lower() != "bearer":
if self.auto_error:
+10
View File
@@ -133,6 +133,7 @@ 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:
@@ -272,3 +273,12 @@ class GameInLobby(BaseModel):
game_pin: str
quiz_title: str
game_id: uuid.UUID
#
# class UserProfileLinks(ormar.Model):
# id: int = ormar.Integer(primary_key=True, autoincrement=True)
# user: Optional[User] = ormar.ForeignKey(User)
# github_username: str | None = ormar.Text(nullable=True)
# reddit_username: str | None = ormar.Text(nullable=True)
# kahoot_user_id: str | None = ormar.Text(nullable=True)
+47 -3
View File
@@ -4,11 +4,15 @@
from datetime import timedelta, datetime
from fastapi import APIRouter, Request, Response
from fastapi.security.utils import get_authorization_scheme_param
from jose import jws, jwt, JWTError, JWSError
from classquiz.auth import ACCESS_TOKEN_EXPIRE_MINUTES, create_access_token
from classquiz.db.models import UserSession
from classquiz.oauth import google, github
from classquiz.config import settings
settings = settings()
router = APIRouter()
router.include_router(google.router, prefix="/google")
@@ -18,7 +22,45 @@ router.include_router(github.router, prefix="/github")
async def rememberme_middleware(request: Request, call_next):
rememberme_cookie = request.cookies.get("rememberme_token")
bearer_token = request.cookies.get("access_token")
if rememberme_cookie is not None and bearer_token is None:
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)
# if bearer token is none, we can just do the request, since you can't be signed in
if scheme is None or param is None:
conditions_to_handle_met = False
# if rememberme token is none, we can just do the request, since you can't be signed in
if rememberme_cookie is None:
conditions_to_handle_met = False
if scheme.lower() != "bearer":
conditions_to_handle_met = False
# Verifying the bearer
try:
jwt.decode(
param, settings.secret_key, algorithms=["HS256"]
) # checking if the token is valid, throws error if not
conditions_to_handle_met = False
except JWTError:
try:
jws.verify(
param, settings.secret_key, algorithms=["HS256"]
) # Verifying only the signature of the jwt, throws error if signature is invalid
except JWSError:
conditions_to_handle_met = False
if conditions_to_handle_met:
user_session: UserSession | None = (
await UserSession.objects.filter(session_key=rememberme_cookie)
.select_related(UserSession.user)
@@ -27,17 +69,19 @@ async def rememberme_middleware(request: Request, call_next):
if (user_session is None) or (user_session.user is None):
response: Response = await call_next(request)
return response
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES * 60)
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}"
request.cookies.pop("access_token")
response: Response = await call_next(request)
response.set_cookie(
key="access_token",
value=f"Bearer {access_token}",
httponly=True,
samesite="lax",
max_age=ACCESS_TOKEN_EXPIRE_MINUTES * 60,
max_age=60 * 60 * 24 * 365,
)
else:
response: Response = await call_next(request)
+2 -2
View File
@@ -43,7 +43,7 @@ async def log_user_in(user: User, request: Request, response: Response):
value=f"Bearer {access_token}",
httponly=True,
samesite="lax",
max_age=settings.access_token_expire_minutes * 60,
max_age=60 * 60 * 24 * 365,
)
response.set_cookie(
key="rememberme_token", value=session_key, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 365
@@ -64,7 +64,7 @@ async def rememberme_check(rememberme_token: str, response: Response):
value=f"Bearer {access_token}",
httponly=True,
samesite="lax",
max_age=settings.access_token_expire_minutes * 60,
max_age=60 * 60 * 24 * 365,
)
response.set_cookie(key="expiry", value="", max_age=settings.access_token_expire_minutes * 60)
response.status_code = 200
+34
View File
@@ -0,0 +1,34 @@
# 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 fastapi import APIRouter, HTTPException
from uuid import UUID
from classquiz.db.models import User, Quiz
router = APIRouter()
#
@router.get("/user/{user_id}", response_model_include={"username", "created_at", "id"}, response_model=User)
async def get_user_by_user_id(user_id: UUID):
user = await User.objects.get_or_none(id=user_id)
# .select_related("quizs")
# print(user)
if user is None:
raise HTTPException(status_code=404, detail="user not found")
else:
return user
@router.get("/quizzes/{user_id}", response_model_exclude={"questions", "user_id"}, response_model=list[Quiz])
async def get_quizzes_from_user(user_id: UUID, imported: bool | None = None):
if imported is None:
quizzes = await Quiz.objects.all(user_id=user_id, public=True)
else:
quizzes = await Quiz.objects.all(user_id=user_id, public=True, imported_from_kahoot=imported)
if len(quizzes) == 0:
raise HTTPException(status_code=404, detail="no quizzes found")
else:
return quizzes
+15 -4
View File
@@ -18,7 +18,7 @@ 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
from classquiz.db.models import Quiz, QuizInput, User, PlayGame, GameInLobby, QuizQuestion
from classquiz.kahoot_importer.import_quiz import import_quiz
import html
import urllib.parse
@@ -75,17 +75,28 @@ async def get_quiz_from_id(quiz_id: str, user: User | None = Depends(get_current
return quiz
@router.get("/get/public/{quiz_id}", response_model=Quiz)
class PublicQuizResponseUser(BaseModel):
username: str
id: uuid.UUID
class PublicQuizResponse(Quiz.get_pydantic()):
user_id: PublicQuizResponseUser
questions: list[QuizQuestion]
@router.get("/get/public/{quiz_id}")
async def get_public_quiz(quiz_id: str):
try:
quiz_id = uuid.UUID(quiz_id)
except ValueError:
raise HTTPException(status_code=400, detail="badly formed quiz id")
quiz = await Quiz.objects.get_or_none(id=quiz_id)
quiz = await Quiz.objects.select_related("user_id").get_or_none(id=quiz_id)
print(quiz.dict(exclude={"user_id": {"avatar"}}))
if quiz is None:
return JSONResponse(status_code=404, content={"detail": "quiz not found"})
else:
return quiz
return PublicQuizResponse(**quiz.dict())
@router.post("/start/{quiz_id}")
+33 -35
View File
@@ -15,23 +15,22 @@
},
"devDependencies": {
"@beyonk/svelte-mapbox": "^8.2.0",
"@ckeditor/ckeditor5-autoformat": "^35.3.2",
"@ckeditor/ckeditor5-basic-styles": "^35.3.2",
"@ckeditor/ckeditor5-build-balloon": "^35.3.2",
"@ckeditor/ckeditor5-editor-balloon": "^35.3.2",
"@ckeditor/ckeditor5-essentials": "^35.3.2",
"@ckeditor/ckeditor5-theme-lark": "^35.3.2",
"@felte/reporter-tippy": "^1.1.4",
"@felte/validator-yup": "^1.0.10",
"@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",
"@felte/reporter-tippy": "^1.1.5",
"@felte/validator-yup": "^1.0.11",
"@fontsource/marck-script": "^4.5.11",
"@neodrag/svelte": "^1.2.4",
"@sentry/browser": "^7.23.0",
"@sentry/tracing": "^7.23.0",
"@sentry/browser": "^7.31.1",
"@sentry/tracing": "^7.31.1",
"@simplewebauthn/browser": "^6.2.2",
"@sveltejs/adapter-auto": "^1.0.0",
"@sveltejs/adapter-node": "^1.0.0-next.101",
"@sveltejs/kit": "^1.0.1",
"@tailwindcss/typography": "^0.5.8",
"@sveltejs/adapter-auto": "^1.0.2",
"@sveltejs/adapter-node": "^1.1.4",
"@sveltejs/kit": "^1.2.2",
"@tailwindcss/typography": "^0.5.9",
"@types/canvas-confetti": "^1.6.0",
"@types/cookie": "^0.5.1",
"@types/js-cookie": "^3.0.2",
@@ -39,8 +38,8 @@
"@types/qrcode": "^1.5.0",
"@types/sortablejs": "^1.15.0",
"@types/ua-parser-js": "^0.7.36",
"@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",
"@typescript-eslint/eslint-plugin": "^5.48.2",
"@typescript-eslint/parser": "^5.48.2",
"@uppy/compressor": "^1.0.1",
"@uppy/core": "^3.0.4",
"@uppy/dashboard": "^3.2.0",
@@ -56,46 +55,45 @@
"cookie": "^0.5.0",
"crypto-js": "^4.1.1",
"cssnano": "^5.1.14",
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0",
"eslint": "^8.32.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-svelte3": "^4.0.0",
"felte": "^1.2.6",
"felte": "^1.2.7",
"fuse.js": "^6.6.2",
"highlight.js": "^11.7.0",
"i18next-browser-languagedetector": "^7.0.1",
"js-cookie": "^3.0.1",
"luxon": "^3.1.1",
"mapbox-gl": "^2.11.0",
"luxon": "^3.2.1",
"mapbox-gl": "^2.12.0",
"mdsvex": "^0.10.6",
"minisearch": "^5.1.0",
"pikaso": "^2.7.4",
"plausible-tracker": "^0.3.8",
"postcss": "^8.4.19",
"postcss": "^8.4.21",
"postcss-import": "^14.1.0",
"postcss-load-config": "^4.0.1",
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.8.1",
"prettier": "^2.8.3",
"prettier-plugin-svelte": "^2.9.0",
"qrcode": "^1.5.1",
"sass": "^1.56.1",
"sass": "^1.57.1",
"socket.io-client": "^4.5.4",
"sortablejs": "^1.15.0",
"svelte": "^3.53.1",
"svelte-check": "^2.10.0",
"svelte-preprocess": "^5.0.0",
"svelte-range-slider-pips": "^2.1.0",
"svelte": "^3.55.1",
"svelte-check": "^3.0.2",
"svelte-preprocess": "^5.0.1",
"svelte-range-slider-pips": "^2.1.1",
"svelte-tippy": "^1.3.2",
"swiper": "^8.4.5",
"swiper": "^8.4.6",
"tailwindcss": "^3.2.4",
"tippy.js": "^6.3.7",
"tslib": "^2.4.1",
"typescript": "~4.7.4",
"ua-parser-js": "^1.0.32",
"vite": "^4.0.1",
"ua-parser-js": "^1.0.33",
"vite": "^4.0.4",
"vite-plugin-iso-import": "^1.0.0",
"yup": "^0.32.11"
},
"type": "module",
"dependencies": {
"i18next": "^22.0.6"
"i18next": "^22.4.9"
}
}
+558 -577
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -26,7 +26,12 @@
.normal-background {
@apply bg-gradient-to-r from-[#009444] via-[#39b54a] to-[#8dc63f] dark:bg-[#0f2702] dark:from-[#0f2702] dark:via-[#0f2702] dark:to[#0f2702];
}
.admin-button {
@apply px-4 py-2 leading-5 text-white transition-colors duration-200 transform bg-gray-700 rounded text-center hover:bg-gray-600 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50;
}
.action-button {
@apply px-4 py-2 leading-5 text-black dark:text-white transition-colors duration-200 transform bg-gray-50 dark:bg-gray-700 rounded text-center hover:bg-gray-300 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-gray-600;
}
}
+2
View File
@@ -15,6 +15,7 @@ import ca from './locales/ca.json';
import it from './locales/it.json';
import es from './locales/es.json';
import nb_no from './locales/nb_NO.json';
import zh_Hant from './locales/zh_Hant.json';
import LanguageDetector from 'i18next-browser-languagedetector';
import type { i18n, Resource } from 'i18next';
@@ -68,6 +69,7 @@ export class I18nService {
this.i18n.addResourceBundle('ca', 'translation', ca);
this.i18n.addResourceBundle('es', 'translation', es);
this.i18n.addResourceBundle('nb_NO', 'translation', nb_no);
this.i18n.addResourceBundle('zh_Hant', 'translation', zh_Hant);
}
changeLanguage(language: string): void {
+7 -3
View File
@@ -17,7 +17,7 @@
"see_all_quizzes": "Sieh all deine Quizze",
"students_site": "Seite der Schüler*innen",
"teachers_site": "Seite der Lehrer*innen",
"no_tracking_content": "Kahoot! trackt mit mindestens 2 amerikanischen Drittanbietern und ClassQuiz trackt gar nicht mit Drittanbietern!",
"no_tracking_content": "Kahoot! trackt mit mindestens zwei amerikanischen Drittanbietern und ClassQuiz trackt gar nicht mit Drittanbietern!",
"quiz_results_downloadable_content": "Quiz-Ergebnisse können einfach in Form einer Excel-Tabelle heruntergeladen werden (Wusste gar nicht, dass andere dies nicht können).",
"multilingual_content": "ClassQuiz ist jetzt schon komplett in Englisch, Deutsch, Türkisch, Französisch, norwegischem Bokmål und Italienisch verfügbar, wobei es auch teilweise in Indonesisch und Katalanisch verfügbar ist.",
"no_tracking": "Kein Tracking",
@@ -92,7 +92,8 @@
"unexpected": "Der gute alte unerwartete Fehler hat uns heimgesucht!"
}
}
}
},
"use_backup_code": "Backup-Code benutzen"
},
"words": {
"question": "Frage",
@@ -150,7 +151,10 @@
"practice": "Üben",
"error": "Fehler",
"voting": "Umfrage",
"download": "Herunterladen"
"download": "Herunterladen",
"continue": "Weiter",
"totp": "TOTP",
"backup_code": "Backup-Code"
},
"editor": {
"time_in_seconds": "Zeit in Sekunden",
+48 -29
View File
@@ -8,7 +8,7 @@
"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": "Destaca el editor sencillo, así como la función de exportar los resultados de los cuestionarios a archivos de Excel."
"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."
},
"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 edítalo en ClassQuiz",
"import_quiz_from_kahoot_and_edit": "¡Importa 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 gratuito",
"completely_free": "Totalmente gratis",
"quiz_results_downloadable": "Los resultados de los cuestionarios se pueden descargar",
"multilingual": "Multilingüe",
"completely_free_content": "ClassQuiz es completamente gratuito, sin planes de pago ni molestas redirecciones a la página de actualización. Sin embargo, ¡se agradece una donación!",
"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.",
"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á totalmente traducido al inglés, alemán, turco, francés e italiano. También está parcialmente traducido al indonesio y al catalán.",
"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.",
"select_answer": "Selecciona la respuesta",
"view_results": "Ver los resultados",
"check_if_chosen_wisely": "Comprueba, si has elegido bien",
@@ -37,52 +37,57 @@
"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 trata de ser lo más fácil de uso posible, por lo que es fácil de usar para todo el mundo",
"user_friendly_content": "ClassQuiz está diseñado para ser simple y fácil de usar para todos.",
"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 con al menos 2 proveedores americanos y ClassQuiz no rastrea con ningún proveedor!",
"self_hostable": "Autohospedable"
"no_tracking_content": "Kahoot! rastrea y comparte su perfil con 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": "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."
},
"create_page": {
"success": {
"body": "¡Creación del quiz con éxito!",
"title": "¡Quiz creado con éxito!"
"body": "Que empiecen los juegos.",
"title": "Cuestionario creado."
}
},
"login_page": {
"modal": {
"success": {
"success_check_mail": "¡Inicio de sesión exitoso! ¡Comprueba tu buzón de correo!",
"success_check_mail": "Conectado. Por favor revise su bandeja de entrada de correo electrónico.",
"description": {
"success_check_mail": "Por favor, comprueba tu buzón, ya que deberías haber recibido un correo, con un enlace en el que puedes hacer clic para iniciar sesión.",
"success": "¡Se ha iniciado la sesión con éxito!"
"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."
},
"success": "¡Inicio de sesión exitoso!"
"success": "Conectado."
},
"error": {
"wrong_creds": "Correo electrónico o contraseña incorrectos!",
"wrong_creds": "Dirección de correo electrónico o contraseña incorrecta.",
"unexpected": "¡Error inesperado!",
"description": {
"wrong_creds": "Asegúrate de que tu contraseña y tu correo electrónico son correctos!",
"wrong_creds": "Por favor, asegúrese de que su contraseña y dirección de correo electrónico sean correctas.",
"unexpected": "Se produjo el típico error inesperado!"
}
}
},
"welcome_back": "¡Bienvenido de nuevo!",
"login_or_create_account": "Iniciar sesión o crear una cuenta",
"already_have_account": "¿No tienes una cuenta?"
"welcome_back": "Bienvenido de nuevo.",
"login_or_create_account": "Ingresar o Crear una cuenta",
"already_have_account": "¿No tienes una cuenta?",
"use_backup_code": "Usar el código de la copia de seguridad"
},
"overview_page": {
"created_at": "Creado en",
"question_count": "Recuento de preguntas",
"no_quizzes": "Parece que no tienes ningún quiz. ¿Quieres cambiar eso? ¡Haz clic en el botón \"Crear\" o importa un cuestionario de Kahoot!"
"no_quizzes": "Haga clic en el botón \"Crear\" o importe un cuestionario de Kahoot. para ponerse en marcha."
},
"edit_page": {
"success_update_title": "¡Quiz actualizado con éxito!",
"success_update_body": "¡Actualización del quiz con éxito!"
"success_update_title": "Cuestionario actualizado.",
"success_update_body": "Nadie esperará la Inquisición."
},
"register_page": {
"greeting": "¡Encantado de conocerte!",
@@ -114,8 +119,8 @@
"answer": "Respuesta",
"stats": "Estadísticas",
"features": "Características",
"login": "Inicio de sesión",
"email": "Correo electrónico",
"login": "Iniciar sesión",
"email": "Dirección de correo electrónico",
"username": "Nombre de usuario",
"count": "Cuenta",
"range": "Zona",
@@ -145,7 +150,11 @@
"correct": "Correcto",
"result": "Resultado",
"result_plural": "Resultados",
"password": "Contraseña"
"password": "Contraseña",
"download": "Descargar",
"continue": "Continuar",
"backup_code": "Código de la copia de seguridad",
"totp": "contraseña de un solo uso (Totp)"
},
"admin_page": {
"export_results": "Exportar resultados",
@@ -157,7 +166,7 @@
"start_game": "Iniciar el juego",
"time_left": "Tiempo restante",
"get_final_results": "Ver los resultados finales",
"start_by_showing_first_question": "Comienza mostrando la primera pregunta!",
"start_by_showing_first_question": "Comienza mostrando la primera pregunta.",
"no_answers": "¡No hay respuestas!"
},
"settings_page": {
@@ -183,7 +192,7 @@
},
"footer": {
"self_ads": "Hecho con ❤️ por {{mawoka_link}} y con la ayuda de {{others_link}}.",
"more_details_here": "Más detalles aquí",
"more_details_here": "Para más información",
"donate": "Si te resulta útil, considera {{donate_link}}."
},
"error_page": {
@@ -201,19 +210,29 @@
},
"import_page": {
"need_help": "¿Necesitas ayuda?",
"visit_docs": "Visite la documentación"
"visit_docs": "Visite la documentación",
"url_should_look_like_this": "La URL debería verse así: https://create.kahoot.it/details/...",
"a_kahoot_quiz": "¡Un Kahoot! Prueba",
"side_import_kahoot": "¡En este lado puedes importar cuestionarios, que viven en Kahoot!.",
"classquiz_quiz": "Un ClassQuiz-Quiz",
"upload_file_ending": "Cargue el archivo que termina en .cqa",
"this_side_classquiz": "Desde aquí puede importar cuestionarios exportados desde ClassQuiz."
},
"explore_page": {
"made_by": "Hecho por",
"imported_by": "Importado por"
},
"search_page": {
"at_least_3_characters": "Escribe al menos 3 caracteres..."
"at_least_3_characters": "Escribe al menos 3 caracteres...",
"nothing_here": "No hay nada aquí..."
},
"password_reset_page": {
"reset_password": "Restablecer contraseña"
},
"dashboard": {
"search_for_own_quizzes": "Busca tus propios quizzes"
},
"uploader": {
"add_image": "Añadir una imagen"
}
}
+194
View File
@@ -0,0 +1,194 @@
{
"index_page": {
"meta": {
"title": "首頁",
"description": "ClassQuiz 是一款為學生設計的似 Kahoot! 測驗應用程式,不僅開源還免費"
},
"slogan": "開源的測驗平台!",
"create_or_import": "建立或匯入",
"see_all_quizzes": "查看你所有的測驗",
"teachers_site": "教師頁面",
"students_site": "學生頁面",
"no_tracking": "零追蹤",
"self_hostable": "可自行架設",
"german_server": "德國伺服器",
"user_friendly": "易於使用",
"completely_free": "完全免費",
"multilingual": "多語言",
"dark_mode": "深色模式",
"get_a_quiz": "1. 取得測驗",
"create_a_quiz_from_scratch": "使用編輯器建立測驗並加入圖片和更多內容",
"import_quiz_from_kahoot_and_edit": "從 Kahoot! 匯入測驗並在 ClassQuiz 上進行編輯",
"play_quiz": "2. 遊玩測驗",
"select_answer": "選擇答案",
"choose_answer_wisely": "明智地選擇你的答案",
"view_results": "檢視結果",
"check_if_chosen_wisely": "驗證你的選擇是否正確",
"list_winners": "列出贏家",
"get_ranking_and_winners": "取得排名和贏家",
"why_classquiz": "為什麼選擇 ClassQuiz",
"download_quizzes": "下載測驗",
"stats": "ClassQuiz 上已有 {{user_count}} 位使用者和 {{quiz_count}} 個測驗。",
"features_description": {
"1": "ClassQuiz 是一個可以讓你建立和管理測驗的測驗平台。",
"2": "最主要的功能之一是允許你匯入 Kahoot! 上的測驗。"
},
"quiz_results_downloadable": "可被下載的測驗結果",
"find_or_explore": "搜尋 (或瀏覽) 其他人建立和匯入的測驗",
"no_tracking_content": "Kahoot! 會追蹤並將你的資料分享給第三方,但 ClassQuiz 不會。",
"user_friendly_content": "ClassQuiz 旨在簡單,每個人都可以輕鬆使用。",
"dark_mode_content": "一個網站最重要的功能之一!",
"german_server_content": "ClassQuiz 的伺服器位於德國,並由 netcup 提供。"
},
"edit_page": {
"success_update_title": "測驗已更新。"
},
"create_page": {
"success": {
"title": "測驗已建立。",
"body": "讓遊戲開始吧。"
}
},
"register_page": {
"create_account": "建立帳戶",
"forgot_password?": "忘記密碼?",
"already_have_account?": "已經擁有帳戶?",
"greeting": "很高興見到你!"
},
"login_page": {
"login_or_create_account": "登入或建立帳戶",
"already_have_account": "沒有帳戶?",
"modal": {
"error": {
"wrong_creds": "電子郵件地址或密碼錯誤。",
"unexpected": "非預期錯誤!",
"description": {
"wrong_creds": "請確認你的密碼和電子郵件地址是否正確。"
}
},
"success": {
"success": "已登入。",
"success_check_mail": "已登入。請檢查你的電子信箱收件匣。",
"description": {
"success": "已登入。"
}
}
},
"welcome_back": "歡迎回來。",
"use_backup_code": "使用備用代碼"
},
"editor": {
"delete_question": "刪除問題",
"add_new_question": "新增問題",
"delete_answer": "刪除答案",
"add_new_answer": "新增答案"
},
"import_page": {
"need_help": "需要幫助?"
},
"admin_page": {
"start_game": "開始遊戲",
"get_results": "取得結果",
"get_results_and_stop_time": "取得結果並停止計時",
"get_final_results": "取得最終結果",
"export_results": "匯出結果",
"show_next_question": "顯示下一個問題",
"time_left": "剩餘時間",
"stop_time": "停止計時"
},
"password_reset_page": {
"reset_password": "重設密碼"
},
"settings_page": {
"old_password": "舊密碼",
"new_password": "新密碼",
"repeat_password": "再次輸入密碼",
"change_password_submit": "變更密碼!",
"check_location": "檢查位置"
},
"explore_page": {
"made_by": "作者為",
"imported_by": "匯入者為"
},
"play_page": {
"2nd_place": "第二名",
"3rd place": "第三名",
"with_out_of": "{{total_question_count}} 題中答對 {{correct_questions}} 題",
"1st_place": "第一名"
},
"editor_page": {
"add_an_answer": "新增一個答案",
"right_click_to_delete": "右鍵點擊一個答案即可將它刪除!"
},
"footer": {
"more_details_here": "更多詳細資訊"
},
"uploader": {
"add_image": "新增圖片"
},
"words": {
"find": "尋找",
"question": "問題",
"answer": "答案",
"stats": "統計資料",
"features": "功能",
"login": "登入",
"email": "電子郵件地址",
"username": "使用者名稱",
"password": "密碼",
"edit": "編輯",
"delete": "刪除",
"public": "公開",
"create": "建立",
"import": "匯入",
"logout": "登出",
"title": "標題",
"url": "URL",
"submit": "提交",
"pin": "PIN",
"kick": "踢出",
"register": "註冊",
"close": "關閉",
"save": "儲存",
"description": "描述",
"image": "圖片",
"settings": "設定",
"repeat_password": "再次輸入密碼",
"explore": "發現",
"screenshot": "螢幕截圖",
"screenshot_plural": "螢幕截圖",
"browser": "瀏覽器",
"correct": "正確",
"result": "結果",
"result_plural": "結果",
"range": "範圍",
"multiple_choice": "選擇題",
"private": "私人",
"dashboard": "儀錶板",
"question_plural": "問題",
"game_pin": "遊戲 PIN",
"other": "其他",
"search": "搜尋",
"other_plural": "其他",
"download": "下載",
"voting": "投票",
"docs": "文件",
"donating": "贊助",
"practice": "練習",
"error": "錯誤",
"continue": "繼續",
"backup_code": "備用代碼",
"totp": ""
},
"search_page": {
"at_least_3_characters": "輸入至少 3 個字元..."
},
"dashboard": {
"search_for_own_quizzes": "搜尋你的測驗"
},
"overview_page": {
"created_at": "建立於",
"question_count": "問題數",
"no_quizzes": "點擊「建立」按鈕或從 Kahoot! 匯入測驗。"
}
}
+5
View File
@@ -97,6 +97,11 @@
code: 'nb_NO',
name: 'Norsk',
flag: '🇳🇴'
},
{
code: 'zh_Hant',
name: 'Chinese (traditional)',
flag: '🇨🇳'
}
];
const get_selected_language = (): string => {
+1 -1
View File
@@ -172,7 +172,7 @@
<img
alt="QR code to join the game"
src="/api/v1/utils/qr/{quiz_data.game_pin}?dark_mode={bg_color ? false : darkMode}"
class="block mx-auto w-1/6"
class="block mx-auto w-1/6 mt-12"
/>
<p class="text-3xl text-center ">{$t('words.pin')}: {quiz_data.game_pin}</p>
<div class="flex justify-center w-full mt-4">
@@ -0,0 +1,182 @@
<!--
- 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 { PageData } from './$types';
import { getLocalization } from '$lib/i18n';
import { onMount } from 'svelte';
import { createTippy } from 'svelte-tippy';
import { signedIn } from '$lib/stores';
import StartGamePopup from '$lib/dashboard/start_game.svelte';
const { t } = getLocalization();
// import { DateTime } from 'luxon';
let start_game = null;
const tippy = createTippy({
arrow: true,
animation: 'perspective-subtle',
placement: 'right'
});
const close_start_game_if_esc_is_pressed = (key: KeyboardEvent) => {
if (key.code === 'Escape') {
start_game = null;
}
};
onMount(() => {
document.body.addEventListener('keydown', close_start_game_if_esc_is_pressed);
});
export let data: PageData;
console.log(data, 'data');
</script>
<svelte:head>
<title>ClassQuiz - @{data.user.username}</title>
</svelte:head>
<div class="h-full">
<div class="grid grid-cols-6 h-full">
<div class="pl-2">
<div class="flex justify-center">
<img src={`/api/v1/users/avatar/${data.user.id}`} alt="profile" />
</div>
<h2 class="text-3xl text-center">
@{data.user.username}
</h2>
<p class="italic text-center">
Joined on {new Date(data.user.created_at).toLocaleDateString()}
</p>
</div>
<div
class="col-start-2 col-end-7 border-l border-black h-full p-4 overflow-y-scroll flex flex-col gap-4"
>
{#each data.quizzes as quiz}
<div
class="rounded-lg border-2 border-black hover:outline transition-all outline-[#B07156] -outline-offset-2 outline-8"
>
<div class="grid grid-cols-6 h-[25vh]">
<!-- <p
style='writing-mode: vertical-lr'
class='text-center h-full text-xl p-2'
>
{@html quiz.title}
</p>-->
<div class="col-start-2 col-end-6">
<h3 class="text-center text-2xl">{@html quiz.title}</h3>
<p class="text-center">
{@html quiz.description}
</p>
{#if quiz.cover_image}
<div class="flex justify-center align-middle items-center">
<div class="h-[20vh] m-auto w-auto max-h-[18vh]">
<img
class="max-h-full max-w-full block"
src={quiz.cover_image}
alt="Not provided"
loading="lazy"
/>
</div>
</div>
{/if}
</div>
<!-- <div class='flex justify-end'>
<p
style='writing-mode: sideways-lr'
class='text-center text-xl p-2'
>
{@html quiz.title}
</p>
</div>-->
</div>
<div class="flex justify-center">
<a href="/view/{quiz.id}" class="action-button w-1/6">{$t('words.view')}</a>
</div>
<div class="flex justify-center pb-10 pt-8">
<div class="grid grid-cols-2 gap-3 w-1/3">
{#if $signedIn}
<button
on:click={() => {
start_game = quiz.id;
}}
class="action-button"
>
{$t('words.start')}
</button>
<a
href="/api/v1/eximport/{quiz.id}"
aria-label="Download the quiz"
class="flex justify-center px-4 py-2 leading-5 text-black dark:text-white transition-colors duration-200 transform bg-gray-50 dark:bg-gray-700 rounded text-center hover:bg-gray-300 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-gray-600"
><!-- heroicons/download -->
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</a>
{:else}
<div
use:tippy={{
content: 'You need be signed in to start a game.'
}}
class="w-full"
>
<button
on:click={() => {
start_game = quiz.id;
}}
disabled
class="action-button w-full"
>
{$t('words.start')}
</button>
</div>
<div
use:tippy={{
content: 'You need be signed in to download a game.'
}}
class="w-full"
>
<button
disabled
class="action-button w-full flex justify-center"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</button>
</div>
{/if}
</div>
</div>
</div>
{/each}
</div>
</div>
</div>
{#if start_game !== null}
<StartGamePopup bind:quiz_id={start_game} />
{/if}
@@ -0,0 +1,24 @@
/*
* 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 type { PageLoad } from './$types';
export const load = async ({ params, fetch }) => {
const user_req = await fetch(`/api/v1/community/user/${params.user_id}`);
const user = await user_req.json();
if (!user) {
return {
user: undefined,
quizzes: undefined
};
}
const quiz_req = await fetch(`/api/v1/community/quizzes/${params.user_id}?imported=false`);
const quizzes = await quiz_req.json();
return {
user,
quizzes
};
}; // satisfies PageLoad;
@@ -68,6 +68,9 @@
<div class="text-center">
<p>{@html quiz.description}</p>
</div>
<p class="text-center">
Made by <a href="/user/{quiz.user_id.id}" class="underline">@{quiz.user_id.username}</a>
</p>
{#if quiz.cover_image}
<div class="flex justify-center align-middle items-center">
<div class="h-[15vh] m-auto w-auto my-3">