🔀 Merged ClassQuizController
This commit is contained in:
@@ -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,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 {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user