Remove Backups and copies
This commit is contained in:
@@ -1,107 +0,0 @@
|
||||
# SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
|
||||
#
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
import re
|
||||
from functools import lru_cache
|
||||
|
||||
from redis import asyncio as redis_lib
|
||||
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
|
||||
|
||||
|
||||
class CustomOpenIDProvider(BaseModel):
|
||||
scopes: str = "openid email profile"
|
||||
server_metadata_url: str
|
||||
client_id: str
|
||||
client_secret: str
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""
|
||||
Settings class for the shop app.
|
||||
"""
|
||||
|
||||
root_address: str = "http://127.0.0.1:8000"
|
||||
redis: RedisDsn = "redis://localhost:6379/0?decode_responses=True"
|
||||
skip_email_verification: bool = False
|
||||
db_url: str | PostgresDsn = "postgresql://postgres:mysecretpassword@localhost:5432/classquiz"
|
||||
hcaptcha_key: str | None = None
|
||||
recaptcha_key: str | None = None
|
||||
mail_address: str
|
||||
mail_password: str
|
||||
mail_username: str
|
||||
mail_server: str
|
||||
mail_port: int
|
||||
secret_key: str
|
||||
access_token_expire_minutes: int = 30
|
||||
cache_expiry: int = 86400
|
||||
sentry_dsn: str | None
|
||||
meilisearch_url: str = "http://127.0.0.1:7700"
|
||||
meilisearch_index: str = "classquiz"
|
||||
google_client_id: Optional[str]
|
||||
google_client_secret: Optional[str]
|
||||
github_client_id: Optional[str]
|
||||
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
|
||||
mods: list[str] = []
|
||||
registration_disabled: bool = False
|
||||
|
||||
# storage_backend
|
||||
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()
|
||||
|
||||
|
||||
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,
|
||||
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)
|
||||
|
||||
ALLOWED_TAGS_FOR_QUIZ = ["b", "strong", "i", "em", "small", "mark", "del", "sub", "sup"]
|
||||
|
||||
ALLOWED_MIME_TYPES = ["image/png", "video/mp4", "image/jpeg", "image/gif", "image/webp"]
|
||||
|
||||
server_regex = rf"^{re.escape(settings().root_address)}/api/v1/storage/download/.{{36}}--.{{36}}$"
|
||||
@@ -1,492 +0,0 @@
|
||||
# SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
|
||||
#
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
|
||||
import os
|
||||
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 .quiztivity import QuizTivityPage
|
||||
from sqlalchemy import func
|
||||
|
||||
|
||||
class UserAuthTypes(Enum):
|
||||
LOCAL = "LOCAL"
|
||||
GOOGLE = "GOOGLE"
|
||||
GITHUB = "GITHUB"
|
||||
CUSTOM = "CUSTOM"
|
||||
|
||||
|
||||
class User(ormar.Model):
|
||||
"""
|
||||
The user model in the database
|
||||
"""
|
||||
|
||||
id: uuid.UUID = ormar.UUID(primary_key=True, default=uuid.uuid4())
|
||||
email: str = ormar.String(unique=True, max_length=100)
|
||||
username: str = ormar.String(unique=True, max_length=100)
|
||||
password: Optional[str] = ormar.String(max_length=100, nullable=True)
|
||||
verified: bool = ormar.Boolean(default=False)
|
||||
verify_key: str = ormar.String(unique=True, max_length=100, nullable=True)
|
||||
created_at: datetime = ormar.DateTime(default=datetime.now())
|
||||
auth_type: UserAuthTypes = ormar.Enum(enum_class=UserAuthTypes, default=UserAuthTypes.LOCAL)
|
||||
google_uid: Optional[str] = ormar.String(unique=True, max_length=255, nullable=True)
|
||||
avatar: bytes = ormar.LargeBinary(max_length=25000, represent_as_base64_str=True)
|
||||
github_user_id: int | None = ormar.Integer(nullable=True)
|
||||
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"
|
||||
metadata = metadata
|
||||
database = database
|
||||
|
||||
class Config:
|
||||
use_enum_values = True
|
||||
|
||||
|
||||
class FidoCredentials(ormar.Model):
|
||||
pk: int = ormar.Integer(autoincrement=True, primary_key=True)
|
||||
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, ondelete=ReferentialAction.CASCADE)
|
||||
|
||||
class Meta:
|
||||
tablename = "fido_credentials"
|
||||
metadata = metadata
|
||||
database = database
|
||||
|
||||
|
||||
class ApiKey(ormar.Model):
|
||||
key: str = ormar.String(max_length=48, min_length=48, primary_key=True)
|
||||
user: Optional[User] = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
|
||||
|
||||
class Meta:
|
||||
tablename = "api_keys"
|
||||
metadata = metadata
|
||||
database = database
|
||||
|
||||
|
||||
class UserSession(ormar.Model):
|
||||
"""
|
||||
The user session model for user-sessions
|
||||
"""
|
||||
|
||||
id: uuid.UUID = ormar.UUID(primary_key=True, default=uuid.uuid4())
|
||||
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)
|
||||
user_agent: str = ormar.String(max_length=255, nullable=True)
|
||||
last_seen: datetime = ormar.DateTime(default=datetime.now())
|
||||
|
||||
class Meta:
|
||||
tablename = "user_sessions"
|
||||
metadata = metadata
|
||||
database = database
|
||||
|
||||
|
||||
class ABCDQuizAnswer(BaseModel):
|
||||
right: bool
|
||||
answer: str
|
||||
color: str | None
|
||||
|
||||
|
||||
class RangeQuizAnswer(BaseModel):
|
||||
min: int
|
||||
max: int
|
||||
min_correct: int
|
||||
max_correct: int
|
||||
|
||||
|
||||
class VotingQuizAnswer(BaseModel):
|
||||
answer: str
|
||||
image: str | None = None
|
||||
color: str | None
|
||||
|
||||
|
||||
class QuizQuestionType(str, Enum):
|
||||
ABCD = "ABCD"
|
||||
RANGE = "RANGE"
|
||||
VOTING = "VOTING"
|
||||
SLIDE = "SLIDE"
|
||||
TEXT = "TEXT"
|
||||
ORDER = "ORDER"
|
||||
CHECK = "CHECK"
|
||||
|
||||
|
||||
class TextQuizAnswer(BaseModel):
|
||||
answer: str
|
||||
case_sensitive: bool
|
||||
|
||||
|
||||
class QuizQuestion(BaseModel):
|
||||
question: str
|
||||
time: str # in Secs
|
||||
type: None | QuizQuestionType = QuizQuestionType.ABCD
|
||||
answers: list[ABCDQuizAnswer] | RangeQuizAnswer | list[TextQuizAnswer] | list[VotingQuizAnswer] | str
|
||||
image: str | None = None
|
||||
|
||||
@validator("answers")
|
||||
def answers_not_none_if_abcd_type(cls, v, values):
|
||||
if values["type"] == QuizQuestionType.ABCD and not isinstance(v[0], ABCDQuizAnswer):
|
||||
raise ValueError("Answers can't be none if type is ABCD")
|
||||
if values["type"] == QuizQuestionType.RANGE and not isinstance(v, RangeQuizAnswer):
|
||||
raise ValueError("Answer must be from type RangeQuizAnswer if type is RANGE")
|
||||
if values["type"] == QuizQuestionType.VOTING and not isinstance(v[0], VotingQuizAnswer):
|
||||
raise ValueError("Answer must be from type VotingQuizAnswer if type is VOTING")
|
||||
if values["type"] == QuizQuestionType.TEXT and not isinstance(v[0], TextQuizAnswer):
|
||||
raise ValueError("Answer must be from type TextQuizAnswer if type is TEXT")
|
||||
if values["type"] == QuizQuestionType.ORDER and not isinstance(v[0], VotingQuizAnswer):
|
||||
raise ValueError("Answer must be from type VotingQuizAnswer if type is ORDER")
|
||||
if values["type"] == QuizQuestionType.SLIDE and not isinstance(v, str):
|
||||
raise ValueError("Answer must be from type SlideElement if type is SLIDE")
|
||||
if values["type"] == QuizQuestionType.CHECK and not isinstance(v[0], ABCDQuizAnswer):
|
||||
raise ValueError("Answers can't be none if type is CHECK")
|
||||
return v
|
||||
|
||||
|
||||
class QuizInput(BaseModel):
|
||||
public: bool = False
|
||||
title: str
|
||||
description: str
|
||||
cover_image: str | None
|
||||
background_color: str | None
|
||||
questions: list[QuizQuestion]
|
||||
background_image: str | None
|
||||
|
||||
|
||||
class Quiz(ormar.Model):
|
||||
id: uuid.UUID = ormar.UUID(primary_key=True, default=uuid.uuid4(), nullable=False, unique=True)
|
||||
public: bool = ormar.Boolean(default=False)
|
||||
title: str = ormar.Text()
|
||||
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, 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)
|
||||
background_color: str | None = ormar.Text(nullable=True, unique=False)
|
||||
background_image: str | None = ormar.Text(nullable=True, unique=False)
|
||||
kahoot_id: uuid.UUID | None = ormar.UUID(nullable=True, default=None)
|
||||
likes: int = ormar.Integer(nullable=False, default=0, server_default="0")
|
||||
dislikes: int = ormar.Integer(nullable=False, default=0, server_default="0")
|
||||
plays: int = ormar.Integer(nullable=False, default=0, server_default="0")
|
||||
views: int = ormar.Integer(nullable=False, default=0, server_default="0")
|
||||
mod_rating: int | None = ormar.SmallInteger(nullable=True)
|
||||
|
||||
class Meta:
|
||||
tablename = "quiz"
|
||||
metadata = metadata
|
||||
database = database
|
||||
|
||||
|
||||
class InstanceData(ormar.Model):
|
||||
instance_id: uuid.UUID = ormar.UUID(primary_key=True, default=uuid.uuid4(), nullable=False, unique=True)
|
||||
|
||||
class Meta:
|
||||
tablename = "instance_data"
|
||||
metadata = metadata
|
||||
database = database
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
"""
|
||||
For JWT
|
||||
"""
|
||||
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
"""
|
||||
For JWT
|
||||
"""
|
||||
|
||||
email: str | None = None
|
||||
|
||||
|
||||
class PlayGame(BaseModel):
|
||||
quiz_id: uuid.UUID | str
|
||||
description: str
|
||||
user_id: uuid.UUID
|
||||
title: str
|
||||
questions: list[QuizQuestion]
|
||||
game_id: uuid.UUID
|
||||
game_pin: str
|
||||
started: bool = False
|
||||
captcha_enabled: bool = False
|
||||
cover_image: str | None
|
||||
game_mode: str | None
|
||||
current_question: int = -1
|
||||
background_color: str | None
|
||||
background_image: str | None
|
||||
custom_field: str | None
|
||||
question_show: bool = False
|
||||
|
||||
|
||||
class GamePlayer(BaseModel):
|
||||
username: str
|
||||
sid: str | None
|
||||
|
||||
|
||||
class GameAnswer2(BaseModel):
|
||||
username: str
|
||||
right: bool
|
||||
answer: str
|
||||
|
||||
|
||||
class GameAnswer1(BaseModel):
|
||||
id: int
|
||||
answers: list[GameAnswer2]
|
||||
|
||||
|
||||
class GameSession(BaseModel):
|
||||
admin: str
|
||||
game_id: str
|
||||
# players: list[GamePlayer | None]
|
||||
answers: list[GameAnswer1 | None]
|
||||
|
||||
|
||||
class UpdatePassword(BaseModel):
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class AnswerData(BaseModel):
|
||||
username: str
|
||||
answer: str
|
||||
right: bool
|
||||
time_taken: float # In milliseconds
|
||||
score: int
|
||||
|
||||
|
||||
class AnswerDataList(BaseModel):
|
||||
# Just a method to make a top-level list
|
||||
__root__: list[AnswerData]
|
||||
|
||||
|
||||
class GameInLobby(BaseModel):
|
||||
game_pin: str
|
||||
quiz_title: str
|
||||
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)
|
||||
# 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)
|
||||
|
||||
|
||||
class GameResults(ormar.Model):
|
||||
id: uuid.UUID = ormar.UUID(primary_key=True)
|
||||
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)
|
||||
answers: Json[list[AnswerData]] = ormar.JSON(True)
|
||||
player_scores: Json[dict[str, str]] = ormar.JSON(nullable=True)
|
||||
custom_field_data: Json[dict[str, str]] | None = ormar.JSON(nullable=True)
|
||||
title: str = ormar.Text(nullable=False)
|
||||
description: str = ormar.Text(nullable=False)
|
||||
questions: Json[list[QuizQuestion]] = ormar.JSON(nullable=False)
|
||||
|
||||
class Meta:
|
||||
tablename = "game_results"
|
||||
metadata = metadata
|
||||
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)
|
||||
secret_key: str = ormar.String(nullable=False, max_length=24, min_length=24)
|
||||
player_name: str = ormar.Text(nullable=False)
|
||||
last_seen: datetime | None = ormar.DateTime(nullable=True)
|
||||
first_seen: datetime | None = ormar.DateTime(nullable=True)
|
||||
name: str = ormar.Text(nullable=False)
|
||||
os_version: str | None = ormar.Text(nullable=True)
|
||||
wanted_os_version: str = ormar.Text(nullable=True, default=None)
|
||||
|
||||
class Meta:
|
||||
tablename = "controller"
|
||||
metadata = metadata
|
||||
database = database
|
||||
|
||||
|
||||
class Rating(ormar.Model):
|
||||
id: uuid.UUID = ormar.UUID(primary_key=True)
|
||||
user: uuid.UUID | User = ormar.ForeignKey(User)
|
||||
positive: bool = ormar.Boolean(nullable=False)
|
||||
created_at: datetime = ormar.DateTime(nullable=False, server_default=func.now())
|
||||
quiz: uuid.UUID | Quiz = ormar.ForeignKey(Quiz)
|
||||
|
||||
class Meta:
|
||||
tablename = "rating"
|
||||
metadata = metadata
|
||||
database = database
|
||||
@@ -1,63 +0,0 @@
|
||||
# SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
|
||||
#
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Response
|
||||
from jinja2 import Template
|
||||
from pydantic import BaseModel
|
||||
from classquiz.config import redis, settings
|
||||
from classquiz.db import database
|
||||
|
||||
settings = settings()
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
sitemap_template = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
{% for entry in pages -%}
|
||||
<url>
|
||||
<loc>{{ root_address}}/view/{{ entry.id.hex }}</loc>
|
||||
{%- if entry.updated_at != None -%}
|
||||
<lastmod>{{ entry.updated_at.strftime("%Y-%m-%d") }}</lastmod>
|
||||
{% endif %}
|
||||
</url>
|
||||
{%- endfor %}
|
||||
</urlset>"""
|
||||
|
||||
|
||||
sql_statement_metadata = """
|
||||
SELECT id, title, description, updated_at from quiz where public ='t'
|
||||
"""
|
||||
|
||||
sql_statement_id_and_modified_only = """
|
||||
SELECT id, updated_at from quiz where public ='t'
|
||||
"""
|
||||
|
||||
template = Template(sitemap_template, enable_async=True)
|
||||
|
||||
|
||||
class SitemapQuiz(BaseModel):
|
||||
id: uuid.UUID
|
||||
updated_at: datetime.datetime
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
@router.get("/get")
|
||||
async def get_sitemap():
|
||||
redis_cache_resp = await redis.get("sitemap")
|
||||
if redis_cache_resp is None:
|
||||
res = await database.fetch_all(sql_statement_id_and_modified_only)
|
||||
entries = []
|
||||
for i in res:
|
||||
entries.append(SitemapQuiz.from_orm(i))
|
||||
rendered_sitemap = await template.render_async({"pages": entries, "root_address": settings.root_address})
|
||||
await redis.set("sitemap", rendered_sitemap, ex=86400)
|
||||
return Response(content=rendered_sitemap, media_type="application/xml")
|
||||
else:
|
||||
return Response(content=redis_cache_resp, media_type="application/xml")
|
||||
@@ -1,121 +0,0 @@
|
||||
# SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
|
||||
#
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
frontend:
|
||||
restart: always
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
depends_on:
|
||||
- redis
|
||||
- api
|
||||
environment:
|
||||
REDIS_URL: redis://redis:6379/0?decode_responses=True
|
||||
API_URL: http://api:80
|
||||
api:
|
||||
build: &build_cfg
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: &restart always
|
||||
depends_on: &depends
|
||||
- db
|
||||
- redis
|
||||
|
||||
environment: &env_vars
|
||||
# --- DON'T CHANGE FROM HERE ---
|
||||
DB_URL: "postgresql://postgres:classquiz@db:5432/classquiz" # DON'T CHANGE
|
||||
REDIS: "redis://redis:6379/0?decode_responses=True" # DON'T CHANGE
|
||||
SECRET_KEY: "TOP_SECRET" # Don't change it manually, use the one-liner provided in the documentation
|
||||
MAX_WORKERS: "1" # Very important and DON'T CHANGE
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: 30 # DON'T CHANGE
|
||||
MEILISEARCH_URL: "http://meilisearch:7700" # DON'T CHANGE
|
||||
# -- DON'T CHANGE TILL HERE ---
|
||||
|
||||
# --- GENERAL CONFI ---
|
||||
ROOT_ADDRESS: "https://classquiz.de" # CHANGE IT (without a "/" at the end)
|
||||
|
||||
# --- MAIL CONFIG ---
|
||||
MAIL_PORT: "587"
|
||||
MAIL_ADDRESS: "email@email@email.email"
|
||||
MAIL_PASSWORD: "PASSWORT"
|
||||
MAIL_USERNAME: "email@email@email.email"
|
||||
MAIL_SERVER: "email@email@email.emai"
|
||||
SKIP_EMAIL_VERIFICATION: "True" # Does the user have to confirm its email by clicking a link?
|
||||
|
||||
# --- EXTERNAL API CONFIG ---
|
||||
# HCAPTCHA_KEY: "HCAPTCHA_PRIVATE_KEY"
|
||||
# PIXABAY_API_KEY: "" # Get it from here: https://pixabay.com/api/docs/
|
||||
# RECAPTCHA_KEY: "" Get it from Google for the Captcha.
|
||||
|
||||
# -- STORAGE CONFIG ---
|
||||
STORAGE_BACKEND: "local" # Could also be s3
|
||||
STORAGE_PATH: "/app/data" # When s3 is used, this isn't needed.
|
||||
# If STORAGE_BACKEND is "s3"
|
||||
#S3_ACCESS_KEY: "YOUR_ACCESS_KEY"
|
||||
#S3_SECRET_KEY: "YOUR_SECRET_KEY"
|
||||
#S3_BASE_URL: "YOUR_S3_BASE_URL"
|
||||
|
||||
# --- GOOGLE_AUTH ---
|
||||
#GOOGLE_CLIENT_ID: "" # Your Google-Client ID, or leave it unset if you don't want it.
|
||||
#GOOGLE_CLIENT_SECRET: "" # Your Google-Client Secret, or leave it unset if you don't want it.
|
||||
|
||||
# --- GITHUB_AUTH ---
|
||||
#GITHUB_CLIENT_ID: "" # Your GitHub-Client ID, or leave it unset if you don't want it.
|
||||
#GITHUB_CLIENT_SECRET: "" # Your GitHub-Client Secret, or leave it unset if you don't want it.
|
||||
|
||||
# --- Custom OpenID ---
|
||||
#CUSTOM_OPENID_PROVIDER__CLIENT_ID: "" # Adjust if needed
|
||||
#CUSTOM_OPENID_PROVIDER__CLIENT_SECRET: "" # Adjust if needed
|
||||
#CUSTOM_OPENID_PROVIDER__SERVER_METADATA_URL: "/.well-known/openid-configuration" # Adjust if needed
|
||||
|
||||
volumes: # Only needed if you chose the "local" storage-backend
|
||||
- ./uploads:/var/storage
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: [ "CMD", "redis-cli","ping" ]
|
||||
|
||||
db:
|
||||
image: postgres:14-alpine
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
environment:
|
||||
POSTGRES_PASSWORD: "classquiz"
|
||||
POSTGRES_DB: "classquiz"
|
||||
volumes:
|
||||
- data:/var/lib/postgresql/data
|
||||
proxy:
|
||||
image: caddy:alpine
|
||||
restart: always
|
||||
volumes:
|
||||
- ./Caddyfile-docker:/etc/caddy/Caddyfile
|
||||
ports:
|
||||
- "8000:8080" # The 8000 can be changed.
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v0.28.0
|
||||
restart: always
|
||||
environment:
|
||||
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,55 +0,0 @@
|
||||
# SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
|
||||
#
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
### Build Step
|
||||
# pull the Node.js Docker image
|
||||
FROM node:19-bullseye as builder
|
||||
# ENV API_URL=http://api:80
|
||||
ENV API_URL=https://mawoka.eu
|
||||
#Ah, I've noticed that!!!
|
||||
ENV REDIS_URL=redis://localhost:6379
|
||||
ENV VITE_MAPBOX_ACCESS_TOKEN=pk.eyJ1IjoibWF3b2thIiwiYSI6ImNsMjBob3d4ZjBhcGszYnE0bWp4aXB1ZW4ifQ.IByxV1qeIuEWpHCWsuB88A
|
||||
# This Mapbox-token is restricted to the following urls: classquiz.de, classquiz.mawoka.eu, test.com
|
||||
ENV VITE_HCAPTCHA=ee81b2a1-acf3-4d20-b2a4-a7ea94c7eba5
|
||||
# ENV VITE_SENTRY=https://75cb4ef1be624d8f81bbaf864b722f8a@glitch.mawoka.eu/2
|
||||
#ENV VITE_GOOGLE_AUTH_ENABLED=true
|
||||
#ENV VITE_GITHUB_AUTH_ENABLED=true
|
||||
#ENV VITE_CAPTCHA_ENABLED=true
|
||||
#ENV VITE_REGISTRATION_DISABLED=True
|
||||
#ENV VITE_PLAUSIBLE_DATA_URL=
|
||||
# change working directory
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
|
||||
# copy the package.json files from local machine to the workdir in container
|
||||
COPY package*.json ./
|
||||
COPY pnpm-lock.yaml ./
|
||||
|
||||
# run npm install in our local machine
|
||||
RUN corepack enable && corepack prepare pnpm@8.14.0 --activate && pnpm i
|
||||
|
||||
# copy the generated modules and all other files to the container
|
||||
COPY . .
|
||||
|
||||
# build the application
|
||||
RUN pnpm run build
|
||||
|
||||
### Serve Step
|
||||
# pull the Node.js Docker image
|
||||
FROM node:19-bullseye-slim
|
||||
|
||||
# change working directory
|
||||
WORKDIR /app
|
||||
COPY --from=builder /usr/src/app/package.json .
|
||||
COPY --from=builder /usr/src/app/pnpm-lock.yaml .
|
||||
RUN corepack enable && corepack prepare pnpm@8.14.0 --activate && pnpm i
|
||||
# copy files from previous step
|
||||
COPY --from=builder /usr/src/app/build .
|
||||
COPY --from=builder /usr/src/app/node_modules ./node_modules
|
||||
|
||||
# our app is running on port 3000 within the container, so need to expose it
|
||||
EXPOSE 3000
|
||||
|
||||
# the command that starts our app
|
||||
CMD ["pnpm", "run", "run:prod"]
|
||||
@@ -1,109 +0,0 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "NODE_ENV=production vite build",
|
||||
"package": "vite package",
|
||||
"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' '!pnpm-lock.yaml' '!src/app.html' && eslint --ignore-path .gitignore .",
|
||||
"lint-without-format-checking": "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": "^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.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/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.1",
|
||||
"@types/ua-parser-js": "^0.7.36",
|
||||
"@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.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.2.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",
|
||||
"highlight.js": "^11.7.0",
|
||||
"i18next": "^22.4.15",
|
||||
"i18next-browser-languagedetector": "^7.0.1",
|
||||
"js-cookie": "^3.0.1",
|
||||
"jws": "^4.0.0",
|
||||
"luxon": "^3.3.0",
|
||||
"mapbox-gl": "^2.14.1",
|
||||
"marked": "^5.0.0",
|
||||
"mdsvex": "^0.10.6",
|
||||
"minisearch": "^6.0.1",
|
||||
"pikaso": "^2.7.6",
|
||||
"plausible-tracker": "^0.3.8",
|
||||
"postcss": "^8.4.31",
|
||||
"postcss-import": "^15.1.0",
|
||||
"postcss-load-config": "^4.0.1",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-svelte": "^2.10.1",
|
||||
"qrcode": "^1.5.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.7",
|
||||
"tailwindcss": "^3.3.1",
|
||||
"thumbhash": "^0.1.1",
|
||||
"tinykeys": "^2.1.0",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tslib": "^2.5.0",
|
||||
"typescript": "~5.0.4",
|
||||
"ua-parser-js": "^1.0.35",
|
||||
"vite": "^4.2.3",
|
||||
"vite-plugin-cross-origin-isolation": "^0.1.6",
|
||||
"vite-plugin-iso-import": "^1.0.0",
|
||||
"yup": "^1.1.1"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
|
||||
|
||||
SPDX-License-Identifier: 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}
|
||||
@@ -1,555 +0,0 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
|
||||
|
||||
SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { navbarVisible } from '$lib/stores';
|
||||
import { getLocalization } from '$lib/i18n';
|
||||
import Footer from '$lib/footer.svelte';
|
||||
import WebPOpenGraph from '$lib/assets/landing/opengraph-home.webp';
|
||||
import JpgOpenGraph from '$lib/assets/landing/opengraph-home.jpg';
|
||||
import Newsletter from '$lib/landing/newsletter.svelte';
|
||||
import { fly, fade } from 'svelte/transition';
|
||||
|
||||
/* import LandingPromo from '$lib/landing/landing-promo.svelte';*/
|
||||
|
||||
import FindScreenshot from '$lib/assets/landing_new/find.webp';
|
||||
import ImportScreenshot from '$lib/assets/landing_new/import.webp';
|
||||
import EditScreenshot from '$lib/assets/landing_new/edit.webp';
|
||||
import SelectScreenshot from '$lib/assets/landing_new/select.webp';
|
||||
import ResultScreenshot from '$lib/assets/landing_new/result.webp';
|
||||
import WinnersScreenshot from '$lib/assets/landing_new/winners.webp';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const { t } = getLocalization();
|
||||
|
||||
navbarVisible.set(true);
|
||||
|
||||
/* interface StatsData {
|
||||
quiz_count: number;
|
||||
user_count: number;
|
||||
}*/
|
||||
|
||||
/* const getStats = async (): Promise<StatsData> => {
|
||||
const response = await fetch('/api/v1/stats/combined');
|
||||
return await response.json();
|
||||
};*/
|
||||
let newsletterModalOpen;
|
||||
onMount(() => {
|
||||
const ls = localStorage.getItem('newsletter');
|
||||
newsletterModalOpen = ls === null;
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
enum SelectedCreateThing {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
Create,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
Find,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
Import
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
enum SelectedPlayThing {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
Select,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
Results,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
Winners
|
||||
}
|
||||
|
||||
let selected_create_thing = SelectedCreateThing.Create;
|
||||
let selected_play_thing = SelectedPlayThing.Select;
|
||||
|
||||
/* <li>No;
|
||||
Tracking < /li>
|
||||
< li > Self - hostable < /li>
|
||||
< li > German;
|
||||
Server < /li>
|
||||
< li > user - friendly < /li>
|
||||
< li > Completely;
|
||||
free < /li>
|
||||
< li > Quiz - results;
|
||||
are;
|
||||
downloadable < /li>;*/
|
||||
|
||||
const classquiz_reasons = [
|
||||
{
|
||||
headline: $t('index_page.no_player_limit'),
|
||||
content: $t('index_page.no_player_limit_content')
|
||||
},
|
||||
{
|
||||
headline: $t('index_page.no_tracking'),
|
||||
content: $t('index_page.no_tracking_content')
|
||||
},
|
||||
{
|
||||
headline: $t('index_page.self_hostable'),
|
||||
content: $t('index_page.self_hostable_content')
|
||||
},
|
||||
{
|
||||
headline: $t('index_page.german_server'),
|
||||
content: $t('index_page.german_server_content')
|
||||
},
|
||||
{
|
||||
headline: $t('index_page.user_friendly'),
|
||||
content: $t('index_page.user_friendly_content')
|
||||
},
|
||||
{
|
||||
headline: $t('index_page.completely_free'),
|
||||
content: $t('index_page.completely_free_content')
|
||||
},
|
||||
{
|
||||
headline: $t('index_page.quiz_results_downloadable'),
|
||||
content: $t('index_page.quiz_results_downloadable_content')
|
||||
},
|
||||
{
|
||||
headline: $t('index_page.multilingual'),
|
||||
content: $t('index_page.multilingual_content')
|
||||
},
|
||||
{
|
||||
headline: $t('index_page.dark_mode'),
|
||||
content: $t('index_page.dark_mode_content')
|
||||
},
|
||||
{
|
||||
headline: $t('index_page.download_quizzes'),
|
||||
content: $t('index_page.download_quizzes_content')
|
||||
},
|
||||
{
|
||||
headline: $t('index_page.community_driven'),
|
||||
content: $t('index_page.community_driven_content')
|
||||
}
|
||||
];
|
||||
let selected_classquiz_reason = 0;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>ClassQuiz - {$t('index_page.meta.title')}</title>
|
||||
<meta name="description" content={$t('index_page.meta.description')} />
|
||||
<title>ClassQuiz - Home</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="ClassQuiz is a quiz-application like KAHOOT!, but open-source. You can create quizzes and play them remotely with other people."
|
||||
/>
|
||||
|
||||
<meta property="og:url" content="https://classquiz.de/" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="ClassQuiz - {$t('index_page.meta.title')}" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="ClassQuiz is a quiz-application like KAHOOT!, but open-source. You can create quizzes and play them remotely with other people."
|
||||
/>
|
||||
<meta property="og:image" content={JpgOpenGraph} />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:domain" content="classquiz.de" />
|
||||
<meta property="twitter:url" content="https://classquiz.de/" />
|
||||
<meta name="twitter:title" content="ClassQuiz - {$t('index_page.meta.title')}" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="ClassQuiz is a quiz-application like KAHOOT!, but open-source. You can create quizzes and play them remotely with other people."
|
||||
/>
|
||||
<meta name="twitter:image" content={WebPOpenGraph} />
|
||||
</svelte:head>
|
||||
|
||||
<!--<div class="min-h-screen flex flex-col">
|
||||
<section class="pb-40">
|
||||
<div class="pt-12 text-center">
|
||||
<h1 class="sm:text-8xl text-6xl mt-6 marck-script">ClassQuiz</h1>
|
||||
<p class="text-xl mt-4">{$t('index_page.slogan')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="features" class="mt-8">
|
||||
<div class="text-center snap-y">
|
||||
<h1 class="sm:text-6xl text-4xl">{$t('words.features')}</h1>
|
||||
<p class="text-xl pt-4">
|
||||
{$t('index_page.features_description.1')}
|
||||
<br />
|
||||
{$t('index_page.features_description.2')}
|
||||
<br />
|
||||
{$t('index_page.features_description.3')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="py-8">
|
||||
<h1 class="sm:text-6xl text-4xl text-center break-words">
|
||||
{$t('words.screenshot', { count: 2 })}
|
||||
</h1>
|
||||
<div>
|
||||
<LandingPromo />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h1 class="sm:text-6xl text-4xl text-center">Testimonials</h1>
|
||||
{#await import('$lib/landing/testimonials.svelte') then testimonials}
|
||||
<svelte:component this={testimonials.default} />
|
||||
{/await}
|
||||
</section>
|
||||
|
||||
<section id="stats">
|
||||
<div class="text-center pb-20 pt-10 snap-y">
|
||||
<h1 class="sm:text-6xl text-4xl">{$t('words.stats')}</h1>
|
||||
<p class="text-xl pt-4">
|
||||
{#await getStats() then stats}
|
||||
{$t('index_page.stats', {
|
||||
user_count: stats.user_count,
|
||||
quiz_count: stats.quiz_count
|
||||
})}
|
||||
{/await}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>-->
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<section class="pb-40">
|
||||
<div class="pt-12 text-center">
|
||||
<h1 class="sm:text-8xl text-6xl mt-6 marck-script">ClassQuiz</h1>
|
||||
<p class="text-xl mt-4">{$t('index_page.slogan')}</p>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2 class="text-center text-5xl mb-6">{$t('index_page.how_does_classquiz_work')}</h2>
|
||||
|
||||
<div class="flex justify-center w-full">
|
||||
<h3 class="text-center text-3xl rounded-t-lg bg-opacity-40 bg-white py-2 px-6">
|
||||
{$t('index_page.get_a_quiz')}
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-rows-2 lg:grid-rows-1 lg:grid-cols-2 bg-opacity-40 bg-white shadow-lg mb-12 lg:mx-12 mx-4 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<div class="p-2 rounded-lg">
|
||||
{#if selected_create_thing === SelectedCreateThing.Create}
|
||||
<img
|
||||
class="rounded-lg relative"
|
||||
src={EditScreenshot}
|
||||
in:fade
|
||||
alt="Screenshot of the import-page showing an URL to Kahoot! entered"
|
||||
/>
|
||||
{:else if selected_create_thing === SelectedCreateThing.Find}
|
||||
<img
|
||||
class="rounded-lg relative"
|
||||
src={FindScreenshot}
|
||||
in:fade
|
||||
alt="Screenshot of the search-page showing one found quiz for the term 'Country'"
|
||||
/>
|
||||
{:else if selected_create_thing === SelectedCreateThing.Import}
|
||||
<img
|
||||
class="rounded-lg relative"
|
||||
src={ImportScreenshot}
|
||||
in:fade
|
||||
alt="Screenshot of the import-page showing an URL to Kahoot! entered"
|
||||
/>
|
||||
{:else}
|
||||
<p>Shouldn't happen!</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="lg:border-l lg:border-l-black lg:border-t-0 border-t border-t-black flex lg:flex-col flex-row stretch"
|
||||
>
|
||||
<div
|
||||
class="m-2 rounded-lg p-2 bg-opacity-40 bg-white transition-all cursor-pointer lg:h-full"
|
||||
on:click={() => {
|
||||
selected_create_thing = SelectedCreateThing.Create;
|
||||
}}
|
||||
class:shadow-2xl={selected_create_thing === SelectedCreateThing.Create}
|
||||
class:opacity-70={selected_create_thing !== SelectedCreateThing.Create}
|
||||
>
|
||||
<div
|
||||
class="rounded-lg w-fit p-1 bg-lime-500 hover:bg-lime-400 transition shadow-lg"
|
||||
>
|
||||
<svg
|
||||
aria-label="Pencil-Icon"
|
||||
class="w-8 h-8 text-black"
|
||||
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="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h5 class="text-xl w-fit dark:text-black">{$t('words.create')}</h5>
|
||||
<p class="dark:text-black">{$t('index_page.create_a_quiz_from_scratch')}</p>
|
||||
</div>
|
||||
<div
|
||||
class="m-2 rounded-lg p-2 bg-opacity-40 bg-white transition-all cursor-pointer lg:h-full"
|
||||
on:click={() => {
|
||||
selected_create_thing = SelectedCreateThing.Find;
|
||||
}}
|
||||
class:shadow-2xl={selected_create_thing === SelectedCreateThing.Find}
|
||||
class:opacity-70={selected_create_thing !== SelectedCreateThing.Find}
|
||||
>
|
||||
<div
|
||||
class="rounded-lg w-fit p-1 bg-lime-500 hover:bg-lime-400 transition shadow-lg"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-black"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="magnifying glass-Icon"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h5 class="text-xl dark:text-black">{$t('words.find')}</h5>
|
||||
<p class="dark:text-black">{$t('index_page.find_or_explore')}</p>
|
||||
</div>
|
||||
<!--<div
|
||||
class="m-2 rounded-lg p-2 bg-opacity-40 bg-white transition-all cursor-pointer lg:h-full"
|
||||
on:click={() => {
|
||||
selected_create_thing = SelectedCreateThing.Import;
|
||||
}}
|
||||
class:shadow-2xl={selected_create_thing === SelectedCreateThing.Import}
|
||||
class:opacity-70={selected_create_thing !== SelectedCreateThing.Import}
|
||||
>
|
||||
<div
|
||||
class="rounded-lg w-fit p-1 bg-lime-500 hover:bg-lime-400 transition shadow-lg"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-black"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Cloud with arrow pointing down"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h5 class="text-xl dark:text-black">{$t('words.import')}</h5>
|
||||
<p class="dark:text-black">
|
||||
{$t('index_page.import_quiz_from_kahoot_and_edit')}
|
||||
</p>
|
||||
</div>-->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-24">
|
||||
<div class="flex justify-center w-full">
|
||||
<h2 class="text-center text-3xl rounded-t-lg bg-opacity-40 bg-white py-2 px-6">
|
||||
{$t('index_page.play_quiz')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid grid-rows-2 lg:grid-rows-1 lg:grid-cols-2 bg-opacity-40 bg-white shadow-lg mb-12 lg:mx-12 mx-4 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<div class="p-2 rounded-lg">
|
||||
{#if selected_play_thing === SelectedPlayThing.Select}
|
||||
<img
|
||||
class="rounded-lg relative"
|
||||
src={SelectScreenshot}
|
||||
in:fade
|
||||
alt="Screenshot of the screen where an answer can be selected"
|
||||
/>
|
||||
{:else if selected_play_thing === SelectedPlayThing.Results}
|
||||
<img
|
||||
class="rounded-lg relative"
|
||||
src={ResultScreenshot}
|
||||
in:fade
|
||||
alt="Screenshot of the results with a table showing how many players chose which answer"
|
||||
/>
|
||||
{:else if selected_play_thing === SelectedPlayThing.Winners}
|
||||
<img
|
||||
class="rounded-lg relative"
|
||||
src={WinnersScreenshot}
|
||||
in:fade
|
||||
alt="Screenshot of the import-page showing an URL to Kahoot! entered"
|
||||
/>
|
||||
{:else}
|
||||
<p>Shouldn't happen!</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="lg:border-l lg:border-l-black lg:border-t-0 border-t border-t-black flex lg:flex-col flex-row stretch"
|
||||
>
|
||||
<div
|
||||
class="m-2 rounded-lg p-2 bg-opacity-40 bg-white transition-all cursor-pointer lg:h-full"
|
||||
on:click={() => {
|
||||
selected_play_thing = SelectedPlayThing.Select;
|
||||
}}
|
||||
class:shadow-2xl={selected_play_thing === SelectedPlayThing.Select}
|
||||
class:opacity-70={selected_play_thing !== SelectedPlayThing.Select}
|
||||
>
|
||||
<div
|
||||
class="rounded-lg bg-emerald-300 w-fit p-1 bg-lime-500 hover:bg-lime-400 transition shadow-lg"
|
||||
>
|
||||
<svg
|
||||
aria-label="Mouse-Click icon"
|
||||
class="w-8 h-8 text-black"
|
||||
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="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h5 class="text-xl w-fit dark:text-black">{$t('index_page.select_answer')}</h5>
|
||||
<p class="dark:text-black">{$t('index_page.choose_answer_wisely')}</p>
|
||||
</div>
|
||||
<div
|
||||
class="m-2 rounded-lg p-2 bg-opacity-40 bg-white transition-all cursor-pointer lg:h-full"
|
||||
on:click={() => {
|
||||
selected_play_thing = SelectedPlayThing.Results;
|
||||
}}
|
||||
class:shadow-2xl={selected_play_thing === SelectedPlayThing.Results}
|
||||
class:opacity-70={selected_play_thing !== SelectedPlayThing.Results}
|
||||
>
|
||||
<div
|
||||
class="rounded-lg bg-emerald-300 w-fit p-1 bg-lime-500 hover:bg-lime-400 transition shadow-lg"
|
||||
>
|
||||
<svg
|
||||
aria-label="context-menu icon"
|
||||
class="w-8 h-8 text-black"
|
||||
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 6h16M4 10h16M4 14h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h5 class="text-xl dark:text-black">{$t('index_page.view_results')}</h5>
|
||||
<p class="dark:text-black">{$t('index_page.check_if_chosen_wisely')}</p>
|
||||
</div>
|
||||
<div
|
||||
class="m-2 rounded-lg p-2 bg-opacity-40 bg-white transition-all cursor-pointer lg:h-full"
|
||||
on:click={() => {
|
||||
selected_play_thing = SelectedPlayThing.Winners;
|
||||
}}
|
||||
class:shadow-2xl={selected_play_thing === SelectedPlayThing.Winners}
|
||||
class:opacity-70={selected_play_thing !== SelectedPlayThing.Winners}
|
||||
>
|
||||
<div
|
||||
class="rounded-lg bg-emerald-300 w-fit p-1 bg-lime-500 hover:bg-lime-400 transition shadow-lg"
|
||||
>
|
||||
<svg
|
||||
aria-label="sparkling stars-icon"
|
||||
class="w-8 h-8 text-black"
|
||||
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="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h5 class="text-xl dark:text-black">{$t('index_page.list_winners')}</h5>
|
||||
<p class="dark:text-black">{$t('index_page.get_ranking_and_winners')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-24">
|
||||
<div class="flex justify-center w-full">
|
||||
<h2 class="text-center text-3xl rounded-t-lg bg-opacity-40 bg-white py-2 px-6">
|
||||
{$t('index_page.why_classquiz')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid grid-rows-2 lg:grid-rows-1 lg:grid-cols-2 bg-opacity-40 bg-white shadow-lg mb-12 lg:mx-12 mx-4 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<div class="p-12 rounded-lg flex justify-center items-center h-full">
|
||||
<p class="dark:text-black">
|
||||
{classquiz_reasons[selected_classquiz_reason].content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="lg:border-l lg:border-l-black lg:border-t-0 border-t border-t-black flex lg:flex-col flex-row stretch overflow-x-auto why-classquiz"
|
||||
>
|
||||
{#each classquiz_reasons as reason, index}
|
||||
<div
|
||||
class="m-2 rounded-lg p-2 bg-opacity-40 bg-white transition-all cursor-pointer lg:h-full"
|
||||
on:click={() => {
|
||||
selected_classquiz_reason = index;
|
||||
}}
|
||||
class:shadow-2xl={selected_classquiz_reason === index}
|
||||
class:opacity-70={selected_classquiz_reason !== index}
|
||||
>
|
||||
<h5 class="text-xl dark:text-black">{reason.headline}</h5>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{#if newsletterModalOpen}
|
||||
<div
|
||||
class="fixed bottom-8 right-5 bg-white rounded-lg h-fit w-11/12 ml-5 lg:w-2/12 z-50 p-2 bg-white dark:bg-gray-700"
|
||||
transition:fly
|
||||
>
|
||||
<Newsletter bind:open={newsletterModalOpen} />
|
||||
</div>
|
||||
{/if}
|
||||
<Footer />
|
||||
|
||||
<style>
|
||||
.why-classquiz::-webkit-scrollbar {
|
||||
height: 0.8rem;
|
||||
margin-bottom: 5rem;
|
||||
}
|
||||
|
||||
.why-classquiz::-webkit-scrollbar-track {
|
||||
box-shadow: inset 0 0 10px 10px transparent;
|
||||
border: solid 3px transparent;
|
||||
}
|
||||
|
||||
.why-classquiz::-webkit-scrollbar-thumb {
|
||||
box-shadow: inset 0 0 10px 10px #374151;
|
||||
border: solid 3px transparent;
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
.why-classquiz::-webkit-scrollbar-thumb:hover {
|
||||
box-shadow: inset 0 0 10px 10px #555;
|
||||
border: solid 3px transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -1,141 +0,0 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
|
||||
|
||||
SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
<script lang="ts">
|
||||
export let session_data = {};
|
||||
export let step;
|
||||
export let selected_method;
|
||||
|
||||
let available_methods;
|
||||
|
||||
const set_available_methods = (step_var: number) => {
|
||||
if (step_var === 1) {
|
||||
available_methods = session_data.step_1;
|
||||
} else if (step_var === 2) {
|
||||
available_methods = session_data.step_2;
|
||||
}
|
||||
};
|
||||
|
||||
$: set_available_methods(step);
|
||||
</script>
|
||||
|
||||
<div class="px-6 py-4">
|
||||
<h2 class="text-3xl font-bold text-center text-gray-700 dark:text-white">ClassQuiz</h2>
|
||||
|
||||
<div class="w-full mt-4">
|
||||
<div class="dark:bg-gray-800 bg-white p-4 rounded-lg">
|
||||
<ul class="flex flex-col gap-4">
|
||||
{#if available_methods.includes('PASSKEY')}
|
||||
<div
|
||||
class="flex flex-row bg-gray-100 dark:bg-gray-700 rounded-lg p-2 hover:cursor-pointer hover:bg-gray-200 transition"
|
||||
on:click={() => {
|
||||
selected_method = 'PASSKEY';
|
||||
}}
|
||||
on:keyup={() => {
|
||||
selected_method = 'PASSKEY';
|
||||
}}
|
||||
>
|
||||
<!-- heroicons/key -->
|
||||
<svg
|
||||
class="w-12 h-12"
|
||||
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="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="ml-2">
|
||||
<p>Key</p>
|
||||
<p class="text-sm">Authenticate using a security key</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if available_methods.includes('PASSWORD')}
|
||||
<div
|
||||
class="flex flex-row bg-gray-100 dark:bg-gray-700 rounded-lg p-2 hover:cursor-pointer hover:bg-gray-200 transition"
|
||||
on:click={() => {
|
||||
selected_method = 'PASSWORD';
|
||||
}}
|
||||
on:keyup={() => {
|
||||
selected_method = 'PASSWORD';
|
||||
}}
|
||||
>
|
||||
<!-- iconoir/password-cursor -->
|
||||
<svg
|
||||
class="w-12 h-12 dark:text-white"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M21 13V8a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h7"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.03"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M20.879 16.917c.494.304.463 1.043-.045 1.101l-2.567.291-1.151 2.312c-.228.459-.933.234-1.05-.334l-1.255-6.116c-.099-.48.333-.782.75-.525l5.318 3.271z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.03"
|
||||
/>
|
||||
<path
|
||||
d="M12 11.01l.01-.011M16 11.01l.01-.011M8 11.01l.01-.011"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.03"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<div class="ml-2">
|
||||
<p>Password</p>
|
||||
<p class="text-sm">Authenticate using a Password</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if available_methods.includes('TOTP')}
|
||||
<div
|
||||
class="flex flex-row bg-gray-100 rounded-lg p-2 hover:cursor-pointer hover:bg-gray-200 transition"
|
||||
on:click={() => {
|
||||
selected_method = 'TOTP';
|
||||
}}
|
||||
on:keyup={() => {
|
||||
selected_method = 'TOTP';
|
||||
}}
|
||||
>
|
||||
<!-- heroicons/clock -->
|
||||
<svg
|
||||
class="w-12 h-12"
|
||||
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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="ml-2">
|
||||
<p>Totp</p>
|
||||
<p class="text-sm">Authenticate using a one-time password</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,113 +0,0 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
|
||||
|
||||
SPDX-License-Identifier: MPL-2.0
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { getLocalization } from '$lib/i18n';
|
||||
import OAuthBlock from './oauth_block.svelte';
|
||||
|
||||
export let session_data = {};
|
||||
export let step;
|
||||
|
||||
const { t } = getLocalization();
|
||||
let email = '';
|
||||
let emailEmpty = true;
|
||||
let isSubmitting = false;
|
||||
|
||||
$: emailEmpty = email === '';
|
||||
const start_login = async (): Promise<void> => {
|
||||
if (emailEmpty) {
|
||||
return;
|
||||
}
|
||||
isSubmitting = true;
|
||||
// alert("Alert message");
|
||||
const res = await fetch('/api/v1/login/start', {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ email: email })
|
||||
});
|
||||
session_data = await res.json();
|
||||
step = 1;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="px-6 py-4">
|
||||
<h2 class="text-3xl font-bold text-center text-gray-700 dark:text-white">ClassQuiz</h2>
|
||||
|
||||
<h3 class="mt-1 text-xl font-medium text-center text-gray-600 dark:text-gray-200">
|
||||
{$t('login_page.welcome_back')}
|
||||
</h3>
|
||||
|
||||
<p class="mt-1 text-center text-gray-500 dark:text-gray-400">
|
||||
{$t('login_page.login_or_create_account')}
|
||||
</p>
|
||||
|
||||
<form on:submit|preventDefault={start_login}>
|
||||
<div class="w-full mt-4">
|
||||
<div class="dark:bg-gray-800 bg-white p-4 rounded-lg">
|
||||
<div class="relative bg-inherit w-full">
|
||||
<input
|
||||
id="email"
|
||||
bind:value={email}
|
||||
name="email"
|
||||
type="text"
|
||||
class="w-full peer bg-transparent h-10 rounded-lg text-gray-700 dark:text-white placeholder-transparent ring-2 px-2 ring-gray-500 focus:ring-sky-600 focus:outline-none focus:border-rose-600"
|
||||
placeholder={$t('login_page.email_or_username')}
|
||||
autocomplete="email"
|
||||
/>
|
||||
<label
|
||||
for="email"
|
||||
class="absolute cursor-text left-0 -top-3 text-sm text-gray-700 dark:text-white bg-inherit mx-1 px-1 peer-placeholder-shown:text-base peer-placeholder-shown:text-gray-500 peer-placeholder-shown:top-2 peer-focus:-top-3 peer-focus:text-sky-600 peer-focus:text-sm transition-all"
|
||||
>
|
||||
{$t('login_page.email_or_username')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<a
|
||||
href="/account/reset-password"
|
||||
class="text-sm text-gray-600 dark:text-gray-200 hover:text-gray-500"
|
||||
>{$t('register_page.forgot_password?')}</a
|
||||
>
|
||||
|
||||
<button
|
||||
class="px-4 py-2 leading-5 text-white transition-colors duration-200 transform bg-gray-700 rounded hover:bg-gray-600 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={emailEmpty}
|
||||
type="submit"
|
||||
>
|
||||
{#if isSubmitting}
|
||||
<svg class="h-4 w-4 animate-spin mx-auto" viewBox="3 3 18 18">
|
||||
<path
|
||||
class="fill-black"
|
||||
d="M12 5C8.13401 5 5 8.13401 5 12C5 15.866 8.13401 19 12 19C15.866 19 19 15.866 19 12C19 8.13401 15.866 5 12 5ZM3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12Z"
|
||||
/>
|
||||
<path
|
||||
class="fill-blue-100"
|
||||
d="M16.9497 7.05015C14.2161 4.31648 9.78392 4.31648 7.05025 7.05015C6.65973 7.44067 6.02656 7.44067 5.63604 7.05015C5.24551 6.65962 5.24551 6.02646 5.63604 5.63593C9.15076 2.12121 14.8492 2.12121 18.364 5.63593C18.7545 6.02646 18.7545 6.65962 18.364 7.05015C17.9734 7.44067 17.3403 7.44067 16.9497 7.05015Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
{$t('words.continue')}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<OAuthBlock />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center py-4 text-center bg-gray-50 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-200"
|
||||
>{$t('login_page.already_have_account')}
|
||||
</span>
|
||||
|
||||
<a
|
||||
href="/account/register"
|
||||
class="mx-2 text-sm font-bold text-blue-500 dark:text-blue-400 hover:underline"
|
||||
>{$t('words.register')}</a
|
||||
>
|
||||
</div>
|
||||
@@ -1,37 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2023 Marlon W (Mawoka)
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
|
||||
/** @type {import("vite").UserConfig} */
|
||||
const config = {
|
||||
plugins: [
|
||||
sveltekit(),
|
||||
{
|
||||
name: 'configure-response-headers',
|
||||
configureServer: (server) => {
|
||||
server.middlewares.use((_req, res, next) => {
|
||||
/* res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
|
||||
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
||||
res.setHeader("Access-Control-Allow-Origin", "https://ncs3.classquiz.de");*/
|
||||
next();
|
||||
});
|
||||
}
|
||||
}
|
||||
],
|
||||
server: {
|
||||
port: 3000
|
||||
},
|
||||
preview: {
|
||||
port: 3000
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['swiper', 'tippy.js']
|
||||
},
|
||||
build: {
|
||||
sourcemap: true
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
Reference in New Issue
Block a user