New Remote-Control page

This commit is contained in:
Mawoka
2022-12-27 16:48:43 +01:00
parent 47f74cf247
commit a3647f1018
10 changed files with 565 additions and 91 deletions
+3
View File
@@ -27,6 +27,7 @@ from classquiz.routers import (
eximport, eximport,
login, login,
sitemap, sitemap,
remote,
) )
from classquiz.socket_server import sio from classquiz.socket_server import sio
from classquiz.helpers import meilisearch_init, telemetry_ping, bg_tasks from classquiz.helpers import meilisearch_init, telemetry_ping, bg_tasks
@@ -80,6 +81,7 @@ async def auth_middleware_wrapper(request: Request, call_next):
return await rememberme_middleware(request, call_next) return await rememberme_middleware(request, call_next)
app.include_router(remote.router, tags=["remote"], prefix="/api/v1/remote", include_in_schema=True)
app.include_router(login.router, tags=["auth"], prefix="/api/v1/login", include_in_schema=True) app.include_router(login.router, tags=["auth"], prefix="/api/v1/login", include_in_schema=True)
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key) app.add_middleware(SessionMiddleware, secret_key=settings.secret_key)
@@ -96,4 +98,5 @@ app.include_router(
app.include_router(editor.router, tags=["editor"], prefix="/api/v1/editor", include_in_schema=True) app.include_router(editor.router, tags=["editor"], prefix="/api/v1/editor", include_in_schema=True)
app.include_router(eximport.router, tags=["export", "import"], prefix="/api/v1/eximport", include_in_schema=True) app.include_router(eximport.router, tags=["export", "import"], prefix="/api/v1/eximport", include_in_schema=True)
app.include_router(sitemap.router, tags=["sitemap"], prefix="/api/v1/sitemap", include_in_schema=True) app.include_router(sitemap.router, tags=["sitemap"], prefix="/api/v1/sitemap", include_in_schema=True)
app.mount("/", ASGIApp(sio)) app.mount("/", ASGIApp(sio))
+6
View File
@@ -240,3 +240,9 @@ class GameSession(BaseModel):
class UpdatePassword(BaseModel): class UpdatePassword(BaseModel):
old_password: str old_password: str
new_password: str new_password: str
class GameInLobby(BaseModel):
game_pin: str
quiz_title: str
game_id: uuid.UUID
+10 -4
View File
@@ -4,6 +4,8 @@
import json import json
import uuid
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, validator from pydantic import BaseModel, validator
from classquiz.config import settings, redis from classquiz.config import settings, redis
@@ -155,17 +157,21 @@ async def get_game_user_count(game_pin: str, api_key: str, as_string: bool = Fal
@router.get( @router.get(
"/players", "/players",
) )
async def get_game_session(game_pin: str, api_key: str): async def get_game_session(game_pin: str, api_key: str | None = None, game_id: uuid.UUID | None = None):
if game_id is None and api_key is None:
raise HTTPException(status_code=401, detail="API-Key and Quiz-ID are missing")
user_id = await check_api_key(api_key) user_id = await check_api_key(api_key)
redis_res = await redis.get(f"game_session:{game_pin}") redis_res = await redis.get(f"game_session:{game_pin}")
if redis_res is None: if redis_res is None:
game_pin = await redis.get(f"game_pin:{user_id}:{game_pin}") game_pin = await redis.get(f"game_pin:{user_id}:{game_pin}")
redis_res = await redis.get(f"game_session:{game_pin}") redis_res = await redis.get(f"game_session:{game_pin}")
if redis_res is None or user_id is None: if redis_res is None:
raise HTTPException(status_code=404, detail="Game not found or API key not found") raise HTTPException(status_code=404, detail="Game not found or API key not found")
data = GameSession.parse_raw(redis_res) data = GameSession.parse_raw(redis_res)
if user_id is None and data.game_id != str(game_id):
raise HTTPException(status_code=401, detail="Game not found or API key not found")
game = PlayGame.parse_raw(await redis.get(f"game:{game_pin}")) game = PlayGame.parse_raw(await redis.get(f"game:{game_pin}"))
if game.user_id != user_id: if game.user_id != user_id and data.game_id != str(game_id):
raise HTTPException(status_code=404, detail="Game not found or API key not found") raise HTTPException(status_code=404, detail="Game not found or API key not found")
for i in range(0, len(game.questions)): for i in range(0, len(game.questions)):
res = await redis.get(f"game_session:{game_pin}:{i}") res = await redis.get(f"game_session:{game_pin}:{i}")
@@ -195,7 +201,7 @@ async def set_next_question(game_pin: str, question_number: int, api_key: str):
if game_data.user_id != user_id: if game_data.user_id != user_id:
raise HTTPException(status_code=404, detail="Game not found or API key not found") raise HTTPException(status_code=404, detail="Game not found or API key not found")
game_data.current_question = question_number game_data.current_question = question_number
await redis.set(f"game:{game_pin}", game_data.json()) await redis.set(f"game:{game_pin}", game_data.json(), ex=18000)
await sio.emit( await sio.emit(
"set_question_number", "set_question_number",
{ {
+6 -1
View File
@@ -18,7 +18,7 @@ import bleach
from classquiz.auth import get_current_user from classquiz.auth import get_current_user
from classquiz.config import redis, settings, storage, meilisearch from classquiz.config import redis, settings, storage, meilisearch
from classquiz.db.models import Quiz, QuizInput, User, PlayGame from classquiz.db.models import Quiz, QuizInput, User, PlayGame, GameInLobby
from classquiz.kahoot_importer.import_quiz import import_quiz from classquiz.kahoot_importer.import_quiz import import_quiz
import html import html
import urllib.parse import urllib.parse
@@ -129,6 +129,11 @@ async def start_quiz(
) )
await redis.set(f"game:{str(game.game_pin)}", game.json(), ex=18000) await redis.set(f"game:{str(game.game_pin)}", game.json(), ex=18000)
await redis.set(f"game_pin:{user.id}:{quiz_id}", game_pin, ex=18000) await redis.set(f"game_pin:{user.id}:{quiz_id}", game_pin, ex=18000)
await redis.set(
f"game_in_lobby:{user.id.hex}",
GameInLobby(game_id=game.game_id, game_pin=str(game_pin), quiz_title=quiz.title).json(),
ex=900,
)
return {**quiz.dict(exclude={"id"}), **game.dict(exclude={"questions"})} return {**quiz.dict(exclude={"id"}), **game.dict(exclude={"questions"})}
+19
View File
@@ -0,0 +1,19 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from fastapi import APIRouter, Depends, HTTPException
from classquiz.auth import get_current_user
from classquiz.db.models import User, GameInLobby
from classquiz.config import redis
router = APIRouter()
@router.get("/game_waiting")
async def get_game_in_lobby(user: User = Depends(get_current_user)):
game_in_lobby_raw = await redis.get(f"game_in_lobby:{user.id.hex}")
if game_in_lobby_raw is None:
raise HTTPException(status_code=404, detail="No game waiting")
game_in_lobby = GameInLobby.parse_raw(game_in_lobby_raw)
return game_in_lobby
+59 -12
View File
@@ -116,7 +116,7 @@ async def join_game(sid: str, data: dict):
) )
redis_res = await redis.get(f"game_session:{data.game_pin}") redis_res = await redis.get(f"game_session:{data.game_pin}")
redis_res = GameSession.parse_raw(redis_res) redis_res = GameSession.parse_raw(redis_res)
await redis.set(f"game_session:{data.game_pin}:players:{data.username}", sid, ex=18000) await redis.set(f"game_session:{data.game_pin}:players:{data.username}", sid, ex=7200)
await redis.sadd(f"game_session:{data.game_pin}:players", GamePlayer(username=data.username, sid=sid).json()) await redis.sadd(f"game_session:{data.game_pin}:players", GamePlayer(username=data.username, sid=sid).json())
if data.custom_field == "": if data.custom_field == "":
data.custom_field = None data.custom_field = None
@@ -131,7 +131,7 @@ async def join_game(sid: str, data: dict):
await sio.emit( await sio.emit(
"player_joined", "player_joined",
{"username": data.username, "sid": sid}, {"username": data.username, "sid": sid},
room=redis_res.admin, room=f"admin:{data.game_pin}",
) )
# +++ Time-Sync +++ # +++ Time-Sync +++
encrypted_datetime = fernet.encrypt(datetime.now().isoformat().encode("utf-8")).decode("utf-8") encrypted_datetime = fernet.encrypt(datetime.now().isoformat().encode("utf-8")).decode("utf-8")
@@ -146,7 +146,8 @@ async def start_game(sid: str, _data: dict):
if session["admin"]: if session["admin"]:
game_data = PlayGame.parse_raw(await redis.get(f"game:{session['game_pin']}")) game_data = PlayGame.parse_raw(await redis.get(f"game:{session['game_pin']}"))
game_data.started = True game_data.started = True
await redis.set(f"game:{session['game_pin']}", game_data.json()) await redis.set(f"game:{session['game_pin']}", game_data.json(), ex=7200)
await redis.delete(f"game_in_lobby:{game_data.user_id.hex}")
await sio.emit("start_game", room=session["game_pin"]) await sio.emit("start_game", room=session["game_pin"])
@@ -169,7 +170,7 @@ async def register_as_admin(sid: str, data: dict):
await redis.set( await redis.set(
f"game_session:{game_pin}", f"game_session:{game_pin}",
GameSession(admin=sid, game_id=game_id, answers=[]).json(), GameSession(admin=sid, game_id=game_id, answers=[]).json(),
ex=18000, ex=7200,
) )
await sio.emit( await sio.emit(
@@ -180,7 +181,9 @@ async def register_as_admin(sid: str, data: dict):
async with sio.session(sid) as session: async with sio.session(sid) as session:
session["game_pin"] = game_pin session["game_pin"] = game_pin
session["admin"] = True session["admin"] = True
session["remote"] = False
sio.enter_room(sid, game_pin) sio.enter_room(sid, game_pin)
sio.enter_room(sid, f"admin:{data.game_pin}")
else: else:
await sio.emit("already_registered_as_admin", room=sid) await sio.emit("already_registered_as_admin", room=sid)
@@ -227,8 +230,8 @@ async def set_question_number(sid, data: str):
game_pin = session["game_pin"] game_pin = session["game_pin"]
game_data = PlayGame.parse_raw(await redis.get(f"game:{session['game_pin']}")) game_data = PlayGame.parse_raw(await redis.get(f"game:{session['game_pin']}"))
game_data.current_question = int(float(data)) game_data.current_question = int(float(data))
await redis.set(f"game:{session['game_pin']}", game_data.json()) await redis.set(f"game:{session['game_pin']}", game_data.json(), ex=7200)
await redis.set(f"game:{session['game_pin']}:current_time", datetime.now().isoformat()) await redis.set(f"game:{session['game_pin']}:current_time", datetime.now().isoformat(), ex=7200)
temp_return = game_data.dict(include={"questions"})["questions"][int(float(data))] temp_return = game_data.dict(include={"questions"})["questions"][int(float(data))]
if game_data.questions[int(float(data))].type == QuizQuestionType.SLIDE: if game_data.questions[int(float(data))].type == QuizQuestionType.SLIDE:
return return
@@ -322,7 +325,7 @@ async def submit_answer(sid: str, data: dict):
) )
] ]
).json(), ).json(),
ex=18000, ex=7200,
) )
else: else:
answers = _AnswerDataList.parse_raw(answers) answers = _AnswerDataList.parse_raw(answers)
@@ -338,7 +341,7 @@ async def submit_answer(sid: str, data: dict):
await redis.set( await redis.set(
f"game_session:{session['game_pin']}:{data.question_index}", f"game_session:{session['game_pin']}:{data.question_index}",
answers.json(), answers.json(),
ex=18000, ex=7200,
) )
answers = _AnswerDataList.parse_raw(await redis.get(f"game_session:{session['game_pin']}:{data.question_index}")) answers = _AnswerDataList.parse_raw(await redis.get(f"game_session:{session['game_pin']}:{data.question_index}"))
player_count = await redis.scard(f"game_session:{session['game_pin']}:players") player_count = await redis.scard(f"game_session:{session['game_pin']}:players")
@@ -349,7 +352,9 @@ async def submit_answer(sid: str, data: dict):
# room=session["game_pin"], # room=session["game_pin"],
# ) # )
await sio.emit("everyone_answered", {}) await sio.emit("everyone_answered", {})
# await redis.set(f"game_data:{session['game_pin']}", json.dumps(data))
# await redis.set(f"game_data:{session['game_pin']}", json.dumps(data))
@sio.event @sio.event
@@ -363,14 +368,14 @@ async def get_final_results(sid: str, _data: dict):
@sio.event @sio.event
async def get_export_token(sid): async def get_export_token(sid: str):
session = await sio.get_session(sid) session = await sio.get_session(sid)
if not session["admin"]: if not session["admin"]:
return return
game_data = PlayGame(**json.loads(await redis.get(f"game:{session['game_pin']}"))) game_data = PlayGame(**json.loads(await redis.get(f"game:{session['game_pin']}")))
results = await generate_final_results(game_data, session["game_pin"]) results = await generate_final_results(game_data, session["game_pin"])
token = os.urandom(32).hex() token = os.urandom(32).hex()
await redis.set(f"export_token:{token}", json.dumps(results)) await redis.set(f"export_token:{token}", json.dumps(results), ex=7200)
await sio.emit("export_token", token, room=sid) await sio.emit("export_token", token, room=sid)
@@ -407,7 +412,6 @@ async def kick_player(sid: str, data: dict):
return return
session: dict = await sio.get_session(sid) session: dict = await sio.get_session(sid)
print(sid)
if not session["admin"]: if not session["admin"]:
return return
@@ -417,3 +421,46 @@ async def kick_player(sid: str, data: dict):
) )
sio.leave_room(player_sid, session["game_pin"]) sio.leave_room(player_sid, session["game_pin"])
await sio.emit("kick", room=player_sid) await sio.emit("kick", room=player_sid)
class _RegisterAsRemoteInput(BaseModel):
game_pin: str
game_id: str
@sio.event
async def register_as_remote(sid: str, data: dict):
try:
data = _RegisterAsRemoteInput(**data)
except ValidationError as e:
await sio.emit("error", room=sid)
print(e)
return
await sio.emit(
"registered_as_admin",
{"game_id": data.game_id, "game": await redis.get(f"game:{data.game_pin}")},
room=sid,
)
await sio.emit("control_visibility", {"visible": False}, room=f"admin:{data.game_pin}")
async with sio.session(sid) as session:
session["game_pin"] = data.game_pin
session["admin"] = True
session["remote"] = True
sio.enter_room(sid, data.game_pin)
sio.enter_room(sid, f"admin:{data.game_pin}")
class _SetControlVisibilityInput(BaseModel):
visible: bool
@sio.event
async def set_control_visibility(sid: str, data: dict):
try:
data = _SetControlVisibilityInput(**data)
except ValidationError as e:
await sio.emit("error", room=sid)
print(e)
return
session: dict = await sio.get_session(sid)
await sio.emit("control_visibility", {"visible": data.visible}, room=f"admin:{session['game_pin']}")
+35 -16
View File
@@ -28,27 +28,38 @@
let shown_question_now: number; let shown_question_now: number;
let final_results_clicked = false; let final_results_clicked = false;
let timer_interval; let timer_interval;
export let control_visible: boolean;
export let player_scores; export let player_scores;
export const set_question_number = (q_number: number) => { export const set_question_number = (q_number: number) => {
socket.emit('set_question_number', q_number.toString());
};
socket.on('set_question_number', () => {
console.log('set_question_number');
});
socket.on('get_question_results', () => {
console.log('get_question_results');
});
socket.on('question_results', (_) => {
timer_res = '0';
clearInterval(timer_interval);
});
socket.on('set_question_number', (data) => {
timer_res = '0'; timer_res = '0';
clearInterval(timer_interval); clearInterval(timer_interval);
// setTimeout(() => {
question_results = null; question_results = null;
socket.emit('set_question_number', q_number.toString()); shown_question_now = data.question_index;
shown_question_now = q_number; timer_res = quiz_data.questions[data.question_index].time;
timer_res = quiz_data.questions[q_number].time;
selected_question = selected_question + 1; selected_question = selected_question + 1;
timer(timer_res); timer(timer_res);
// }, 1000); });
};
const get_question_results = () => { const get_question_results = () => {
socket.emit('get_question_results', { socket.emit('get_question_results', {
game_id: game_token, game_id: game_token,
question_number: shown_question_now question_number: shown_question_now
}); });
timer_res = '0';
}; };
const show_solutions = () => { const show_solutions = () => {
socket.emit('show_solutions', {}); socket.emit('show_solutions', {});
@@ -57,12 +68,17 @@
const get_final_results = () => { const get_final_results = () => {
socket.emit('get_final_results', {}); socket.emit('get_final_results', {});
final_results_clicked = true;
timer_res = '0';
}; };
socket.on('solutions', (_) => {
timer_res = '0';
clearInterval(timer_interval);
});
socket.on('final_results', (data) => { socket.on('final_results', (data) => {
// data = JSON.parse(data); // data = JSON.parse(data);
final_results_clicked = true;
timer_res = '0';
final_results = data; final_results = data;
}); });
@@ -105,11 +121,12 @@
} }
</script> </script>
<div {#if control_visible}
<div
class="fixed top-0 w-full h-10 z-20 grid grid-cols-2" class="fixed top-0 w-full h-10 z-20 grid grid-cols-2"
style="background: {bg_color ? bg_color : 'transparent'}" style="background: {bg_color ? bg_color : 'transparent'}"
class:text-black={bg_color} class:text-black={bg_color}
> >
<p class="mr-auto ml-0 col-start-1 col-end-1"> <p class="mr-auto ml-0 col-start-1 col-end-1">
{selected_question === -1 ? '0' : selected_question + 1} {selected_question === -1 ? '0' : selected_question + 1}
/{quiz_data.questions.length} /{quiz_data.questions.length}
@@ -142,8 +159,8 @@
</button> </button>
{:else} {:else}
<button on:click={get_question_results} class="admin-button" <button on:click={get_question_results} class="admin-button"
>Show results</button >Show results
> </button>
{/if} {/if}
{/if} {/if}
{:else if selected_question !== -1} {:else if selected_question !== -1}
@@ -171,15 +188,17 @@
</button>--> </button>-->
{/if} {/if}
</div> </div>
</div> </div>
{/if}
{#if timer_res !== '0' && selected_question >= 0} {#if timer_res !== '0' && selected_question >= 0}
<span <span
class="fixed top-0 bg-red-500 h-8 transition-all mt-10" class="fixed top-0 bg-red-500 h-8 transition-all"
class:mt-10={control_visible}
style="width: {(100 / quiz_data.questions[selected_question].time) * parseInt(timer_res)}vw" style="width: {(100 / quiz_data.questions[selected_question].time) * parseInt(timer_res)}vw"
/> />
{/if} {/if}
<div class="w-full h-full pt-28"> <div class="w-full h-full" class:pt-28={control_visible} class:pt-12={!control_visible}>
{#if timer_res !== undefined && !final_results_clicked && !question_results} {#if timer_res !== undefined && !final_results_clicked && !question_results}
{#if quiz_data.questions[selected_question].type === QuizQuestionType.SLIDE} {#if quiz_data.questions[selected_question].type === QuizQuestionType.SLIDE}
{#await import('$lib/play/admin/slide.svelte')} {#await import('$lib/play/admin/slide.svelte')}
+24 -4
View File
@@ -46,20 +46,39 @@
copy_toast_open = false; copy_toast_open = false;
}, 2000); }, 2000);
}; };
let game_in_lobby: { game_pin: string; game_id: string; quiz_title: string } | undefined =
undefined;
const get_game_in_lobby_fn = async () => {
const res = await fetch('/api/v1/remote/game_waiting');
console.log('Fetched.');
if (res.ok) {
game_in_lobby = await res.json();
}
};
onMount(() => { onMount(() => {
document.body.addEventListener('keydown', close_start_game_if_esc_is_pressed); document.body.addEventListener('keydown', close_start_game_if_esc_is_pressed);
}); });
$: console.log(quizzes, 'quizzes_here');
$: console.log(visibleImages, 'visible images'); get_game_in_lobby_fn();
</script> </script>
{#if copy_toast_open} {#if copy_toast_open || game_in_lobby}
<div class="fixed w-screen top-10 z-50 flex justify-center" transition:fly={{ y: -100 }}> <div class="fixed w-screen top-10 z-50 flex justify-center" transition:fly={{ y: -100 }}>
<div <div
class="flex items-center p-4 w-full max-w-xs text-gray-500 bg-white rounded-lg shadow dark:text-gray-400 dark:bg-gray-800" class="flex items-center p-4 w-full max-w-xs text-gray-500 bg-white rounded-lg shadow dark:text-gray-400 dark:bg-gray-800"
role="alert" role="alert"
> >
<div class="ml-3 text-sm font-normal">ID copied to clipboard!</div> <div class="ml-3 text-sm font-normal">
{#if copy_toast_open}ID copied to clipboard!
{:else}A game is currently in the lobby. Click <a
class="underline"
href="/remote?game_pin={game_in_lobby.game_pin}&game_id={game_in_lobby.game_id}"
>here</a
> to join as a remote.
{/if}
</div>
<button <button
type="button" type="button"
class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700"
@@ -67,6 +86,7 @@
aria-label="Close" aria-label="Close"
on:click={() => { on:click={() => {
copy_toast_open = false; copy_toast_open = false;
game_in_lobby = undefined;
}} }}
> >
<span class="sr-only">Close</span> <span class="sr-only">Close</span>
+15 -1
View File
@@ -33,6 +33,7 @@
let errorMessage = ''; let errorMessage = '';
let game_started = false; let game_started = false;
let quiz_data: QuizData; let quiz_data: QuizData;
let control_visible = true;
//let question_number = '0'; //let question_number = '0';
// let question_results = null; // let question_results = null;
let final_results: Array<null> | Array<Array<PlayerAnswer>> = [null]; let final_results: Array<null> | Array<Array<PlayerAnswer>> = [null];
@@ -70,6 +71,14 @@
errorMessage = $t('admin_page.already_registered_as_admin'); errorMessage = $t('admin_page.already_registered_as_admin');
}); });
socket.on('start_game', (_) => {
game_started = true;
});
socket.on('control_visibility', (data) => {
control_visible = data.visible;
});
/* socket.on('question_results', (int_data) => { /* socket.on('question_results', (int_data) => {
try { try {
int_data = JSON.parse(int_data); int_data = JSON.parse(int_data);
@@ -83,7 +92,10 @@
warnToLeave = false; warnToLeave = false;
dataexport_download_a.href = `/api/v1/quiz/export_data/${int_data}?ts=${new Date().getTime()}&game_pin=${game_pin}`; dataexport_download_a.href = `/api/v1/quiz/export_data/${int_data}?ts=${new Date().getTime()}&game_pin=${game_pin}`;
dataexport_download_a.click(); dataexport_download_a.click();
setTimeout(() => {
warnToLeave = true; warnToLeave = true;
}, 200);
}); });
const confirmUnload = () => { const confirmUnload = () => {
@@ -134,11 +146,13 @@
class:text-black={bg_color} class:text-black={bg_color}
> >
{#if JSON.stringify(final_results) !== JSON.stringify([null])} {#if JSON.stringify(final_results) !== JSON.stringify([null])}
{#if control_visible}
<div class="w-screen flex justify-center mt-16"> <div class="w-screen flex justify-center mt-16">
<button on:click={request_answer_export} class="admin-button" <button on:click={request_answer_export} class="admin-button"
>{$t('admin_page.export_results')}</button >{$t('admin_page.export_results')}</button
> >
</div> </div>
{/if}
<FinalResults bind:data={player_scores} bind:show_final_results /> <FinalResults bind:data={player_scores} bind:show_final_results />
{/if} {/if}
{#if !success} {#if !success}
@@ -181,7 +195,6 @@
id="startGame" id="startGame"
on:click={() => { on:click={() => {
socket.emit('start_game', ''); socket.emit('start_game', '');
game_started = true;
}} }}
>{$t('admin_page.start_game')} >{$t('admin_page.start_game')}
</button> </button>
@@ -197,6 +210,7 @@
bind:game_mode bind:game_mode
bind:bg_color bind:bg_color
bind:player_scores bind:player_scores
bind:control_visible
/> />
{/if} {/if}
</div> </div>
+335
View File
@@ -0,0 +1,335 @@
<!--
- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<script lang="ts">
import { page } from '$app/stores';
import { socket } from '$lib/socket';
import type { QuizData } from '$lib/quiz_types';
import { QuizQuestionType } from '$lib/quiz_types.js';
import Spinner from '$lib/Spinner.svelte';
import CircularTimer from '$lib/play/circular_progress.svelte';
import { getLocalization } from '$lib/i18n';
import { navbarVisible } from '$lib/stores';
const data = {
game_pin: $page.url.searchParams.get('game_pin'),
game_id: $page.url.searchParams.get('game_id')
};
navbarVisible.set(false);
const { t } = getLocalization();
let timer_interval;
let timer_res = undefined;
let selected_question = -1;
let game_started = false;
let question_results;
let final_results = [null];
let warnToLeave = true;
let dataexport_download_a;
let players: Array<{ sid: string; username: string }> = [];
let game_data: QuizData;
let shown_question_now;
let control_visible = false;
if (!data.game_id || !data.game_pin) {
console.log('Error!');
} else {
socket.emit('register_as_remote', data);
}
const timer = (time: string) => {
let seconds = Number(time);
timer_interval = setInterval(() => {
if (timer_res === '0') {
clearInterval(timer_interval);
// socket.emit('show_solutions', {});
return;
} else {
seconds--;
}
timer_res = seconds.toString();
}, 1000);
};
const confirmUnload = (e: Event) => {
if (warnToLeave) {
e.preventDefault();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
e.returnValue = '';
}
};
const set_question_number = (q_number: number) => {
socket.emit('set_question_number', q_number.toString());
};
const start_game = () => {
socket.emit('start_game', '');
};
const get_question_results = () => {
socket.emit('get_question_results', {
question_number: shown_question_now
});
};
const show_solutions = () => {
socket.emit('show_solutions', {});
};
const get_final_results = () => {
socket.emit('get_final_results', {});
};
const request_answer_export = async () => {
await socket.emit('get_export_token');
};
const get_already_joined_players = async () => {
console.log('GETTING PLAYERS');
const res = await fetch(
`/api/v1/live/players?game_pin=${data.game_pin}&game_id=${data.game_id}`
);
if (res.ok) {
players = await res.json();
}
};
let circular_progress = 0;
$: {
try {
circular_progress =
1 -
((100 / game_data.questions[selected_question].time) * parseInt(timer_res)) / 100;
} catch {
circular_progress = 0;
}
}
socket.on('solutions', (_) => {
timer_res = '0';
clearInterval(timer_interval);
});
socket.on('question_results', (_) => {
timer_res = '0';
clearInterval(timer_interval);
});
socket.on('registered_as_admin', (data) => {
game_data = JSON.parse(data.game);
});
socket.on('start_game', (_) => {
game_started = true;
});
socket.on('control_visibility', (data) => {
control_visible = data.visible;
});
socket.on('question_results', (data) => {
try {
question_results = JSON.parse(data);
timer_res = '0';
} catch {
question_results = undefined;
}
});
socket.on('set_question_number', (data) => {
timer_res = '0';
clearInterval(timer_interval);
question_results = null;
shown_question_now = data.question_index;
timer_res = game_data.questions[data.question_index].time;
selected_question = selected_question + 1;
timer(timer_res);
});
socket.on('final_results', (data) => {
// data = JSON.parse(data);
final_results = data;
});
socket.on('everyone_answered', (_) => {
timer_res = '0';
});
socket.on('export_token', (int_data) => {
warnToLeave = false;
dataexport_download_a.href = `/api/v1/quiz/export_data/${int_data}?ts=${new Date().getTime()}&game_pin=${
game_data.game_pin
}`;
dataexport_download_a.click();
setTimeout(() => {
warnToLeave = true;
}, 200);
});
socket.on('player_joined', (data) => {
players = [...players, data];
});
</script>
<svelte:window on:beforeunload={confirmUnload} />
{#if game_started}
{#if selected_question + 1 === game_data.questions.length && ((timer_res === '0' && question_results !== null) || game_data?.questions?.[selected_question]?.type === QuizQuestionType.SLIDE)}
{#if JSON.stringify(final_results) === JSON.stringify([null])}
<button on:click={get_final_results} class="admin-button">Get final results </button>
{:else}
<div class="w-screen flex justify-center mt-16">
<button on:click={request_answer_export} class="admin-button"
>{$t('admin_page.export_results')}</button
>
</div>
{/if}
{:else if timer_res === '0' || selected_question === -1}
{#if (selected_question + 1 !== game_data.questions.length && question_results !== null) || selected_question === -1}
<button
on:click={() => {
set_question_number(selected_question + 1);
}}
class="admin-button"
>Next Question ({selected_question + 2})
</button>
{/if}
{#if question_results === null && selected_question !== -1}
{#if game_data.questions[selected_question].type === QuizQuestionType.SLIDE}
<button
on:click={() => {
set_question_number(selected_question + 1);
}}
class="admin-button"
>Next Question ({selected_question + 2})
</button>
{:else}
<button on:click={get_question_results} class="admin-button">Show results </button>
{/if}
{/if}
{:else if selected_question !== -1}
{#if game_data.questions[selected_question].type === QuizQuestionType.SLIDE}
<button
on:click={() => {
set_question_number(selected_question + 1);
}}
class="admin-button"
>Next Question ({selected_question + 2})
</button>
{:else}
<button on:click={show_solutions} class="admin-button"
>Stop time and show solutions
</button>
{/if}
{/if}
{#if selected_question === -1}
<h1>{game_data.title}</h1>
<p>{game_data.description}</p>
{:else if game_data.questions[selected_question].type === QuizQuestionType.SLIDE}
{#await import('$lib/play/admin/slide.svelte')}
<Spinner my_20={false} />
{:then c}
<svelte:component
this={c.default}
bind:question={game_data.questions[selected_question]}
/>
{/await}
{:else}
<div class="flex flex-col justify-center w-screen h-1/6">
<h1 class="text-6xl text-center">
{@html game_data.questions[selected_question].question}
</h1>
<!-- <span class='text-center py-2 text-lg'>{$t('admin_page.time_left')}: {timer_res}</span>-->
<div class="mx-auto my-2">
<CircularTimer
bind:text={timer_res}
bind:progress={circular_progress}
color="#ef4444"
/>
</div>
{#if game_data.questions[selected_question].image !== null}
<div>
<img
src={game_data.questions[selected_question].image}
class="max-h-[20vh] object-cover mx-auto mb-8 w-auto"
alt="Content for Question"
/>
</div>
{/if}
{#if game_data.questions[selected_question].type === QuizQuestionType.ABCD || game_data.questions[selected_question].type === QuizQuestionType.VOTING}
<div class="grid grid-cols-2 gap-2 w-full p-4">
{#each game_data.questions[selected_question].answers as answer, i}
<div
class="rounded-lg h-fit flex"
style="background-color: {answer.color ?? '#B45309'}"
class:opacity-50={!answer.right &&
game_data.questions[selected_question].type ===
QuizQuestionType.ABCD}
>
<span class="text-center text-2xl px-2 py-4 w-full text-black"
>{answer.answer}</span
>
<span class="pl-4 w-10" />
</div>
{/each}
</div>
{/if}
</div>
{/if}
{#if timer_res === '0' && JSON.stringify(final_results) === JSON.stringify( [null] ) && game_data.questions[selected_question].type !== QuizQuestionType.SLIDE}
{#if game_data.questions[selected_question].type === QuizQuestionType.VOTING && question_results}
{#await import('$lib/play/admin/voting_results.svelte')}
<Spinner />
{:then c}
<svelte:component
this={c.default}
bind:data={question_results}
bind:question={game_data.questions[selected_question]}
/>
{/await}
{/if}
{/if}
{:else}
<button on:click={start_game} class="admin-button">Start Game!</button>
<h2>Players:</h2>
{#await get_already_joined_players()}
<Spinner my_20={false} />
{:then _}
<div>
<ul>
{#each players as player}
<li>{player.username}</li>
{/each}
</ul>
</div>
{/await}
{/if}
<div class="fixed top-0 right-0">
{#if control_visible}
<button
on:click={() => {
socket.emit('set_control_visibility', { visible: false });
}}
>Hide Controls
</button>
{:else}
<button
on:click={() => {
socket.emit('set_control_visibility', { visible: true });
}}
>Show Controls
</button>
{/if}
</div>
<a
on:click|preventDefault={request_answer_export}
href="#"
bind:this={dataexport_download_a}
class="absolute -top-3/4 -left-3/4 opacity-0 hidden">Download</a
>