Compare commits

...
Sign in to create a new pull request.

17 commits
main ... main

Author SHA1 Message Date
NetMan
c99576617b many minor fixes:
nuxtimg, categories composabled, tailwind config in js, remove comments, next question operation, media fit
2025-04-28 13:11:07 +02:00
NetMan
940a93c232 minor, add points and resultTF to rightbar 2025-04-16 20:19:26 +02:00
NetMan
05b2b81db7 fix depends and answer basic question choosing 2025-04-15 21:35:35 +02:00
NetMan
a180381f99 comply with dark theme + minor resultend fix 2025-04-15 20:30:07 +02:00
NetMan
d500031f34 major DB overhaul, api changes, lint 2025-04-15 19:51:13 +02:00
NetMan
74ba3a5023 quick, minor: result question choosing change & category in summary 2025-04-07 09:56:28 +02:00
NetMan
86da74cf11 try to fix examstore value between pages again, check value again before navigating 2025-03-08 23:58:17 +01:00
NetMan
26c36e1650 minor fix, revert nuxt to 3.15.4 - 3.16 is unstable 2025-03-08 23:42:55 +01:00
NetMan
3659346b2f minor adjustments 2025-03-08 23:12:29 +01:00
NetMan
64fe08f749 try to fix examstore value change between pages, improve & add loading screen 2025-03-08 14:49:02 +01:00
NetMan
652550a41d daisyui, categories, check examstore at middleware, remove 7.css 2025-03-08 14:14:19 +01:00
NetMan
3c5511f067 result - show if chosen correct/incorrect answer 2025-03-06 19:03:49 +01:00
NetMan
8b35c0fe12 quick, minor: cdn_url in env, env->runtimeconfig 2025-03-05 23:49:24 +01:00
NetMan
cf868f7d65 fixup result, add totaltime in exam, add todo in readme 2025-03-05 23:42:40 +01:00
NetMan
2d3854a4fe fix and improve results, fix api advanced 2025-03-04 23:38:21 +01:00
NetMan
d79369eabe quick, minor: remove little comments 2025-03-04 19:32:16 +01:00
NetMan
1f5c269934 pinia, points result at end, remove comments, cosmetic 2025-03-04 19:27:50 +01:00
40 changed files with 4862 additions and 2895 deletions

View file

@ -1 +1,2 @@
DATABASE_URL="postgres://USERNAME:PASSWORD@HOST:PORT/DATABASE"
DATABASE_URL="postgres://USERNAME:PASSWORD@HOST:PORT/DATABASE"
CDN_URL="http://DOMAIN.TLD/FOLDER"

View file

@ -8,7 +8,31 @@ This project utilizes `pnpm`, thus it is recommended
pnpm install
```
More information about setting up database will come here later
## Required
The [db-prawo-jazdy](https://git.mandarynki.eu/netman/db-prawo-jazdy) project is designed for this one in mind, so use it in conjunction with this - visit it for more details
You also need the exam media files from the (Ministry of Infrasture)[https://www.gov.pl/web/infrastruktura/prawo-jazdy]. The newest at the moment of me writing this (19th of April 2025) are the (visualisations for questions from the 18th of January of 2024)[https://www.gov.pl/pliki/mi/wizualizacje_do_pytan_18_01_2024.zip]
# To-do:
- [x] re-forge database structure (good for now)
- [x] choose category (good for now)
- [x] come up with how to show results appropriately
- [x] db: script for processing, share appropriate files
- [x] better answer click recognition
- [x] beautify website (good for now)
- [ ] <b>fix pinia middleware between pages, MAJOR ISSUE - finishing exam sometimes redirects to homepage instead of results, help appreciated</b>
- [ ] exam (& results?) warning leave message on exit and timer end (and definitely on refresh)
- [ ] question timers
- [ ] lazy loading
- [ ] i18n - pl, en, de, ua (not all questions are not available in ua, api handle)
## Some info
My intention is, to share access to test exams free of charge - all data is free of charge and is already available as public information, either on the gov website, or by writing to the MI
This project is an SSR website mimicking an official driver's license exam (for different categories) with a seperate CDN for media, connected using an ORM to a postgres DB
## Development Server

View file

@ -1,7 +1,5 @@
<template>
<div>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<NuxtPage />
</div>
</template>

12
assets/main.css Normal file
View file

@ -0,0 +1,12 @@
.btn {
height: initial !important;
min-height: var(--size);
}
.set-translate {
@apply absolute top-[50%] left-[50%];
transform: translate(-50%, -50%);
}
.info-little-box {
@apply inline-block px-[15px] py-[8px] bg-blue-500 text-white font-bold rounded-md;
}

14
categories.ts Normal file
View file

@ -0,0 +1,14 @@
export default [
'A',
'B',
'C',
'D',
'T',
'AM',
'A1',
'A2',
'B1',
'C1',
'D1',
'PT',
];

View file

@ -1,82 +0,0 @@
<script lang="ts" setup>
defineProps<{
question: AdvancedQuestion | undefined;
}>();
const abc_model = defineModel();
</script>
<template>
<div class="flex flex-col gap-3">
<div class="text-xl">{{ question?.pytanie }}</div>
<div>
<div class="flex flex-col">
<input
type="radio"
name="abc"
id="odp_a"
v-model="abc_model"
value="a"
class="hidden"
/>
<label for="odp_a">
<div
:class="`btn-answer ${abc_model == 'a' ? ' !bg-fuchsia-500' : ''}`"
>
A
</div>
{{ question?.odp_a }}
</label>
<input
type="radio"
name="abc"
id="odp_b"
v-model="abc_model"
value="b"
class="hidden"
/>
<label for="odp_b">
<div
:class="`btn-answer ${abc_model == 'b' ? ' !bg-fuchsia-500' : ''}`"
>
B
</div>
{{ question?.odp_b }}
</label>
<input
type="radio"
name="abc"
id="odp_c"
v-model="abc_model"
value="c"
class="hidden"
/>
<label for="odp_c">
<div
:class="`btn-answer ${abc_model == 'c' ? ' !bg-fuchsia-500' : ''}`"
>
C
</div>
{{ question?.odp_c }}
</label>
</div>
</div>
</div>
</template>
<style scoped>
.btn-answer {
display: inline-block;
}
label {
cursor: pointer;
}
label:hover .btn-answer {
@apply bg-blue-300;
}
label:hover {
@apply bg-slate-200;
}
</style>

View file

@ -1,51 +0,0 @@
<script lang="ts" setup>
defineProps<{
question: BasicQuestion | undefined;
}>();
const tak_nie_model = defineModel();
</script>
<template>
<div class="flex flex-col gap-3">
<div class="text-xl">{{ question?.pytanie }}</div>
<div>
<div class="flex flex-row justify-around">
<input
type="radio"
name="tak_nie"
id="odp_tak"
v-model="tak_nie_model"
value="tak"
class="hidden"
/>
<label for="odp_tak">
<div
:class="`btn-answer ${
tak_nie_model == 'tak' ? ' !bg-fuchsia-500' : ''
}`"
>
TAK
</div>
</label>
<input
type="radio"
name="tak_nie"
id="odp_nie"
v-model="tak_nie_model"
value="nie"
class="hidden"
/>
<label for="odp_nie">
<div
:class="`btn-answer ${
tak_nie_model == 'nie' ? ' !bg-fuchsia-500' : ''
}`"
>
NIE
</div>
</label>
</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

@ -0,0 +1,8 @@
<template>
<div
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 class="block">Ładowanie</span>
</div>
</template>

View file

@ -1,31 +0,0 @@
<script setup lang="ts">
const runtimeConfig = useRuntimeConfig();
const cdnUrl = runtimeConfig.public.cdn_url;
defineProps<{
media: {
fileName: string | undefined;
fileType: string | undefined;
ogName: string | null | undefined;
};
}>();
</script>
<template>
<div
class="select-none z-[-1] flex-1 flex items-stretch w-full justify-center *:object-contain"
>
<!-- OLD -->
<!-- + [media.fileName, media.fileType].join('.') -->
<img :src="cdnUrl + media.ogName" alt="" v-if="media.fileType == 'jpg'" />
<video v-else-if="media.fileType == 'wmv'" :key="media.fileName" autoplay>
<source
:src="cdnUrl + [media.fileName, 'mp4'].join('.')"
type="video/mp4"
/>
</video>
<span v-else class="text-4xl font-bold flex items-center justify-center"
>Brak mediów</span
>
</div>
</template>

50
components/MediaBox.vue Normal file
View file

@ -0,0 +1,50 @@
<script setup lang="ts">
import { joinURL } from 'ufo';
const runtimeConfig = useRuntimeConfig();
const cdnUrl = runtimeConfig.public.cdn_url;
const route = useRoute();
const props = defineProps<{
mediaPath: string | null | undefined;
}>();
const media = computed(() => {
const dotSplit = props.mediaPath?.split('.');
const extension = dotSplit?.pop()?.toLowerCase();
let type = null;
if (extension === 'jpg') {
type = 'image';
} else if (extension === 'wmv') {
type = 'video';
}
return { name: dotSplit?.join('.'), type };
});
</script>
<template>
<div
class="select-none flex-auto w-full *:object-contain *:w-full *:h-full *:max-h-full relative *:absolute"
:class="route.path === '/exam' ? 'z-[-1]' : ''"
>
<NuxtImg
v-if="media.type === 'image'"
:key="`${mediaPath}-image`"
provider="selfhost"
:src="'/' + mediaPath"
:alt="mediaPath ?? ''"
/>
<video
v-else-if="media.type === 'video'"
:key="`${mediaPath}-video`"
:autoplay="route.path === '/exam'"
:controls="route.path === '/result'"
>
<source :src="joinURL(cdnUrl, media.name + '.mp4')" type="video/mp4" />
</video>
<span v-else class="text-5xl font-bold flex items-center justify-center"
>Brak mediów</span
>
</div>
</template>

View file

@ -0,0 +1,32 @@
<script setup lang="ts">
const myModal = useTemplateRef('myModal');
onMounted(() => {
myModal.value?.showModal();
});
</script>
<template>
<dialog
ref="myModal"
class="flex justify-center items-center backdrop-blur-sm modal"
>
<div class="flex flex-col p-3 bg-base 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">Punkty: <slot name="points" /> / 74</div>
<div class="*:inline">Wynik: <slot name="resultTrueFalse" /></div>
<div class="flex flex-row gap-2">
<NuxtLink to="/" class="btn btn-soft">Wróć na stronę główną</NuxtLink>
<NuxtLink to="/exam" class="btn btn-outline">
Rozpocznij jeszcze raz
</NuxtLink>
<button class="btn btn-neutral" @click="myModal?.close()">
Przejrzyj odpowiedzi
</button>
</div>
</div>
</dialog>
</template>

View file

@ -1,111 +0,0 @@
<script setup lang="ts">
import "7.css/dist/7.scoped.css";
const props = defineProps<{
questionaries: BasicQuestion[] | null;
countBasic: number;
countAdvanced: number;
dataBasic: BasicQuestion[] | null;
dataAdvanced: AdvancedQuestion[] | null;
now: string | null | undefined;
}>();
const isBasic = computed(() => props.now == "basic");
const isAdvanced = computed(() => props.now == "advanced");
</script>
<template>
<div class="flex flex-col items-center p-4 gap-10">
<div>
<button class="btn-major">Zakończ egzamin</button>
</div>
<div class="flex flex-row gap-6">
<div :class="isBasic ? '' : 'opacity-45'">
Pytania podstawowe
<div class="win7 *:!h-10 *:*:!h-10 *:*:*:h-10 text-lg">
<div
role="progressbar"
class="relative"
:class="isBasic ? 'animate' : ''"
>
<div :class="`w-full`">
<div
class="set-translate w-full text-center bg-blue-500 bg-opacity-60"
:class="isBasic ? 'font-semibold' : ''"
>
<span class="block set-translate w-full text-center"
>{{ countBasic + 1 }} / {{ dataBasic?.length }}</span
>
</div>
</div>
</div>
</div>
</div>
<div :class="isAdvanced ? '' : 'opacity-45'">
Pytania specjalistyczne
<div class="win7 *:!h-10 *:*:!h-10 *:*:*:h-10 text-lg">
<div
role="progressbar"
class="relative"
:class="isAdvanced ? 'animate' : ''"
>
<div class="w-full">
<div
class="set-translate w-full text-center bg-blue-500 bg-opacity-60"
:class="isAdvanced ? 'font-semibold' : ''"
>
<span class="block set-translate w-full text-center">
{{ countAdvanced + 1 }} / {{ dataAdvanced?.length }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="text-center text-xl">
Czas na zapoznanie się z pytaniem<br />
Czas na udzielenie odpowiedzi
<div class="flex flex-row items-stretch">
<div class="btn-major">START</div>
<div class="win7 flex-1 *:!h-[100%] *:*:!h-[100%]">
<div role="progressbar" class="relative min-h-6">
<div class="progressive !bg-orange-500">
<div class="set-translate w-full text-center text-3xl">10 s</div>
</div>
</div>
</div>
</div>
</div>
<div>
<button @click="$emit('next-question')" class="btn-major">
Następne pytanie
</button>
</div>
<br />
<div class="max-h-[150px] overflow-y-scroll">{{ questionaries }}</div>
</div>
</template>
<style>
.set-translate {
@apply absolute top-[50%] left-[50%];
transform: translate(-50%, -50%);
}
.progressive {
animation: progressZapoznanie 20s linear;
}
@keyframes progressZapoznanie {
0% {
width: 0;
}
100% {
width: 100%;
}
}
</style>

View file

@ -1,26 +0,0 @@
<script setup lang="ts">
defineProps<{
points: number | undefined;
category: string | undefined;
timeRemaining: string | undefined;
}>();
</script>
<template>
<div class="flex flex-row gap-4 *:flex *:items-center *:gap-3">
<div>
<span class="block">Wartość punktowa</span>
<div class="info-little-box">
{{ points }}
</div>
</div>
<div>
<span class="block">Aktualna kategoria (implement)</span>
<div class="info-little-box">{{ category }}</div>
</div>
<div>
<span class="block">Czas do końca egzaminu (implement)</span>
<div class="info-little-box">{{ timeRemaining }}</div>
</div>
</div>
</template>

44
components/bar/Top.vue Normal file
View file

@ -0,0 +1,44 @@
<script setup lang="ts">
import type { Duration } from 'date-fns';
const props = defineProps<{
points: number | null | undefined;
category: string | undefined;
timeRemaining?: Duration | undefined;
}>();
const timeRemainingFriendly = computed(() => {
if (typeof props.timeRemaining !== 'undefined') {
const seconds = props.timeRemaining.seconds ?? 0;
return `${props.timeRemaining.minutes ?? 0}:${
seconds >= 0 && seconds < 10 ? 0 : ''
}${seconds ?? 0}`;
}
return null;
});
</script>
<template>
<div
class="flex flex-none flex-row gap-4 *:flex *:items-center *:gap-3 border-b p-4 border-base-300 bg-base-100"
>
<div>
<span class="block">Wartość punktowa</span>
<div class="info-little-box">
{{ points }}
</div>
</div>
<div>
<span class="block">Aktualna kategoria</span>
<div class="info-little-box">
{{ category }}
</div>
</div>
<div v-if="typeof timeRemaining !== 'undefined'">
<span class="block">Czas do końca egzaminu</span>
<div class="info-little-box w-20 text-center">
{{ timeRemainingFriendly }}
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,91 @@
<script setup lang="ts">
defineProps<{
countBasic: number;
countAdvanced: number;
now: string | null | undefined;
ending: boolean;
}>();
const emit = defineEmits<{
endExam: [];
nextQuestion: [];
}>();
</script>
<template>
<div
class="flex flex-col items-stretch p-4 gap-10 border-l border-base-300 bg-base-100"
>
<button class="btn btn-warning btn-xl" @click="emit('endExam')">
Zakończ egzamin
</button>
<div class="flex flex-row gap-6 *:flex-1 w-full">
<CurrentQuestionCount
:class="now === 'basic' ? 'font-semibold' : 'opacity-45'"
>
<template #title> Pytania podstawowe </template>
<template #count> {{ countBasic + 1 }} / 20 </template>
</CurrentQuestionCount>
<CurrentQuestionCount
:class="now === 'advanced' ? 'font-semibold' : 'opacity-45'"
>
<template #title> Pytania specjalistyczne </template>
<template #count> {{ countAdvanced + 1 }} / 12 </template>
</CurrentQuestionCount>
</div>
<div class="text-center text-xl flex flex-col gap-2">
<span>Czas na zapoznanie się z pytaniem</span>
<div class="flex flex-row items-stretch gap-2">
<div class="btn btn-primary">START</div>
<div class="h-full flex-1 relative">
<progress
class="progress progress-warning w-full h-full"
value="50"
max="100"
/>
<span class="block set-translate z-10 text-black text-2xl">20s</span>
</div>
</div>
</div>
<div class="text-center text-xl flex flex-col gap-2">
<span>Czas na udzielenie odpowiedzi</span>
<div class="h-9 relative">
<progress
class="progress progress-warning w-full h-full"
value="50"
max="100"
/>
<span class="block set-translate z-10 text-black text-2xl">15s</span>
</div>
</div>
<div class="flex-1" />
<button
class="btn btn-warning btn-xl"
:disabled="ending"
@click="emit('nextQuestion')"
>
Następne pytanie
</button>
</div>
</template>
<style>
/*.progressive {
animation: progressZapoznanie 20s linear;
}
@keyframes progressZapoznanie {
0% {
width: 0;
}
100% {
width: 100%;
}
}*/
</style>

View file

@ -0,0 +1,94 @@
<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-base-300 bg-base-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'
} ${now === 'basic' && countBasic === num ? 'outline-set-solid outline-2' : ''}`"
: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'
} ${now === 'advanced' && countAdvanced === num ? 'outline-set-solid outline-2' : ''}`"
:checked="now === 'advanced' ? countAdvanced === num : false"
@click="
emit('changeNow', 'advanced');
emit('changeCount', num);
"
/>
</div>
<div class="flex-1">
<div class="*:inline">Punkty: <slot name="points" /> / 74</div>
<div class="*:inline">Wynik: <slot name="resultTrueFalse" /></div>
</div>
<NuxtLink to="/exam" class="btn btn-warning btn-xl">
Rozpocznij jeszcze raz
</NuxtLink>
</div>
</template>
<style scoped>
.outline-set-solid {
outline-style: solid;
}
</style>

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-base-300 bg-base-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-base-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-none flex-col gap-6 border-t px-4 py-5 border-base-300 bg-base-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.toString()"
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 { defineConfig } from "drizzle-kit";
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: "postgresql",
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: 'postgresql',
schema: './src/db/schema.ts',
out: './drizzle',
dbCredentials: {
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,3 +0,0 @@
<template>
<div><slot /></div>
</template>

9
middleware/exam.ts Normal file
View file

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

8
middleware/result.ts Normal file
View file

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

View file

@ -1,15 +1,49 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
import 'dotenv/config';
export default defineNuxtConfig({
compatibilityDate: "2024-11-01",
devtools: { enabled: true },
modules: ["@nuxtjs/tailwindcss", "@nuxt/fonts"],
modules: [
'@nuxtjs/tailwindcss',
'@nuxt/fonts',
'@pinia/nuxt',
'@nuxt/eslint',
'@nuxt/image',
],
ssr: true,
imports: {
dirs: ["types/*.ts", "store/*.ts", "types/**/*.ts"],
dirs: ['types/*.ts', 'store/*.ts', 'types/**/*.ts'],
},
devtools: { enabled: true },
css: ['~/assets/main.css'],
runtimeConfig: {
public: {
cdn_url: "http://pj.netman.ovh/",
cdn_url: process.env.CDN_URL,
},
},
routeRules: {
'/': { prerender: true },
},
compatibilityDate: '2024-11-01',
eslint: {
config: {
stylistic: {
indent: 2,
semi: true,
quotes: 'single',
jsx: false,
},
},
},
image: {
providers: {
selfhost: {
name: 'selfhost',
provider: '~/providers/selfhost.ts',
options: {
baseUrl: process.env.CDN_URL,
},
},
},
provider: 'selfhost',
},
});

View file

@ -7,28 +7,41 @@
"dev": "nuxt dev",
"generate": "nuxt generate",
"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": {
"7.css": "^0.17.0",
"@nuxt/fonts": "0.10.3",
"@nuxtjs/tailwindcss": "6.13.1",
"@nuxt/fonts": "0.11.1",
"@nuxt/image": "1.10.0",
"@nuxtjs/tailwindcss": "6.13.2",
"@pinia/nuxt": "0.11.0",
"array-shuffle": "^3.0.0",
"daisyui": "^5.0.27",
"date-fns": "^4.1.0",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.40.0",
"nuxt": "^3.15.4",
"pg": "^8.13.3",
"dotenv": "^16.5.0",
"drizzle-kit": "^0.31.0",
"drizzle-orm": "^0.42.0",
"eslint": "^9.24.0",
"lodash": "^4.17.21",
"nuxt": "~3.16.2",
"pg": "^8.14.1",
"pinia": "^3.0.2",
"ufo": "^1.6.1",
"vue": "latest",
"vue-router": "latest"
},
"packageManager": "pnpm@10.4.1+sha512.c753b6c3ad7afa13af388fa6d808035a008e30ea9993f58c6663e2bc5ff21679aa834db094987129aa4d488b86df57f7b634981b2f827cdcacc698cc0cfb88af",
"devDependencies": {
"@types/pg": "^8.11.11",
"drizzle-kit": "^0.30.5",
"tsx": "^4.19.3"
},
"resolutions": {
"nitropack": "2.8.1"
"@nuxt/eslint": "1.3.0",
"@types/lodash": "^4.17.16",
"@types/pg": "^8.11.13",
"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,178 +1,172 @@
<script lang="ts" setup>
import "7.css/dist/7.scoped.css";
import { intervalToDuration, addMinutes, addSeconds, isEqual } from 'date-fns';
import { useExamStore } from '~/store/examStore';
definePageMeta({ middleware: ['exam'] });
useHead({
title: "Pytanie 1/20",
title: 'Pytanie 1/20',
});
const nowTime = ref(new Date());
const timeEnd = addMinutes(new Date(), 25);
const timeRemainingTotal = computed(() =>
intervalToDuration({
start: nowTime.value,
end: timeEnd,
}),
);
// const timeRemainingQuestion - to implement
onMounted(() => {
const endInterval = setInterval(() => {
nowTime.value = addSeconds(nowTime.value, 1);
if (isEqual(nowTime.value, timeEnd)) {
clearInterval(endInterval);
endExam();
}
}, 1000);
watchEffect(() => {
if (now.value === 'basic')
useHead({ title: `Pytanie ${countBasic.value + 1}/20` });
if (now.value === 'advanced')
useHead({ title: `Pytanie ${countAdvanced.value + 1}/12` });
});
});
const examStore = useExamStore();
const {
data: dataBasic,
error: errorBasic,
status: statusBasic,
} = await useFetch<BasicQuestion[]>("/api/basic");
} = await useLazyFetch<BasicQuestion[]>(`/api/basic`, {
query: {
category: examStore.category,
},
});
const {
data: dataAdvanced,
error: errorAdvanced,
status: statusAdvanced,
} = await useFetch<AdvancedQuestion[]>("/api/advanced");
} = await useLazyFetch<AdvancedQuestion[]>(`/api/advanced`, {
query: {
category: examStore.category,
},
});
const countBasic = ref(0);
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();
async function next() {
if (countBasic.value + 1 < dataBasic.value?.length!) {
questionaries.value.push({
question: question.value,
chosen_answer: tak_nie_model.value ?? "",
chosen_is_correct:
tak_nie_model.value == question.value?.poprawna_odp?.toLowerCase(),
liczba_pkt: question.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) {
questionaries.value.push({
question: question.value,
chosen_answer: abc_model.value ?? "",
chosen_is_correct:
abc_model.value == question.value?.poprawna_odp?.toLowerCase(),
liczba_pkt: question.value?.liczba_pkt,
});
} else {
now.value = "advanced";
}
countAdvanced.value++;
abc_model.value = "";
}
}
async function endExam() {
return;
}
const ending = ref(false);
const questionBasic = computed<BasicQuestion | undefined>(() =>
dataBasic.value?.at(countBasic.value)
dataBasic.value?.at(countBasic.value),
);
const questionAdvanced = computed<AdvancedQuestion | undefined>(() =>
dataAdvanced.value?.at(countAdvanced.value)
dataAdvanced.value?.at(countAdvanced.value),
);
const question = computed(() => {
if (now.value == "basic") {
return questionBasic.value;
} else if (now.value == "advanced") {
return questionAdvanced.value;
} else {
return;
if (now.value === 'basic') return questionBasic.value;
if (now.value === 'advanced') return questionAdvanced.value;
return null;
});
const result: Ref<ResultEndType> = ref({
basic: [],
advanced: [],
});
async function next() {
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 = '';
const media = computed(() => {
const mediaSplit = question.value?.media?.split(".");
return {
fileType: mediaSplit?.pop()?.toLowerCase(),
fileName: mediaSplit?.join("."),
ogName: question.value?.media,
};
});
if (now.value === 'basic') {
if (countBasic.value < 19) {
countBasic.value++;
} else {
now.value = 'advanced';
countAdvanced.value++;
}
} else if (now.value === 'advanced') {
if (countAdvanced.value < 11) {
countAdvanced.value++;
}
if (countAdvanced.value >= 11) {
ending.value = true;
}
}
}
const questionaries: Ref<Array<any>> = ref([]);
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 });
}
}
}
// onMounted(() => {
// const progresInterval = setInterval(() => {
// progres.value.value = +progres.value.value + 1;
// if (progres.value.value >= 100) {
// clearInterval(progresInterval);
// }
// }, 100);
// });
const loading = ref(false);
</script>
<template>
<div>
<div v-if="statusBasic === 'success'">
<!-- as in to transition to the next page -->
<LoadingScreen v-if="loading" />
<div v-if="statusBasic === 'success' && statusAdvanced === 'success'">
<div class="grid grid-cols-4 min-h-dvh">
<div class="col-span-3 flex flex-col gap-4 p-4">
<TopBar
:points="question?.liczba_pkt"
:category="`B`"
:time-remaining="`25:00`"
<div class="col-span-3 flex flex-col">
<BarTop
:points="question?.weight"
:category="examStore.category"
:time-remaining="timeRemainingTotal"
/>
<Media :media="media" />
<BasicQuestionBlock
v-if="countAdvanced < 0"
<MediaBox :media-path="question?.media_url" />
<QuestionBasic
v-if="now === 'basic'"
v-model="answer"
:question="questionBasic"
v-model="tak_nie_model"
/>
<AdvancedQuestionBlock
v-else
<QuestionAdvanced
v-else-if="now === 'advanced'"
v-model="answer"
:question="questionAdvanced"
v-model="abc_model"
/>
</div>
<RightBar
:questionaries="questionaries"
:data-basic="dataBasic"
:data-advanced="dataAdvanced"
<BarRightExam
:count-basic="countBasic"
:count-advanced="countAdvanced"
@next-question="next()"
:now="now"
:ending="ending"
@next-question="next()"
@end-exam="endExam()"
/>
</div>
</div>
<div v-else-if="statusBasic === 'error' || statusAdvanced === 'error'">
An API error occurred: {{ errorBasic }} {{ errorAdvanced }}
</div>
<div v-else>Loading...</div>
<LoadingScreen v-else />
</div>
</template>
<style>
.btn {
@apply box-border text-white font-bold p-3 rounded-md w-fit cursor-pointer border-[4px] transition duration-100;
}
.btn:active {
@apply duration-0;
}
.btn-answer {
@apply btn bg-blue-500 text-xl;
}
.btn-answer:hover {
@apply bg-blue-300;
}
.btn-answer:active {
@apply bg-blue-400;
}
.btn-major {
@apply btn bg-orange-400 text-base;
}
.btn-major:hover {
@apply bg-orange-200;
}
.btn-major:active {
@apply bg-orange-300;
}
.info-little-box {
@apply inline-block px-[15px] py-[8px] bg-blue-500 text-white font-bold;
}
</style>

View file

@ -1,12 +1,47 @@
<script setup lang="ts">
import categories from '~/categories';
onMounted(() => {
useHead({
title: 'Test na prawo jazdy',
});
});
const loading = ref(false);
const examStore = useExamStore();
function setAndGo(category: string) {
loading.value = true;
examStore.setCategory(category);
while (true) {
if (examStore.category === category) {
return navigateTo('/exam');
}
}
}
</script>
<template>
<div>
<h1>Test na prawo jazdy kat.B</h1>
<p>
Witaj w teście na prawo jazdy kat.B, aby rozpocząć, naciśnij poniższy
przycisk:
</p>
<NuxtLink to="/exam" class="text-4xl font-bold bg-fuchsia-200"
>START!</NuxtLink
>
<div v-if="!loading" class="text-3xl">
<span>Test na prawo jazdy</span>
<p>
Witaj w teście na prawo jazdy, aby rozpocząć, naciśnij jeden z
poniższych przycisków:
<br />
</p>
<div class="flex flex-row flex-wrap gap-2">
<button
v-for="category in categories"
:key="`btn-${category}`"
class="btn btn-xl btn-secondary"
@click="setAndGo(category)"
>
{{ category }}
</button>
</div>
</div>
<LoadingScreen v-else />
</div>
</template>

127
pages/result.vue Normal file
View file

@ -0,0 +1,127 @@
<script setup lang="ts">
import ResultModal from '~/components/ResultModal.vue';
definePageMeta({ middleware: ['result'] });
const examStore = useExamStore();
const points = ref<number>(0);
examStore.result.basic.forEach((answer) => {
if (answer.chosen_is_correct) {
points.value += answer.question?.weight ?? 0;
}
});
examStore.result.advanced.forEach((answer) => {
if (answer.chosen_is_correct) {
points.value += answer.question?.weight ?? 0;
}
});
const resultTrueFalse = ref(points.value >= 68 ? 'pozytywny' : 'negatywny');
onMounted(() => {
useHead({
title: `${
String(resultTrueFalse.value[0]).toUpperCase() +
String(resultTrueFalse.value).slice(1)
} (${points.value}/74)`,
});
});
const countBasic = ref(0);
const countAdvanced = ref(0);
const resultQuestionBasic = computed<ResultType<BasicQuestion> | undefined>(
() => examStore.result.basic.at(countBasic.value),
);
const resultQuestionAdvanced = computed<
ResultType<AdvancedQuestion> | undefined
>(() => examStore.result.advanced.at(countAdvanced.value));
const questionBasic = computed<BasicQuestion | undefined>(
() => resultQuestionBasic.value?.question,
);
const questionAdvanced = computed<AdvancedQuestion | undefined>(
() => resultQuestionAdvanced.value?.question,
);
const now = ref('basic');
const question = computed(() => {
if (now.value === 'basic') {
return questionBasic.value;
} else if (now.value === 'advanced') {
return questionAdvanced.value;
} else {
return null;
}
});
const resultQuestion = computed(() => {
if (now.value === 'basic') {
return resultQuestionBasic.value;
} else if (now.value === 'advanced') {
return resultQuestionAdvanced.value;
} else {
return null;
}
});
const answer = computed(() => resultQuestion.value?.chosen_answer);
function changeNow(to: string) {
now.value = to;
}
function changeCount(num: number) {
if (now.value === 'basic') {
countBasic.value = num;
} else if (now.value === 'advanced') {
countAdvanced.value = num;
}
}
</script>
<template>
<div>
<ResultModal>
<template #title>Egzamin teorytyczny</template>
<template #category>{{ examStore.category }}</template>
<template #points>{{ points }}</template>
<template #resultTrueFalse>{{ resultTrueFalse }}</template>
</ResultModal>
<div>
<div class="grid grid-cols-4 min-h-dvh">
<div class="col-span-3 flex flex-col">
<BarTop :points="question?.weight" :category="examStore.category" />
<MediaBox :media-path="question?.media_url" />
<QuestionBasic
v-if="now === 'basic'"
v-model="answer"
:question="questionBasic"
class="select-none z-[-1]"
/>
<QuestionAdvanced
v-else-if="now === 'advanced'"
v-model="answer"
:question="questionAdvanced"
class="select-none z-[-1]"
/>
</div>
<BarRightResult
:result="examStore.result"
:count-basic="countBasic"
:count-advanced="countAdvanced"
:now="now"
@change-now="changeNow"
@change-count="changeCount"
>
<template #points>{{ points }}</template>
<template #resultTrueFalse>{{ resultTrueFalse }}</template>
</BarRightResult>
</div>
</div>
</div>
</template>

5977
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;

18
providers/selfhost.ts Normal file
View file

@ -0,0 +1,18 @@
import { joinURL } from 'ufo';
import type { ProviderGetImage } from '@nuxt/image';
import { createOperationsGenerator } from '#image';
const operationsGenerator = createOperationsGenerator();
export const getImage: ProviderGetImage = (
src,
{ modifiers = {}, baseUrl } = {},
) => {
baseUrl ??= '';
const operations = operationsGenerator(modifiers);
return {
url: joinURL(baseUrl, src + (operations ? '?' + operations : '')),
};
};

1
public/placeholder.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="680.764" height="528.354" viewBox="0 0 180.119 139.794"><g transform="translate(-13.59 -66.639)" paint-order="fill markers stroke"><path fill="#d0d0d0" d="M13.591 66.639H193.71v139.794H13.591z"/><path d="m118.507 133.514-34.249 34.249-15.968-15.968-41.938 41.937H178.726z" opacity=".675" fill="#fff"/><circle cx="58.217" cy="108.555" r="11.773" opacity=".675" fill="#fff"/><path fill="none" d="M26.111 77.634h152.614v116.099H26.111z"/></g></svg>

After

Width:  |  Height:  |  Size: 492 B

View file

@ -1,73 +1,67 @@
import "dotenv/config";
import { drizzle } from "drizzle-orm/node-postgres";
import { dane, punkty } from "@/src/db/schema";
import { sql, eq, isNotNull, and } from "drizzle-orm";
import { AdvancedQuestion } from "~/types/basic";
import 'dotenv/config';
import { drizzle } from 'drizzle-orm/node-postgres';
import { sql, eq, and } from 'drizzle-orm';
import arrayShuffle from 'array-shuffle';
import {
tasks_advanced,
questions_advanced,
categories_db,
} from '@/src/db/schema';
import type { AdvancedQuestion } from '~/types';
import categories from '~/categories';
export default defineEventHandler(async (event) => {
const query = getQuery(event);
const amount = query?.amount;
const db = drizzle(process.env.DATABASE_URL!);
const questions: AdvancedQuestion[] = 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,
const category = query.category;
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)
)
// sql`lower(${dane.poprawna_odp})='a' or lower(${dane.poprawna_odp})='b' or lower(${dane.poprawna_odp})='c'`
// sql`${dane.odp_a} is not null or ${dane.odp_b} is not null or ${dane.odp_c} is not null`
);
const randoms: Array<number> = [];
const randomizedQuestions = [];
for (let i = 0; i < +(amount ?? 12); i++) {
let randomized = Math.floor(Math.random() * (questions.length - 1 + 1)) + 0;
while (randoms.includes(randomized)) {
randomized = Math.floor(Math.random() * (questions.length - 1 + 1)) + 0;
}
randoms.push(randomized);
if (questions[randomized].kategorie?.split(",").includes("B")) {
randomizedQuestions.push(questions[randomized]);
} else {
i--;
}
if (category === '' || typeof category !== 'string') {
throw createError({
statusCode: 400,
statusMessage:
'category argument has to be string (or not to be defined at all)',
});
}
return randomizedQuestions;
if (!categories.includes(`${category.toUpperCase()}`)) {
throw createError({
statusCode: 400,
statusMessage: `category argument has to be equal to either: ${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 randomizedQuestions: AdvancedQuestion[] = [];
for (const [key, value] of Object.entries({ 1: 2, 2: 4, 3: 6 })) {
randomizedQuestions.push(...(await getFromDb(+key, value, category)));
}
return arrayShuffle(randomizedQuestions);
});

View file

@ -1,73 +1,57 @@
import "dotenv/config";
import { drizzle } from "drizzle-orm/node-postgres";
import { dane, punkty } from "@/src/db/schema";
import { sql, eq, and, or } from "drizzle-orm";
import { BasicQuestion } from "~/types/basic";
import arrayShuffle from "array-shuffle";
import 'dotenv/config';
import { drizzle } from 'drizzle-orm/node-postgres';
import { sql, eq, and } from 'drizzle-orm';
import arrayShuffle from 'array-shuffle';
import { tasks, questions, categories_db } from '@/src/db/schema';
import type { BasicQuestion } from '~/types';
import categories from '~/categories';
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 category = query.category;
if (typeof category != "undefined" && typeof category != "string") {
throw new Error(
"category argument has to be string (or not to be defined at all)"
);
if (category === '' || typeof category !== 'string') {
throw createError({
statusCode: 400,
statusMessage:
'category argument has to be string (or not to be defined at all)',
});
}
if (!categories.includes(`${category.toUpperCase()}`)) {
throw createError({
statusCode: 400,
statusMessage: `category argument has to be equal to either: ${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 randomizedQuestions: BasicQuestion[] = [];
for (let [key, value] of Object.entries({ 1: 4, 2: 6, 3: 10 })) {
const questionsKeyPoints: BasicQuestion[] = await getFromDb(key);
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 ?? "B")
) {
chosenRandomQuestions.push(questionsKeyPoints[randomized]);
} else {
j--;
}
}
chosenRandomQuestions.forEach((q) => randomizedQuestions.push(q));
for (const [key, value] of Object.entries({ 1: 4, 2: 6, 3: 10 })) {
randomizedQuestions.push(...(await getFromDb(+key, value, category)));
}
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", {
id: integer().primaryKey().notNull(),
nr_pytania: integer().notNull(),
pytanie: text().notNull(),
odp_a: text(),
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 tasks = pgTable('tasks', {
id: integer().notNull(),
correct_answer: text(),
media_url: text(),
weight: smallint(),
});
export const punkty = pgTable("punkty", {
nr_pytania: integer().primaryKey().notNull(),
liczba_pkt: integer().notNull(),
export const questions = pgTable('questions', {
task_id: integer(),
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(),
});

43
store/examStore.ts Normal file
View file

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

8
tailwind.config.js Normal file
View file

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

View file

@ -1,46 +0,0 @@
export interface BasicQuestion {
id: number;
nr_pytania: number;
pytanie: string;
// odp_a: null;
// odp_b: null;
// odp_c: null;
poprawna_odp: string;
media: string | null;
kategorie: string;
nazwa_media_pjm_tresc_pyt: 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;
pytanie_eng: string;
// odp_a_eng: string | null;
// odp_b_eng: string | null;
// odp_c_eng: string | null;
pytanie_de: string;
// odp_a_de: string | null;
// odp_b_de: string | null;
// odp_c_de: string | null;
pytanie_ua: string | null;
// odp_a_ua: string | null;
// odp_b_ua: string | null;
// odp_c_ua: string | null;
liczba_pkt: number;
}
export interface AdvancedQuestion extends BasicQuestion {
odp_a: string;
odp_b: string;
odp_c: string;
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;
}

24
types/index.ts Normal file
View file

@ -0,0 +1,24 @@
export interface BasicQuestion {
id: number | null;
correct_answer: string | null;
media_url: string | null;
weight: number | null;
text: string | null;
}
export interface AdvancedQuestion extends BasicQuestion {
answer_a: string | null;
answer_b: string | null;
answer_c: string | null;
}
export interface ResultType<T> {
question: T | undefined;
chosen_answer: string;
chosen_is_correct: boolean | undefined;
}
export interface ResultEndType {
basic: ResultType<BasicQuestion>[];
advanced: ResultType<AdvancedQuestion>[];
}