🔀 Merged ClassQuizController

This commit is contained in:
Mawoka
2023-06-30 01:36:48 +02:00
183 changed files with 10232 additions and 3260 deletions
+1 -1
View File
@@ -29,7 +29,7 @@
{$t('error_page.404_text')}
</p>
{:else}
<p>
<p class="text-center">
{$t('error_page.unknown_error_text')}
</p>
{/if}
+1 -1
View File
@@ -1,7 +1,7 @@
import { signedIn } from '$lib/stores';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
export const load: LayoutServerLoad = ({ locals }) => {
if (locals.email) {
signedIn.set(true);
} else {
+11 -5
View File
@@ -6,12 +6,13 @@
<script>
import '../app.css';
import Navbar from '$lib/navbar.svelte';
import { navbarVisible, pathname, alertModal } from '$lib/stores';
import { navbarVisible, pathname } from '$lib/stores';
import * as Sentry from '@sentry/browser';
import { BrowserTracing } from '@sentry/tracing';
import { initLocalizationContext } from '$lib/i18n';
import { browser } from '$app/environment';
import Alert from '$lib/modals/alert.svelte';
import CommandPalette from '$lib/components/commandpalette.svelte';
// import Alert from '$lib/modals/alert.svelte';
/* afterNavigate(() => {
if (browser) {
@@ -91,9 +92,14 @@
{:else}
<slot />
{/if}
{#if $alertModal.open}
<CommandPalette />
<!--{#if $alertModal.open ?? false}
<div
class="fixed inset-0 h-screen w-screen bg-black z-30 bg-opacity-60 flex items-center justify-center content-center"
class="fixed inset-0 h-screen w-screen bg-black z-30 bg-opacity-60 items-center justify-center content-center"
class:hidden={!$alertModal.open}
class:flex={$alertModal.open}
class:visible={$alertModal.open}
>
<Alert
bind:title={$alertModal.title}
@@ -101,7 +107,7 @@
bind:open={$alertModal.open}
/>
</div>
{/if}
{/if}-->
<style lang="scss">
:global(html:not(.dark)) {
+6 -6
View File
@@ -208,7 +208,7 @@
</div>
</section>
<section>
<h2 class="text-center text-5xl mb-6">How does ClassQuiz even work?</h2>
<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">
@@ -258,7 +258,7 @@
class:opacity-70={selected_create_thing !== SelectedCreateThing.Create}
>
<div
class="rounded-lg bg-emerald-300 w-fit p-1 bg-lime-500 hover:bg-lime-400 transition shadow-lg"
class="rounded-lg w-fit p-1 bg-lime-500 hover:bg-lime-400 transition shadow-lg"
>
<svg
aria-label="Pencil-Icon"
@@ -288,7 +288,7 @@
class:opacity-70={selected_create_thing !== SelectedCreateThing.Find}
>
<div
class="rounded-lg bg-emerald-300 w-fit p-1 bg-lime-500 hover:bg-lime-400 transition shadow-lg"
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"
@@ -309,7 +309,7 @@
<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
<!--<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;
@@ -318,7 +318,7 @@
class:opacity-70={selected_create_thing !== SelectedCreateThing.Import}
>
<div
class="rounded-lg bg-emerald-300 w-fit p-1 bg-lime-500 hover:bg-lime-400 transition shadow-lg"
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"
@@ -340,7 +340,7 @@
<p class="dark:text-black">
{$t('index_page.import_quiz_from_kahoot_and_edit')}
</p>
</div>
</div>-->
</div>
</div>
</section>
@@ -5,7 +5,6 @@
-->
<script lang="ts">
import { getLocalization } from '$lib/i18n';
import { alertModal } from '../../../lib/stores';
export let session_data;
export let selected_method;
@@ -37,19 +36,16 @@
try {
data = await res.json();
} catch {
alertModal.set({
open: true,
body: "This shouldn't happen. Please try again.",
title: 'Unknown error'
});
alert("This shouldn't happen");
window.location.reload();
}
if (data.detail === 'wrong credentials') {
alertModal.set({
/* alertModal.set({
open: true,
body: 'Please try again. Your email and or password were incorrect.',
title: 'Wrong Credentials'
});
});*/
alert('Wrong credentials');
}
}
};
@@ -5,7 +5,7 @@
-->
<script lang="ts">
import { getLocalization } from '$lib/i18n';
import { alertModal } from '$lib/stores';
// import { alertModal } from '$lib/stores';
export let session_data;
export let selected_method;
@@ -44,19 +44,21 @@
try {
data = await res.json();
} catch {
alertModal.set({
/* alertModal.set({
open: true,
body: "This shouldn't happen. Please try again.",
title: 'Unknown error'
});
});*/
alert('Unknown error');
window.location.reload();
}
if (data.detail === 'totp wrong') {
alertModal.set({
/* alertModal.set({
open: true,
body: 'Wrong Totp-Code. please try again.',
title: 'Totp Error'
});
});*/
alert('TOTP code was incorrect');
totp = '';
}
}
@@ -6,7 +6,7 @@
<script lang="ts">
import { startAuthentication } from '@simplewebauthn/browser';
import { getLocalization } from '$lib/i18n';
import { alertModal } from '$lib/stores';
// import { alertModal } from '$lib/stores';
const { t } = getLocalization();
export let session_data;
@@ -24,11 +24,12 @@
asseResp = await startAuthentication(data);
} catch (e) {
console.error(e);
alertModal.set({
/* alertModal.set({
open: true,
body: e,
title: 'Unknown error'
});
});*/
alert('Unknown error');
isLoading = false;
}
const res = await fetch(
@@ -51,19 +52,21 @@
try {
data = await res.json();
} catch {
alertModal.set({
/* alertModal.set({
open: true,
body: "This shouldn't happen. Please try again.",
title: 'Unknown error'
});
});*/
alert('Unknown error');
window.location.reload();
}
if (data.detail === 'webauthn failed') {
alertModal.set({
/* alertModal.set({
open: true,
body: 'Webauthn failed. Please try again.',
title: 'Webauthn Error'
});
});*/
alert('Webauthn failed');
}
}
isLoading = false;
@@ -170,7 +170,9 @@
alt="Profile image of {user.username}"
/>
<div class="m-2 flex justify-center">
<BrownButton href="/account/settings/avatar">Change avatar</BrownButton>
<BrownButton href="/account/settings/avatar"
>{$t('settings_page.change_avatar')}</BrownButton
>
</div>
</div>
<div class="grid grid-rows-2 col-start-2 col-end-7">
@@ -184,7 +186,7 @@
<div class="p-4 flex justify-center">
<div class="m-auto">
<BrownButton href="/account/settings/security"
>Security-Settings
>{$t('settings_page.security_settings')}
</BrownButton>
<BrownButton href="/account/controllers">ClassQuizController</BrownButton>
</div>
@@ -220,20 +222,25 @@
</div>
</form>
<div>
<button on:click={add_api_key}>Add API-Key</button>
<div class="w-fit">
<BrownButton on:click={add_api_key}
>{$t('settings_page.add_api_key')}</BrownButton
>
</div>
{#await api_keys}
<Spinner />
{:then keys}
{#each keys as key}
<div>
{key.key}
<button
on:click={() => {
delete_api_key(key.key);
}}
class="admin-button"
>Delete
</button>
<div class="inline-block">
<BrownButton
on:click={() => {
delete_api_key(key.key);
}}
>{$t('words.delete')}
</BrownButton>
</div>
</div>
{/each}
{/await}
@@ -94,7 +94,7 @@
on:click={() => {
index = index - 1;
}}
disabled={index < 1}>Back</BrownButton
disabled={index < 1}>{$t('words.back')}</BrownButton
>
</div>
<div class="mx-auto">
@@ -103,7 +103,7 @@
</h2>
</div>
<div class="ml-auto">
<BrownButton disabled={index < 11}>Finish</BrownButton>
<BrownButton disabled={index < 11}>{$t('words.finish')}</BrownButton>
</div>
</div>
<div class="grid grid-cols-4">
@@ -167,11 +167,11 @@
<Spinner my_20={false} />
{/if}
</BrownButton>
<BrownButton href="/account/settings">Go back</BrownButton>
<BrownButton href="/account/settings">{$t('avatar_settings.go_back')}</BrownButton>
<BrownButton
on:click={() => {
finished = false;
}}>Close</BrownButton
}}>{$t('words.close')}</BrownButton
>
</div>
</div>
@@ -10,6 +10,9 @@
import TotpSetup from './totp_setup.svelte';
import BackupCodes from './backup_codes.svelte';
import BrownButton from '$lib/components/buttons/brown.svelte';
import { getLocalization } from '$lib/i18n';
const { t } = getLocalization();
let user_data: object | undefined;
let security_keys: Array<{ id: number }> | undefined;
@@ -101,15 +104,17 @@
<div class="grid grid-rows-2 h-screen">
<div class="grid grid-cols-2 h-full border-b-2 border-black">
<div class="h-full w-full border-r-2 border-black">
<h2 class="text-center text-2xl">Backup-Code</h2>
<h2 class="text-center text-2xl">{$t('security_settings.backup_code')}</h2>
<div class="flex h-full w-full justify-center">
<div class="m-auto">
<BrownButton on:click={get_backup_code}>Get Backup-Codes</BrownButton>
<BrownButton on:click={get_backup_code}
>{$t('security_settings.get_backup_code')}</BrownButton
>
</div>
</div>
</div>
<div class="h-full w-full">
<h2 class="text-center text-2xl">Activate 2 Factor</h2>
<h2 class="text-center text-2xl">{$t('security_settings.activate_2fa')}</h2>
<div class="flex h-full w-full justify-center flex-col">
<div class="m-auto">
{#if user_data.require_password}
@@ -130,7 +135,7 @@
/>
</button>
<span class="text-sm font-medium text-gray-700 dark:text-white"
>Two Factor authentication is activated</span
>{$t('security_settings.2fa_activated')}</span
>
</div>
{:else}
@@ -151,7 +156,7 @@
/>
</button>
<span class="text-sm font-medium text-gray-700 dark:text-white"
>Two Factor authentication is deactivated</span
>{$t('security_settings.2fa_deactivated')}</span
>
</div>
{/if}
@@ -161,17 +166,19 @@
</div>
<div class="grid grid-cols-2 h-full">
<div class="h-full w-full flex flex-col border-r-2 border-black">
<h2 class="text-center text-2xl">Webauthn</h2>
<h2 class="text-center text-2xl">{$t('security_settings.webauthn')}</h2>
<div class="flex justify-center">
{#if security_keys.length > 0}
<p>Webauthn is available</p>
<p>{$t('security_settings.webauthn_available')}</p>
{:else}
<p>Webauthn is not available</p>
<p>{$t('security_settings.webauthn_unavailable')}</p>
{/if}
</div>
<div class="flex justify-center">
<div class="m-auto">
<BrownButton on:click={add_security_key}>Add Security-Key</BrownButton>
<BrownButton on:click={add_security_key}
>{$t('security_settings.add_security_key')}</BrownButton
>
</div>
</div>
<div class="flex justify-center">
@@ -190,21 +197,25 @@
</div>
</div>
<div class="h-full w-full flex flex-col">
<h2 class="text-center text-2xl">Totp</h2>
<h2 class="text-center text-2xl">{$t('security_settings.totp')}</h2>
<div class="flex justify-center">
{#if totp_activated}
<p>Totp is available</p>
<p>{$t('security_settings.totp_available')}</p>
{:else}
<p>Totp is not available</p>
<p>{$t('security_settings.totp_unavailable')}</p>
{/if}
</div>
<div class="flex justify-center">
<div class="m-auto">
{#if totp_activated}
<BrownButton on:click={disable_totp}>Disable Totp</BrownButton>
<BrownButton on:click={disable_totp}
>{$t('security_settings.disable_totp')}</BrownButton
>
{:else}
<BrownButton on:click={enable_totp}>Enable Totp</BrownButton>
<BrownButton on:click={enable_totp}
>{$t('security_settings.enable_totp')}</BrownButton
>
{/if}
</div>
</div>
@@ -4,6 +4,9 @@
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<script lang="ts">
import { getLocalization } from '$lib/i18n';
const { t } = getLocalization();
export let backup_code;
let already_downloaded = false;
@@ -30,12 +33,12 @@
on:click={() => {
backup_code = undefined;
}}
>Close
>{$t('words.close')}
</button>
<div
class="bg-white dark:bg-gray-700 rounded-b-lg rounded-tr-lg w-full h-full flex flex-col"
>
<h2 class="text-3xl m-auto">Your Backup-Code</h2>
<h2 class="text-3xl m-auto">{$t('security_settings.backup_codes.your_backup_code')}</h2>
<p
class="select-all font-mono text-xl m-auto"
on:click={() => {
@@ -44,13 +47,14 @@
>
{backup_code}
</p>
<p class="m-auto">Save this somewhere safe!</p>
<p class="m-auto">{$t('security_settings.backup_codes.save_somewhere_save')}</p>
<button
on:click={() => {
download_code(true);
}}
class="m-auto p-2 bg-[#B07156] rounded-lg">Download code</button
>
class="m-auto p-2 bg-[#B07156] rounded-lg"
>{$t('security_settings.backup_codes.download_code')}
</button>
</div>
</div>
</div>
@@ -6,6 +6,9 @@
<script lang="ts">
import QRCode from 'qrcode';
import Spinner from '$lib/Spinner.svelte';
import { getLocalization } from '$lib/i18n';
const { t } = getLocalization();
export let totp_data: { url: string; secret: string } | undefined;
@@ -21,23 +24,25 @@
on:click={() => {
totp_data = undefined;
}}
>Close
>{$t('words.close')}
</button>
<div class="bg-white dark:bg-gray-700 rounded-b-lg rounded-tr-lg w-full h-full">
<div class="grid grid-cols-3 w-full h-full">
<div class="flex flex-col justify-center w-full h-5/6">
<span class="m-auto" />
<div class="h-5/6 flex">
<p class="my-auto ml-auto">Scan this to set up the code</p>
<p class="my-auto ml-auto">
{$t('security_settings.totp_setup.scan_to_set_up')}
</p>
</div>
<div class="flex">
<p class="my-auto ml-auto">
Enter this as the secret if you can't scan the code
{$t('security_settings.totp_setup.enter_as_secret_if_no_see_code')}
</p>
</div>
</div>
<div class="flex flex-col justify-start w-full h-5/6">
<h2 class="text-2xl m-auto">Totp-Setup</h2>
<h2 class="text-2xl m-auto">{$t('security_settings.totp_setup.totp_setup')}</h2>
{#await get_image_url()}
<Spinner my_20={false} />
{:then data}
@@ -52,7 +57,9 @@
<p class="m-auto select-all font-mono">{totp_data.secret}</p>
</div>
<div class="flex justify-center h-5/6 w-full">
<p class="m-auto text-3xl p-4">Do not forget to save your recovery-code!</p>
<p class="m-auto text-3xl p-4">
{$t('security_settings.totp_setup.do_not_forget_backup_code')}
</p>
</div>
</div>
</div>
+26 -18
View File
@@ -28,7 +28,8 @@
// };
export let data;
let game_mode;
let { game_pin, auto_connect, game_token } = data;
let { auto_connect, game_token } = data;
const game_pin = data.game_pin;
let players: Array<Player> = [];
let player_scores = {};
@@ -42,6 +43,7 @@
let success = false;
let dataexport_download_a;
let warnToLeave = true;
let export_token = undefined;
const connect = async () => {
socket.emit('register_as_admin', {
@@ -81,18 +83,17 @@
});
/* socket.on('question_results', (int_data) => {
try {
int_data = JSON.parse(int_data);
} catch (e) {
console.error('Failed to parse question results');
return;
}
question_results = int_data;
});*/
try {
int_data = JSON.parse(int_data);
} catch (e) {
console.error('Failed to parse question results');
return;
}
question_results = int_data;
});*/
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_pin}`;
dataexport_download_a.click();
export_token = int_data;
setTimeout(() => {
warnToLeave = true;
@@ -151,9 +152,17 @@
{#if control_visible}
<div class="w-screen flex justify-center mt-16">
<div class="w-fit">
<GrayButton on:click={request_answer_export}
>{$t('admin_page.export_results')}</GrayButton
>
{#if export_token === undefined}
<GrayButton on:click={request_answer_export}
>{$t('admin_page.request_export_results')}</GrayButton
>
{:else}
<GrayButton
target="_blank"
href="/api/v1/quiz/export_data/{export_token}?ts={new Date().getTime()}&game_pin={game_pin}"
>{$t('admin_page.download_export_results')}</GrayButton
>
{/if}
</div>
</div>
<div class="w-screen flex justify-center mt-2">
@@ -183,9 +192,6 @@
<FinalResults bind:data={player_scores} bind:show_final_results />
{/if}
{#if !success}
<input placeholder="game id" bind:value={game_token} />
<input placeholder="game pin" bind:value={game_pin} />
<button on:click={connect}>{$t('words.connect')}!</button>
{#if errorMessage !== ''}
<p class="text-red-700">{errorMessage}</p>
{/if}
@@ -199,7 +205,7 @@
{:else}
<SomeAdminScreen
bind:final_results
bind:game_pin
{game_pin}
bind:game_token
bind:quiz_data
bind:game_mode
@@ -212,6 +218,8 @@
<a
on:click|preventDefault={request_answer_export}
href="#"
target="_blank"
bind:this={dataexport_download_a}
download=""
class="absolute -top-3/4 -left-3/4 opacity-0">Download</a
>
+6 -4
View File
@@ -8,8 +8,8 @@
import Editor from '$lib/editor.svelte';
import { getLocalization } from '$lib/i18n';
import { navbarVisible } from '$lib/stores';
import { QuizQuestionType } from '$lib/quiz_types';
import type { Question } from '$lib/quiz_types';
import { page } from '$app/stores';
navbarVisible.set(false);
@@ -31,17 +31,19 @@
onMount(() => {
const from_localstorage = localStorage.getItem('create_game');
if (from_localstorage === null) {
let title = $page.url.searchParams.get('title');
title ??= '';
data = {
description: '',
public: false,
title: '',
title,
questions: [
{
/* {
type: QuizQuestionType.ABCD,
question: '',
time: '20',
answers: [{ right: false, answer: '' }]
}
}*/
]
};
} else {
+177 -56
View File
@@ -8,9 +8,14 @@
import { getLocalization } from '$lib/i18n';
import Footer from '$lib/footer.svelte';
import { navbarVisible, signedIn } from '$lib/stores';
import Spinner from '$lib/Spinner.svelte';
// import Spinner from "$lib/Spinner.svelte";
import Fuse from 'fuse.js';
import BrownButton from '$lib/components/buttons/brown.svelte';
import type { PageData } from './$types';
import { fly } from 'svelte/transition';
import StartGamePopup from '$lib/dashboard/start_game.svelte';
// import GrayButton from "$lib/components/buttons/gray.svelte";
interface QuizData {
id: string;
@@ -23,59 +28,73 @@
questions: Question[];
}
export let data: PageData;
let search_term = '';
let start_game = null;
signedIn.set(true);
navbarVisible.set(true);
const { t } = getLocalization();
let quizzes_to_show = [];
let quizzes: Array<any>;
let items_to_show = [];
let all_items: Array<any>;
let fuse;
/* const minisearch = new MiniSearch({
fields: ['title', 'description'],
idField: 'id',
storeFields: ['id']
})*/
let id_to_position_map = {};
const getData = async (): Promise<Array<QuizData>> => {
const res = await fetch('/api/v1/quiz/list');
quizzes_to_show = await res.json();
fuse = new Fuse(quizzes_to_show, {
keys: ['title', 'description', 'questions.question'],
items_to_show = [];
for (let i = 0; i < data.quizzes.length; i++) {
items_to_show.push({ ...data.quizzes[i], type: 'quiz' });
}
for (let i = 0; i < data.quiztivities.length; i++) {
items_to_show.push({ ...data.quiztivities[i], type: 'quiztivity' });
}
fuse = new Fuse(items_to_show, {
keys: ['title', 'description', 'questions.title'],
findAllMatches: true
});
quizzes = quizzes_to_show;
for (let i = 0; i < quizzes.length; i++) {
id_to_position_map[quizzes[i].id] = i;
all_items = items_to_show;
for (let i = 0; i < all_items.length; i++) {
id_to_position_map[all_items[i].id] = i;
}
return quizzes_to_show;
return all_items;
};
let suggestions = [];
const search = () => {
if (search_term === '') {
quizzes_to_show = [];
quizzes_to_show = quizzes;
quizzes_to_show = quizzes_to_show;
items_to_show = all_items;
} else {
const res = fuse.search(search_term);
console.log(res, 'search_res');
quizzes_to_show = [];
items_to_show = [];
for (const quiz_data of res) {
quizzes_to_show.push(quiz_data.item);
items_to_show.push(quiz_data.item);
}
quizzes_to_show = quizzes_to_show;
items_to_show = items_to_show;
}
};
$: {
search_term;
// console.log(search_term);
search();
}
const deleteQuiz = async (to_delete: string, type: 'quiz' | 'quiztivity') => {
if (!confirm('Do you really want to delete this quiz?')) {
return;
}
if (type === 'quiz') {
await fetch(`/api/v1/quiz/delete/${to_delete}`, {
method: 'DELETE'
});
} else {
await fetch(`/api/v1/quiztivity/${to_delete}`, {
method: 'DELETE'
});
}
window.location.reload();
};
let create_button_clicked = false;
</script>
<svelte:head>
@@ -95,40 +114,120 @@
/>
</svg>
{:then quizzes}
<div class="flex flex-col w-fit mx-auto">
<div class="flex flex-col w-full mx-auto">
<!-- <button
class='px-4 py-2 font-medium tracking-wide text-gray-500 whitespace-nowrap dark:text-gray-400 capitalize transition-colors dark:bg-gray-700 duration-200 transform bg-[#B07156] rounded-md hover:bg-green-600 focus:outline-none focus:ring focus:ring-blue-300 focus:ring-opacity-80'>
Primary
</button>-->
<div class="w-full grid grid-cols-4 gap-2">
<BrownButton href="/create">{$t('words.create')}</BrownButton>
class='px-4 py-2 font-medium tracking-wide text-gray-500 whitespace-nowrap dark:text-gray-400 capitalize transition-colors dark:bg-gray-700 duration-200 transform bg-[#B07156] rounded-md hover:bg-green-600 focus:outline-none focus:ring focus:ring-blue-300 focus:ring-opacity-80'>
Primary
</button>-->
<div class="w-full grid lg:grid-cols-4 gap-2 grid-cols-2 px-4">
{#if create_button_clicked}
<div class="flex gap-2" transition:fly={{ y: 10 }}>
<BrownButton href="/create">{$t('words.quiz')}</BrownButton>
<BrownButton href="/quiztivity/create">{$t('words.quiztivity')}</BrownButton
>
</div>
{:else}
<BrownButton
on:click={() => {
create_button_clicked = true;
}}>{$t('words.create')}</BrownButton
>
{/if}
<BrownButton href="/import">{$t('words.import')}</BrownButton>
<BrownButton href="/results">{$t('words.results')}</BrownButton>
<BrownButton href="/account/settings">
{$t('words.settings')}
</BrownButton>
<div class="flex gap-2">
<BrownButton href="/edit/files">{$t('words.files_library')}</BrownButton>
<BrownButton href="/account/settings">
{$t('words.settings')}
</BrownButton>
</div>
</div>
{#if quizzes.length !== 0}
{#await import('$lib/dashboard/main_slider.svelte')}
<Spinner />
{:then c}
<div class="flex justify-center pt-4 w-full">
<div class="flex justify-center pt-4 w-full">
<div>
<div>
<div>
<input
bind:value={search_term}
class="p-2 rounded-lg outline-none text-center w-96 dark:bg-gray-700"
placeholder={$t('dashboard.search_for_own_quizzes')}
/>
<button
on:click={() => {
search_term = '';
quizzes_to_show = quizzes;
suggestions = [];
}}
<input
bind:value={search_term}
class="p-2 rounded-lg outline-none text-center w-96 dark:bg-gray-700"
placeholder={$t('dashboard.search_for_own_quizzes')}
/>
<button
on:click={() => {
search_term = '';
items_to_show = all_items;
}}
>
<svg
class="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="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
</div>
<div class="flex flex-col gap-4 mt-4 px-2">
{#each items_to_show as quiz}
<div
class="grid grid-cols-2 lg:grid-cols-3 w-full rounded border-[#B07156] border-2 p-2 h-[20vh] overflow-hidden max-h-[20vh]"
>
<div class="hidden lg:flex w-auto h-full items-center relative">
{#if quiz.cover_image}
<img
src="/api/v1/storage/download/{quiz.cover_image}"
alt="user provided"
loading="lazy"
class="shrink-0 max-w-full max-h-full absolute"
/>
{/if}
</div>
<div class="my-auto mx-auto max-h-full overflow-hidden">
<p class="text-xl text-center">{@html quiz.title}</p>
<p class="text-sm text-center text-clip overflow-hidden">
{@html quiz.description ?? ''}
</p>
</div>
<div
class="grid grid-cols-2 grid-rows-2 ml-auto gap-2 w-fit self-end my-auto"
>
<BrownButton
href={quiz.type === 'quiz'
? `/edit?quiz_id=${quiz.id}`
: `/quiztivity/edit?id=${quiz.id}`}
>{$t('words.edit')}</BrownButton
>
{#if quiz.type === 'quiz'}
<BrownButton
on:click={() => {
start_game = quiz.id;
}}
>
{$t('words.start')}
</BrownButton>
{:else}
<BrownButton href="/quiztivity/play?id={quiz.id}">
{$t('words.play')}
</BrownButton>
{/if}
<BrownButton
on:click={() => {
deleteQuiz(quiz.id, quiz.type);
}}
flex={true}
>
<!-- heroicons/trash -->
<svg
class="h-8 inline-block"
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -138,15 +237,34 @@
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</BrownButton>
<BrownButton
href="/api/v1/eximport/{quiz.id}"
flex={true}
disabled={quiz.type !== 'quiz'}
><!-- heroicons/download -->
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</BrownButton>
</div>
</div>
</div>
<svelte:component this={c.default} bind:quizzes={quizzes_to_show} />
{/await}
{/each}
</div>
{:else}
<p>
{$t('overview_page.no_quizzes')}
@@ -158,3 +276,6 @@
{/await}
</div>
<Footer />
{#if start_game !== null}
<StartGamePopup bind:quiz_id={start_game} />
{/if}
+18
View File
@@ -0,0 +1,18 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import type { PageLoad } from './$types';
export const load = (async ({ fetch }) => {
const quiz_res = await fetch('/api/v1/quiz/list?page_size=100');
const quizzes = await quiz_res.json();
const quiztivity_res = await fetch('/api/v1/quiztivity/');
const quiztivities = await quiztivity_res.json();
return {
quizzes,
quiztivities
};
}) satisfies PageLoad;
@@ -0,0 +1,34 @@
<!--
- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<script lang="ts">
import type { PageData } from './$types';
// import FileDahboard from "$lib/files/dashboard.svelte"
// import { thumbHashToDataURL } from 'thumbhash';
export let data: PageData;
const files = data.files;
/* const base64ToBytes = (value: string) => {
const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
const decodedData =
typeof Buffer !== 'undefined'
? Buffer.from(base64, 'base64')
: Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
return new Uint8Array(decodedData);
};*/
// let images_loaded = [];
</script>
<div class="w-full h-full">
<div class="grid grid-cols-2 w-full h-full gap-4">
{#each files as file, i}
<div class="w-full h-full flex" />
{/each}
</div>
</div>
@@ -0,0 +1,11 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import type { PageLoad } from './$types';
export const load = (async ({ fetch }) => {
const res = await fetch('/api/v1/storage/list');
return { files: await res.json() };
}) satisfies PageLoad;
@@ -0,0 +1,8 @@
# Question Types
- [Range](/docs/quiz/question-types/range)
- [Multiple Choice](/docs/quiz/question-types/multiple-choice)
- [Voting](/docs/quiz/question-types/voting)
- [Text](/docs/quiz/question-types/text)
- [Order](/docs/quiz/question-types/order)
- [Check Choice](/docs/quiz/question-types/check-choice)
@@ -0,0 +1,11 @@
# Check Choice
## Summary
- Interface similar to [Multiple Choice](/docs/quiz/question-types/multiple-choice)
- Up to 4 answers
- All answers marked as correct have to be selected
- No points if only one correct one is selected
## Use case
- Select all correct statements
- Select all correct nicknames
@@ -0,0 +1,9 @@
# Multiple Choice
## Summary
- The standard question type everyone knows
- 1-4 answers can be correct
- User can only choose one answer
## Use case
Nothing specific; Main component of a quiz
@@ -0,0 +1,9 @@
# Order
## Summary
- Bring the answers in a correct order
- The order gets randomized before the quiz so that every player has the same random order
- Remember to give some time, since it's kinda slow to move the answers up and down
## Use case
- Order historic events
@@ -0,0 +1,10 @@
# Range Answers
## Summary
- The player has a slider where a number can be set
- The creator can choose the range and the correct range
## Use case
- Guessing a year
- Guessing the population of a country
- Guessing in general
@@ -0,0 +1,9 @@
# Text
## Summary
- Players can enter text
- 4 different correct solutions can be given
- case sensitivity can be set per answer
## Use case
- Test if a name is remembered correctly
@@ -0,0 +1,10 @@
# Voting
## Summary
- Like [Multiple Choice](/docs/quiz/question-types/multiple-choice)
- No points
- No correct answers
- Bar graph shows how many voted for what
## Use case
- Get the opinion of the audience
@@ -50,6 +50,15 @@
<li>
<a href="https://github.com/RicoLaaa">RicoLaaa</a> (Spanish)
</li>
<li>
<a href="https://github.com/WaldiSt">Waldemar Stoczkowski</a> (Polish)
</li>
<li>
<a href="https://github.com/OptimisticSean">OptimisticSean</a> (Chinese)
</li>
<li>
<a href="https://github.com/OptimisticSean">Miniontoby</a> (Dutch)
</li>
</ul>
<p><i>...Maybe you?</i></p>
@@ -87,6 +87,10 @@
Start Caddy:
<pre><code>caddy run</code></pre>
</li>
<li>
Start the background-worker:
<pre><code>arq classquiz.worker.WorkerSettings</code></pre>
</li>
</ol>
<p>If you're done developing: <code>./run_tests.sh -</code></p>
<p>If you want to run all the tests: <code>./run_tests.sh a</code></p>
+32 -18
View File
@@ -96,18 +96,20 @@
<h3>Storage Provider</h3>
<p>
You'll have to set up a storage provider for some pictures (these getting imported from
KAHOOT!). For now, you can use <a href="https://deta.sh">Deta</a> or the local filesystem.
Please note that I would <b>NOT</b> use the local file system because of
<a href="https://wiki.owasp.org/index.php/Path_Traversal">Path Traversals</a>. I tried to
prevent these attacks, but I really wouldn't trust it, because it's a regex! You'll have to
set the
<code>STORAGE_BACKEND</code>-environment-variable to either <code>deta</code> or
<code>local</code>.
Kahoot!). For now, you can use <a href="https://min.io/" target="_blank">Minio (S3)</a> or
the local filesystem. Please not that I'd recommend Minio for larger instances, since it can
be scaled and the media doesn't have to streamed through the (comparatively) slow ClassQuiz
server. Now, that you've decided on a storage backend, you can set the
<code>STORAGE_BACKEND</code>-environment-variable to either <code>s3</code> or
<code>local</code>. If you ask yourself what happened with deta, I've decided to remove it,
since the went all-in with their spaces and I think that hardly anyone used it anyway.
</p>
<h4>If you chose Deta...</h4>
<h4>If you chose Minio (S3)...</h4>
<p>
...you'll also have to set the <code>DETA_PROJECT_KEY</code> and the
<code>DETA_PROJECT_ID</code>.
...you'll also have to set the <code>S3_ACCESS_KEY</code>, <code>S3_SECRET_KEY</code> and
the
<code>S3_BASE_URL</code>. The <code>S3_BUCKET_NAME</code> can also be set, but defaults to
<code>classquiz</code>.
</p>
<h4>If you chose the local filesystem...</h4>
<p>
@@ -165,15 +167,15 @@ services:
REDIS_URL: redis://redis:6379/0?decode_responses=True # don't change
API_URL: http://api:80 # don't change
api:
build:
build: &build_cfg
context: .
dockerfile: Dockerfile
restart: always
depends_on:
restart: &restart always
depends_on: &depends
- db
- redis
environment:
environment: &env_vars
ROOT_ADDRESS: "https://classquiz.de" # Base-URL (change it)
DB_URL: "postgresql://postgres:classquiz@db:5432/classquiz" # don't change
MAIL_ADDRESS: "classquiz@mawoka.eu" # Email-Address (change it)
@@ -188,11 +190,13 @@ services:
MEILISEARCH_URL: "http://meilisearch:7700" # don't change
ACCESS_TOKEN_EXPIRE_MINUTES: 30 # don't change
HCAPTCHA_KEY: "" # Private hCaptcha key for verification (change it)
STORAGE_BACKEND: "deta" # MUST BE EITHER "deta" OR "local"
STORAGE_BACKEND: "local" # MUST BE EITHER "s3" OR "local"
FREE_STORAGE_LIMIT: "1074000000" # Free storage limit in bytes (default: 1GB)
# If STORAGE_BACKEND is "deta"
DETA_PROJECT_KEY: "YOUR_DETA_PROJECT_KEY"
DETA_PROJECT_ID: "YOUR_DETA_PROJECT_ID"
# If STORAGE_BACKEND is "s3"
S3_ACCESS_KEY=YOUR_ACCESS_KEY
S3_SECRET_KEY=YOUR_SECRET_KEY
S3_BASE_URL=YOUR_S3_BASE_URL
# If STORAGE_BACKEND is "local"
STORAGE_PATH: "/var/storage"
@@ -205,6 +209,8 @@ services:
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.
volumes: # Only needed if you chose the "local" storage-backend
- ./uploads:/var/storage
redis:
image: redis:alpine
@@ -241,6 +247,14 @@ services:
MEILI_NO_ANALYTICS: true
volumes:
- meilisearch-data:/data.ms
worker:
build: *build_cfg
environment: *env_vars
restart: *restart
depends_on: *depends
command: arq classquiz.worker.WorkerSettings
volumes:
data:
meilisearch-data:
+1 -2
View File
@@ -7,7 +7,7 @@
import Editor from '$lib/editor.svelte';
import { getLocalization } from '$lib/i18n';
import { navbarVisible } from '$lib/stores';
import { QuizQuestionType } from '../../lib/quiz_types';
import { QuizQuestionType } from '$lib/quiz_types';
navbarVisible.set(false);
@@ -38,7 +38,6 @@
export let data;
let { quiz_id } = data;
console.log(quiz_id);
let quiz_data: Data;
const get_quiz = async (): Promise<void> => {
+157
View File
@@ -0,0 +1,157 @@
<!--
- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<script lang="ts">
import type { PageData } from './$types';
import { fade } from 'svelte/transition';
// import MediaComponent from '$lib/editor/MediaComponent.svelte';
import BrownButton from '$lib/components/buttons/brown.svelte';
import { onMount } from 'svelte';
import Uploader from './uploader.svelte';
import { getLocalization } from '$lib/i18n';
const { t } = getLocalization();
export let data: PageData;
let edit_popup = null;
const images = data.images;
const close_popup_handler = (e: Event) => {
if (e.target !== e.currentTarget) return;
edit_popup = null;
};
onMount(() => {
window.onkeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
edit_popup = null;
}
};
});
const save_image_metadata = async () => {
await fetch(`/api/v1/storage/meta/${edit_popup.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ filename: edit_popup.filename, alt_text: edit_popup.alt_text })
});
edit_popup = null;
window.location.reload();
};
const delete_image = async (id: string) => {
await fetch(`/api/v1/storage/meta/${id}`, { method: 'DELETE' });
window.location.reload();
};
</script>
<div>
<h2 class="text-center text-4xl">
{$t('file_dashboard.storage_usage', {
used: (data.storage_usage.used / (1024 * 1024)).toFixed(2),
total: (data.storage_usage.limit / (1024 * 1024)).toFixed(0),
percent: ((data.storage_usage.used / data.storage_usage.limit) * 100).toFixed(0)
})}
</h2>
<Uploader />
<div class="grid grid-cols-1 lg:grid-cols-2 p-4 gap-4">
{#each images as image}
<div
class="border-2 border-[#B07156] rounded p-2 grid grid-cols-2 hover:opacity-100 transition-all"
class:opacity-40={image.quiztivities.length === 0 && image.quizzes.length === 0}
>
<img
src="/api/v1/storage/download/{image.id}"
class="m-auto h-auto w-auto max-h-[30vh]"
loading="lazy"
alt={image.alt_text || $t('file_dashboard.not_available')}
/>
<div class="flex flex-col my-auto ml-4">
<p>
{$t('file_dashboard.size', {
size: (image.size / (1024 * 1024)).toFixed(2)
})}
</p>
<p>
{$t('file_dashboard.caption', {
caption: image.alt_text ?? $t('file_dashboard.missing')
})}
</p>
<p>
{$t('file_dashboard.filename', {
filename: image.filename ?? $t('file_dashboard.missing')
})}
</p>
<p>
{$t('file_dashboard.uploaded', {
date: new Date(image.uploaded_at).toLocaleString()
})}
</p>
<p>
{$t('file_dashboard.imported', {
yes_or_no: image.imported ? $t('words.yes') : $t('words.no')
})}
</p>
<div class="flex flex-col gap-2">
<BrownButton
on:click={() => {
edit_popup = image;
}}
>{$t('file_dashboard.edit_details')}
</BrownButton>
{#if image.quiztivities.length === 0 && image.quizzes.length === 0}
<BrownButton
on:click={() => {
delete_image(image.id);
}}>{$t('file_dashboard.delete_image')}</BrownButton
>
{/if}
</div>
</div>
</div>
{/each}
</div>
</div>
{#if edit_popup}
<div
transition:fade|local={{ duration: 100 }}
class="fixed top-0 left-0 h-screen w-screen z-40 flex bg-black bg-opacity-50"
on:click={close_popup_handler}
>
<div class="w-auto h-auto m-auto rounded bg-white dark:bg-gray-700 p-4">
<h1 class="text-2xl text-center">{$t('file_dashboard.edit_the_image')}</h1>
<form class="flex flex-col" on:submit|preventDefault={save_image_metadata}>
<div class="flex flex-row">
<div class="flex flex-col mr-4">
<label for="name" class="m-auto">{$t('file_dashboard.filename_word')}</label
>
<label for="alt_text" class="m-auto">{$t('file_dashboard.alt_text')}</label>
</div>
<div class="flex flex-col gap-3">
<input
class="rounded outline-none dark:bg-gray-500 p-0.5 border-4 border-transparent"
id="name"
type="text"
bind:value={edit_popup.filename}
/>
<input
class:border-red-700={!edit_popup.alt_text}
class="transition rounded outline-none dark:bg-gray-500 p-0.5 border-4 border-transparent"
id="alt_text"
type="text"
bind:value={edit_popup.alt_text}
/>
</div>
</div>
<div class="mt-4">
<BrownButton type="submit">{$t('words.save')}</BrownButton>
</div>
</form>
</div>
</div>
{/if}
+24
View File
@@ -0,0 +1,24 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import type { PageLoad } from './$types';
import type { PrivateImageData } from '$lib/quiz_types';
export const load = (async ({ fetch }) => {
const res = await fetch('/api/v1/storage/list');
const res2 = await fetch('/api/v1/storage/limit');
let json: PrivateImageData[];
if (res.ok) {
json = await res.json();
} else {
json = [];
}
const storage_usage: { limit: number; limit_reached: boolean; used: number } =
await res2.json();
return {
images: json,
storage_usage
};
}) satisfies PageLoad;
@@ -0,0 +1,33 @@
<!--
- 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 Spinner from '$lib/Spinner.svelte';
let uppyOpen = false;
let edit_id = null;
let selected_question = undefined;
let data = { cover_image: undefined };
$: {
if (data.cover_image) {
window.location.reload();
}
}
</script>
{#await import('$lib/editor/uploader.svelte')}
<Spinner my_20={false} />
{:then c}
<svelte:component
this={c.default}
bind:modalOpen={uppyOpen}
bind:edit_id
bind:data
bind:selected_question
video_upload={true}
library_enabled={false}
/>
{/await}
@@ -0,0 +1,14 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import type { PageServerLoad } from './$types';
export const load = (({ setHeaders }) => {
setHeaders({
'Cross-Origin-Embedder-Policy': 'require-corp',
'Cross-Origin-Opener-Policy': 'same-origin'
});
}) satisfies PageServerLoad;
@@ -0,0 +1,207 @@
<!--
- 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/.
-->
<!--
- 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 { createFFmpeg, fetchFile } from '@ffmpeg/ffmpeg';
import BrownButton from '$lib/components/buttons/brown.svelte';
import { navbarVisible } from '$lib/stores';
import { getLocalization } from '$lib/i18n';
const { t } = getLocalization();
let file_input: HTMLInputElement;
navbarVisible.set(false);
let stats: { progress: number; time_elapsed: number; speed: number } = {
progress: 0,
time_elapsed: 0,
speed: 0
};
let upload_stats: { progress: number; upload_started: undefined | Date; time_elapsed: number } =
{
progress: 0,
upload_started: undefined,
time_elapsed: 0
};
const speed_extraction_regex = /speed=(\d+\.\d+)x(?![\s\S]*speed=\d+\.\d+x)/;
let last_time_elapsed = 0;
// eslint-disable-next-line no-unused-vars
enum Status {
// eslint-disable-next-line no-unused-vars
Idle,
// eslint-disable-next-line no-unused-vars
Compressing,
// eslint-disable-next-line no-unused-vars
CompressDone,
// eslint-disable-next-line no-unused-vars
Uploading,
// eslint-disable-next-line no-unused-vars
Done
}
let status: Status = Status.Idle;
let file_size_in_mi: undefined | number = undefined;
let original_file_size_in_mi: undefined | number = undefined;
let file_data: undefined | Blob = undefined;
const compress_video = async () => {
if (status !== Status.Idle) {
return;
}
status = Status.Compressing;
const ffmpeg = createFFmpeg({
// log: true,
progress: (p) => {
if (stats.progress >= 1) {
stats.time_elapsed = last_time_elapsed;
return;
}
stats.time_elapsed = p.time ?? 0;
last_time_elapsed = stats.time_elapsed;
stats.progress = p.ratio ?? 0;
}
});
const file = file_input.files[0];
original_file_size_in_mi = file.size / 1_048_576;
ffmpeg.setLogger(({ message }) => {
const speed_match = message.match(speed_extraction_regex);
stats.speed = speed_match ? parseFloat(speed_match[1]) : 0;
});
await ffmpeg.load();
ffmpeg.FS('writeFile', file.name, await fetchFile(file));
await ffmpeg.run(
'-i',
file.name,
'-vcodec',
'libx264',
'-crf',
'20',
'-vf',
'scale=1080:-2',
'out.mp4'
);
const data = await ffmpeg.FS('readFile', 'out.mp4');
file_size_in_mi = data.length / 1_048_576;
file_data = new Blob([data]);
status = Status.CompressDone;
stats.progress = 1;
};
const upload_video = async () => {
status = Status.Uploading;
upload_stats.upload_started = new Date();
/* const progressTrackingStream = new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk);
bytesUploaded += chunk.byteLength;
console.log("upload progress:", bytesUploaded / totalBytes);
upload_stats.progress = bytesUploaded / totalBytes;
upload_stats.time_elapsed = (new Date() - upload_stats.upload_started) / 1000;
},
flush(_controller) {
console.log("completed stream");
}
});*/
/* const response = await fetch("/api/v1/storage/raw", {
method: "POST",
headers: {
"Content-Type": "video/mp4"
},
body: file_data.stream().pipeThrough(progressTrackingStream),
duplex: "half"
});
const json = await response.json()*/
const xhr = new XMLHttpRequest();
const success = await new Promise((resolve) => {
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
upload_stats.progress = event.loaded / event.total;
upload_stats.time_elapsed = (new Date() - upload_stats.upload_started) / 1000;
}
});
xhr.addEventListener('loadend', () => {
resolve({
success: xhr.readyState === 4 && xhr.status === 200,
body: xhr.responseText
});
});
xhr.open('POST', '/api/v1/storage/raw', true);
xhr.setRequestHeader('Content-Type', 'video/mp4');
xhr.send(file_data);
});
console.log('success:', success);
const json = JSON.parse(success.body);
localStorage.setItem('video_upload_id', json.id);
console.log(json, 'done');
status = Status.Done;
window.close();
};
</script>
<div class="w-screen h-screen m-auto flex flex-col dark:bg-gray-700 p-2 bg-white">
<input type="file" bind:this={file_input} class="m-auto" accept="video/mp4" />
<div class="mt-auto flex justify-center">
{#if status === Status.Compressing || status === Status.CompressDone}
<table>
<tr>
<th>{$t('words.speed')}</th>
<th>{$t('words.progress')}</th>
<th>{$t('video_uploader.time_elapsed')}</th>
<th>{$t('video_uploader.time_remaining')}</th>
</tr>
<tr>
<td>{stats.speed}</td>
<td>{Math.round(stats.progress * 100)}%</td>
<td>{upload_stats.time_elapsed.toFixed(2)}s</td>
<td
>{(stats.time_elapsed / stats.progress - upload_stats.time_elapsed).toFixed(
2
)}s</td
>
</tr>
</table>
{:else if status === Status.Uploading}
<table>
<tr>
<th>{$t('words.progress')}</th>
<th>{$t('video_uploader.time_elapsed')}</th>
<th>{$t('video_uploader.time_remaining')}</th>
</tr>
<tr>
<td>{Math.round(upload_stats.progress * 100)}%</td>
<td>{stats.time_elapsed.toFixed(2)}s</td>
<td>{(stats.time_elapsed / stats.progress - stats.time_elapsed).toFixed(2)}s</td
>
</tr>
</table>
{/if}
</div>
<div class="flex relative my-2">
<div class="w-full">
<BrownButton disabled={status !== Status.CompressDone} on:click={upload_video}>
{$t('words.upload')}
{file_size_in_mi ? `(${file_size_in_mi.toFixed(2)} Mi)` : ''}</BrownButton
>
</div>
<span
class="h-full transition-all absolute rounded bg-black bg-opacity-50"
style="width: {100 - stats.progress * 100}%"
/>
</div>
<div class="flex justify-center">
<BrownButton on:click={compress_video} disabled={status !== Status.Idle}
>{$t('words.submit')}</BrownButton
>
</div>
</div>
+11 -31
View File
@@ -5,42 +5,22 @@
-->
<script lang="ts">
import { navbarVisible } from '$lib/stores';
navbarVisible.set(true);
import SearchCard from '$lib/search-card.svelte';
const getData = async () => {
const response = await fetch('/api/v1/search/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
q: '*',
sort: ['created_at:desc']
})
});
return await response.json();
};
import type { PageData } from './$types';
export let data: PageData;
const quizzes = data.results;
</script>
<svelte:head>
<title>ClassQuiz - Explore</title>
</svelte:head>
{#await getData()}
<svg class="h-8 w-8 animate-spin mx-auto my-20" 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>
{:then quizzes}
<div class="grid lg:grid-cols-3 grid-cols-1">
{#each quizzes.hits as quiz}
<SearchCard {quiz} />
{/each}
</div>
{/await}
<div class="grid lg:grid-cols-3 grid-cols-1">
{#each quizzes.hits as quiz}
<SearchCard {quiz} />
{/each}
</div>
+22
View File
@@ -0,0 +1,22 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import type { PageLoad } from './$types';
export const load = (async ({ fetch }) => {
const response = await fetch('/api/v1/search/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
q: '*',
sort: ['created_at:desc']
})
});
return {
results: await response.json()
};
}) satisfies PageLoad;
+20 -7
View File
@@ -5,7 +5,9 @@
-->
<script lang="ts">
import { getLocalization } from '$lib/i18n';
import { navbarVisible, alertModal } from '$lib/stores';
import { navbarVisible } from '$lib/stores';
import { onMount } from 'svelte';
import { page } from '$app/stores';
navbarVisible.set(true);
@@ -35,17 +37,19 @@
if (res.status === 200) {
window.location.href = '/dashboard';
} else if (res.status === 400) {
alertModal.set({
/* alertModal.set({
open: true,
title: 'Import failed',
body: "This quiz isn't (yet) supported!"
});
});*/
alert("This quiz isn't (yet) supported!");
} else {
alertModal.set({
/* alertModal.set({
open: true,
title: 'Import failed',
body: 'Unknown error while importing the quiz!'
});
});*/
alert('Import failed with unknown reason');
}
is_loading = false;
};
@@ -61,16 +65,25 @@
if (res.status === 200) {
window.location.href = '/dashboard';
} else {
alertModal.set({
/* alertModal.set({
open: true,
title: 'Import failed',
body: 'Something went wrong!'
});
});*/
alert('Something went wrong!');
}
is_loading = false;
};
$: console.log(file_input);
onMount(() => {
let url_from_path = $page.url.searchParams.get('url');
if (url_from_path === '') {
url_from_path = null;
}
url_input = url_from_path ?? '';
});
</script>
<svelte:head>
@@ -0,0 +1,42 @@
<!--
- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<script lang="ts">
import type { Data } from '$lib/quiztivity/types';
import Editor from '$lib/quiztivity/editor.svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
let title = $page.url.searchParams.get('title');
title ??= '';
let data: Data = { pages: [], id: undefined, title };
let saving = false;
const save_quiztivity = async () => {
saving = true;
console.log(data);
const stringification = JSON.stringify(data);
console.log(stringification);
const res = await fetch('/api/v1/quiztivity/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: stringification
});
if (res.ok) {
await goto('/dashboard');
} else {
alert("Couldn't save");
}
saving = false;
};
</script>
<div class="h-full">
<Editor bind:data on:save={save_quiztivity} bind:saving />
</div>
@@ -0,0 +1,45 @@
<!--
- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<script lang="ts">
import type { PageData } from './$types';
import Editor from '$lib/quiztivity/editor.svelte';
import SharesPopover from '$lib/quiztivity/shares_popover.svelte';
import { goto } from '$app/navigation';
export let data: PageData;
let saving = false;
let quiztivity = data.quiztivity;
let shares_menu_open = false;
const save_quiztivity = async () => {
saving = true;
const stringification = JSON.stringify(quiztivity);
const res = await fetch(`/api/v1/quiztivity/${quiztivity.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: stringification
});
if (res.ok) {
await goto('/dashboard');
} else {
alert("Couldn't save");
}
saving = false;
};
</script>
<div class="h-full">
<Editor bind:data={quiztivity} on:save={save_quiztivity} bind:saving />
</div>
{#if shares_menu_open}
<SharesPopover id={quiztivity.id} bind:open={shares_menu_open} />
{/if}
@@ -0,0 +1,24 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import type { PageLoad } from './$types';
import { error } from '@sveltejs/kit';
import type { Data } from '$lib/quiztivity/types';
export const load = (async ({ url, fetch }) => {
const id = url.searchParams.get('id');
if (!id) {
throw error(400, 'id missing');
}
const resp = await fetch(`/api/v1/quiztivity/${id}`);
if (!resp.ok) {
throw error(404, 'quiztivity not found');
}
const data: Data = await resp.json();
return {
quiztivity: data
};
}) satisfies PageLoad;
@@ -0,0 +1,53 @@
<!--
- 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 { navbarVisible } from '$lib/stores';
import { page } from '$app/stores';
import { getLocalization } from '$lib/i18n';
navbarVisible.set(true);
let status = $page.status;
const { t } = getLocalization();
</script>
<!--
- 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/.
-->
<!--
- 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/.
-->
<svelte:head>
<title>{$t('words.error')} - {status}</title>
</svelte:head>
<h1 class="text-6xl text-center">{status}</h1>
{#if status === 404}
<p class="text-center">
{$t('error_page.404_text')}
</p>
{:else if status === 410}
<p class="text-center">
{$t('quiztivity.share_expired')}
</p>
{:else}
<p class="text-center">
{$t('error_page.unknown_error_text')}
</p>
{/if}
<div class="flex justify-center mt-8">
<img
class="rounded-lg"
src="https://http.cat/{status}"
alt="Cat representing the {status}-http error code"
/>
</div>
@@ -0,0 +1,42 @@
<!--
- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<script lang="ts">
import type { PageData } from './$types';
import type { QuizTivityPage } from '$lib/quiztivity/types';
import { QuizTivityTypes } from '$lib/quiztivity/types';
import NavigationBar from './navigation_bar.svelte';
export let data: PageData;
let quiztivity = data.quiztivity;
for (let i = 0; i < quiztivity.pages.length; i++) {
const id = (Math.random() + 1).toString(36).substring(7);
const type: QuizTivityTypes = quiztivity.pages[i].type as QuizTivityTypes;
quiztivity.pages[i] = { ...quiztivity.pages[i], id, type };
}
let current_slide_index = 0;
let current_slide: QuizTivityPage = quiztivity.pages[current_slide_index];
$: current_slide = quiztivity.pages[current_slide_index];
</script>
<div class="w-full h-full flex flex-col overflow-scroll">
<div class="w-full h-full">
{#if current_slide.type === QuizTivityTypes.MARKDOWN}
{#await import('$lib/quiztivity/components/markdown/play.svelte') then c}
<svelte:component this={c.default} data={current_slide.data} />
{/await}
{:else if current_slide.type === QuizTivityTypes.MEMORY}
{#await import('$lib/quiztivity/components/memory/play.svelte') then c}
<svelte:component this={c.default} data={current_slide.data} />
{/await}
{:else if current_slide.type === QuizTivityTypes.ABCD}
{#await import('$lib/quiztivity/components/abcd/play.svelte') then c}
<svelte:component this={c.default} data={current_slide.data} />
{/await}
{/if}
</div>
<NavigationBar bind:current_slide_index question_count={quiztivity.pages.length} />
</div>
@@ -0,0 +1,30 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import type { PageLoad } from './$types';
import { error } from '@sveltejs/kit';
import type { Data } from '$lib/quiztivity/types';
export const load = (async ({ url, fetch }) => {
const id = url.searchParams.get('id');
const share = url.searchParams.get('share') === 'true';
if (!id) {
throw error(400, 'id missing');
}
let resp: Response;
if (share) {
resp = await fetch(`/api/v1/quiztivity/shares/${id}`);
} else {
resp = await fetch(`/api/v1/quiztivity/${id}`);
}
if (!resp.ok) {
throw error(resp.status);
}
const data: Data = await resp.json();
return {
quiztivity: data
};
}) satisfies PageLoad;
@@ -0,0 +1,36 @@
<!--
- 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 BrownButton from '$lib/components/buttons/brown.svelte';
import { getLocalization } from '$lib/i18n';
export let current_slide_index: number;
export let question_count: number;
const { t } = getLocalization();
</script>
<div class="mt-auto h-[7vh] bg-opacity-50 bg-black shadow-2xl flex justify-between">
<div class="my-auto justify-start ml-4">
<BrownButton
disabled={current_slide_index < 1}
on:click={() => {
current_slide_index -= 1;
}}>{$t('words.back')}</BrownButton
>
</div>
<div class="my-auto">
{current_slide_index + 1}/{question_count}
</div>
<div class="my-auto justify-end mr-4">
<BrownButton
disabled={current_slide_index === 1}
on:click={() => {
current_slide_index += 1;
}}>{$t('words.next')}</BrownButton
>
</div>
</div>
@@ -0,0 +1,14 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load = (({ params }) => {
const quiz_id = params.share_id;
throw redirect(301, `/quiztivity/play?id=${quiz_id}&share=true`);
}) satisfies PageServerLoad;
+2 -1
View File
@@ -253,7 +253,8 @@
{#if game_data.questions[selected_question].image !== null}
<div>
<img
src={game_data.questions[selected_question].image}
src="/api/v1/storage/download/{game_data.questions[selected_question]
.image}"
class="max-h-[20vh] object-cover mx-auto mb-8 w-auto"
alt="Content for Question"
/>
@@ -5,6 +5,9 @@
-->
<script lang="ts">
import type { Question } from '$lib/quiz_types';
import { getLocalization } from '$lib/i18n';
const { t } = getLocalization();
export let questions: Question[];
export let answers: {
@@ -41,10 +44,12 @@
<div class="w-full">
<div class="flex justify-center w-full">
<p class="text-3xl w-5/6 text-center">
The quiz with the title '<strong>{title}</strong>' was played on
<strong>{new Date(timestamp).toLocaleString()}</strong>
with <strong>{usernames.length}</strong> players. The players achieved an average score
of <strong>{get_average_final_score()}</strong>.
{$t('results_page.general_overview.sentence', {
title,
date: new Date(timestamp).toLocaleString(),
player_count: usernames.length,
average_score: get_average_final_score()
})}
</p>
</div>
</div>
@@ -25,7 +25,7 @@
<th class="border-r dark:border-gray-500 p-1 mx-auto border-gray-300"
>{$t('result_page.player_name')}
</th>
<th class="p-1 mx-auto">Player Score</th>
<th class="p-1 mx-auto">{$t('result_page.player_score')}</th>
{#if Object.keys(custom_field).length !== 0}
<th class="border-l dark:border-gray-500 p-1 mx-auto border-gray-300"
>{$t('result_page.custom_field')}
@@ -6,10 +6,11 @@
<script lang="ts">
import { QuizQuestionType } from '$lib/quiz_types';
import { getLocalization } from '$lib/i18n';
import type { Question } from '$lib/quiz_types';
const { t } = getLocalization();
export let question;
export let question: Question;
interface Answer {
username: string;
@@ -24,8 +25,23 @@
const get_answer_count_for_answer = (answer: string): number => {
let count = 0;
for (const a of answers) {
if (a.answer === answer) {
let answer_id = 0;
if (question.type === QuizQuestionType.CHECK) {
for (let i = 0; i < question.answers.length; i++) {
if (answer === question.answers[i].answer) {
answer_id = i;
break;
}
}
}
for (let i = 0; i < answers.length; i++) {
const a = answers[i];
if (question.type === QuizQuestionType.CHECK) {
console.log(a.answer.includes('2'), answer_id);
if (a.answer.includes(String(answer_id))) {
count++;
}
} else if (a.answer === answer) {
count++;
}
}
@@ -79,8 +95,8 @@
<th class="p-1 mx-auto">{$t('words.answer')} </th>
{#if question.type !== QuizQuestionType.VOTING}
<th class="border-l dark:border-gray-500 p-1 mx-auto border-gray-300"
>{$t('words.correct')}?</th
>
>{$t('words.correct')}?
</th>
{/if}
</tr>
{#each answers as answer}
+12 -3
View File
@@ -7,6 +7,7 @@
import { getLocalization } from '$lib/i18n';
const { t } = getLocalization();
import SearchCard from '$lib/search-card.svelte';
import { onMount } from 'svelte';
let search_term = '';
let resp_data = null;
@@ -26,10 +27,18 @@
resp_data_temp = resp_data_temp.replaceAll('<em>', '<mark>');
resp_data_temp = resp_data_temp.replaceAll('</em>', '</mark>');
resp_data = JSON.parse(resp_data_temp);
const url = new URLSearchParams(window.location.search);
url.set('q', search_term);
history.pushState(null, null, '?' + url.toString());
} else {
console.error('Error!', res.status);
}
};
onMount(() => {
const url = new URLSearchParams(window.location.search);
search_term = url.get('q') ?? '';
submit();
});
</script>
<svelte:head>
@@ -52,7 +61,7 @@
bind:value={search_term}
/>
<button
class="inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out flex items-center disabled:opacity-50 disabled:cursor-not-allowed"
class="px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out flex items-center disabled:opacity-50 disabled:cursor-not-allowed"
id="button-addon2"
disabled={search_term.length <= 2}
type="submit"
@@ -94,9 +103,9 @@
Not finding what you are looking for? Search on <a
class="underline"
href="https://create.kahoot.it/search?query={search_term}&tags=test&filter=filter%3D1"
target="_blank">KAHOOT!</a
target="_blank">Kahoot!</a
>
and <a href="/import" class="underline">Import</a> it!
and <a href="/import" class="underline">import</a> it!
</p>
</div>
{/if}
@@ -40,7 +40,7 @@
<div class="grid grid-cols-6 h-full">
<div class="pl-2">
{#if data.user.username === undefined}
<h2 class="text-4xl text-center p-4">User not found</h2>
<h2 class="text-4xl text-center p-4">{$t('public_user_page.user_not_found')}</h2>
{:else}
<div class="flex justify-center">
<img src={`/api/v1/users/avatar/${data.user.id}`} alt="profile" />
@@ -49,7 +49,9 @@
@{data.user.username}
</h2>
<p class="italic text-center">
Joined on {new Date(data.user.created_at).toLocaleDateString()}
{$t('public_user_page.joined_on', {
date: new Date(data.user.created_at).toLocaleDateString()
})}
</p>
{/if}
</div>
@@ -57,7 +59,7 @@
class="col-start-2 col-end-7 border-l border-black h-full p-4 overflow-y-scroll flex flex-col gap-4"
>
{#if data.quizzes.length === 0}
<p class="text-center text-4xl">No original quizzes found...</p>
<p class="text-center text-4xl">{$t('public_user_page.no_original_quizzes')}</p>
{:else}
{#each data.quizzes as quiz}
<div
@@ -80,7 +82,7 @@
<div class="h-[20vh] m-auto w-auto max-h-[18vh]">
<img
class="max-h-full max-w-full block"
src={quiz.cover_image}
src="/api/v1/storage/download/{quiz.cover_image}"
alt="Not provided"
loading="lazy"
/>
@@ -13,6 +13,7 @@
import { onMount } from 'svelte';
import Spinner from '$lib/Spinner.svelte';
import GrayButton from '$lib/components/buttons/gray.svelte';
import MediaComponent from '$lib/editor/MediaComponent.svelte';
const tippy = createTippy({
arrow: true,
@@ -71,14 +72,15 @@
<p>{@html quiz.description}</p>
</div>
<p class="text-center">
Made by <a href="/user/{quiz.user_id.id}" class="underline">@{quiz.user_id.username}</a>
{$t('view_quiz_page.made_by')}
<a href="/user/{quiz.user_id.id}" class="underline">@{quiz.user_id.username}</a>
</p>
{#if quiz.cover_image}
<div class="flex justify-center align-middle items-center">
<div class="h-[15vh] m-auto w-auto my-3">
<img
class="max-h-full max-w-full block"
src={quiz.cover_image}
src="/api/v1/storage/download/{quiz.cover_image}"
alt="Not provided"
/>
</div>
@@ -95,7 +97,7 @@
href="https://create.kahoot.it/details/{quiz.kahoot_id}"
target="_blank"
>
View on <i>Kahoot!</i>
{$t('view_quiz_page.view_on_kahoot')}
</GrayButton>
</div>
{/if}
@@ -188,7 +190,12 @@
<!-- </label>-->
{#if question.image}
<span>
<img class="pl-8" src={question.image} alt="Not provided" />
<MediaComponent
css_classes="mx-auto"
src={question.image}
alt="Not provided"
muted={true}
/>
</span>
{/if}
<p
@@ -210,7 +217,7 @@
</svg>
<span class="text-lg">{question.time}s</span>
</p>
{#if question.type === QuizQuestionType.ABCD || question.type === undefined}
{#if question.type === QuizQuestionType.ABCD || question.type === undefined || question.type === QuizQuestionType.CHECK}
<div class="grid grid-cols-2 gap-4 m-4 p-6">
{#each question.answers as answer, index_answer}
<div