Compare commits

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

24 commits
main ... main

Author SHA1 Message Date
NetMan
19508f1148 i18n-ised ui variables 2025-12-15 23:49:06 +01:00
NetMan
6fb8e80af0 i18n-ised polish ui (todo:en,de,ua) 2025-12-15 23:48:26 +01:00
NetMan
81346eb3b9 add docker compose and dockerfile 2025-12-15 23:21:00 +01:00
NetMan
c05db22b6b add keybinds to btns in exam 2025-12-15 21:51:54 +01:00
NetMan
59497e8b01 exam: warning on refresh and end-exam
warning on end-exam if it isn't the last question - otherwise exam ends without warning
2025-12-15 21:05:54 +01:00
NetMan
97b8d5dab9 question timers, little fixes to nav/store 2025-12-15 20:14:54 +01:00
NetMan
63121da4b7 postgres -> sqlite, pinia/middleware fix?
other smaller: little type fixes, little changes to ui - more readable category chooser
- result screen with correct, incorrect and chosen answers
little changes to readme
2025-12-13 16:07:23 +01:00
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
50 changed files with 7346 additions and 2736 deletions

View file

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

40
Dockerfile Normal file
View file

@ -0,0 +1,40 @@
# Build Stage 1
FROM node:22-alpine AS build
WORKDIR /app
RUN corepack enable
# Copy package.json and your lockfile, here we add pnpm-lock.yaml for illustration
#COPY package.json pnpm-lock.yaml .npmrc ./
COPY package.json pnpm-lock.yaml ./
# Install dependencies
RUN pnpm i
# Copy the entire project
COPY . ./
# Build the project
RUN pnpm run build
# Build Stage 2
FROM node:22-alpine
WORKDIR /app
RUN corepack enable
# Only `.output` folder is needed from the build stage
COPY --from=build /app/.output/ ./
RUN mkdir -p /app/server/db/
COPY --from=build /app/db/database.db /app/server/db/
WORKDIR /app/server
RUN pnpm i
# Change the port and host
ENV PORT=33550
ENV HOST=0.0.0.0
EXPOSE 33550
CMD ["node", "/app/server/index.mjs"]

View file

@ -1,5 +1,52 @@
# nuxt-prawo-jazdy
## 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 latest files should be there
The newest at the moment of me writing this (December 13th 2025) are the (visualisations for questions from November 2025)[https://www.gov.pl/pliki/mi/pytania_egzaminacyjne_na_prawo_jazdy_11_2025.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] better answer click recognition
- [x] beautify website (good for now)
- [x] <b>Fixed?</b> Needs testing, but should be fine question-mark? - fix pinia middleware between pages, MAJOR ISSUE - finishing exam sometimes redirects to homepage instead of results
- [x] question timers
- [x] exam (& results?) warning leave message on exit and timer end (and definitely on refresh)
- [x] add keybinds:
- S - start
- D - nast.pyt
- X - koniec egzaminu (na pewno chcesz zakonczyc egzamin?)
- T - Tak
- N - Nie
- A - A
- B - B
- C - C
- [ ] i18n - pl, en, de, ua (not all questions are available in ua, api handle)
- [ ] UI i18n
- [x] pl
- [ ] en
- [ ] de
- [ ] ua
- [ ] db: examstore add language field, api handle languages
- [ ] db: (revise) script for processing, (revise and) share appropriate files
- [ ] clean up js code in exam.vue and result.vue (currently a little bit of a mess)
## Some information about the project
My intention is, to share access to test exams free of charge, you don't have to pay me - although you can, I greatly appreciate if you donate!
In the future I will host this project publicly `aaS`, and will probably put non-invasive, privacy friendly ads if it gains enough traction
All data used by this software is public information by definition provided in the Polish Constitution - (article 61.)[https://www.sejm.gov.pl/prawo/konst/polski/kon1.htm], and can be acquired by either checking above links on the gov website, or by writing to the Ministry ((if something happened to be missing))[placeholder_for_post_about_missing_points_column]
This project is a website mimicking an official driver's license theoritical exam (for different license categories) with a seperate media server, connected using drizzle ORM to a SQLite database
## Setup
This project utilizes `pnpm`, thus it is recommended
@ -8,8 +55,6 @@ This project utilizes `pnpm`, thus it is recommended
pnpm install
```
More information about setting up database will come here later
## Development Server
Start the development server on `http://localhost:3000`:

10
app.vue
View file

@ -1,7 +1,11 @@
<template>
<div>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<NuxtPage />
</div>
</template>
<style>
.outline-set-solid {
outline-style: solid;
}
</style>

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>

29
components/EndModal.vue Normal file
View file

@ -0,0 +1,29 @@
<script setup lang="ts">
const props = defineProps<{ showModal: boolean }>();
const endModal = useTemplateRef('endModal');
const emit = defineEmits(['hideEndModal', 'endExam']);
watchEffect(() => {
if (props.showModal && endModal.value?.open == false) {
endModal.value?.showModal();
emit('hideEndModal');
}
});
</script>
<template>
<dialog
ref="endModal"
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]">{{ $t('examEnd') }}</h1>
<div class="*:inline">{{ $t('doYouReallyWantToEndExam') }}</div>
<div class="flex flex-row gap-2 justify-around">
<div class="btn btn-lg btn-success" @click="emit('endExam')">Tak</div>
<div class="btn btn-lg btn-error" @click="endModal?.close()">Nie</div>
</div>
</div>
</dialog>
</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">{{ $t('loading') }}</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>

71
components/MediaBox.vue Normal file
View file

@ -0,0 +1,71 @@
<script setup lang="ts">
import { joinURL } from 'ufo';
const runtimeConfig = useRuntimeConfig();
const cdnUrl = runtimeConfig.public.cdn_url;
const route = useRoute();
const emit = defineEmits(['mediaload']);
const props = defineProps<{
mediaPath: string | null | undefined;
phase: string;
}>();
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 };
});
const video = ref();
function onVideoLoad() {
if (route.path === '/exam') {
video.value.play();
let duration = video.value.duration;
if (isNaN(duration) || duration == Infinity) {
duration = 0;
}
setTimeout(() => {
emit('mediaload');
}, duration * 1000);
}
}
</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]' : ''"
>
<img v-if="phase == 'set-basic'" src="/placeholder.svg" alt="placeholder" />
<NuxtImg
v-else-if="media.type === 'image'"
:key="`${mediaPath}-image`"
provider="selfhost"
:src="'/' + mediaPath"
:alt="mediaPath ?? ''"
@load="emit('mediaload')"
/>
<video
v-else-if="media.type === 'video'"
:key="`${mediaPath}-video`"
ref="video"
:controls="route.path === '/result'"
@canplaythrough="onVideoLoad()"
>
<source :src="joinURL(cdnUrl, media.name + '.mp4')" type="video/mp4" />
</video>
<span v-else class="text-5xl font-bold flex items-center justify-center">{{
$t('questionWithoutVisual')
}}</span>
</div>
</template>

View file

@ -0,0 +1,42 @@
<script setup lang="ts">
const myModal = useTemplateRef('myModal');
onMounted(() => {
myModal.value?.showModal();
});
defineEmits(['again', 'home']);
</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">
{{ $t('categoryWord') }}: <slot name="category" />
</div>
<div class="*:inline">
{{ $t('points') }}: <slot name="points" /> / 74
</div>
<div class="*:inline">
{{ $t('result') }}: <slot name="resultTrueFalse" />
</div>
<div class="flex flex-row gap-2">
<div class="btn btn-soft" @click="$emit('home')">
{{ $t('goBackToHomePage') }}
</div>
<div class="btn btn-outline" @click="$emit('again')">
{{ $t('startAgain') }}
</div>
<button class="btn btn-neutral" @click="myModal?.close()">
{{ $t('viewAnswers') }}
</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">{{ $t('pointValue') }}</span>
<div class="info-little-box">
{{ points }}
</div>
</div>
<div>
<span class="block">{{ $t('currentCategory') }}</span>
<div class="info-little-box">
{{ category }}
</div>
</div>
<div v-if="typeof timeRemaining !== 'undefined'">
<span class="block">{{ $t('timeToExamEnd') }}</span>
<div class="info-little-box w-20 text-center">
{{ timeRemainingFriendly }}
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,161 @@
<script setup lang="ts">
import { onKeyStroke } from '@vueuse/core';
const props = defineProps<{
countBasic: number;
countAdvanced: number;
now: string | null | undefined;
ending: boolean;
setBasic: boolean;
time: number;
phase: string;
}>();
const emit = defineEmits<{
endExam: [];
nextQuestion: [];
nextTime: [];
showEndModal: [];
}>();
function tryEndExam() {
if (props.ending == false) {
emit('showEndModal');
} else {
emit('endExam');
}
}
const startButton = useTemplateRef('start-button');
onKeyStroke(['S', 's'], () => {
startButton.value?.click();
});
const nextButton = useTemplateRef('next-button');
onKeyStroke(['D', 'd'], () => {
nextButton.value?.click();
});
const endButton = useTemplateRef('end-button');
onKeyStroke(['X', 'x'], () => {
endButton.value?.click();
});
</script>
<template>
<div
class="flex flex-col items-stretch p-4 gap-10 border-l border-base-300 bg-base-100"
>
<button
ref="end-button"
class="btn btn-warning btn-xl"
@click="tryEndExam()"
>
{{ $t('endExam') }}
</button>
<div class="flex flex-row gap-6 *:flex-1 w-full">
<CurrentQuestionCount
:class="now === 'basic' ? 'font-semibold' : 'opacity-45'"
>
<template #title>
{{ $t('basicQuestions') }}
</template>
<template #count> {{ countBasic + 1 }} / 20 </template>
</CurrentQuestionCount>
<CurrentQuestionCount
:class="now === 'advanced' ? 'font-semibold' : 'opacity-45'"
>
<template #title>
{{ $t('advancedQuestions') }}
</template>
<template #count> {{ countAdvanced + 1 }} / 12 </template>
</CurrentQuestionCount>
</div>
<div
v-if="phase == 'set-basic'"
class="text-center text-xl flex flex-col gap-2"
>
<span>
{{ $t('timeToGetAcquaintedWithTheQuestion') }}
</span>
<div class="flex flex-row items-stretch gap-2">
<div
ref="start-button"
class="btn btn-primary"
@click="emit('nextTime')"
>
{{ $t('startBtn') }}
</div>
<div class="h-full flex-1 relative">
<progress
class="progress progress-warning w-full h-full"
:value="time"
max="20"
></progress>
<span class="block set-translate z-10 text-black text-2xl">
{{ time >= 0 ? time : 0 }} {{ $t('second') }}
</span>
</div>
</div>
</div>
<div v-else class="text-center text-xl flex flex-col gap-2">
<span>
{{ $t('timeForAnswer') }}
</span>
<div class="h-9 relative">
<progress
class="progress progress-warning w-full h-full"
:value="time"
:max="phase == 'start-basic' ? 15 : 45"
></progress>
<span class="block set-translate z-10 text-black text-2xl">
{{ time >= 0 ? time : 0 }} {{ $t('second') }}
</span>
</div>
</div>
<div class="flex-1">
<span class="text-xl">
{{ $t('keybinds') }}
</span>
<table class="table table-sm">
<tbody>
<tr
v-for="key in ['S', 'D', 'X', 'T / Y', 'N', 'A', 'B', 'C']"
:key="`keybind${key}`"
>
<td>{{ key }}</td>
<td>{{ $t(`bindedKeys.${key}`) }}</td>
</tr>
</tbody>
</table>
</div>
<button
ref="next-button"
class="btn btn-warning btn-xl"
:disabled="ending || setBasic"
@click="emit('nextQuestion')"
>
{{ $t('nextQuestion') }}
</button>
</div>
</template>
<style>
.progressive {
width: calc(100% / v-bind('time'));
transition: all 1s linear;
}
progress[value]::-webkit-progress-value {
transition: width 0.5s;
}
</style>

View file

@ -0,0 +1,98 @@
<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];
again: [];
home: [];
}>();
</script>
<template>
<div
class="flex flex-col items-stretch p-4 gap-6 border-l border-base-300 bg-base-100"
>
<div class="btn btn-warning btn-xl" @click="$emit('home')">
{{ $t('goBackToHomePage') }}
</div>
<button class="btn btn-info btn-lg" @click="emit('changeNow', 'basic')">
{{ $t('basicQuestions') }}
</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].chosen_answer == ''
? 'btn-warning'
: 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')">
{{ $t('advancedQuestions') }}
</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].chosen_answer == ''
? 'btn-warning'
: 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">
{{ $t('points') }}: <slot name="points" /> / 74
</div>
<div class="*:inline">
{{ $t('result') }}: <slot name="resultTrueFalse" />
</div>
</div>
<div class="btn btn-warning btn-xl" @click="$emit('again')">
{{ $t('startAgain') }}
</div>
</div>
</template>

View file

@ -0,0 +1,91 @@
<script lang="ts" setup>
import { onKeyStroke } from '@vueuse/core';
const props = defineProps<{
question: Question;
phase: string;
}>();
const answer = defineModel<string | null | undefined>();
const aButton = useTemplateRef('A-button');
onKeyStroke(['A', 'a'], () => {
if (props.phase != 'result') {
aButton.value[0].click();
}
});
const bButton = useTemplateRef('B-button');
onKeyStroke(['B', 'b'], () => {
if (props.phase != 'result') {
bButton.value[0].click();
}
});
const cButton = useTemplateRef('C-button');
onKeyStroke(['C', 'c'], () => {
if (props.phase != 'result') {
cButton.value[0].click();
}
});
</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 as AdvancedQuestion)?.answer_a,
B: (question as AdvancedQuestion)?.answer_b,
C: (question as AdvancedQuestion)?.answer_c,
})"
:key="`btn_answer_${element}_${value}`"
>
<input
:id="`odp_${element}`"
:ref="`${element}-button`"
v-model="answer"
type="radio"
name="abc"
:value="element"
class="hidden"
/>
<label :for="`odp_${element}`">
<div
:class="
phase == 'exam'
? answer === element
? ' !btn-secondary'
: ''
: `${answer === element ? 'outline-set-solid outline-2' : ''} ${element == question?.correct_answer ? 'btn-success' : 'btn-error'}`
"
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,70 @@
<script lang="ts" setup>
import { onKeyStroke } from '@vueuse/core';
const props = defineProps<{
question: Question;
phase: string;
}>();
const answer = defineModel<string | null | undefined>();
const yesButton = useTemplateRef('true-button');
onKeyStroke(['T', 't', 'Y', 'y'], () => {
if (props.phase != 'result') {
yesButton.value[0].click();
}
});
const noButton = useTemplateRef('false-button');
onKeyStroke(['N', 'n'], () => {
if (props.phase != 'result') {
noButton.value[0].click();
}
});
</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}`"
:ref="`${value}-button`"
: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="
phase == 'exam'
? answer == null
? false
: answer === value.toString()
? '!btn-secondary'
: ''
: `${
answer === value.toString()
? 'outline-set-solid outline-2'
: ''
} ${
question?.correct_answer?.toString() == value.toString()
? ' btn-success'
: ' btn-error'
}`
"
:checked="answer == null ? false : answer === value.toString()"
/>
</div>
</div>
</div>
</template>

BIN
db/database.db Normal file

Binary file not shown.

36
db/schema.ts Normal file
View file

@ -0,0 +1,36 @@
import { sqliteTable, text, int } from 'drizzle-orm/sqlite-core';
export const tasks = sqliteTable('tasks', {
id: int().notNull(),
correct_answer: text(),
media_url: text(),
weight: int(),
});
export const questions = sqliteTable('questions', {
task_id: int(),
lang: text(),
text: text(),
});
export const tasks_advanced = sqliteTable('tasks_advanced', {
id: int().notNull(),
correct_answer: text(),
media_url: text(),
weight: int(),
});
export const questions_advanced = sqliteTable('questions_advanced', {
task_id: int(),
lang: text(),
text: text(),
answer_a: text(),
answer_b: text(),
answer_c: text(),
});
export const categories_db = sqliteTable('categories', {
name: text(),
task_id: int(),
});

7
docker-compose.yml Normal file
View file

@ -0,0 +1,7 @@
services:
prawojazdy:
container_name: prawojazdy
build: .
ports:
- 33550:33550
env_file: '.env'

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",
out: './drizzle',
schema: './db/schema.ts',
dialect: 'sqlite',
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,
);

73
i18n/locales/de.json Normal file
View file

@ -0,0 +1,73 @@
{
"mainTitle": "Test na prawo jazdy",
"loading": "Ładowanie",
"keybinds": "Skróty klawiszowe",
"bindedKeys": {
"S": "niebieski przycisk start",
"D": "następne pytanie",
"X": "zakończ egzamin",
"T / Y": "Tak",
"N": "Nie",
"A": "A",
"B": "B",
"C": "C"
},
"endExam": "Zakończ egzamin",
"examEnd": "Koniec egzaminu",
"basicQuestions": "Pytania podstawowe",
"advancedQuestions": "Pytania specjalistyczne",
"timeToGetAcquaintedWithTheQuestion": "Czas na zapoznanie się z pytaniem",
"timeForAnswer": "Czas na udzielenie odpowiedzi",
"startBtn": "START",
"second": "s",
"nextQuestion": "Następne pytanie",
"goBackToHomePage": "Wróć na stronę główną",
"points": "Punkty",
"pointValue": "Wartość punktowa",
"currentCategory": "Aktualna kategoria",
"timeToExamEnd": "Czas do końca egzaminu",
"result": "Wynik",
"startAgain": "Rozpocznij jeszcze raz",
"viewAnswers": "Przejrzyj odpowiedzi",
"doYouReallyWantToEndExam": "Czy na pewno chcesz zakończyć egzamin?",
"questionWithoutVisual": "Pytanie bez wizualizacji",
"categoryWord": "Kategoria",
"anAnomalyHasOccured": "Nastąpiła anomalia",
"redirectFrom": "Przekierowanie z",
"end": "Koniec",
"question": "Pytanie",
"anAPIErrorOccured": "Wystąpił błąd z API",
"positive": "pozytywny",
"negative": "negatywny",
"theoreticalExam": "Egzamin teorytyczny",
"category": {
"description": {
"A": "motocykle bez ograniczeń mocy",
"B": "⭐ samochody osobowe do 3,5 t",
"C": "pojazdy ciężarowe powyżej 3,5 t",
"D": "autobusy",
"T": "ciągniki rolnicze i pojazdy wolnobieżne",
"AM": "motorowery i lekkie czterokołowce",
"A1": "motocykle do 125 cm³ i 11 kW",
"A2": "motocykle do 35 kW",
"B1": "czterokołowce (np. quady)",
"C1": "pojazdy od 3,5 t do 7,5 t",
"D1": "autobusy do 16 pasażerów",
"PT": "tramwaje"
},
"age": {
"A": "(24 lata; lub 20 lat jeśli masz kat. A2 min. 2 lata)",
"B": "(18 lat)",
"C": "(21 lat; lub 18 lat z kwalifikacją wstępną)",
"D": "(24 lata; lub 21 lat z kwalifikacją wstępną)",
"T": "(16 lat)",
"AM": "(14 lat)",
"A1": "(16 lat)",
"A2": "(18 lat)",
"B1": "(16 lat)",
"C1": "(18 lat)",
"D1": "(21 lat; lub 18 lat z kwalifikacją wstępną)",
"PT": "(21 lat)"
}
}
}

73
i18n/locales/en.json Normal file
View file

@ -0,0 +1,73 @@
{
"mainTitle": "Test na prawo jazdy",
"loading": "Ładowanie",
"keybinds": "Skróty klawiszowe",
"bindedKeys": {
"S": "niebieski przycisk start",
"D": "następne pytanie",
"X": "zakończ egzamin",
"T / Y": "Tak",
"N": "Nie",
"A": "A",
"B": "B",
"C": "C"
},
"endExam": "Zakończ egzamin",
"examEnd": "Koniec egzaminu",
"basicQuestions": "Pytania podstawowe",
"advancedQuestions": "Pytania specjalistyczne",
"timeToGetAcquaintedWithTheQuestion": "Czas na zapoznanie się z pytaniem",
"timeForAnswer": "Czas na udzielenie odpowiedzi",
"startBtn": "START",
"second": "s",
"nextQuestion": "Następne pytanie",
"goBackToHomePage": "Wróć na stronę główną",
"points": "Punkty",
"pointValue": "Wartość punktowa",
"currentCategory": "Aktualna kategoria",
"timeToExamEnd": "Czas do końca egzaminu",
"result": "Wynik",
"startAgain": "Rozpocznij jeszcze raz",
"viewAnswers": "Przejrzyj odpowiedzi",
"doYouReallyWantToEndExam": "Czy na pewno chcesz zakończyć egzamin?",
"questionWithoutVisual": "Pytanie bez wizualizacji",
"categoryWord": "Kategoria",
"anAnomalyHasOccured": "Nastąpiła anomalia",
"redirectFrom": "Przekierowanie z",
"end": "Koniec",
"question": "Pytanie",
"anAPIErrorOccured": "Wystąpił błąd z API",
"positive": "pozytywny",
"negative": "negatywny",
"theoreticalExam": "Egzamin teorytyczny",
"category": {
"description": {
"A": "motocykle bez ograniczeń mocy",
"B": "⭐ samochody osobowe do 3,5 t",
"C": "pojazdy ciężarowe powyżej 3,5 t",
"D": "autobusy",
"T": "ciągniki rolnicze i pojazdy wolnobieżne",
"AM": "motorowery i lekkie czterokołowce",
"A1": "motocykle do 125 cm³ i 11 kW",
"A2": "motocykle do 35 kW",
"B1": "czterokołowce (np. quady)",
"C1": "pojazdy od 3,5 t do 7,5 t",
"D1": "autobusy do 16 pasażerów",
"PT": "tramwaje"
},
"age": {
"A": "(24 lata; lub 20 lat jeśli masz kat. A2 min. 2 lata)",
"B": "(18 lat)",
"C": "(21 lat; lub 18 lat z kwalifikacją wstępną)",
"D": "(24 lata; lub 21 lat z kwalifikacją wstępną)",
"T": "(16 lat)",
"AM": "(14 lat)",
"A1": "(16 lat)",
"A2": "(18 lat)",
"B1": "(16 lat)",
"C1": "(18 lat)",
"D1": "(21 lat; lub 18 lat z kwalifikacją wstępną)",
"PT": "(21 lat)"
}
}
}

73
i18n/locales/pl.json Normal file
View file

@ -0,0 +1,73 @@
{
"mainTitle": "Test na prawo jazdy",
"loading": "Ładowanie",
"keybinds": "Skróty klawiszowe",
"bindedKeys": {
"S": "niebieski przycisk start",
"D": "następne pytanie",
"X": "zakończ egzamin",
"T / Y": "Tak",
"N": "Nie",
"A": "A",
"B": "B",
"C": "C"
},
"endExam": "Zakończ egzamin",
"examEnd": "Koniec egzaminu",
"basicQuestions": "Pytania podstawowe",
"advancedQuestions": "Pytania specjalistyczne",
"timeToGetAcquaintedWithTheQuestion": "Czas na zapoznanie się z pytaniem",
"timeForAnswer": "Czas na udzielenie odpowiedzi",
"startBtn": "START",
"second": "s",
"nextQuestion": "Następne pytanie",
"goBackToHomePage": "Wróć na stronę główną",
"points": "Punkty",
"pointValue": "Wartość punktowa",
"currentCategory": "Aktualna kategoria",
"timeToExamEnd": "Czas do końca egzaminu",
"result": "Wynik",
"startAgain": "Rozpocznij jeszcze raz",
"viewAnswers": "Przejrzyj odpowiedzi",
"doYouReallyWantToEndExam": "Czy na pewno chcesz zakończyć egzamin?",
"questionWithoutVisual": "Pytanie bez wizualizacji",
"categoryWord": "Kategoria",
"anAnomalyHasOccured": "Nastąpiła anomalia",
"redirectFrom": "Przekierowanie z",
"end": "Koniec",
"question": "Pytanie",
"anAPIErrorOccured": "Wystąpił błąd z API",
"positive": "pozytywny",
"negative": "negatywny",
"theoreticalExam": "Egzamin teorytyczny",
"category": {
"description": {
"A": "motocykle bez ograniczeń mocy",
"B": "⭐ samochody osobowe do 3,5 t",
"C": "pojazdy ciężarowe powyżej 3,5 t",
"D": "autobusy",
"T": "ciągniki rolnicze i pojazdy wolnobieżne",
"AM": "motorowery i lekkie czterokołowce",
"A1": "motocykle do 125 cm³ i 11 kW",
"A2": "motocykle do 35 kW",
"B1": "czterokołowce (np. quady)",
"C1": "pojazdy od 3,5 t do 7,5 t",
"D1": "autobusy do 16 pasażerów",
"PT": "tramwaje"
},
"age": {
"A": "(24 lata; lub 20 lat jeśli masz kat. A2 min. 2 lata)",
"B": "(18 lat)",
"C": "(21 lat; lub 18 lat z kwalifikacją wstępną)",
"D": "(24 lata; lub 21 lat z kwalifikacją wstępną)",
"T": "(16 lat)",
"AM": "(14 lat)",
"A1": "(16 lat)",
"A2": "(18 lat)",
"B1": "(16 lat)",
"C1": "(18 lat)",
"D1": "(21 lat; lub 18 lat z kwalifikacją wstępną)",
"PT": "(21 lat)"
}
}
}

73
i18n/locales/ua.json Normal file
View file

@ -0,0 +1,73 @@
{
"mainTitle": "Test na prawo jazdy",
"loading": "Ładowanie",
"keybinds": "Skróty klawiszowe",
"bindedKeys": {
"S": "niebieski przycisk start",
"D": "następne pytanie",
"X": "zakończ egzamin",
"T / Y": "Tak",
"N": "Nie",
"A": "A",
"B": "B",
"C": "C"
},
"endExam": "Zakończ egzamin",
"examEnd": "Koniec egzaminu",
"basicQuestions": "Pytania podstawowe",
"advancedQuestions": "Pytania specjalistyczne",
"timeToGetAcquaintedWithTheQuestion": "Czas na zapoznanie się z pytaniem",
"timeForAnswer": "Czas na udzielenie odpowiedzi",
"startBtn": "START",
"second": "s",
"nextQuestion": "Następne pytanie",
"goBackToHomePage": "Wróć na stronę główną",
"points": "Punkty",
"pointValue": "Wartość punktowa",
"currentCategory": "Aktualna kategoria",
"timeToExamEnd": "Czas do końca egzaminu",
"result": "Wynik",
"startAgain": "Rozpocznij jeszcze raz",
"viewAnswers": "Przejrzyj odpowiedzi",
"doYouReallyWantToEndExam": "Czy na pewno chcesz zakończyć egzamin?",
"questionWithoutVisual": "Pytanie bez wizualizacji",
"categoryWord": "Kategoria",
"anAnomalyHasOccured": "Nastąpiła anomalia",
"redirectFrom": "Przekierowanie z",
"end": "Koniec",
"question": "Pytanie",
"anAPIErrorOccured": "Wystąpił błąd z API",
"positive": "pozytywny",
"negative": "negatywny",
"theoreticalExam": "Egzamin teorytyczny",
"category": {
"description": {
"A": "motocykle bez ograniczeń mocy",
"B": "⭐ samochody osobowe do 3,5 t",
"C": "pojazdy ciężarowe powyżej 3,5 t",
"D": "autobusy",
"T": "ciągniki rolnicze i pojazdy wolnobieżne",
"AM": "motorowery i lekkie czterokołowce",
"A1": "motocykle do 125 cm³ i 11 kW",
"A2": "motocykle do 35 kW",
"B1": "czterokołowce (np. quady)",
"C1": "pojazdy od 3,5 t do 7,5 t",
"D1": "autobusy do 16 pasażerów",
"PT": "tramwaje"
},
"age": {
"A": "(24 lata; lub 20 lat jeśli masz kat. A2 min. 2 lata)",
"B": "(18 lat)",
"C": "(21 lat; lub 18 lat z kwalifikacją wstępną)",
"D": "(24 lata; lub 21 lat z kwalifikacją wstępną)",
"T": "(16 lat)",
"AM": "(14 lat)",
"A1": "(16 lat)",
"A2": "(18 lat)",
"B1": "(16 lat)",
"C1": "(18 lat)",
"D1": "(21 lat; lub 18 lat z kwalifikacją wstępną)",
"PT": "(21 lat)"
}
}
}

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.end) {
return '/result';
}
if (examStore.category === '') {
return '/anomaly';
}
});

6
middleware/result.ts Normal file
View file

@ -0,0 +1,6 @@
export default defineNuxtRouteMiddleware(async () => {
const examStore = useExamStore();
if (!examStore.end || examStore.category === '') {
return '/anomaly';
}
});

View file

@ -1,15 +1,63 @@
// 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"],
ssr: true,
modules: [
'@nuxtjs/tailwindcss',
'@nuxt/fonts',
'@pinia/nuxt',
'pinia-plugin-persistedstate/nuxt',
'@nuxt/eslint',
'@nuxt/image',
'@nuxtjs/i18n',
],
ssr: false,
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,
},
},
},
i18n: {
locales: [
{ code: 'pl', language: 'pl-PL', file: 'pl.json' },
{ code: 'en', language: 'en-GB', file: 'en.json' },
{ code: 'de', language: 'de-DE', file: 'de.json' },
{ code: 'ua', language: 'uk-UK', file: 'ua.json' },
],
defaultLocale: 'pl',
},
image: {
providers: {
selfhost: {
name: 'selfhost',
provider: '~/providers/selfhost.ts',
options: {
baseUrl: process.env.CDN_URL,
},
},
},
provider: 'selfhost',
},
pinia: {
storesDirs: ['./store/**'],
},
});

View file

@ -7,28 +7,44 @@
"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",
"@libsql/client": "^0.15.15",
"@nuxt/fonts": "0.11.1",
"@nuxt/image": "1.10.0",
"@nuxtjs/i18n": "10.2.1",
"@nuxtjs/tailwindcss": "6.13.2",
"@pinia/nuxt": "0.11.0",
"@vueuse/core": "^14.1.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",
"pinia": "^3.0.2",
"pinia-plugin-persistedstate": "^4.7.1",
"ufo": "^1.6.1",
"vue": "latest",
"vue-country-flag-next": "^2.3.2",
"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",
"eslint-config-prettier": "^10.1.2",
"prettier": "^3.5.3",
"tsx": "^4.19.3",
"typescript": "^5.8.3",
"vite-plugin-eslint2": "^5.0.3"
}
}

25
pages/anomaly.vue Normal file
View file

@ -0,0 +1,25 @@
<script setup lang="ts">
const examStore = useExamStore();
const basicStore = useBasicStore();
const advancedStore = useAdvancedStore();
</script>
<template>
<div class="flex flex-col gap-2 items-start m-2">
<h1 class="text-2xl">{{ $t('anAnomalyHasOccured') }}</h1>
<br />
{{ $t('redirectFrom') }}: {{ useRoute().redirectedFrom ?? '""' }} <br />
{{ $t('categoryWord') }}:
{{ examStore.category != '' ? examStore.category : '""' }}
<br />
{{ $t('end') }}: {{ examStore.end }} <br />
{{ $t('basicQuestions') }}:
<code class="text-xs">{{ basicStore.basic }}</code>
<br />
{{ $t('advancedQuestions') }}:
<code class="text-xs">{{ advancedStore.advanced }}</code> <br />
<NuxtLink to="/" class="btn btn-primary">
{{ $t('goBackToHomePage') }}
</NuxtLink>
</div>
</template>

View file

@ -1,178 +1,272 @@
<script lang="ts" setup>
import "7.css/dist/7.scoped.css";
import { intervalToDuration, addMinutes, addSeconds, isAfter } from 'date-fns';
import { useNow } from '@vueuse/core';
import { useExamStore } from '~/store/examStore';
useHead({
title: "Pytanie 1/20",
definePageMeta({ middleware: 'exam' });
const examStore = useExamStore();
const basicStore = useBasicStore();
const advancedStore = useAdvancedStore();
await callOnce(() => examStore.mildReset(), { mode: 'navigation' });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function preventRefresh(e: any) {
e.preventDefault();
e.returnValue = ''; // polyfill for older chrome
}
const now = ref('basic');
const nowTime = useNow();
const timeEnd = addMinutes(new Date(), 25);
const questionEnd = ref(addSeconds(new Date(), 20));
const mediaLoaded = ref(false);
const time = ref({
total: computed(() =>
intervalToDuration({
start: nowTime.value,
end: timeEnd,
}),
),
question: computed(() => {
const interval = intervalToDuration({
start: nowTime.value,
end: questionEnd.value,
});
if (Object.hasOwn(interval, 'seconds') && interval.seconds != undefined) {
return interval.seconds;
} else {
return 0;
}
}),
phase: 'set-basic',
});
function changeQuestionTimeAfterNext() {
if (time.value.phase == 'set-basic') {
time.value.phase = 'start-basic';
questionEnd.value = addSeconds(new Date(), 15);
} else if (time.value.phase == 'start-basic') {
if (now.value == 'basic') {
time.value.phase = 'set-basic';
mediaLoaded.value = false;
questionEnd.value = addSeconds(new Date(), 20);
} else {
time.value.phase = 'set-advanced';
questionEnd.value = addSeconds(new Date(), 50);
}
} else if (time.value.phase == 'set-advanced') {
questionEnd.value = addSeconds(new Date(), 50);
}
}
function onMediaLoad() {
mediaLoaded.value = true;
}
function clickNext() {
if (ending.value) {
endExam();
}
if (time.value.phase != 'set-basic') {
next();
}
changeQuestionTimeAfterNext();
}
onMounted(() => {
useHead({
title: `${$t('question')} 1/20`,
});
window.addEventListener('beforeunload', preventRefresh);
watchEffect(() => {
if (isAfter(nowTime.value, timeEnd)) endExam();
});
watchEffect(() => {
if (now.value === 'basic')
useHead({ title: `${$t('question')} ${countBasic.value + 1}/20` });
if (now.value === 'advanced')
useHead({ title: `${$t('question')} ${countAdvanced.value + 1}/12` });
});
watchEffect(() => {
if (mediaLoaded.value == false && time.value.phase == 'start-basic') {
questionEnd.value = addSeconds(new Date(), 15);
}
if (time.value.question < 0 && ending.value == false) {
clickNext();
}
});
});
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', preventRefresh);
});
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 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;
if (now.value === 'basic') return questionBasic.value;
if (now.value === 'advanced') return questionAdvanced.value;
return null;
});
const result: Ref<ResultEndType> = ref({
basic: [],
advanced: [],
});
function next() {
if (examStore.end == false) {
result.value[now.value as keyof ResultEndType].push({
question: question.value,
chosen_answer: answer.value,
correct_answer: question.value?.correct_answer ?? null,
chosen_is_correct: answer.value == question.value?.correct_answer,
});
answer.value = '';
} else {
return;
console.error('next() incorrectly executed - exam has already ended.');
}
});
if (now.value === 'basic') {
if (countBasic.value + 1 <= 19) {
countBasic.value++;
} else {
now.value = 'advanced';
countAdvanced.value++;
}
} else if (now.value === 'advanced') {
if (countAdvanced.value + 1 <= 11) {
countAdvanced.value++;
}
if (countAdvanced.value + 1 >= 12) {
ending.value = true;
}
}
}
const media = computed(() => {
const mediaSplit = question.value?.media?.split(".");
return {
fileType: mediaSplit?.pop()?.toLowerCase(),
fileName: mediaSplit?.join("."),
ogName: question.value?.media,
};
});
function endExam() {
loading.value = true;
while (ending.value == false) {
if (time.value.phase != 'set-basic') {
next();
}
changeQuestionTimeAfterNext();
}
next();
basicStore.set(result.value.basic);
advancedStore.set(result.value.advanced);
examStore.setEnd(true);
if (
basicStore.basic == result.value.basic &&
advancedStore.advanced == result.value.advanced &&
examStore.end
) {
return navigateTo(`/result`, { replace: true });
} else {
return navigateTo(`/anomaly`);
}
}
const questionaries: Ref<Array<any>> = ref([]);
const loading = ref(false);
// onMounted(() => {
// const progresInterval = setInterval(() => {
// progres.value.value = +progres.value.value + 1;
// if (progres.value.value >= 100) {
// clearInterval(progresInterval);
// }
// }, 100);
// });
const showEndModal = 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="time.total"
/>
<Media :media="media" />
<BasicQuestionBlock
v-if="countAdvanced < 0"
<MediaBox
:media-path="question?.media_url"
:phase="time.phase"
@mediaload="onMediaLoad()"
/>
<QuestionBasic
v-if="now === 'basic'"
v-model="answer"
phase="exam"
:question="questionBasic"
v-model="tak_nie_model"
/>
<AdvancedQuestionBlock
v-else
<QuestionAdvanced
v-else-if="now === 'advanced'"
v-model="answer"
phase="exam"
: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"
:set-basic="time.phase == 'set-basic'"
:time="time.question"
:phase="time.phase"
@show-end-modal="showEndModal = true"
@next-question="clickNext()"
@end-exam="endExam()"
@next-time="changeQuestionTimeAfterNext()"
/>
</div>
</div>
<div v-else-if="statusBasic === 'error' || statusAdvanced === 'error'">
An API error occurred: {{ errorBasic }} {{ errorAdvanced }}
{{ $t('anAPIErrorOccured') }}:<br />
<code class="text-sm">{{ errorBasic }}</code>
<br />
<code class="text-sm">{{ errorAdvanced }}</code>
</div>
<div v-else>Loading...</div>
<LoadingScreen v-else />
<EndModal
:show-modal="showEndModal"
@hide-end-modal="showEndModal = false"
@end-exam="endExam()"
/>
</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,73 @@
<script setup lang="ts">
import CountryFlag from 'vue-country-flag-next';
import categories from '~/categories';
onMounted(() => {
useHead({
title: $t('mainTitle'),
});
});
const { setLocale } = useI18n();
const loading = ref(false);
const examStore = useExamStore();
await callOnce(() => examStore.resetExam(), { mode: 'navigation' });
function setAndGo(category: string) {
loading.value = true;
examStore.setCategory(category);
while (true) {
if (examStore.category === category) {
return navigateTo('/exam');
}
}
}
const langSelect = ref(examStore.lang);
function changeLanguage() {
examStore.setLang(langSelect.value);
setLocale(langSelect.value as 'pl' | 'en' | 'de' | 'ua');
}
</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 m-2 flex flex-col gap-2">
<span>{{ $t('mainTitle') }}</span>
<div class="flex gap-2">
<CountryFlag
class="block border border-1 border-black"
:country="langSelect != 'en' ? langSelect : 'gb'"
size="big"
/>
<select v-model="langSelect" class="select" @change="changeLanguage">
<option value="pl">Polish (Polski)</option>
<option value="en">English</option>
<option value="de">German (Deutsch)</option>
<option value="ua">Ukrainian (Українська)</option>
</select>
</div>
<div
class="flex flex-col flex-wrap gap-2 items-start p-4 bg-slate-100 border-1 border-slate-500 rounded-xl w-fit"
>
<div
v-for="category in categories"
:key="`btn-${category}`"
class="flex flex-row gap-3 items-center"
>
<button class="btn btn-xl btn-secondary" @click="setAndGo(category)">
{{ category }}
</button>
<div class="flex flex-col text-sm">
<div>{{ $t(`category.description.${category}`) }}</div>
<div>{{ $t(`category.age.${category}`) }}</div>
</div>
</div>
</div>
</div>
<LoadingScreen v-else />
</div>
</template>

155
pages/result.vue Normal file
View file

@ -0,0 +1,155 @@
<script setup lang="ts">
import ResultModal from '~/components/ResultModal.vue';
definePageMeta({ middleware: ['result'] });
const examStore = useExamStore();
const basicStore = useBasicStore();
const advancedStore = useAdvancedStore();
const loading = ref(false);
const points = ref<number>(0);
basicStore.basic.forEach((answer) => {
if (answer.chosen_is_correct) {
points.value += answer.question?.weight ?? 0;
}
});
advancedStore.advanced.forEach((answer) => {
if (answer.chosen_is_correct) {
points.value += answer.question?.weight ?? 0;
}
});
const resultTrueFalse = ref(
points.value >= 68 ? $t('positive') : $t('negative'),
);
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 | undefined>(() =>
basicStore.basic.at(countBasic.value),
);
const resultQuestionAdvanced = computed<ResultType | undefined>(() =>
advancedStore.advanced.at(countAdvanced.value),
);
const questionBasic = computed<Question>(
() => resultQuestionBasic.value?.question,
);
const questionAdvanced = computed<Question>(
() => 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;
}
}
async function again() {
loading.value = true;
await examStore.mildReset();
return await navigateTo('/exam');
}
async function home() {
loading.value = true;
await examStore.resetExam();
return await navigateTo('/');
}
</script>
<template>
<div>
<LoadingScreen v-if="loading" />
<div v-else>
<ResultModal @again="again" @home="home">
<template #title>{{ $t('theoreticalExam') }}</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" phase="" />
<QuestionBasic
v-if="now === 'basic'"
v-model="answer"
:question="questionBasic"
phase="result"
class="select-none z-[-1]"
/>
<QuestionAdvanced
v-else-if="now === 'advanced'"
v-model="answer"
:question="questionAdvanced"
phase="result"
class="select-none z-[-1]"
/>
</div>
<BarRightResult
:result="{
basic: basicStore.basic,
advanced: advancedStore.advanced,
}"
:count-basic="countBasic"
:count-advanced="countAdvanced"
:now="now"
@change-now="changeNow"
@change-count="changeCount"
@home="home"
@again="again"
>
<template #points>{{ points }}</template>
<template #resultTrueFalse>{{ resultTrueFalse }}</template>
</BarRightResult>
</div>
</div>
</div>
</div>
</template>

7513
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,63 @@
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/libsql';
import { sql, eq, and } from 'drizzle-orm';
import arrayShuffle from 'array-shuffle';
import { tasks_advanced, questions_advanced, categories_db } from '~/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/libsql';
import { sql, eq, and } from 'drizzle-orm';
import arrayShuffle from 'array-shuffle';
import { tasks, questions, categories_db } from '~/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 +0,0 @@
import { integer, pgTable, text } 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 punkty = pgTable("punkty", {
nr_pytania: integer().primaryKey().notNull(),
liczba_pkt: integer().notNull(),
});

58
store/examStore.ts Normal file
View file

@ -0,0 +1,58 @@
export const useBasicStore = defineStore('basicStore', {
state: () => ({
basic: [] as ResultType[],
}),
actions: {
async set(basic: ResultType[]) {
this.basic = basic;
},
},
persist: {
storage: piniaPluginPersistedstate.localStorage(),
},
});
export const useAdvancedStore = defineStore('advancedStore', {
state: () => ({
advanced: [] as ResultType[],
}),
actions: {
async set(advanced: ResultType[]) {
this.advanced = advanced;
},
},
persist: {
storage: piniaPluginPersistedstate.localStorage(),
},
});
export const useExamStore = defineStore('examStore', {
state: () => ({
category: '',
end: false,
lang: 'pl',
}),
actions: {
async setCategory(category: string) {
this.category = category;
},
async setEnd(end: boolean) {
this.end = end;
},
async setLang(lang: string) {
this.lang = lang;
},
async mildReset() {
this.end = false;
useBasicStore().set([]);
useAdvancedStore().set([]);
},
async resetExam() {
this.category = '';
this.mildReset();
},
},
persist: {
storage: piniaPluginPersistedstate.cookies(),
},
});

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

27
types/index.ts Normal file
View file

@ -0,0 +1,27 @@
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 type Question = BasicQuestion | AdvancedQuestion | null | undefined;
export interface ResultType {
question: Question;
chosen_answer: string;
correct_answer: string | null;
chosen_is_correct: boolean | undefined;
}
export interface ResultEndType {
basic: ResultType[];
advanced: ResultType[];
}