Remove Backups and copies

This commit is contained in:
Mawoka
2024-07-08 10:33:11 +02:00
parent 20911c74cd
commit 695b3b43c6
11 changed files with 0 additions and 2072 deletions
-107
View File
@@ -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}}$"
-492
View File
@@ -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
-63
View File
@@ -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")
-121
View File
@@ -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:
-55
View File
@@ -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"]
-109
View File
@@ -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}
&lbrace;<span class="text-indigo-400">{arg}</span
>&rbrace;{/each}
{/if}
</p>
</div>
<p class="text-sm">{vi.description}</p>
</div>
{/each}
</div>
</div>
</div>
{/if}
@@ -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>
-37
View File
@@ -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;