major DB overhaul, api changes, lint

This commit is contained in:
NetMan 2025-04-15 19:51:13 +02:00
parent 74ba3a5023
commit d500031f34
32 changed files with 2468 additions and 949 deletions

View file

@ -4,17 +4,20 @@
This project utilizes `pnpm`, thus it is recommended This project utilizes `pnpm`, thus it is recommended
Also use [db-prawo-jazdy](https://git.mandarynki.eu/netman/db-prawo-jazdy) for running this project
```bash ```bash
pnpm install pnpm install
``` ```
# To-do: # To-do:
- [ ] re-forge database structure, script for processing, share appropriate files - [x] re-forge database structure (good for now)
- [ ] choose category - [ ] db: script for processing, share appropriate files
- [x] choose category (good for now)
- [ ] beautify website - [ ] beautify website
- [ ] better answer click recognition - [ ] better answer click recognition
- [ ] come up with how to show results appropriately - [x] come up with how to show results appropriately
- [ ] i18n - pl, en, de, ua (not all questions are not available in ua, api handle) - [ ] i18n - pl, en, de, ua (not all questions are not available in ua, api handle)
- [ ] exam (maybe also results?) warning leave message on exit (refresh) - [ ] exam (maybe also results?) warning leave message on exit (refresh)
- [ ] lazy loading - [ ] lazy loading

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import "~/assets/css/main.css"; import '~/assets/css/main.css';
</script> </script>
<template> <template>

View file

@ -1,85 +0,0 @@
<script lang="ts" setup>
defineProps<{
question: AdvancedQuestion | undefined;
}>();
const abc_model = defineModel();
</script>
<template>
<div
class="flex flex-col gap-5 border-t px-4 py-5 border-slate-300 bg-slate-100"
>
<div class="text-xl">{{ question?.pytanie }}</div>
<div>
<div class="flex flex-col gap-3">
<input
type="radio"
name="abc"
id="odp_a"
v-model="abc_model"
value="a"
class="hidden"
/>
<label for="odp_a">
<div
:class="`btn btn-primary btn-lg ${
abc_model == 'a' ? ' !btn-secondary' : ''
}`"
>
A
</div>
<span class="block">{{ question?.odp_a }}</span>
</label>
<input
type="radio"
name="abc"
id="odp_b"
v-model="abc_model"
value="b"
class="hidden"
/>
<label for="odp_b">
<div
:class="`btn btn-primary btn-lg ${
abc_model == 'b' ? ' !btn-secondary' : ''
}`"
>
B
</div>
<span class="block">{{ question?.odp_b }}</span>
</label>
<input
type="radio"
name="abc"
id="odp_c"
v-model="abc_model"
value="c"
class="hidden"
/>
<label for="odp_c">
<div
:class="`btn btn-primary btn-lg ${
abc_model == 'c' ? ' !btn-secondary' : ''
}`"
>
C
</div>
<span class="block">{{ question?.odp_c }}</span>
</label>
</div>
</div>
</div>
</template>
<style scoped>
label {
@apply cursor-pointer flex flex-row gap-2 items-center;
&:hover {
@apply bg-slate-200;
}
}
span {
@apply text-lg;
}
</style>

View file

@ -1,31 +0,0 @@
<script lang="ts" setup>
defineProps<{
question: BasicQuestion | undefined;
}>();
const tak_nie_model = defineModel();
</script>
<template>
<div
class="flex flex-col gap-6 border-t px-4 py-5 border-slate-300 bg-slate-100"
>
<div class="text-xl">{{ question?.pytanie }}</div>
<div>
<div class="flex flex-row justify-around">
<input
type="radio"
name="tak_nie"
v-for="tn in ['tak', 'nie']"
:id="`odp_${tn}`"
v-model="tak_nie_model"
:value="tn"
class="btn btn-primary btn-xl"
:aria-label="tn.toUpperCase()"
:class="`${tak_nie_model == tn ? ' !btn-secondary' : ''}`"
:checked="tak_nie_model == tn"
/>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,10 @@
<script lang="ts" setup></script>
<template>
<div class="flex flex-col gap-1">
<span class="text-lg"><slot name="title" /></span>
<div class="info-little-box w-full text-center">
<slot name="count" />
</div>
</div>
</template>

View file

@ -2,7 +2,7 @@
<div <div
class="flex min-h-dvh justify-center items-center text-5xl flex-col gap-10" class="flex min-h-dvh justify-center items-center text-5xl flex-col gap-10"
> >
<span class="loading loading-spinner loading-xl scale-[2.5] block"></span> <span class="loading loading-spinner loading-xl scale-[2.5] block" />
<span class="block">Ładowanie</span> <span class="block">Ładowanie</span>
</div> </div>
</template> </template>

View file

@ -2,13 +2,15 @@
const runtimeConfig = useRuntimeConfig(); const runtimeConfig = useRuntimeConfig();
const cdnUrl = runtimeConfig.public.cdn_url; const cdnUrl = runtimeConfig.public.cdn_url;
defineProps<{ const props = defineProps<{
media: { media: string | null | undefined;
fileName: string | undefined;
fileType: string | undefined;
ogName: string | null | undefined;
};
}>(); }>();
const mediaSplit = computed(() => {
const dotSplit = props.media?.split('.');
const extension = dotSplit?.pop()?.toLowerCase();
return [dotSplit?.join('.'), extension];
});
</script> </script>
<template> <template>
@ -18,20 +20,13 @@ defineProps<{
<!-- Reserved for getting to know the question (20s) in basic questions section <!-- Reserved for getting to know the question (20s) in basic questions section
src="~/public/placeholder.svg" --> src="~/public/placeholder.svg" -->
<img <img
:src="cdnUrl + media.ogName" v-if="mediaSplit[1] === 'jpg'"
:key="`${media}-photo`"
:src="cdnUrl + media"
alt="" alt=""
v-if="media.fileType == 'jpg'"
:key="`${media.ogName}-photo`"
/>
<video
v-else-if="media.fileType == 'wmv'"
:key="`${media.ogName}-video`"
autoplay
>
<source
:src="cdnUrl + [media.fileName, 'mp4'].join('.')"
type="video/mp4"
/> />
<video v-else-if="mediaSplit[1] === 'wmv'" :key="`${media}-video`" autoplay>
<source :src="cdnUrl + mediaSplit[0] + '.mp4'" type="video/mp4" />
</video> </video>
<span v-else class="text-4xl font-bold flex items-center justify-center" <span v-else class="text-4xl font-bold flex items-center justify-center"
>Brak mediów</span >Brak mediów</span

View file

@ -1,40 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import { VueFinalModal } from "vue-final-modal"; const myModal = useTemplateRef('myModal');
defineProps<{ onMounted(() => {
title?: string; myModal.value?.showModal();
}>(); });
const emit = defineEmits<{
(e: "homepage"): void;
(e: "newExam"): void;
(e: "close"): void;
}>();
</script> </script>
<template> <template>
<VueFinalModal <dialog
class="flex justify-center items-center backdrop-blur-sm" ref="myModal"
content-class="flex flex-col p-3 bg-white rounded-md gap-3" class="flex justify-center items-center backdrop-blur-sm modal"
overlay-transition="vfm-fade"
content-transition="vfm-fade"
:click-to-close="false"
:esc-to-close="false"
> >
<h1 class="text-[1.5rem]">{{ title }}</h1> <div
class="flex flex-col p-3 bg-white rounded-md gap-3 modal-box min-w-fit"
>
<h1 class="text-[1.5rem]">
<slot name="title" />
</h1>
<div class="*:inline">Kategoria: <slot name="category" /></div> <div class="*:inline">Kategoria: <slot name="category" /></div>
<div class="*:inline">Punkty: <slot name="points" /> / 74</div> <div class="*:inline">Punkty: <slot name="points" /> / 74</div>
<div class="*:inline">Wynik: <slot name="resultTrueFalse" /></div> <div class="*:inline">Wynik: <slot name="resultTrueFalse" /></div>
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<button class="btn btn-soft" @click="emit('homepage')"> <NuxtLink to="/" class="btn btn-soft">Wróć na stronę główną</NuxtLink>
Wróć na stronę główną <NuxtLink to="/exam" class="btn btn-outline">
</button>
<button class="btn btn-outline" @click="emit('newExam')">
Rozpocznij jeszcze raz Rozpocznij jeszcze raz
</button> </NuxtLink>
<button class="btn btn-neutral" @click="emit('close')"> <button class="btn btn-neutral" @click="myModal?.close()">
Przejrzyj odpowiedzi Przejrzyj odpowiedzi
</button> </button>
</div> </div>
</VueFinalModal> </div>
</dialog>
</template> </template>

View file

@ -1,105 +0,0 @@
<script setup lang="ts">
import { range } from "lodash";
const props = defineProps<{
result: ResultEndType;
countBasic: number;
countAdvanced: number;
question: BasicQuestion | AdvancedQuestion | undefined;
questionBasic: BasicQuestion | undefined;
questionAdvanced: AdvancedQuestion | undefined;
now: string | null | undefined;
}>();
const isBasic = computed(() => props.now == "basic");
const isAdvanced = computed(() => props.now == "advanced");
</script>
<template>
<div
class="flex flex-col items-stretch p-4 gap-6 border-l border-slate-300 bg-slate-100"
>
<button @click="$emit('nav', '/')" class="btn btn-warning btn-xl">
Wróć na stronę główną
</button>
<div class="flex flex-row gap-4 *:flex-1 w-full flex-wrap">
<button class="btn btn-info btn-lg" @click="$emit('change-now', 'basic')">
Pytania podstawowe
</button>
<button
class="btn btn-info btn-lg"
@click="$emit('change-now', 'advanced')"
>
Pytania specjalistyczne
</button>
</div>
<div
class="grid grid-cols-[repeat(auto-fit,50px)] gap-2 justify-around w-full"
>
<input
type="radio"
:aria-label="(num + 1).toString()"
class="btn btn-md"
name="chooser"
v-for="num in range(0, 20)"
@click="
$emit('change-now', 'basic');
$emit('change-count', num);
"
:class="`${
result.basic[num].question?.poprawna_odp.toLowerCase() ==
result.basic[num].chosen_answer
? 'btn-success'
: 'btn-error'
}`"
:checked="isBasic ? countBasic == num : false"
:key="`choose-${num}-basic`"
/>
</div>
<div
class="grid grid-cols-[repeat(auto-fit,50px)] gap-2 justify-around w-full"
>
<input
type="radio"
:aria-label="`${num + 1}`"
class="btn btn-md"
name="chooser"
v-for="num in range(0, 12)"
@click="
$emit('change-now', 'advanced');
$emit('change-count', num);
"
:class="`${
result.advanced[num].question?.poprawna_odp.toLowerCase() ==
result.advanced[num].chosen_answer
? 'btn-success'
: 'btn-error'
}`"
:checked="isAdvanced ? countAdvanced == num : false"
:key="`choose-${num}-advanced`"
/>
</div>
<div class="text-center text-4xl flex flex-col gap-5">
<span class="block">Poprawna odpowiedź</span>
<span class="block">{{ question?.poprawna_odp }}</span>
<span class="block">Zaznaczona odpowiedź</span>
<span class="block">
{{
isBasic
? result.basic[countBasic].chosen_answer.trim() == ""
? "BrakBasic"
: result.basic[countBasic].chosen_answer
: isAdvanced
? result.advanced[countAdvanced].chosen_answer.trim() == ""
? "BrakAdvanced"
: result.advanced[countAdvanced].chosen_answer
: "Błąd"
}}
</span>
</div>
<div class="flex-1"></div>
<button @click="$emit('nav', '/exam')" class="btn btn-warning btn-xl">
Rozpocznij jeszcze raz
</button>
</div>
</template>

View file

@ -1,19 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Duration } from "date-fns"; import type { Duration } from 'date-fns';
const props = defineProps<{ const props = defineProps<{
points: number | undefined; points: number | null | undefined;
category: string | undefined; category: string | undefined;
timeRemaining?: Duration | undefined; timeRemaining?: Duration | undefined;
}>(); }>();
const timeRemainingFriendly = computed(() => { const timeRemainingFriendly = computed(() => {
if (typeof props.timeRemaining !== "undefined") { if (typeof props.timeRemaining !== 'undefined') {
let seconds = props.timeRemaining.seconds ?? 0; const seconds = props.timeRemaining.seconds ?? 0;
return `${props.timeRemaining.minutes ?? 0}:${ return `${props.timeRemaining.minutes ?? 0}:${
seconds >= 0 && seconds < 10 ? 0 : "" seconds >= 0 && seconds < 10 ? 0 : ''
}${seconds ?? 0}`; }${seconds ?? 0}`;
} }
return null;
}); });
</script> </script>
@ -29,7 +30,9 @@ const timeRemainingFriendly = computed(() => {
</div> </div>
<div> <div>
<span class="block">Aktualna kategoria</span> <span class="block">Aktualna kategoria</span>
<div class="info-little-box">{{ category }}</div> <div class="info-little-box">
{{ category }}
</div>
</div> </div>
<div v-if="typeof timeRemaining !== 'undefined'"> <div v-if="typeof timeRemaining !== 'undefined'">
<span class="block">Czas do końca egzaminu</span> <span class="block">Czas do końca egzaminu</span>

View file

@ -1,45 +1,41 @@
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps<{ defineProps<{
result: ResultEndType;
countBasic: number; countBasic: number;
countAdvanced: number; countAdvanced: number;
dataBasic: BasicQuestion[] | null;
dataAdvanced: AdvancedQuestion[] | null;
now: string | null | undefined; now: string | null | undefined;
ending: boolean; ending: boolean;
}>(); }>();
const isBasic = computed(() => props.now == "basic"); const emit = defineEmits<{
const isAdvanced = computed(() => props.now == "advanced"); endExam: [];
nextQuestion: [];
}>();
</script> </script>
<template> <template>
<div <div
class="flex flex-col items-stretch p-4 gap-10 border-l border-slate-300 bg-slate-100" class="flex flex-col items-stretch p-4 gap-10 border-l border-slate-300 bg-slate-100"
> >
<button @click="$emit('end-exam')" class="btn btn-warning btn-xl"> <button class="btn btn-warning btn-xl" @click="emit('endExam')">
Zakończ egzamin Zakończ egzamin
</button> </button>
<div class="flex flex-row gap-6 *:flex-1 w-full"> <div class="flex flex-row gap-6 *:flex-1 w-full">
<div class="flex flex-col gap-1" :class="isBasic ? '' : 'opacity-45'"> <CurrentQuestionCount
<span class="text-lg">Pytania podstawowe</span> :class="now === 'basic' ? 'font-semibold' : 'opacity-45'"
<div
class="info-little-box w-full text-center"
:class="isBasic ? 'font-semibold' : ''"
> >
{{ countBasic + 1 }} / {{ dataBasic?.length }} <template #title> Pytania podstawowe </template>
</div> <template #count> {{ countBasic + 1 }} / 20 </template>
</div> </CurrentQuestionCount>
<div class="flex flex-col gap-1" :class="isAdvanced ? '' : 'opacity-45'">
<span class="text-lg">Pytania specjalistyczne</span> <CurrentQuestionCount
<div :class="now === 'advanced' ? 'font-semibold' : 'opacity-45'"
class="info-little-box w-full text-center"
:class="isAdvanced ? 'font-semibold' : ''"
> >
{{ countAdvanced + 1 }} / {{ dataAdvanced?.length }} <template #title> Pytania specjalistyczne </template>
</div> <template #count> {{ countAdvanced + 1 }} / 12 </template>
</div> </CurrentQuestionCount>
</div> </div>
<div class="text-center text-xl flex flex-col gap-2"> <div class="text-center text-xl flex flex-col gap-2">
<span>Czas na zapoznanie się z pytaniem</span> <span>Czas na zapoznanie się z pytaniem</span>
<div class="flex flex-row items-stretch gap-2"> <div class="flex flex-row items-stretch gap-2">
@ -49,11 +45,12 @@ const isAdvanced = computed(() => props.now == "advanced");
class="progress progress-warning w-full h-full" class="progress progress-warning w-full h-full"
value="50" value="50"
max="100" max="100"
></progress> />
<span class="block set-translate z-10 text-black text-2xl">20s</span> <span class="block set-translate z-10 text-black text-2xl">20s</span>
</div> </div>
</div> </div>
</div> </div>
<div class="text-center text-xl flex flex-col gap-2"> <div class="text-center text-xl flex flex-col gap-2">
<span>Czas na udzielenie odpowiedzi</span> <span>Czas na udzielenie odpowiedzi</span>
<div class="h-9 relative"> <div class="h-9 relative">
@ -61,18 +58,17 @@ const isAdvanced = computed(() => props.now == "advanced");
class="progress progress-warning w-full h-full" class="progress progress-warning w-full h-full"
value="50" value="50"
max="100" max="100"
></progress> />
<span class="block set-translate z-10 text-black text-2xl">15s</span> <span class="block set-translate z-10 text-black text-2xl">15s</span>
</div> </div>
</div> </div>
<div class="max-h-[150px] overflow-y-scroll">
{{ result }} <div class="flex-1" />
</div>
<div class="flex-1"></div>
<button <button
@click="$emit('next-question')"
class="btn btn-warning btn-xl" class="btn btn-warning btn-xl"
:disabled="ending" :disabled="ending"
@click="emit('nextQuestion')"
> >
Następne pytanie Następne pytanie
</button> </button>
@ -80,7 +76,7 @@ const isAdvanced = computed(() => props.now == "advanced");
</template> </template>
<style> <style>
.progressive { /*.progressive {
animation: progressZapoznanie 20s linear; animation: progressZapoznanie 20s linear;
} }
@ -91,5 +87,5 @@ const isAdvanced = computed(() => props.now == "advanced");
100% { 100% {
width: 100%; width: 100%;
} }
} }*/
</style> </style>

View file

@ -0,0 +1,85 @@
<script setup lang="ts">
import { range } from 'lodash';
defineProps<{
result: ResultEndType;
countBasic: number;
countAdvanced: number;
now: string | null | undefined;
}>();
const emit = defineEmits<{
changeNow: [value: string];
changeCount: [num: number];
}>();
</script>
<template>
<div
class="flex flex-col items-stretch p-4 gap-6 border-l border-slate-300 bg-slate-100"
>
<NuxtLink to="/" class="btn btn-warning btn-xl">
Wróć na stronę główną
</NuxtLink>
<button class="btn btn-info btn-lg" @click="emit('changeNow', 'basic')">
Pytania podstawowe
</button>
<div
class="grid grid-cols-[repeat(auto-fit,50px)] gap-2 justify-around w-full"
>
<input
v-for="num in range(0, 20)"
:key="`choose-${num}-basic`"
type="radio"
:aria-label="(num + 1).toString()"
class="btn btn-md"
name="chooser"
:class="`${
result.basic[num].question?.correct_answer ===
result.basic[num].chosen_answer
? 'btn-success'
: 'btn-error'
}`"
:checked="now === 'basic' ? countBasic === num : false"
@click="
emit('changeNow', 'basic');
emit('changeCount', num);
"
/>
</div>
<button class="btn btn-info btn-lg" @click="emit('changeNow', 'advanced')">
Pytania specjalistyczne
</button>
<div
class="grid grid-cols-[repeat(auto-fit,50px)] gap-2 justify-around w-full"
>
<input
v-for="num in range(0, 12)"
:key="`choose-${num}-advanced`"
type="radio"
:aria-label="`${num + 1}`"
class="btn btn-md"
name="chooser"
:class="`${
result.advanced[num].question?.correct_answer ===
result.advanced[num].chosen_answer
? 'btn-success'
: 'btn-error'
}`"
:checked="now === 'advanced' ? countAdvanced === num : false"
@click="
emit('changeNow', 'advanced');
emit('changeCount', num);
"
/>
</div>
<div class="flex-1" />
<NuxtLink to="/exam" class="btn btn-warning btn-xl">
Rozpocznij jeszcze raz
</NuxtLink>
</div>
</template>

View file

@ -0,0 +1,57 @@
<script lang="ts" setup>
defineProps<{
question: AdvancedQuestion | undefined;
}>();
const answer = defineModel<string | null | undefined>();
</script>
<template>
<div
class="flex flex-col gap-5 border-t px-4 py-5 border-slate-300 bg-slate-100"
>
<div class="text-xl">
{{ question?.text }}
</div>
<div class="flex flex-col gap-3">
<div
v-for="[element, value] of Object.entries({
A: question?.answer_a,
B: question?.answer_b,
C: question?.answer_c,
})"
:key="`btn_answer_${element}_${value}`"
>
<input
:id="`odp_${element}`"
v-model="answer"
type="radio"
name="abc"
:value="element"
class="hidden"
/>
<label :for="`odp_${element}`">
<div
:class="answer === element ? ' !btn-secondary' : ''"
class="btn btn-primary btn-lg"
>
{{ element }}
</div>
<span class="block">{{ value }}</span>
</label>
</div>
</div>
</div>
</template>
<style scoped>
label {
@apply cursor-pointer flex flex-row gap-2 items-center;
&:hover {
@apply bg-slate-200;
}
}
span {
@apply text-lg;
}
</style>

View file

@ -0,0 +1,40 @@
<script lang="ts" setup>
defineProps<{
question: BasicQuestion | undefined;
}>();
const answer = defineModel<string | null | undefined>();
</script>
<template>
<div
class="flex flex-col gap-6 border-t px-4 py-5 border-slate-300 bg-slate-100"
>
<div class="text-xl">
{{ question?.text }}
</div>
<div>
<div class="flex flex-row justify-around">
<input
v-for="[element, value] of Object.entries({ TAK: true, NIE: false })"
:id="`odp_${element}`"
:key="`btn_answer_${element}`"
v-model="answer"
type="radio"
name="tak_nie"
:value="value"
class="btn btn-primary btn-xl"
:aria-label="element"
:class="
answer == null
? false
: answer === value?.toString()
? '!btn-secondary'
: ''
"
:checked="answer == null ? false : answer === value?.toString()"
/>
</div>
</div>
</div>
</template>

View file

@ -1,11 +1,11 @@
import 'dotenv/config'; import 'dotenv/config';
import { defineConfig } from "drizzle-kit"; import { defineConfig } from 'drizzle-kit';
export default defineConfig({ export default defineConfig({
dialect: "postgresql", dialect: 'postgresql',
schema: "./src/db/schema.ts", schema: './src/db/schema.ts',
out: "./drizzle", out: './drizzle',
dbCredentials: { dbCredentials: {
url: process.env.DATABASE_URL!, url: process.env.DATABASE_URL!,
} },
}); });

8
eslint.config.mjs Normal file
View file

@ -0,0 +1,8 @@
// @ts-check
import eslintConfigPrettier from 'eslint-config-prettier/flat';
import withNuxt from './.nuxt/eslint.config.mjs';
export default withNuxt(
// Your custom configs here
eslintConfigPrettier,
);

View file

@ -1,10 +1,9 @@
export default defineNuxtRouteMiddleware((to, from) => { export default defineNuxtRouteMiddleware(async () => {
const examStore = useExamStore(); const examStore = useExamStore();
if (examStore.category != "") { if (examStore.category === '') {
examStore.mildReset();
} else {
examStore.resetExam(); examStore.resetExam();
return navigateTo("/"); return navigateTo('/');
} }
examStore.mildReset();
}); });

View file

@ -1,8 +1,8 @@
export default defineNuxtRouteMiddleware((to, from) => { export default defineNuxtRouteMiddleware(() => {
const examStore = useExamStore(); const examStore = useExamStore();
if (!examStore.end) { if (!examStore.end) {
examStore.resetExam(); examStore.resetExam();
return navigateTo("/"); return navigateTo('/');
} }
}); });

View file

@ -1,15 +1,18 @@
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
import "dotenv/config"; import 'dotenv/config';
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: "2024-11-01", modules: [
devtools: { enabled: true }, '@nuxtjs/tailwindcss',
modules: ["@nuxtjs/tailwindcss", "@nuxt/fonts", "@pinia/nuxt"], '@nuxt/fonts',
'@pinia/nuxt',
'@nuxt/eslint',
],
ssr: true, ssr: true,
css: ["vue-final-modal/style.css"],
imports: { imports: {
dirs: ["types/*.ts", "store/*.ts", "types/**/*.ts"], dirs: ['types/*.ts', 'store/*.ts', 'types/**/*.ts'],
}, },
devtools: { enabled: true },
// Transition (later) // Transition (later)
// app: { // app: {
// pageTransition: { name: "page", mode: "out-in" }, // pageTransition: { name: "page", mode: "out-in" },
@ -20,6 +23,17 @@ export default defineNuxtConfig({
}, },
}, },
routeRules: { routeRules: {
"/": { prerender: true }, '/': { prerender: true },
},
compatibilityDate: '2024-11-01',
eslint: {
config: {
stylistic: {
indent: 2,
semi: true,
quotes: 'single',
jsx: false,
},
},
}, },
}); });

View file

@ -7,30 +7,39 @@
"dev": "nuxt dev", "dev": "nuxt dev",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"pretty": "prettier --write \"./**/*.{js,mjs,ts,vue,json}\"",
"tsc": "nuxi typecheck"
}, },
"dependencies": { "dependencies": {
"@nuxt/fonts": "0.11.0", "@nuxt/fonts": "0.11.1",
"@nuxtjs/tailwindcss": "6.13.1", "@nuxtjs/tailwindcss": "6.13.1",
"@pinia/nuxt": "0.10.1", "@pinia/nuxt": "0.11.0",
"array-shuffle": "^3.0.0", "daisyui": "^5.0.20",
"daisyui": "^5.0.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv": "^16.4.7", "dotenv": "^16.5.0",
"drizzle-orm": "^0.40.0", "drizzle-orm": "^0.42.0",
"eslint": "^9.24.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"nuxt": "~3.15.4", "nuxt": "~3.15.4",
"pg": "^8.13.3", "pg": "^8.14.1",
"pinia": "^3.0.1", "pinia": "^3.0.2",
"vue": "latest", "vue": "latest",
"vue-final-modal": "^4.5.5",
"vue-router": "latest" "vue-router": "latest"
}, },
"packageManager": "pnpm@10.4.1+sha512.c753b6c3ad7afa13af388fa6d808035a008e30ea9993f58c6663e2bc5ff21679aa834db094987129aa4d488b86df57f7b634981b2f827cdcacc698cc0cfb88af", "packageManager": "pnpm@10.4.1+sha512.c753b6c3ad7afa13af388fa6d808035a008e30ea9993f58c6663e2bc5ff21679aa834db094987129aa4d488b86df57f7b634981b2f827cdcacc698cc0cfb88af",
"devDependencies": { "devDependencies": {
"array-shuffle": "^3.0.0",
"@nuxt/eslint": "1.3.0",
"@types/lodash": "^4.17.16", "@types/lodash": "^4.17.16",
"@types/pg": "^8.11.11", "@types/pg": "^8.11.13",
"drizzle-kit": "^0.30.5", "drizzle-kit": "^0.30.5",
"tsx": "^4.19.3" "eslint-config-prettier": "^10.1.2",
"prettier": "^3.5.3",
"tsx": "^4.19.3",
"typescript": "^5.8.3",
"vite-plugin-eslint2": "^5.0.3"
} }
} }

View file

@ -1,9 +1,12 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useExamStore } from "~/store/examStore"; import { intervalToDuration, addMinutes, addSeconds, isEqual } from 'date-fns';
import { useExamStore } from '~/store/examStore';
import { intervalToDuration, addMinutes, addSeconds, isEqual } from "date-fns"; definePageMeta({ middleware: ['exam'] });
definePageMeta({ middleware: ["exam"] }); useHead({
title: 'Pytanie 1/20',
});
const nowTime = ref(new Date()); const nowTime = ref(new Date());
const timeEnd = addMinutes(new Date(), 25); const timeEnd = addMinutes(new Date(), 25);
@ -12,7 +15,7 @@ const timeRemainingTotal = computed(() =>
intervalToDuration({ intervalToDuration({
start: nowTime.value, start: nowTime.value,
end: timeEnd, end: timeEnd,
}) }),
); );
// const timeRemainingQuestion - to implement // const timeRemainingQuestion - to implement
@ -28,10 +31,6 @@ onMounted(() => {
const examStore = useExamStore(); const examStore = useExamStore();
useHead({
title: "Pytanie 1/20",
});
const { const {
data: dataBasic, data: dataBasic,
error: errorBasic, error: errorBasic,
@ -55,147 +54,113 @@ const {
const countBasic = ref(0); const countBasic = ref(0);
const countAdvanced = ref(-1); const countAdvanced = ref(-1);
const now = ref("basic"); const now = ref('basic');
const answer = ref<string>('');
const tak_nie_model = ref();
const abc_model = ref();
const ending = ref(false); const ending = ref(false);
async function next() {
if (countBasic.value + 1 < dataBasic.value?.length!) {
result.value.basic.push({
question: questionBasic.value,
chosen_answer: tak_nie_model.value ?? "",
chosen_is_correct:
tak_nie_model.value == questionBasic.value?.poprawna_odp?.toLowerCase(),
liczba_pkt: questionBasic.value?.liczba_pkt,
});
tak_nie_model.value = "";
countBasic.value++;
useHead({
title: `Pytanie ${countBasic.value + 1}/${dataBasic.value?.length}`,
});
} else if (countAdvanced.value + 1 <= dataAdvanced.value?.length!) {
if (countAdvanced.value != -1) {
result.value.advanced.push({
question: questionAdvanced.value,
chosen_answer: abc_model.value ?? "",
chosen_is_correct:
abc_model.value ==
questionAdvanced.value?.poprawna_odp?.toLowerCase(),
liczba_pkt: questionAdvanced.value?.liczba_pkt,
});
} else {
now.value = "advanced";
result.value.basic.push({
question: questionBasic.value,
chosen_answer: tak_nie_model.value ?? "",
chosen_is_correct:
tak_nie_model.value ==
questionBasic.value?.poprawna_odp?.toLowerCase(),
liczba_pkt: questionBasic.value?.liczba_pkt,
});
tak_nie_model.value = "";
}
if (countAdvanced.value + 1 < dataAdvanced.value?.length!) {
countAdvanced.value++;
useHead({
title: `Pytanie ${countAdvanced.value + 1}/${
dataAdvanced.value?.length
}`,
});
}
if (countAdvanced.value == dataAdvanced.value?.length! - 1) {
ending.value = true;
}
abc_model.value = "";
}
}
function endExam() {
loading.value = true;
while (!ending.value) {
next();
}
next();
examStore.setResult(result.value);
examStore.setEnd(true);
while (true) {
if (examStore.result == result.value && examStore.end) {
return navigateTo("/result", { replace: true });
}
}
}
const questionBasic = computed<BasicQuestion | undefined>(() => const questionBasic = computed<BasicQuestion | undefined>(() =>
dataBasic.value?.at(countBasic.value) dataBasic.value?.at(countBasic.value),
); );
const questionAdvanced = computed<AdvancedQuestion | undefined>(() => const questionAdvanced = computed<AdvancedQuestion | undefined>(() =>
dataAdvanced.value?.at(countAdvanced.value) dataAdvanced.value?.at(countAdvanced.value),
); );
const question = computed(() => { const question = computed(() => {
if (now.value == "basic") { if (now.value === 'basic') return questionBasic.value;
return questionBasic.value; if (now.value === 'advanced') return questionAdvanced.value;
} else if (now.value == "advanced") { return null;
return questionAdvanced.value;
} else {
return;
}
});
const media = computed(() => {
const mediaSplit = question.value?.media?.split(".");
return {
fileType: mediaSplit?.pop()?.toLowerCase(),
fileName: mediaSplit?.join("."),
ogName: question.value?.media,
};
}); });
const result: Ref<ResultEndType> = ref({ const result: Ref<ResultEndType> = ref({
basic: [], basic: [],
advanced: [], advanced: [],
}); });
async function next() {
function pushVal() {
if (now.value === 'basic' || now.value === 'advanced') {
result.value[now.value].push({
question: question.value,
chosen_answer: answer.value,
chosen_is_correct: answer.value === question.value?.correct_answer,
});
}
answer.value = '';
}
if (now.value === 'basic') {
pushVal();
countBasic.value++;
useHead({
title: `Pytanie ${countBasic.value + 1}/20`,
});
if (countBasic.value >= 20) {
now.value = 'advanced';
countBasic.value--;
countAdvanced.value++;
}
} else if (now.value === 'advanced') {
pushVal();
countAdvanced.value++;
useHead({
title: `Pytanie ${countAdvanced.value + 1}/12`,
});
if (countAdvanced.value >= 11) {
ending.value = true;
}
}
}
function endExam() {
loading.value = true;
do {
next();
} while (!ending.value);
examStore.setResult(result.value);
examStore.setEnd(true);
while (true) {
if (examStore.result == result.value && examStore.end) {
return navigateTo('/result', { replace: true });
}
}
}
const loading = ref(false); const loading = ref(false);
</script> </script>
<template> <template>
<div> <div>
<!-- as in to transition to the next page -->
<Loading v-if="loading" /> <Loading v-if="loading" />
<div v-if="statusBasic === 'success' && statusAdvanced === 'success'"> <div v-if="statusBasic === 'success' && statusAdvanced === 'success'">
<div class="grid grid-cols-4 min-h-dvh"> <div class="grid grid-cols-4 min-h-dvh">
<div class="col-span-3 flex flex-col gap-4"> <div class="col-span-3 flex flex-col gap-4">
<TopBar <BarTop
:points="question?.liczba_pkt" :points="question?.weight"
:category="examStore.category" :category="examStore.category"
:time-remaining="timeRemainingTotal" :time-remaining="timeRemainingTotal"
/> />
<Media :media="media" /> <Media :media="question?.media_url" />
<BasicQuestionBlock <QuestionBasic
v-if="countAdvanced < 0" v-if="now === 'basic'"
v-model="answer"
:question="questionBasic" :question="questionBasic"
v-model="tak_nie_model"
/> />
<AdvancedQuestionBlock <QuestionAdvanced
v-else v-else-if="now === 'advanced'"
v-model="answer"
:question="questionAdvanced" :question="questionAdvanced"
v-model="abc_model"
/> />
</div> </div>
<RightBarExam <BarRightExam
:result="result"
:data-basic="dataBasic"
:data-advanced="dataAdvanced"
:count-basic="countBasic" :count-basic="countBasic"
:count-advanced="countAdvanced" :count-advanced="countAdvanced"
@next-question="next()"
@end-exam="endExam()"
:now="now" :now="now"
:ending="ending" :ending="ending"
@next-question="next()"
@end-exam="endExam()"
/> />
</div> </div>
</div> </div>

View file

@ -1,21 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
useHead({ useHead({
title: "Test na prawo jazdy", title: 'Test na prawo jazdy',
}); });
const categories = [ const categories = [
"A", 'A',
"B", 'B',
"C", 'C',
"D", 'D',
"T", 'T',
"AM", 'AM',
"A1", 'A1',
"A2", 'A2',
"B1", 'B1',
"C1", 'C1',
"D1", 'D1',
"PT", 'PT',
]; ];
const loading = ref(false); const loading = ref(false);
@ -26,8 +26,8 @@ function setAndGo(category: string) {
loading.value = true; loading.value = true;
examStore.setCategory(category); examStore.setCategory(category);
while (true) { while (true) {
if (examStore.category == category) { if (examStore.category === category) {
return navigateTo("/exam"); return navigateTo('/exam');
} }
} }
} }
@ -44,8 +44,9 @@ function setAndGo(category: string) {
</p> </p>
<div class="flex flex-row flex-wrap gap-2"> <div class="flex flex-row flex-wrap gap-2">
<button <button
class="btn btn-xl btn-secondary"
v-for="category in categories" v-for="category in categories"
:key="`btn-${category}`"
class="btn btn-xl btn-secondary"
@click="setAndGo(category)" @click="setAndGo(category)"
> >
{{ category }} {{ category }}

View file

@ -1,8 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ModalsContainer, useModal } from "vue-final-modal"; import ResultModal from '~/components/ResultModal.vue';
import ResultModal from "~/components/ResultModal.vue";
definePageMeta({ middleware: ["result"] }); definePageMeta({ middleware: ['result'] });
const examStore = useExamStore(); const examStore = useExamStore();
@ -10,16 +9,16 @@ const points = ref<number>(0);
examStore.result.basic.forEach((answer) => { examStore.result.basic.forEach((answer) => {
if (answer.chosen_is_correct) { if (answer.chosen_is_correct) {
points.value += answer.question?.liczba_pkt ?? 0; points.value += answer.question?.weight ?? 0;
} }
}); });
examStore.result.advanced.forEach((answer) => { examStore.result.advanced.forEach((answer) => {
if (answer.chosen_is_correct) { if (answer.chosen_is_correct) {
points.value += answer.question?.liczba_pkt ?? 0; points.value += answer.question?.weight ?? 0;
} }
}); });
const resultTrueFalse = ref(points.value >= 68 ? "pozytywny" : "negatywny"); const resultTrueFalse = ref(points.value >= 68 ? 'pozytywny' : 'negatywny');
useHead({ useHead({
title: `${ title: `${
@ -32,122 +31,90 @@ const countBasic = ref(0);
const countAdvanced = ref(0); const countAdvanced = ref(0);
const resultQuestionBasic = computed<ResultType<BasicQuestion> | undefined>( const resultQuestionBasic = computed<ResultType<BasicQuestion> | undefined>(
() => examStore.result.basic.at(countBasic.value) () => examStore.result.basic.at(countBasic.value),
); );
const resultQuestionAdvanced = computed< const resultQuestionAdvanced = computed<
ResultType<AdvancedQuestion> | undefined ResultType<AdvancedQuestion> | undefined
>(() => examStore.result.advanced.at(countAdvanced.value)); >(() => examStore.result.advanced.at(countAdvanced.value));
const questionBasic = computed<BasicQuestion | undefined>( const questionBasic = computed<BasicQuestion | undefined>(
() => resultQuestionBasic.value?.question () => resultQuestionBasic.value?.question,
); );
const questionAdvanced = computed<AdvancedQuestion | undefined>( const questionAdvanced = computed<AdvancedQuestion | undefined>(
() => resultQuestionAdvanced.value?.question () => resultQuestionAdvanced.value?.question,
); );
const now = ref("basic"); const now = ref('basic');
const question = computed(() => { const question = computed(() => {
if (now.value == "basic") { if (now.value === 'basic') {
return questionBasic.value; return questionBasic.value;
} else if (now.value == "advanced") { } else if (now.value === 'advanced') {
return questionAdvanced.value; return questionAdvanced.value;
} else { } else {
return; return null;
} }
}); });
const tak_nie_model = computed(() => const resultQuestion = computed(() => {
resultQuestionBasic.value?.chosen_answer.toLowerCase() if (now.value === 'basic') {
); return resultQuestionBasic.value;
const abc_model = computed(() => } else if (now.value === 'advanced') {
resultQuestionAdvanced.value?.chosen_answer.toLowerCase() return resultQuestionAdvanced.value;
); } else {
return null;
const media = computed(() => { }
const mediaSplit = question.value?.media?.split(".");
return {
fileType: mediaSplit?.pop()?.toLowerCase(),
fileName: mediaSplit?.join("."),
ogName: question.value?.media,
};
}); });
const { open, close } = useModal({ const answer = computed(() => resultQuestion.value?.chosen_answer);
component: ResultModal,
attrs: {
title: "Egzamin teorytyczny",
onClose() {
close();
},
onHomepage() {
return navigateTo("/");
},
onNewExam() {
return navigateTo("/exam");
},
},
slots: {
category: examStore.category,
points: `${points.value}`,
resultTrueFalse: resultTrueFalse,
},
});
open();
function changeNow(to: string) { function changeNow(to: string) {
now.value = to; now.value = to;
} }
function changeCount(num: number) { function changeCount(num: number) {
if (now.value == "basic") { if (now.value === 'basic') {
countBasic.value = num; countBasic.value = num;
} else if (now.value == "advanced") { } else if (now.value === 'advanced') {
countAdvanced.value = num; countAdvanced.value = num;
} }
} }
function nav(route: string) {
return navigateTo(route);
}
</script> </script>
<template> <template>
<div> <div>
<ModalsContainer /> <ResultModal>
<template #title>Egzamin teorytyczny</template>
<template #category>{{ examStore.category }}</template>
<template #points>{{ points }}</template>
<template #resultTrueFalse>{{ resultTrueFalse }}</template>
</ResultModal>
<div> <div>
<div class="grid grid-cols-4 min-h-dvh"> <div class="grid grid-cols-4 min-h-dvh">
<div class="col-span-3 flex flex-col gap-4"> <div class="col-span-3 flex flex-col gap-4">
<TopBar <BarTop :points="question?.weight" :category="examStore.category" />
:points="question?.liczba_pkt" <Media :media="question?.media_url" />
:category="examStore.category" <QuestionBasic
/> v-if="now === 'basic'"
<Media :media="media" /> v-model="answer"
<BasicQuestionBlock
v-if="now == 'basic'"
:question="questionBasic" :question="questionBasic"
v-model="tak_nie_model"
class="select-none z-[-1]" class="select-none z-[-1]"
/> />
<AdvancedQuestionBlock <QuestionAdvanced
v-else-if="now == 'advanced'" v-else-if="now === 'advanced'"
v-model="answer"
:question="questionAdvanced" :question="questionAdvanced"
v-model="abc_model"
class="select-none z-[-1]" class="select-none z-[-1]"
/> />
</div> </div>
<RightBarResult <BarRightResult
:result="examStore.result" :result="examStore.result"
:question="question"
:question-basic="questionBasic"
:question-advanced="questionAdvanced"
:count-basic="countBasic" :count-basic="countBasic"
:count-advanced="countAdvanced" :count-advanced="countAdvanced"
:now="now" :now="now"
@change-now="changeNow" @change-now="changeNow"
@change-count="changeCount" @change-count="changeCount"
@nav="nav"
/> />
</div> </div>
</div> </div>

View file

@ -1,7 +0,0 @@
import { createVfm } from "vue-final-modal";
export default defineNuxtPlugin((nuxtApp) => {
const vfm = createVfm() as any;
nuxtApp.vueApp.use(vfm);
});

1981
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

12
prettier.config.mjs Normal file
View file

@ -0,0 +1,12 @@
/**
* @see https://prettier.io/docs/configuration
* @type {import("prettier").Config}
*/
const config = {
trailingComma: 'all',
tabWidth: 2,
semi: true,
singleQuote: true,
};
export default config;

View file

@ -1,111 +1,79 @@
import "dotenv/config"; import 'dotenv/config';
import { drizzle } from "drizzle-orm/node-postgres"; import { drizzle } from 'drizzle-orm/node-postgres';
import { dane, punkty } from "@/src/db/schema"; import { sql, eq, and } from 'drizzle-orm';
import { sql, eq, and, or, isNotNull } from "drizzle-orm"; import arrayShuffle from 'array-shuffle';
import { AdvancedQuestion } from "~/types"; import {
import arrayShuffle from "array-shuffle"; tasks_advanced,
questions_advanced,
categories_db,
} from '@/src/db/schema';
import type { AdvancedQuestion } from '~/types';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
async function getFromDb(points: number | string) {
return await db
.select({
id: dane.id,
nr_pytania: dane.nr_pytania,
pytanie: dane.pytanie,
poprawna_odp: dane.poprawna_odp,
media: dane.media,
kategorie: dane.kategorie,
nazwa_media_pjm_tresc_pyt: dane.nazwa_media_pjm_tresc_pyt,
pytanie_eng: dane.pytanie_eng,
pytanie_de: dane.pytanie_de,
pytanie_ua: dane.pytanie_ua,
liczba_pkt: punkty.liczba_pkt,
odp_a: dane.odp_a,
odp_b: dane.odp_b,
odp_c: dane.odp_c,
nazwa_media_pjm_tresc_odp_a: dane.nazwa_media_pjm_tresc_odp_a,
nazwa_media_pjm_tresc_odp_b: dane.nazwa_media_pjm_tresc_odp_b,
nazwa_media_pjm_tresc_odp_c: dane.nazwa_media_pjm_tresc_odp_c,
odp_a_eng: dane.odp_a_eng,
odp_b_eng: dane.odp_b_eng,
odp_c_eng: dane.odp_c_eng,
odp_a_de: dane.odp_a_de,
odp_b_de: dane.odp_b_de,
odp_c_de: dane.odp_c_de,
odp_a_ua: dane.odp_a_ua,
odp_b_ua: dane.odp_b_ua,
odp_c_ua: dane.odp_c_ua,
})
.from(dane)
.innerJoin(punkty, eq(dane.nr_pytania, punkty.nr_pytania))
.where(
and(
isNotNull(dane.odp_a),
isNotNull(dane.odp_b),
isNotNull(dane.odp_c),
isNotNull(dane.odp_a_eng),
isNotNull(dane.odp_b_eng),
isNotNull(dane.odp_c_eng),
isNotNull(dane.odp_a_de),
isNotNull(dane.odp_b_de),
isNotNull(dane.odp_c_de),
eq(punkty.liczba_pkt, +points)
)
);
}
const query = getQuery(event); const query = getQuery(event);
const category = query.category; const category = query.category;
const categories = [ const categories = [
"A", 'A',
"B", 'B',
"C", 'C',
"D", 'D',
"T", 'T',
"AM", 'AM',
"A1", 'A1',
"A2", 'A2',
"B1", 'B1',
"C1", 'C1',
"D1", 'D1',
"PT", 'PT',
]; ];
if (category == null || category == "") { if (category === '' || typeof category !== 'string') {
throw new Error( throw createError({
"category argument has to be string (or not to be defined at all)" statusCode: 400,
); statusMessage:
'category argument has to be string (or not to be defined at all)',
});
} }
if (!categories.includes(`${category}`)) { if (!categories.includes(`${category.toUpperCase()}`)) {
throw new Error(`category argument has to be equal to: ${categories}`); throw createError({
statusCode: 400,
statusMessage: `category argument has to be equal to: ${categories}`,
});
}
async function getFromDb(points: number, limit: number, category: string) {
return await db
.select({
id: tasks_advanced.id,
correct_answer: tasks_advanced.correct_answer,
media_url: tasks_advanced.media_url,
weight: tasks_advanced.weight,
text: questions_advanced.text,
answer_a: questions_advanced.answer_a,
answer_b: questions_advanced.answer_b,
answer_c: questions_advanced.answer_c,
})
.from(tasks_advanced)
.leftJoin(
questions_advanced,
eq(questions_advanced.task_id, tasks_advanced.id),
)
.leftJoin(categories_db, eq(categories_db.task_id, tasks_advanced.id))
.where(
and(
eq(categories_db.name, category.toUpperCase()),
eq(questions_advanced.lang, 'PL'),
eq(tasks_advanced.weight, points),
),
)
.orderBy(sql`RANDOM()`)
.limit(limit);
} }
const db = drizzle(process.env.DATABASE_URL!); const db = drizzle(process.env.DATABASE_URL!);
const randomizedQuestions: AdvancedQuestion[] = []; const randomizedQuestions: AdvancedQuestion[] = [];
for (let [key, value] of Object.entries({ 1: 2, 2: 4, 3: 6 })) { for (const [key, value] of Object.entries({ 1: 2, 2: 4, 3: 6 })) {
const questionsKeyPoints: AdvancedQuestion[] = await getFromDb(key); randomizedQuestions.push(...(await getFromDb(+key, value, category)));
const chosenRandomQuestions: AdvancedQuestion[] = [];
const randoms: Array<number> = [];
for (let j = 0; j < value; j++) {
let randomized =
Math.floor(Math.random() * (questionsKeyPoints.length - 1 + 1)) + 0;
while (randoms.includes(randomized)) {
randomized =
Math.floor(Math.random() * (questionsKeyPoints.length - 1 + 1)) + 0;
}
randoms.push(randomized);
if (
questionsKeyPoints[randomized].kategorie
.split(",")
.includes(`${category}`)
) {
chosenRandomQuestions.push(questionsKeyPoints[randomized]);
} else {
j--;
}
}
chosenRandomQuestions.forEach((q) => randomizedQuestions.push(q));
} }
return arrayShuffle(randomizedQuestions); return arrayShuffle(randomizedQuestions);
}); });

View file

@ -1,90 +1,70 @@
import "dotenv/config"; import 'dotenv/config';
import { drizzle } from "drizzle-orm/node-postgres"; import { drizzle } from 'drizzle-orm/node-postgres';
import { dane, punkty } from "@/src/db/schema"; import { sql, eq, and } from 'drizzle-orm';
import { sql, eq, and, or } from "drizzle-orm"; import arrayShuffle from 'array-shuffle';
import { BasicQuestion } from "~/types"; import { tasks, questions, categories_db } from '@/src/db/schema';
import arrayShuffle from "array-shuffle"; import type { BasicQuestion } from '~/types';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
async function getFromDb(points: number | string) {
return await db
.select({
id: dane.id,
nr_pytania: dane.nr_pytania,
pytanie: dane.pytanie,
poprawna_odp: dane.poprawna_odp,
media: dane.media,
kategorie: dane.kategorie,
nazwa_media_pjm_tresc_pyt: dane.nazwa_media_pjm_tresc_pyt,
pytanie_eng: dane.pytanie_eng,
pytanie_de: dane.pytanie_de,
pytanie_ua: dane.pytanie_ua,
liczba_pkt: punkty.liczba_pkt,
})
.from(dane)
.innerJoin(punkty, eq(dane.nr_pytania, punkty.nr_pytania))
.where(
and(
or(
sql`lower(${dane.poprawna_odp})='tak'`,
sql`lower(${dane.poprawna_odp})='nie'`
),
eq(punkty.liczba_pkt, +points)
)
);
}
const query = getQuery(event); const query = getQuery(event);
const category = query.category; const category = query.category;
const categories = [ const categories = [
"A", 'A',
"B", 'B',
"C", 'C',
"D", 'D',
"T", 'T',
"AM", 'AM',
"A1", 'A1',
"A2", 'A2',
"B1", 'B1',
"C1", 'C1',
"D1", 'D1',
"PT", 'PT',
]; ];
if (category == null || category == "") {
throw new Error( if (category === '' || typeof category !== 'string') {
"category argument has to be string (or not to be defined at all)" throw createError({
); statusCode: 400,
statusMessage:
'category argument has to be string (or not to be defined at all)',
});
} }
if (!categories.includes(`${category}`)) { if (!categories.includes(`${category.toUpperCase()}`)) {
throw new Error(`category argument has to be equal to: ${categories}`); throw createError({
statusCode: 400,
statusMessage: `category argument has to be equal to: ${categories}`,
});
}
async function getFromDb(points: number, limit: number, category: string) {
return await db
.select({
id: tasks.id,
correct_answer: tasks.correct_answer,
media_url: tasks.media_url,
weight: tasks.weight,
text: questions.text,
})
.from(tasks)
.leftJoin(questions, eq(questions.task_id, tasks.id))
.leftJoin(categories_db, eq(categories_db.task_id, tasks.id))
.where(
and(
eq(categories_db.name, category.toUpperCase()),
eq(questions.lang, 'PL'),
eq(tasks.weight, points),
),
)
.orderBy(sql`RANDOM()`)
.limit(limit);
} }
const db = drizzle(process.env.DATABASE_URL!); const db = drizzle(process.env.DATABASE_URL!);
const randomizedQuestions: BasicQuestion[] = []; const randomizedQuestions: BasicQuestion[] = [];
for (let [key, value] of Object.entries({ 1: 4, 2: 6, 3: 10 })) { for (const [key, value] of Object.entries({ 1: 4, 2: 6, 3: 10 })) {
const questionsKeyPoints: BasicQuestion[] = await getFromDb(key); randomizedQuestions.push(...(await getFromDb(+key, value, category)));
const chosenRandomQuestions: BasicQuestion[] = [];
const randoms: Array<number> = [];
for (let j = 0; j < value; j++) {
let randomized =
Math.floor(Math.random() * (questionsKeyPoints.length - 1 + 1)) + 0;
while (randoms.includes(randomized)) {
randomized =
Math.floor(Math.random() * (questionsKeyPoints.length - 1 + 1)) + 0;
}
randoms.push(randomized);
if (
questionsKeyPoints[randomized].kategorie
.split(",")
.includes(`${category}`)
) {
chosenRandomQuestions.push(questionsKeyPoints[randomized]);
} else {
j--;
}
}
chosenRandomQuestions.forEach((q) => randomizedQuestions.push(q));
} }
return arrayShuffle(randomizedQuestions); return arrayShuffle(randomizedQuestions);
}); });

View file

@ -1,34 +1,35 @@
import { integer, pgTable, text } from "drizzle-orm/pg-core"; import { integer, pgTable, text, smallint, char } from 'drizzle-orm/pg-core';
export const dane = pgTable("dane", { export const tasks = pgTable('tasks', {
id: integer().primaryKey().notNull(), id: integer().notNull(),
nr_pytania: integer().notNull(), correct_answer: text(),
pytanie: text().notNull(), media_url: text(),
odp_a: text(), weight: smallint(),
odp_b: text(),
odp_c: text(),
poprawna_odp: text().notNull(),
media: text(),
kategorie: text().notNull(),
nazwa_media_pjm_tresc_pyt: text(),
nazwa_media_pjm_tresc_odp_a: text(),
nazwa_media_pjm_tresc_odp_b: text(),
nazwa_media_pjm_tresc_odp_c: text(),
pytanie_eng: text().notNull(),
odp_a_eng: text(),
odp_b_eng: text(),
odp_c_eng: text(),
pytanie_de: text().notNull(),
odp_a_de: text(),
odp_b_de: text(),
odp_c_de: text(),
pytanie_ua: text(),
odp_a_ua: text(),
odp_b_ua: text(),
odp_c_ua: text(),
}); });
export const punkty = pgTable("punkty", { export const questions = pgTable('questions', {
nr_pytania: integer().primaryKey().notNull(), task_id: integer(),
liczba_pkt: integer().notNull(), lang: char({ length: 2 }),
text: text(),
});
export const tasks_advanced = pgTable('tasks_advanced', {
id: integer().notNull(),
correct_answer: char({ length: 1 }),
media_url: text(),
weight: smallint(),
});
export const questions_advanced = pgTable('questions_advanced', {
task_id: integer(),
lang: char({ length: 2 }),
text: text(),
answer_a: text(),
answer_b: text(),
answer_c: text(),
});
export const categories_db = pgTable('categories', {
name: text(),
task_id: integer(),
}); });

View file

@ -1,14 +1,14 @@
import { defineStore } from "pinia"; import { defineStore } from 'pinia';
export const useExamStore = defineStore("exam-store", () => { export const useExamStore = defineStore('exam-store', () => {
const category = ref(""); const category = ref('');
const end = ref(false); const end = ref(false);
const result: Ref<ResultEndType> = ref({ const result: Ref<ResultEndType> = ref({
basic: [], basic: [],
advanced: [], advanced: [],
}); });
function resetExam() { function resetExam() {
category.value = ""; category.value = '';
mildReset(); mildReset();
} }
function mildReset() { function mildReset() {

View file

@ -1,7 +1,7 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
plugins: [require("daisyui")], plugins: [require('daisyui')],
daisyui: { daisyui: {
themes: ["light", "dark"], themes: ['light', 'dark'],
}, },
}; };

View file

@ -1,40 +1,21 @@
export interface BasicQuestion { export interface BasicQuestion {
id: number; id: number | null;
nr_pytania: number; correct_answer: string | null;
pytanie: string; media_url: string | null;
poprawna_odp: string; weight: number | null;
media: string | null; text: string | null;
kategorie: string;
nazwa_media_pjm_tresc_pyt: string | null;
pytanie_eng: string;
pytanie_de: string;
pytanie_ua: string | null;
liczba_pkt: number;
} }
export interface AdvancedQuestion extends BasicQuestion { export interface AdvancedQuestion extends BasicQuestion {
odp_a: string; answer_a: string | null;
odp_b: string; answer_b: string | null;
odp_c: string; answer_c: string | null;
nazwa_media_pjm_tresc_odp_a: string | null;
nazwa_media_pjm_tresc_odp_b: string | null;
nazwa_media_pjm_tresc_odp_c: string | null;
odp_a_eng: string;
odp_b_eng: string;
odp_c_eng: string;
odp_a_de: string;
odp_b_de: string;
odp_c_de: string;
odp_a_ua: string | null;
odp_b_ua: string | null;
odp_c_ua: string | null;
} }
export interface ResultType<T> { export interface ResultType<T> {
question: T | undefined; question: T | undefined;
chosen_answer: string; chosen_answer: string;
chosen_is_correct: boolean | undefined; chosen_is_correct: boolean | undefined;
liczba_pkt: number | undefined;
} }
export interface ResultEndType { export interface ResultEndType {