Merge pull request #57 from mawoka-myblock/dev

This commit is contained in:
Mawoka
2022-06-17 13:02:23 +02:00
committed by GitHub
23 changed files with 1310 additions and 299 deletions
+11 -8
View File
@@ -6,7 +6,7 @@ from starlette.middleware.sessions import SessionMiddleware
from classquiz.config import settings
from classquiz.db import database
from classquiz.routers import users, quiz, utils, stats, storage, search, testing_routes
from classquiz.routers import users, quiz, utils, stats, storage, search, testing_routes, editor
from classquiz.socket_server import sio
from classquiz.helpers import meilisearch_init
@@ -45,11 +45,14 @@ async def shutdown() -> None:
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key)
app.include_router(users.router, tags=["users"], prefix="/api/v1/users")
app.include_router(quiz.router, tags=["quiz"], prefix="/api/v1/quiz")
app.include_router(utils.router, tags=["utils"], prefix="/api/v1/utils")
app.include_router(stats.router, tags=["stats"], prefix="/api/v1/stats")
app.include_router(storage.router, tags=["storage"], prefix="/api/v1/storage")
app.include_router(search.router, tags=["search"], prefix="/api/v1/search")
app.include_router(testing_routes.router, tags=["internal", "testing"], prefix="/api/v1/internal/testing")
app.include_router(users.router, tags=["users"], prefix="/api/v1/users", include_in_schema=False)
app.include_router(quiz.router, tags=["quiz"], prefix="/api/v1/quiz", include_in_schema=False)
app.include_router(utils.router, tags=["utils"], prefix="/api/v1/utils", include_in_schema=True)
app.include_router(stats.router, tags=["stats"], prefix="/api/v1/stats", include_in_schema=True)
app.include_router(storage.router, tags=["storage"], prefix="/api/v1/storage", include_in_schema=True)
app.include_router(search.router, tags=["search"], prefix="/api/v1/search", include_in_schema=True)
app.include_router(
testing_routes.router, tags=["internal", "testing"], prefix="/api/v1/internal/testing", include_in_schema=False
)
app.include_router(editor.router, tags=["editor"], prefix="/api/v1/editor", include_in_schema=True)
app.mount("/", ASGIApp(sio))
+3 -2
View File
@@ -1,6 +1,7 @@
from functools import lru_cache
import redis.asyncio as redis_lib
from redis import asyncio as redis_lib
import redis as redis_base_lib
from pydantic import BaseSettings, RedisDsn, PostgresDsn
import meilisearch as MeiliSearch
from typing import Optional
@@ -53,7 +54,7 @@ def settings() -> Settings:
return Settings()
redis: redis_lib.client.Redis = redis_lib.Redis().from_url(settings().redis)
redis: redis_base_lib.client.Redis = redis_lib.Redis().from_url(settings().redis)
storage: Storage = Storage(
backend=settings().storage_backend,
deta_key=settings().deta_project_key,
+155
View File
@@ -0,0 +1,155 @@
import asyncio
import html
import re
import uuid
from typing import Optional
import asyncpg.exceptions
import bleach
from fastapi import APIRouter, File, UploadFile, HTTPException, Depends
from pydantic import BaseModel
from classquiz.config import settings, redis, storage, meilisearch
from classquiz.db.models import Quiz, QuizInput, User
from classquiz.auth import get_current_user
import os
from datetime import datetime
from uuid import UUID
from classquiz.helpers import get_meili_data
from classquiz.storage.errors import DeletionFailedError
settings = settings()
router = APIRouter()
class InitEditorResponse(BaseModel):
token: str
class EditSessionData(BaseModel):
quiz_id: UUID
edit: bool
user_id: UUID
async def delete_images_for_edit_id(edit_id: str):
await asyncio.sleep(30)
res = await redis.lrange(f"edit_session:{edit_id}:images", 0, -1)
if len(res) != 0:
for image_id in res:
await storage.delete([image_id])
@router.post("/start", response_model=InitEditorResponse)
async def init_editor(edit: bool, quiz_id: Optional[UUID] = None, user: User = Depends(get_current_user)):
if edit and quiz_id is not None and await Quiz.objects.get_or_none(id=quiz_id, user_id=user.id) is None:
raise HTTPException(status_code=404, detail="Quiz not found")
if not edit and quiz_id is not None:
raise HTTPException(status_code=400, detail="You can't choose the id for your quiz")
if edit and quiz_id is None:
raise HTTPException(status_code=400, detail="Edit can't be true if quiz_id is None")
if quiz_id is None:
quiz_id = uuid.uuid4()
edit_id = os.urandom(4).hex()
await redis.sadd("edit_sessions", edit_id)
await redis.set(
f"edit_session:{edit_id}", EditSessionData(quiz_id=quiz_id, edit=edit, user_id=user.id).json(), ex=3600
)
return InitEditorResponse(token=edit_id)
class UploadImageReturn(BaseModel):
id: str
@router.post("/image", response_model=UploadImageReturn)
async def upload_image(edit_id: str, file: UploadFile = File()):
session_data = await redis.get(f"edit_session:{edit_id}")
if session_data is None:
raise HTTPException(status_code=401, detail="Edit ID not found!")
session_data = EditSessionData.parse_raw(session_data)
file_name = f"{session_data.quiz_id}--{uuid.uuid4()}"
print("Uploading...")
await storage.upload(file_name=file_name, file_data=await file.read())
print("Finished Upload")
await redis.lpush(f"edit_session:{edit_id}:images", file_name)
return UploadImageReturn(id=file_name)
@router.post("/finish")
async def finish_edit(edit_id: str, quiz_input: QuizInput):
session_data = await redis.get(f"edit_session:{edit_id}")
if session_data is None:
raise HTTPException(status_code=401, detail="Edit ID not found!")
session_data = EditSessionData.parse_raw(session_data)
quiz_input.title = html.unescape(bleach.clean(quiz_input.title, tags=[], strip=True))
quiz_input.description = html.unescape(bleach.clean(quiz_input.description, tags=[], strip=True))
image_id_regex = r"^.{36}--.{36}$"
imgur_regex = r"^https://i\.imgur\.com\/.{7}.(jpg|png|gif)$"
server_regex = rf"^{re.escape(settings.root_address)}/api/v1/storage/download/.{{36}}--.{{36}}$"
extract_file_name_re = r"^.*/api/v1/storage/download/(.{36}--.{36})$"
images_to_delete = []
old_quiz_data = await Quiz.objects.get_or_none(id=session_data.quiz_id, user_id=session_data.user_id)
def mark_image_for_deletion(new: str | None, index: int, old_quiz: Quiz | None):
if old_quiz is None:
return
print(new, old_quiz.questions[index]["image"])
if new == old_quiz.questions[index]["image"]:
return
else:
images_to_delete.append(old_quiz.questions[index]["image"])
for i, question in enumerate(quiz_input.questions):
image = question.image
if image == "":
question.image = None
mark_image_for_deletion(question.image, i, old_quiz_data)
elif image is None:
mark_image_for_deletion(question.image, i, old_quiz_data)
elif bool(re.match(image_id_regex, question.image)):
question.image = f"{settings.root_address}/api/v1/storage/download/{image}"
mark_image_for_deletion(question.image, i, old_quiz_data)
elif bool(re.match(imgur_regex, image)):
mark_image_for_deletion(question.image, i, old_quiz_data)
elif bool(re.match(server_regex, image)):
mark_image_for_deletion(question.image, i, old_quiz_data)
else:
raise HTTPException(status_code=400, detail="Image URL(s) aren't valid!")
print(images_to_delete)
if session_data.edit:
quiz = old_quiz_data
meilisearch.index(settings.meilisearch_index).update_documents([await get_meili_data(quiz)])
if quiz.public and not quiz_input.public:
meilisearch.index(settings.meilisearch_index).delete_document(str(quiz.id))
if not quiz.public and quiz_input.public:
meilisearch.index(settings.meilisearch_index).add_documents([await get_meili_data(quiz)])
quiz.title = quiz_input.title
quiz.public = quiz_input.public
quiz.description = quiz_input.description
quiz.updated_at = datetime.now()
quiz.questions = quiz_input.dict()["questions"]
for image in images_to_delete:
if image is not None:
try:
await storage.delete([re.search(extract_file_name_re, image).group(1)])
except DeletionFailedError:
pass
await redis.srem("edit_sessions", edit_id)
await redis.delete(f"edit_session:{edit_id}")
await redis.delete(f"edit_session:{edit_id}:images")
return await quiz.update()
else:
quiz = Quiz(**quiz_input.dict(), user_id=session_data.user_id, id=session_data.quiz_id)
await redis.delete("global_quiz_count")
if quiz_input.public:
meilisearch.index(settings.meilisearch_index).add_documents([await get_meili_data(quiz)])
try:
await redis.srem("edit_sessions", edit_id)
await redis.delete(f"edit_session:{edit_id}")
await redis.delete(f"edit_session:{edit_id}:images")
return await quiz.save()
except asyncpg.exceptions.UniqueViolationError:
raise HTTPException(status_code=400, detail="The quiz already exists")
+2 -2
View File
@@ -20,7 +20,7 @@ settings = settings()
router = APIRouter()
@router.post("/create")
@router.post("/create", deprecated=True)
async def create_quiz_lol(quiz_input: QuizInput, user: User = Depends(get_current_user)):
imgur_regex = r"^https://i\.imgur\.com\/.{7}.(jpg|png|gif)$"
server_regex = rf"^{re.escape(settings.root_address)}/api/v1/storage/download/.{36}--.{36}$"
@@ -129,7 +129,7 @@ async def get_quiz_list(user: User = Depends(get_current_user)):
return await Quiz.objects.filter(user_id=user.id).all()
@router.put("/update/{quiz_id}")
@router.put("/update/{quiz_id}", deprecated=True)
async def update_quiz(quiz_id: str, quiz_input: QuizInput, user: User = Depends(get_current_user)):
imgur_regex = r"^https://i\.imgur\.com\/.{7}.(jpg|png|gif)$"
server_regex = rf"^{re.escape(settings.root_address)}/api/v1/storage/download/.{{36}}--.{{36}}$"
+4 -3
View File
@@ -1,4 +1,5 @@
from io import BytesIO
from classquiz.storage.errors import DeletionFailedError, SavingFailedError, DownloadingFailedError
from aiohttp import ClientSession
@@ -26,7 +27,7 @@ class DetaStorage:
elif response.status == 404:
return None
else:
raise Exception("Download failed")
raise DownloadingFailedError
async def upload(self, file: bytes, file_name: str) -> None:
"""
@@ -40,7 +41,7 @@ class DetaStorage:
if response.status == 201:
return None
else:
raise Exception("Upload failed")
raise SavingFailedError
async def delete(self, file_names: [str]) -> None:
async with ClientSession(headers=self.headers) as session, session.delete(
@@ -49,4 +50,4 @@ class DetaStorage:
if response.status == 200:
return None
else:
raise Exception("Delete failed")
raise DeletionFailedError
+10
View File
@@ -0,0 +1,10 @@
class DeletionFailedError(Exception):
pass
class SavingFailedError(Exception):
pass
class DownloadingFailedError(Exception):
pass
+7
View File
@@ -31,6 +31,13 @@
"@types/yup": "^0.29.14",
"@typescript-eslint/eslint-plugin": "^5.27.1",
"@typescript-eslint/parser": "^5.27.1",
"@uppy/compressor": "^0.3.0",
"@uppy/core": "^2.3.1",
"@uppy/dashboard": "^2.3.0",
"@uppy/drop-target": "^1.1.3",
"@uppy/image-editor": "^1.3.0",
"@uppy/svelte": "^1.0.8",
"@uppy/xhr-upload": "^2.1.2",
"autoprefixer": "^10.4.7",
"cookie": "^0.5.0",
"cssnano": "^5.1.11",
+372 -3
View File
@@ -18,6 +18,13 @@ specifiers:
'@types/yup': ^0.29.14
'@typescript-eslint/eslint-plugin': ^5.27.1
'@typescript-eslint/parser': ^5.27.1
'@uppy/compressor': ^0.3.0
'@uppy/core': ^2.3.1
'@uppy/dashboard': ^2.3.0
'@uppy/drop-target': ^1.1.3
'@uppy/image-editor': ^1.3.0
'@uppy/svelte': ^1.0.8
'@uppy/xhr-upload': ^2.1.2
autoprefixer: ^10.4.7
cookie: ^0.5.0
cssnano: ^5.1.11
@@ -73,6 +80,13 @@ devDependencies:
'@types/yup': 0.29.14
'@typescript-eslint/eslint-plugin': 5.27.1_aq7uryhocdbvbqum33pitcm3y4
'@typescript-eslint/parser': 5.27.1_ud6rd4xtew5bv4yhvkvu24pzm4
'@uppy/compressor': 0.3.0_@uppy+core@2.3.1
'@uppy/core': 2.3.1
'@uppy/dashboard': 2.3.0_@uppy+core@2.3.1
'@uppy/drop-target': 1.1.3_@uppy+core@2.3.1
'@uppy/image-editor': 1.3.0_@uppy+core@2.3.1
'@uppy/svelte': 1.0.8_w57owxejta2mqj4rr2tj54bhsi
'@uppy/xhr-upload': 2.1.2_@uppy+core@2.3.1
autoprefixer: 10.4.7_postcss@8.4.14
cookie: 0.5.0
cssnano: 5.1.11_postcss@8.4.14
@@ -592,6 +606,20 @@ packages:
tailwindcss: 3.1.2
dev: true
/@transloadit/prettier-bytes/0.0.7:
resolution:
{
integrity: sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA==
}
dev: true
/@transloadit/prettier-bytes/0.0.9:
resolution:
{
integrity: sha512-pCvdmea/F3Tn4hAtHqNXmjcixSaroJJ+L3STXlYJdir1g1m2mRQpWbN8a4SvgQtaw2930Ckhdx8qXdXBFMKbAA==
}
dev: true
/@trysound/sax/0.2.0:
resolution:
{
@@ -822,6 +850,226 @@ packages:
eslint-visitor-keys: 3.3.0
dev: true
/@uppy/companion-client/2.2.1:
resolution:
{
integrity: sha512-Y3E10NJLMfp/wjgthNhx3gJtT67fzFCPNPFwpNNRs5iJsW6PANhJ420eyMUFzfmEZ56ZzGYxr5pzJZx8YxHICQ==
}
dependencies:
'@uppy/utils': 4.1.0
namespace-emitter: 2.0.1
dev: true
/@uppy/compressor/0.3.0_@uppy+core@2.3.1:
resolution:
{
integrity: sha512-M05fz0TA5Oy0wW930j+eROlcPADNu20S0L2n4ej7bZlUzNLMixjE6UnwW5huSm2vzZV5tEPKvSJ0SnU/E+sM4Q==
}
peerDependencies:
'@uppy/core': ^2.3.0
dependencies:
'@transloadit/prettier-bytes': 0.0.9
'@uppy/core': 2.3.1
'@uppy/utils': 4.1.0
compressorjs: 1.1.1
preact: 10.8.1
promise-queue: 2.2.5
dev: true
/@uppy/core/2.3.1:
resolution:
{
integrity: sha512-KV04X7ueYbYX1p37/i3QsoQSw8IDP8Yb+Bh9KNN0X2Vcun6K2VnNjhVtPmPXtyjDZooK7lVIqhRX8TZWcSfgSQ==
}
dependencies:
'@transloadit/prettier-bytes': 0.0.7
'@uppy/store-default': 2.1.0
'@uppy/utils': 4.1.0
lodash.throttle: 4.1.1
mime-match: 1.0.2
namespace-emitter: 2.0.1
nanoid: 3.3.4
preact: 10.8.1
dev: true
/@uppy/dashboard/2.3.0_@uppy+core@2.3.1:
resolution:
{
integrity: sha512-q7GaLrYt3Tb+x4R8rARfzBuqPtdf6lKnc5Rg/XW18VOIaQM/UZNuBr+EVGhwuDiQt/1QJz1t2lS2Mx246zJ/qA==
}
peerDependencies:
'@uppy/core': ^2.3.0
dependencies:
'@transloadit/prettier-bytes': 0.0.7
'@uppy/core': 2.3.1
'@uppy/informer': 2.1.0_@uppy+core@2.3.1
'@uppy/provider-views': 2.1.1_@uppy+core@2.3.1
'@uppy/status-bar': 2.2.1_@uppy+core@2.3.1
'@uppy/thumbnail-generator': 2.2.0_@uppy+core@2.3.1
'@uppy/utils': 4.1.0
classnames: 2.3.1
is-shallow-equal: 1.0.1
lodash.debounce: 4.0.8
memoize-one: 5.2.1
nanoid: 3.3.4
preact: 10.8.1
dev: true
/@uppy/drag-drop/2.1.1_@uppy+core@2.3.1:
resolution:
{
integrity: sha512-3cTXzmoMpCJvaM0H59acm/CNkS3y6jr03SYK7u6X/vd3/a6e3XyfaGc/jwKSsGBm+oMLftFKv/inhQLKOZR5CQ==
}
peerDependencies:
'@uppy/core': ^2.3.0
dependencies:
'@uppy/core': 2.3.1
'@uppy/utils': 4.1.0
preact: 10.8.1
dev: true
/@uppy/drop-target/1.1.3_@uppy+core@2.3.1:
resolution:
{
integrity: sha512-Cd+L3mdVovS6G0Yv0Hah18Utz3czQ75YrGJ65MlHFzfJccOQJczvxn18FVssKyO6Zc0ce1y0mGKGjUClTM5dMA==
}
peerDependencies:
'@uppy/core': ^2.1.9
dependencies:
'@uppy/core': 2.3.1
'@uppy/utils': 4.1.0
dev: true
/@uppy/image-editor/1.3.0_@uppy+core@2.3.1:
resolution:
{
integrity: sha512-ZTF0A2e39K5ZDBFu6pSxKPL9Tx6Xslsu0k+1eW2bu2pzX0k4b4hVV6wciBZTr2UzitzdF7x83oIefiVQyBZ7/A==
}
peerDependencies:
'@uppy/core': ^2.3.0
dependencies:
'@uppy/core': 2.3.1
'@uppy/utils': 4.1.0
cropperjs: 1.5.7
preact: 10.8.1
dev: true
/@uppy/informer/2.1.0_@uppy+core@2.3.1:
resolution:
{
integrity: sha512-/nR7Dxh1RH1FvKRPQ/xZk1lS3F+lh93xaN6Z9utGg5u1hHREuaHfFTcEXhkeq1/hiKbVYsjNaNZ5aPe7+4flkg==
}
peerDependencies:
'@uppy/core': ^2.3.0
dependencies:
'@uppy/core': 2.3.1
'@uppy/utils': 4.1.0
preact: 10.8.1
dev: true
/@uppy/progress-bar/2.1.1_@uppy+core@2.3.1:
resolution:
{
integrity: sha512-Kh/kM5rGUABxwwDRzR3pjDzkNRELES2T3nIBdstRIfWCxlHjIFpgqOkvvNRXKflm2hcYUzTRELgtfi09LuqE9A==
}
peerDependencies:
'@uppy/core': ^2.3.0
dependencies:
'@uppy/core': 2.3.1
'@uppy/utils': 4.1.0
preact: 10.8.1
dev: true
/@uppy/provider-views/2.1.1_@uppy+core@2.3.1:
resolution:
{
integrity: sha512-Emk18dBAUx73uhkYL/ryFqu9pQ5hVnW5P+b9J/bq3bxDaEtVfQvhFPfaa8/gHNb8t499ytHmRPJYUJlqfG3/5A==
}
peerDependencies:
'@uppy/core': ^2.3.0
dependencies:
'@uppy/core': 2.3.1
'@uppy/utils': 4.1.0
classnames: 2.3.1
preact: 10.8.1
dev: true
/@uppy/status-bar/2.2.1_@uppy+core@2.3.1:
resolution:
{
integrity: sha512-jnJ/DSEuWGPU6+m0i2yfGl5cPb6CJnxDtcL6/gXKOyjGeRfriWjeEsRHmEkdK+xLv/AJC//eEfSOCL3N737p0g==
}
peerDependencies:
'@uppy/core': ^2.3.0
dependencies:
'@transloadit/prettier-bytes': 0.0.7
'@uppy/core': 2.3.1
'@uppy/utils': 4.1.0
classnames: 2.3.1
lodash.throttle: 4.1.1
preact: 10.8.1
dev: true
/@uppy/store-default/2.1.0:
resolution:
{
integrity: sha512-BkcR1wGw6Kwbvr8m1tKF9EDDWSTJoTGnVseBF/iW4bzR22assbtxZIE1iroo68UMqYEG4rv63SX4BUEtNvVjdA==
}
dev: true
/@uppy/svelte/1.0.8_w57owxejta2mqj4rr2tj54bhsi:
resolution:
{
integrity: sha512-A/80S2LCRjszzGbdDe7e9YU77HAa2PiIMqAjmyuOY5ZMyoIsRjeT8G9CNjeDMGMJBsQLyysDWbF3AZqPyJukQA==
}
peerDependencies:
'@uppy/core': ^2.2.0
svelte: ^3.0.0
dependencies:
'@uppy/core': 2.3.1
'@uppy/dashboard': 2.3.0_@uppy+core@2.3.1
'@uppy/drag-drop': 2.1.1_@uppy+core@2.3.1
'@uppy/progress-bar': 2.1.1_@uppy+core@2.3.1
'@uppy/status-bar': 2.2.1_@uppy+core@2.3.1
svelte: 3.48.0
dev: true
/@uppy/thumbnail-generator/2.2.0_@uppy+core@2.3.1:
resolution:
{
integrity: sha512-ySZoB0tK7sQ/PRSFvU5JLxcq/wVqJM/G95cVdJ2Wv1BzQ7+/wKBEPFgJuHDqgzpgUrHwmc+N0JYVXqfk297DZA==
}
peerDependencies:
'@uppy/core': ^2.3.0
dependencies:
'@uppy/core': 2.3.1
'@uppy/utils': 4.1.0
exifr: 7.1.3
dev: true
/@uppy/utils/4.1.0:
resolution:
{
integrity: sha512-C47DUl4uLzmQZdW+VmetIgGRurXuPsvb+/pyYqh9DJn0Phep8u7AOj/tlJA5CHv4pefNHsFjXpaWfSUG3HtW3A==
}
dependencies:
lodash.throttle: 4.1.1
dev: true
/@uppy/xhr-upload/2.1.2_@uppy+core@2.3.1:
resolution:
{
integrity: sha512-VCsb7J5yHsof49nnUa+Y1n27UMtqHPttQmmoCa5hmjqa9R7ZISpBkXKOQmZo526eopKNuAKSAdkHWfCm8efJTA==
}
peerDependencies:
'@uppy/core': ^2.3.1
dependencies:
'@uppy/companion-client': 2.2.1
'@uppy/core': 2.3.1
'@uppy/utils': 4.1.0
nanoid: 3.3.4
dev: true
/@vercel/nft/0.19.1:
resolution:
{
@@ -1061,6 +1309,13 @@ packages:
file-uri-to-path: 1.0.0
dev: true
/blueimp-canvas-to-blob/3.29.0:
resolution:
{
integrity: sha512-0pcSSGxC0QxT+yVkivxIqW0Y4VlO2XSDPofBAqoJ1qJxgH9eiUDLv50Rixij2cDuEfx4M6DpD9UGZpRhT5Q8qg==
}
dev: true
/boolbase/1.0.0:
resolution:
{
@@ -1189,6 +1444,13 @@ packages:
engines: { node: '>=10' }
dev: true
/classnames/2.3.1:
resolution:
{
integrity: sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==
}
dev: true
/cluster-key-slot/1.1.0:
resolution:
{
@@ -1245,6 +1507,16 @@ packages:
engines: { node: '>= 10' }
dev: true
/compressorjs/1.1.1:
resolution:
{
integrity: sha512-SysRuUPfmUNoq+RviE0iMFVUmoX2q/x+7PkEPUmk6NGkd85hDrmvujx0Qtp8UCGA6KMe5kuodsylPQcNaLf60w==
}
dependencies:
blueimp-canvas-to-blob: 3.29.0
is-blob: 2.1.0
dev: true
/concat-map/0.0.1:
resolution: { integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= }
dev: true
@@ -1271,6 +1543,13 @@ packages:
}
dev: true
/cropperjs/1.5.7:
resolution:
{
integrity: sha512-sGj+G/ofKh+f6A4BtXLJwtcKJgMUsXYVUubfTo9grERiDGXncttefmue/fyQFvn8wfdyoD1KhDRYLfjkJFl0yw==
}
dev: true
/cross-spawn/7.0.3:
resolution:
{
@@ -2170,6 +2449,13 @@ packages:
engines: { node: '>=0.10.0' }
dev: true
/exifr/7.1.3:
resolution:
{
integrity: sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==
}
dev: true
/fast-deep-equal/3.1.3:
resolution:
{
@@ -2672,6 +2958,14 @@ packages:
binary-extensions: 2.2.0
dev: true
/is-blob/2.1.0:
resolution:
{
integrity: sha512-SZ/fTft5eUhQM6oF/ZaASFDEdbFVe89Imltn9uZr03wdKMcWNVYSMjQPFtg05QuNkt5l5c135ElvXEQG0rk4tw==
}
engines: { node: '>=6' }
dev: true
/is-core-module/2.9.0:
resolution:
{
@@ -2725,6 +3019,13 @@ packages:
engines: { node: '>=0.12.0' }
dev: true
/is-shallow-equal/1.0.1:
resolution:
{
integrity: sha512-lq5RvK+85Hs5J3p4oA4256M1FEffzmI533ikeDHvJd42nouRRx5wBzt36JuviiGe5dIPyHON/d0/Up+PBo6XkQ==
}
dev: true
/isarray/1.0.0:
resolution:
{
@@ -2819,6 +3120,13 @@ packages:
}
dev: true
/lodash.debounce/4.0.8:
resolution:
{
integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==
}
dev: true
/lodash.defaults/4.2.0:
resolution:
{
@@ -2854,6 +3162,13 @@ packages:
}
dev: true
/lodash.throttle/4.1.1:
resolution:
{
integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==
}
dev: true
/lodash.uniq/4.5.0:
resolution:
{
@@ -2953,6 +3268,13 @@ packages:
}
dev: true
/memoize-one/5.2.1:
resolution:
{
integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
}
dev: true
/merge2/1.4.1:
resolution:
{
@@ -2972,6 +3294,15 @@ packages:
picomatch: 2.3.1
dev: true
/mime-match/1.0.2:
resolution:
{
integrity: sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==
}
dependencies:
wildcard: 1.1.2
dev: true
/min-indent/1.0.1:
resolution:
{
@@ -3092,6 +3423,13 @@ packages:
}
dev: true
/namespace-emitter/2.0.1:
resolution:
{
integrity: sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==
}
dev: true
/nanoclone/0.2.1:
resolution:
{
@@ -3938,6 +4276,13 @@ packages:
}
dev: true
/preact/10.8.1:
resolution:
{
integrity: sha512-p5CKQ0MCEXTGKGOHiFaNE2V2nDq2hvDHykXvIlz+4lbfJ9umLZr8JS/fa1bXUwRcHXK+Ljk8zqmDhr25n0LtVg==
}
dev: true
/prelude-ls/1.2.1:
resolution:
{
@@ -3975,6 +4320,14 @@ packages:
}
dev: true
/promise-queue/2.2.5:
resolution:
{
integrity: sha512-p/iXrPSVfnqPft24ZdNNLECw/UrtLTpT3jpAAMzl/o5/rDsGCPo3/CQS2611flL6LkoEJ3oQZw7C8Q80ZISXRQ==
}
engines: { node: '>= 0.8.0' }
dev: true
/property-expr/2.0.5:
resolution:
{
@@ -4979,11 +5332,17 @@ packages:
dev: true
/webidl-conversions/3.0.1:
resolution: { integrity: sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE= }
resolution:
{
integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==
}
dev: true
/whatwg-url/5.0.0:
resolution: { integrity: sha1-lmRU6HZUYuN2RNNib2dCzotwll0= }
resolution:
{
integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==
}
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
@@ -5009,6 +5368,13 @@ packages:
string-width: 4.2.3
dev: true
/wildcard/1.1.2:
resolution:
{
integrity: sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==
}
dev: true
/word-wrap/1.2.3:
resolution:
{
@@ -5029,7 +5395,10 @@ packages:
dev: true
/wrappy/1.0.2:
resolution: { integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= }
resolution:
{
integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
}
dev: true
/ws/8.2.3:
@@ -0,0 +1,6 @@
*.png
*.xcf
admin_overview.webp
results.webp
select.webp
solution.webp
+16
View File
@@ -0,0 +1,16 @@
/** Dispatch event on click outside of node */
export function clickOutside(node) {
const handleClick = (event) => {
if (node && !node.contains(event.target) && !event.defaultPrevented) {
node.dispatchEvent(new CustomEvent('click_outside', node));
}
};
document.addEventListener('click', handleClick, true);
return {
destroy() {
document.removeEventListener('click', handleClick, true);
}
};
}
+108 -199
View File
@@ -2,33 +2,21 @@
import { getLocalization } from '$lib/i18n';
import * as yup from 'yup';
import { dataSchema } from '$lib/yupSchemas';
import type { EditorData, Question, Answer } from './quiz_types';
import Sidebar from '$lib/editor/sidebar.svelte';
import SettingsCard from '$lib/editor/settings-card.svelte';
import QuizCard from '$lib/editor/card.svelte';
import Spinner from './Spinner.svelte';
const { t } = getLocalization();
let schemaInvalid = false;
let yupErrorMessage = '';
interface Data {
public: boolean;
title: string;
description: string;
questions: Question[];
}
interface Question {
question: string;
time: string;
image?: string;
answers: Answer[];
}
interface Answer {
right: boolean;
answer: string;
}
export let data: Data;
export let data: EditorData;
export let submit_button_text = 'Create';
export let quiz_id: string | null;
let selected_question = -1;
let imgur_links_valid = false;
const empty_question: Question = {
@@ -43,7 +31,7 @@
]
};
const validateInput = async (data: Data) => {
const validateInput = async (data: EditorData) => {
try {
await dataSchema.validate(data, { abortEarly: false });
schemaInvalid = false;
@@ -77,191 +65,112 @@
right: false,
answer: ''
};
let edit_id;
let confirm_to_leave = true;
const getEditID = async () => {
let res;
if (quiz_id === null) {
res = await fetch(`/api/v1/editor/start?edit=false`, {
method: 'POST'
});
} else {
res = await fetch(`/api/v1/editor/start?edit=true&quiz_id=${quiz_id}`, {
method: 'POST'
});
}
if (res.status === 200) {
const json = await res.json();
edit_id = json.token;
console.log(edit_id, json);
} else {
alert('Error!');
}
};
const confirmUnload = (event) => {
console.log(confirm_to_leave);
if (!confirm_to_leave) {
return;
}
event.preventDefault();
event.returnValue = 'Are you sure you want to leave?';
localStorage.setItem('edit_game', JSON.stringify(data));
return 'unload';
};
const saveQuiz = async () => {
if (schemaInvalid) {
return;
}
const res = await fetch(`/api/v1/editor/finish?edit_id=${edit_id}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (res.ok) {
console.log('Hier');
confirm_to_leave = false;
console.log(confirm_to_leave);
window.location.href = '/overview';
} else {
alert('Error');
}
};
</script>
<label class="pl-2 w-2/6 flex gap-2 flex-row">
<input type="checkbox" class="w-fit" bind:checked={data.public} />
<span class="w-fit">{$t('words.public')}?</span>
</label>
<label class="pl-2 flex flex-row gap-2 w-3/5">
<span class="w-fit">{$t('words.title')}:</span>
<input
type="text"
placeholder={$t('words.title')}
bind:value={data.title}
class="text-black bg-inherit border-dotted border-b-2 border-black w-full dark:text-gray-200"
class:border-red-600={!yup.reach(dataSchema, 'title').isValidSync(data.title)}
class:border-solid={!yup.reach(dataSchema, 'title').isValidSync(data.title)}
class:border-2={!yup.reach(dataSchema, 'title').isValidSync(data.title)}
/>
</label>
<label class="pl-2 flex flex-row gap-2 w-3/5">
{$t('words.description')}:
<textarea
placeholder={$t('words.description')}
bind:value={data.description}
class="text-black w-full dark:text-gray-200 dark:bg-gray-800 rounded-lg p-1"
class:border-red-600={!yup.reach(dataSchema, 'description').isValidSync(data.description)}
class:border-solid={!yup.reach(dataSchema, 'description').isValidSync(data.description)}
class:border-2={!yup.reach(dataSchema, 'description').isValidSync(data.description)}
/>
</label>
{#each data.questions as question, index_question}
<div class="ml-8 grid grid-cols-1 gap-2 m-2 border border-black border-2">
<h1 class="text-3xl m-1">{$t('words.question')} {index_question + 1}</h1>
<label class="m-1 flex flex-row gap-2 w-3/5">
{$t('words.question')}:
<input
type="text"
placeholder={$t('words.question')}
bind:value={question.question}
class="text-black w-full bg-inherit border-dotted border-b-2 border-black dark:text-gray-200"
class:border-red-600={!yup
.reach(dataSchema, 'questions[].question')
.isValidSync(question.question)}
class:border-solid={!yup
.reach(dataSchema, 'questions[].question')
.isValidSync(question.question)}
class:border-2={!yup
.reach(dataSchema, 'questions[].question')
.isValidSync(question.question)}
/>
</label>
<label class="m-1 flex flex-row gap-2 w-3/5">
{$t('words.image')}:
<input
type="text"
placeholder="https://i.imgur.com/kSPCidY.png"
bind:value={question.image}
class="text-black w-full bg-inherit border-dotted border-b-2 border-black dark:text-gray-200"
class:border-red-600={!yup
.reach(dataSchema, 'questions[].image')
.isValidSync(question.image)}
class:border-solid={!yup
.reach(dataSchema, 'questions[].image')
.isValidSync(question.image)}
class:border-2={!yup
.reach(dataSchema, 'questions[].image')
.isValidSync(question.image)}
/>
</label>
<label class="m-1 flex flex-row gap-2 w-3/5 flex-nowrap whitespace-nowrap">
{$t('editor.time_in_seconds')}:
<input
type="text"
placeholder="20"
bind:value={question.time}
class="text-black w-full bg-inherit border-dotted border-b-2 border-black dark:text-gray-200"
class:border-red-600={!yup
.reach(dataSchema, 'questions[].time')
.isValidSync(question.time)}
class:border-solid={!yup
.reach(dataSchema, 'questions[].time')
.isValidSync(question.time)}
class:border-2={!yup
.reach(dataSchema, 'questions[].time')
.isValidSync(question.time)}
/>
</label>
{#each question.answers as answer, index_answer}
<div class="ml-8 grid grid-cols-1 gap-2 m-2 border border-black border-2 m-1">
<h1 class="text-3xl m-1">{$t('words.answer')} {index_answer + 1}</h1>
<p class="m-1">
{$t('words.answer')}: {index_answer + 1}
{$t('words.question')}: {index_question + 1}
</p>
<label class="m-1 flex flex-row gap-2 w-3/5 flex-nowrap whitespace-nowrap">
{$t('words.answer')}:
<input
type="text"
placeholder={$t('words.answer')}
bind:value={data.questions[index_question].answers[index_answer].answer}
class="text-black w-full bg-inherit border-dotted border-b-2 border-black dark:text-gray-200"
class:border-red-600={!yup
.reach(dataSchema, 'questions[].answers[].answer')
.isValidSync(
data.questions[index_question].answers[index_answer].answer
)}
class:border-solid={!yup
.reach(dataSchema, 'questions[].answers[].answer')
.isValidSync(
data.questions[index_question].answers[index_answer].answer
)}
class:border-2={!yup
.reach(dataSchema, 'questions[].answers[].answer')
.isValidSync(
data.questions[index_question].answers[index_answer].answer
)}
/>
</label>
<label class="m-1 flex flex-row gap-2 w-2/6 flex-nowrap whitespace-nowrap">
<input
type="checkbox"
bind:checked={answer.right}
class="text-black w-fit dark:text-gray-200"
/>
<span class="w-fit">{$t('editor.right_or_true?')}</span>
</label>
<button
class="text-left border-yellow-500 border-2 w-fit m-1"
type="button"
on:click={() => {
data.questions[index_question].answers.splice(index_answer, 1);
data.questions[index_question].answers =
data.questions[index_question].answers;
}}
>
{$t('editor.delete_answer')}
</button>
<svelte:window on:beforeunload={confirmUnload} />
{#await getEditID()}
<Spinner />
{:then _}
<form on:submit|preventDefault={saveQuiz}>
<div class="grid grid-cols-6 h-screen w-screen">
<div>
<Sidebar bind:data bind:selected_question />
</div>
{/each}
<button
class="text-left border-green-500 border-2 w-fit m-1"
type="button"
on:click={() => {
data.questions[index_question].answers = [
...data.questions[index_question].answers,
{ ...empty_answer }
];
}}
<div class="col-span-5 flex flex-col">
<div
class="h-10 w-full bg-white mb-10 flex align-middle justify-center rounded-br-lg"
>
{$t('editor.add_new_answer')}
</button>
<button
class="text-left border-red-500 border-2 w-fit m-1"
type="button"
on:click={() => {
data.questions.splice(index_question, 1);
data.questions = data.questions;
}}
>
{$t('editor.delete_question')}
</button>
</div>
{/each}
<button
class="text-left"
type="button"
on:click={() => {
data.questions = [...data.questions, { ...empty_question }];
}}
>
{$t('editor.add_new_question')}
</button>
{#if imgur_links_valid}
<p class="text-blue-600 text-xl text-center w-fit mx-auto">
{$t('editor.not_all_links_imgur_links')}
</p>
{/if}
{#if schemaInvalid}
<p class="text-blue-600 text-xl text-center w-fit mx-auto">
<p class="text-center w-full text-red-600 h-full mt-0.5 font-semibold">
{yupErrorMessage}
</p>
{:else}
<p class="text-center w-full text-black h-full align-bottom mt-0.5">
{data.title}
</p>
{/if}
<button
type="submit"
class="text-xl disabled:cursor-not-allowed disabled:border disabled:border-red-500 w-fit mx-auto"
disabled={imgur_links_valid || schemaInvalid}>{submit_button_text}</button
class="pr-2 align-middle bg-purple-400 pl-2 ml-auto whitespace-nowrap disabled:opacity-60 rounded-br-lg"
disabled={schemaInvalid}
>
<span>Save</span>
<svg
class="w-6 h-6 inline-block"
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="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"
/>
</svg>
</button>
</div>
<div class="w-full h-full">
{#if selected_question === -1}
<SettingsCard bind:data />
{:else}
<QuizCard bind:data bind:selected_question bind:edit_id />
{/if}
</div>
</div>
</div>
</form>
{/await}
+155
View File
@@ -0,0 +1,155 @@
<script lang="ts">
import type { EditorData, Answer } from '$lib/quiz_types';
import CheckerBg from '$lib/editor/checker-bg.svg';
import { reach } from 'yup';
import { dataSchema } from '$lib/yupSchemas';
import Spinner from '../Spinner.svelte';
import { fade } from 'svelte/transition';
export let data: EditorData;
export let selected_question: number;
export let edit_id: string;
let question = data.questions[selected_question];
$: question = data.questions[selected_question];
const empty_answer: Answer = {
right: false,
answer: ''
};
console.log(question.image);
let uppyOpen = false;
$: console.log(uppyOpen);
</script>
<div class="w-full h-full pb-20 px-20">
<div class="rounded-lg bg-white w-full h-full border-gray-500 drop-shadow-2xl dark:bg-gray-700">
<div class="h-fit bg-gray-300 rounded-t-lg dark:bg-gray-500">
<div class="flex align-middle p-4 gap-3">
<span
class="inline-block bg-gray-600 w-4 h-4 rounded-full hover:bg-red-400 transition"
/>
<span
class="inline-block bg-gray-600 w-4 h-4 rounded-full hover:bg-yellow-400 transition"
/>
<span
class="inline-block bg-gray-600 w-4 h-4 rounded-full hover:bg-green-400 transition"
/>
</div>
</div>
<div class="flex flex-col">
<div class="flex justify-center pt-10 w-full">
<input
type="text"
bind:value={question.question}
placeholder="No title..."
class="p-3 rounded-lg border-gray-500 border text-center w-2/3 text-lg font-semibold placeholder:italic placeholder:font-normal dark:bg-gray-500"
class:bg-yellow-500={!reach(dataSchema, 'questions[].question').isValidSync(
question.question
)}
/>
</div>
{#if question.image != undefined && question.image !== ''}
<div class="flex justify-center pt-10 w-full max-h-72 w-full">
<img
src={question.image}
alt="not available"
class="max-h-72 h-auto w-auto"
on:contextmenu|preventDefault={() => {
question.image = '';
}}
/>
</div>
{:else}
{#await import('$lib/editor/uploader.svelte')}
<Spinner />
{:then c}
<svelte:component
this={c.default}
bind:modalOpen={uppyOpen}
bind:edit_id
bind:data
bind:selected_question
/>
{/await}
{/if}
<div class="flex justify-center pt-10 w-full">
<div class="grid grid-cols-2 gap-4 w-full px-10">
{#each question.answers as answer, index}
<div
on:contextmenu|preventDefault={() => {
question.answers.splice(index, 1);
question.answers = question.answers;
}}
out:fade={{ duration: 150 }}
class="p-4 rounded-lg flex justify-center w-full transition"
class:bg-red-500={!answer.right}
class:bg-green-500={answer.right}
class:bg-yellow-500={!reach(
dataSchema,
'questions[].answers[].answer'
).isValidSync(answer.answer)}
>
<input
bind:value={answer.answer}
type="text"
class="bg-transparent border-b-2 border-dotted w-5/6 text-center"
placeholder="Empty..."
/>
<button
type="button"
on:click={() => {
answer.right = !answer.right;
}}
>
{#if answer.right}
<svg
class="w-6 h-6 inline-block"
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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{:else}
<svg
class="w-6 h-6 inline-block"
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="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{/if}
</button>
</div>
{/each}
{#if question.answers.length < 4}
<button
class="p-4 rounded-lg bg-transparent border-gray-500 border-2 hover:bg-gray-300 transition dark:hover:bg-gray-600"
type="button"
in:fade={{ duration: 150 }}
on:click={() => {
question.answers = [...question.answers, { empty_answer }];
}}
>
<span class="italic text-center">Add an answer</span>
</button>
{/if}
</div>
</div>
<p class="italic text-center mt-auto pt-4">Right-click on an answer to delete it!</p>
</div>
</div>
</div>
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="16" height="16">
<path fill="#d0d0d0" fill-rule="evenodd" d="M0 0h4v4H0V0zm4 4h4v4H4V4z"/>
</svg>

After

Width:  |  Height:  |  Size: 167 B

@@ -0,0 +1,82 @@
<script lang="ts">
import type { EditorData } from '$lib/quiz_types';
export let data: EditorData;
</script>
<div class="w-full h-full pb-20 px-20">
<div class="rounded-lg bg-white w-full h-full border-gray-500 dark:bg-gray-700">
<div class="h-fit bg-gray-300 rounded-t-lg dark:bg-gray-500">
<div class="flex align-middle p-4 gap-3">
<span
class="inline-block bg-gray-600 w-4 h-4 rounded-full hover:bg-red-400 transition"
/>
<span
class="inline-block bg-gray-600 w-4 h-4 rounded-full hover:bg-yellow-400 transition"
/>
<span
class="inline-block bg-gray-600 w-4 h-4 rounded-full hover:bg-green-400 transition"
/>
</div>
</div>
<div class="dark:bg-gray-700">
<div class="flex justify-center pt-10 w-full">
<input
type="text"
bind:value={data.title}
class="p-3 rounded-lg border-gray-500 border text-center w-1/3 text-lg font-semibold dark:bg-gray-500"
/>
</div>
<div class="flex justify-center pt-10 w-full max-h-32">
<textarea
type="text"
bind:value={data.description}
class="p-3 rounded-lg border-gray-500 border text-center w-1/3 h-20 resize-none dark:bg-gray-500"
/>
</div>
<div class="pt-10 w-full flex justify-center">
<button
type="button"
on:click={() => {
data.public = !data.public;
}}
class="text-center w-fit"
>
{#if data.public}
<svg
class="w-8 h-8 inline-block"
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="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>Public</span>
{:else}
<svg
class="w-8 h-8 inline-block"
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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
/>
</svg>
<span>Private</span>
{/if}
</button>
</div>
</div>
</div>
</div>
+222
View File
@@ -0,0 +1,222 @@
<script lang="ts">
import type { EditorData, Question } from '../quiz_types';
import { reach } from 'yup';
import { dataSchema } from '../yupSchemas';
export let data: EditorData;
export let selected_question = -1;
import { createTippy } from 'svelte-tippy';
import 'tippy.js/animations/perspective-subtle.css';
import 'tippy.js/dist/tippy.css';
const tippy = createTippy({
arrow: true,
animation: 'perspective-subtle',
placement: 'right'
});
let arr_of_cards = Array(data.questions.length);
let propertyCard;
const empty_question: Question = {
question: '',
time: '20',
image: '',
answers: []
};
const setSelectedQuestion = (index: number): void => {
selected_question = index;
if (index === -1) {
propertyCard.scrollIntoView({
behavior: 'smooth'
});
} else {
arr_of_cards[index].scrollIntoView({
behavior: 'smooth'
});
}
};
/* onMount(() => {
propertyCard.scrollIntoView({
behavior: 'smooth'
});
});*/
</script>
<div class="h-screen border-r-2 pt-6 px-6 overflow-scroll">
<div
class="bg-white shadow rounded-lg h-40 p-2 mb-6 hover:cursor-pointer drop-shadow-2xl border border-gray-500 dark:bg-gray-600"
bind:this={propertyCard}
class:bg-green-300={selected_question === -1}
class:dark:bg-green-500={selected_question === -1}
on:click={() => setSelectedQuestion(-1)}
>
<div
use:tippy={{ content: data.title === '' ? "It's empty!" : data.title }}
class="m-1 border border-gray-500 rounded-lg p-0.5"
class:border-red-600={!reach(dataSchema, 'title').isValidSync(data.title)}
class:border-solid={!reach(dataSchema, 'title').isValidSync(data.title)}
class:border-2={!reach(dataSchema, 'title').isValidSync(data.title)}
>
<input
type="text"
class="whitespace-nowrap truncate text-center w-full bg-transparent rounded font-semibold dark:text-white"
class:dark:text-black={selected_question === -1}
bind:value={data.title}
/>
</div>
<div
use:tippy={{ content: data.description === '' ? "It's empty!" : data.description }}
class="m-1 border border-gray-500 rounded-lg p-0.5"
class:border-red-600={!reach(dataSchema, 'description').isValidSync(data.description)}
class:border-solid={!reach(dataSchema, 'description').isValidSync(data.description)}
class:border-2={!reach(dataSchema, 'description').isValidSync(data.description)}
>
<textarea
bind:value={data.description}
class="bg-transparent resize-none w-full rounded text-sm dark:text-white"
class:dark:text-black={selected_question === -1}
/>
</div>
<div
class="w-full flex justify-center dark:text-white"
class:dark:text-black={selected_question === -1}
>
<button
type="button"
on:click={() => {
data.public = !data.public;
}}
class="text-center"
>
{#if data.public}
<svg
class="w-5 h-5 inline-block"
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="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>Public</span>
{:else}
<svg
class="w-5 h-5 inline-block"
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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
/>
</svg>
<span>Private</span>
{/if}
</button>
</div>
</div>
{#each data.questions as question, index}
<div
class="bg-white shadow rounded-lg h-40 p-2 mb-6 hover:cursor-pointer drop-shadow-2xl border border-gray-500 dark:bg-gray-600"
class:bg-green-300={index === selected_question}
class:dark:bg-green-500={index === selected_question}
on:contextmenu|preventDefault={() => {
if (confirm('Do you really want to delete this Question?')) {
data.questions.splice(index, 1);
data.questions = data.questions;
}
}}
on:click={() => {
setSelectedQuestion(index);
}}
bind:this={arr_of_cards[index]}
>
<div
use:tippy={{ content: question.question === '' ? 'No title' : question.question }}
class="m-1 border border-gray-500 rounded-lg p-0.5"
>
<h1
class="whitespace-nowrap truncate text-center rounded-lg dark:text-white transition"
class:bg-yellow-500={!reach(dataSchema, 'questions[].question').isValidSync(
question.question
)}
class:dark:text-black={index === selected_question}
>
{#if question.question === ''}
<span class="italic text-gray-500">No title...</span>
{:else}
{question.question}
{/if}
</h1>
</div>
{#if question.image}
<div class="flex justify-center align-middle pb-0.5">
<img
src={question.image}
class="h-10 border rounded-lg"
alt="Not available"
use:tippy={{
content: `<img src='${question.image}' alt='Not available' class='rounded'>`,
allowHTML: true
}}
/>
</div>
{/if}
<div class="grid grid-cols-2 gap-2">
{#each question.answers as answer}
<span
class="whitespace-nowrap truncate rounded-lg p-0.5 text-sm text-center"
class:bg-green-500={answer.right}
class:bg-red-500={!answer.right}
class:bg-yellow-500={!reach(
dataSchema,
'questions[].answers[].answer'
).isValidSync(answer.answer)}
use:tippy={{ content: answer.answer === '' ? 'Empty...' : answer.answer }}
>{#if answer.answer === ''}
<i>Empty...</i>
{:else}
{answer.answer}
{/if}</span
>
{/each}
</div>
</div>
{/each}
<div
class="bg-white shadow rounded-lg h-40 p-2 mb-6 hover:cursor-pointer drop-shadow-2xl border border-gray-500"
>
<button
type="button"
class="h-full flex justify-center w-full dark:text-black"
on:click={() => {
data.questions = [...data.questions, { ...empty_question }];
}}
>
<svg
class="h-full"
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 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
</button>
</div>
</div>
+96
View File
@@ -0,0 +1,96 @@
<script lang="ts">
import { Dashboard as SvelteDashboard } from '@uppy/svelte';
import Uppy from '@uppy/core';
import DropTarget from '@uppy/drop-target';
import XHRUpload from '@uppy/xhr-upload';
import ImageEditor from '@uppy/image-editor';
import Dashboard from '@uppy/dashboard';
import Compressor from '@uppy/compressor';
import { fade } from 'svelte/transition';
// CSS imports
import '@uppy/core/dist/style.css';
import '@uppy/dashboard/dist/style.css';
import '@uppy/drop-target/dist/style.css';
// import '@uppy/file-input/dist/style.css'
import '@uppy/image-editor/dist/style.css';
import type { EditorData } from '../quiz_types';
export let modalOpen = false;
export let edit_id: string;
export let data: EditorData;
export let selected_question: number;
const uppy = new Uppy()
.use(DropTarget, {
target: document.body
})
.use(Dashboard)
.use(ImageEditor, {
target: Dashboard,
quality: 0.8
})
.use(Compressor, {
quality: 0.6
})
.use(XHRUpload, {
endpoint: `/api/v1/editor/image?edit_id=${edit_id}`
});
const props = { inline: true };
let image_id;
uppy.on('upload-success', (file, response) => {
image_id = response.body.id;
});
uppy.on('complete', (res) => {
data.questions[
selected_question
].image = `${window.location.origin}/api/v1/storage/download/${image_id}`;
modalOpen = false;
});
console.log(edit_id);
</script>
{#if modalOpen}
<div
class="w-full h-full absolute top-0 left-0 bg-opacity-60 z-20 flex justify-center"
transition:fade
>
<div>
<button
type="button"
class="rounded-t-lg bg-black text-white px-1"
on:click={() => {
modalOpen = false;
}}
>Close
</button>
<div>
<SvelteDashboard {uppy} width="100%" {props} />
</div>
</div>
</div>
{/if}
<div class="flex justify-center w-full pt-10" transition:fade>
<button
class="rounded-lg p-4 flex justify-center bg-transparent border-gray-500 border-2 w-1/2 hover:bg-gray-300 dark:hover:bg-gray-600 transition"
type="button"
on:click={() => {
modalOpen = true;
}}
><span class="italic">Add Image</span>
<svg
class="w-6 h-6 inline-block"
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 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</button>
</div>
+1 -1
View File
@@ -23,7 +23,7 @@ export const initLocalizationContext = () => {
const i18n = new I18nService();
const tranlator = new I18NextTranslationService(i18n);
// Setting the Svelte context
// skipcq: JS-0357
setLocalization({
t: tranlator.translate,
currentLanguage: tranlator.locale
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
/* import AdminOverviewScreenshot from '$lib/assets/landing/admin_overview.png';
import SelectScreenshot from '$lib/assets/landing/select.webp';
import ResultsScreenshot from '$lib/assets/landing/results.webp';
import SolutionScreenshot from '$lib/assets/landing/solution.webp';*/
import SelectMockup from '$lib/assets/landing/select-mockup.webp';
import ResultsMockup from '$lib/assets/landing/results-mockup.webp';
import SolutionMockup from '$lib/assets/landing/solution-mockup.webp';
</script>
<div class="grid grid-cols-3 grow pt-4">
<h1 class="text-center h-fit text-3xl marck-script">1: Answer</h1>
<h1 class="text-center h-fit text-3xl marck-script">2: See what was right or wrong</h1>
<h1 class="text-center h-fit text-3xl marck-script">3: See how many were right or wrong</h1>
<div class="flex justify-center">
<img src={SelectMockup} class="max-h-80" alt="Select answer" />
</div>
<div class="flex justify-center">
<img src={SolutionMockup} class="max-h-80" alt="See right and wrong solutions" />
</div>
<div class="flex justify-center">
<img
src={ResultsMockup}
class="max-h-80"
alt="See results in comparison with other players"
/>
</div>
</div>
+7
View File
@@ -20,6 +20,13 @@ export interface Answer {
answer: string;
}
export interface EditorData {
public: boolean;
title: string;
description: string;
questions: Question[];
}
// TODO Keep an eye on this shit
// export interface Answer {
// username: string;
+4 -4
View File
@@ -9,14 +9,14 @@ export const dataSchema = yup.object({
.max(300, 'The title has to be shorter than 300 characters'),
description: yup
.string()
.required()
.min(3, 'The title has to be longer than 3 characters')
.max(300, 'The title has to be shorter than 300 characters'),
.required('The description is required!')
.min(3, 'The description has to be longer than 3 characters')
.max(500, 'The description has to be shorter than 500 characters'),
questions: yup
.array()
.of(
yup.object({
question: yup.string().required('A question is required').max(299),
question: yup.string().required('A question-title is required').max(299),
time: yup.number().required().positive('The time has to be positive'),
image: yup
.string()
+1
View File
@@ -82,6 +82,7 @@
// height: 100%;
// width: 100%;
// bg-gradient-to-r from-[#009444] via-[#39b54a] to-[#8dc63f]
background: linear-gradient(to right, #009444, #39b54a, #8dc63f) repeat-y;
background-size: cover;
/*background: linear-gradient(-225deg, #231557 0%, #44107A 29%, #FF1361 67%, #FFF800 100%); */
+4 -34
View File
@@ -11,7 +11,7 @@
if (session.authenticated) {
signedIn.set(true);
}
return {}
return {};
}
</script>
@@ -22,7 +22,7 @@
import { navbarVisible } from '$lib/stores';
import { dataSchema } from '$lib/yupSchemas';
navbarVisible.set(true);
navbarVisible.set(false);
const { t } = getLocalization();
@@ -50,6 +50,7 @@
let data: Data;
let confirm_to_leave = true;
let quiz_id = null;
onMount(() => {
const from_localstorage = localStorage.getItem('create_game');
if (from_localstorage === null) {
@@ -63,45 +64,14 @@
data = JSON.parse(from_localstorage);
}
});
const submit = async () => {
if (!(await dataSchema.isValid(data))) {
return;
}
const res = await fetch('/api/v1/quiz/create', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
if (res.status === 401) {
localStorage.setItem('create_game', JSON.stringify(data));
window.location.href = '/account/login';
} else if (res.status === 200) {
localStorage.removeItem('create_game');
responseData.open = true;
}
};
const confirmUnload = () => {
if (!confirm_to_leave) {
return;
}
event.preventDefault();
event.returnValue = '';
localStorage.setItem('create_game', JSON.stringify(data));
};
</script>
<svelte:window on:beforeunload={confirmUnload} />
<svelte:head>
<title>ClassQuiz - Create</title>
</svelte:head>
{#if data !== undefined}
<form on:submit|preventDefault={submit} class="grid grid-cols-1 gap-2">
<Editor bind:data />
</form>
<Editor bind:data bind:quiz_id />
{/if}
<div
+5 -36
View File
@@ -2,17 +2,18 @@
import { signedIn } from '$lib/stores';
export async function load({ url, session }) {
const quiz_id = url.searchParams.get('quiz_id');
if (!session.authenticated) {
return {
status: 302,
redirect: '/account/login?returnTo=/edit'
redirect: `/account/login?returnTo=/edit?quiz_id=${quiz_id}`
};
}
if (session.authenticated) {
signedIn.set(true);
}
const quiz_id = url.searchParams.get('quiz_id');
if (quiz_id === null) {
return {
status: 404
@@ -32,7 +33,7 @@
import { navbarVisible } from '$lib/stores';
import { dataSchema } from '$lib/yupSchemas';
navbarVisible.set(true);
navbarVisible.set(false);
const { t } = getLocalization();
@@ -72,38 +73,8 @@
return;
}
};
const submit = async () => {
if (!(await dataSchema.isValid(data))) {
return;
}
const res = await fetch(`/api/v1/quiz/update/${quiz_id}`, {
method: 'PUT',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
if (res.status === 401) {
throw new Error('Unauthorized');
} else if (res.status === 404) {
throw new Error('Quiz not found');
} else if (res.status === 200) {
localStorage.removeItem('edit_game');
responseData.data = '200';
responseData.open = true;
}
};
const confirmUnload = () => {
if (!confirm_to_leave) {
return;
}
event.preventDefault();
event.returnValue = '';
localStorage.setItem('edit_game', JSON.stringify(data));
};
</script>
<svelte:window on:beforeunload={confirmUnload} />
<svelte:head>
<title>ClassQuiz - Edit</title>
</svelte:head>
@@ -120,9 +91,7 @@
</svg>
{:then _}
{#if data !== undefined}
<form on:submit|preventDefault={submit} class="grid grid-cols-1 gap-2">
<Editor bind:data submit_button_text={$t('words.save')} />
</form>
<Editor bind:data submit_button_text={$t('words.save')} bind:quiz_id />
{/if}
{:catch err}
<div class="text-center">