Compare commits

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

11 commits
main ... main

29 changed files with 2165 additions and 795 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,17 @@ This project utilizes `pnpm`, thus it is recommended
pnpm install
```
More information about setting up database will come here later
# To-do:
- [ ] re-forge database structure, script for processing, share appropriate files
- [ ] choose category
- [ ] beautify website
- [ ] better answer click recognition
- [ ] come up with how to show results appropriately
- [ ] i18n - pl, en, de, ua (not all questions are not available in ua, api handle)
- [ ] exam (maybe also results?) warning leave message on exit (refresh)
- [ ] lazy loading
- [ ] question timers, and at end of total timer show a message for a while before immediatly navigating to results (maybe sth similar also when normally ending exam)
## Development Server

View file

@ -1,7 +1,9 @@
<script setup lang="ts">
import "~/assets/css/main.css";
</script>
<template>
<div>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<NuxtPage />
</div>
</template>

23
assets/css/main.css Normal file
View file

@ -0,0 +1,23 @@
.btn {
height: initial !important;
min-height: var(--size);
}
.set-translate {
@apply absolute top-[50%] left-[50%];
transform: translate(-50%, -50%);
}
/* Transition (later)
.page-enter-active,
.page-leave-active {
transition: all 0.4s;
}
.page-enter-from,
.page-leave-to {
opacity: 0;
filter: blur(1rem);
} */
.info-little-box {
@apply inline-block px-[15px] py-[8px] bg-blue-500 text-white font-bold rounded-md;
}

View file

@ -7,10 +7,12 @@ const abc_model = defineModel();
</script>
<template>
<div class="flex flex-col gap-3">
<div
class="flex flex-col gap-5 border-t px-4 py-5 border-slate-300 bg-slate-100"
>
<div class="text-xl">{{ question?.pytanie }}</div>
<div>
<div class="flex flex-col">
<div class="flex flex-col gap-3">
<input
type="radio"
name="abc"
@ -21,11 +23,13 @@ const abc_model = defineModel();
/>
<label for="odp_a">
<div
:class="`btn-answer ${abc_model == 'a' ? ' !bg-fuchsia-500' : ''}`"
:class="`btn btn-primary btn-lg ${
abc_model == 'a' ? ' !btn-secondary' : ''
}`"
>
A
</div>
{{ question?.odp_a }}
<span class="block">{{ question?.odp_a }}</span>
</label>
<input
type="radio"
@ -37,11 +41,13 @@ const abc_model = defineModel();
/>
<label for="odp_b">
<div
:class="`btn-answer ${abc_model == 'b' ? ' !bg-fuchsia-500' : ''}`"
:class="`btn btn-primary btn-lg ${
abc_model == 'b' ? ' !btn-secondary' : ''
}`"
>
B
</div>
{{ question?.odp_b }}
<span class="block">{{ question?.odp_b }}</span>
</label>
<input
type="radio"
@ -53,11 +59,13 @@ const abc_model = defineModel();
/>
<label for="odp_c">
<div
:class="`btn-answer ${abc_model == 'c' ? ' !bg-fuchsia-500' : ''}`"
:class="`btn btn-primary btn-lg ${
abc_model == 'c' ? ' !btn-secondary' : ''
}`"
>
C
</div>
{{ question?.odp_c }}
<span class="block">{{ question?.odp_c }}</span>
</label>
</div>
</div>
@ -65,18 +73,13 @@ const abc_model = defineModel();
</template>
<style scoped>
.btn-answer {
display: inline-block;
}
label {
cursor: pointer;
@apply cursor-pointer flex flex-row gap-2 items-center;
&:hover {
@apply bg-slate-200;
}
}
label:hover .btn-answer {
@apply bg-blue-300;
}
label:hover {
@apply bg-slate-200;
span {
@apply text-lg;
}
</style>

View file

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

8
components/Loading.vue Normal file
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>
<span class="block">Ładowanie</span>
</div>
</template>

View file

@ -15,10 +15,19 @@ defineProps<{
<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>
<!-- Reserved for getting to know the question (20s) in basic questions section
src="~/public/placeholder.svg" -->
<img
:src="cdnUrl + media.ogName"
alt=""
v-if="media.fileType == 'jpg'"
:key="`${media.ogName}-photo`"
/>
<video
v-else-if="media.fileType == 'wmv'"
:key="`${media.ogName}-video`"
autoplay
>
<source
:src="cdnUrl + [media.fileName, 'mp4'].join('.')"
type="video/mp4"

View file

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

@ -0,0 +1,95 @@
<script setup lang="ts">
const props = defineProps<{
result: ResultEndType;
countBasic: number;
countAdvanced: number;
dataBasic: BasicQuestion[] | null;
dataAdvanced: AdvancedQuestion[] | null;
now: string | null | undefined;
ending: boolean;
}>();
const isBasic = computed(() => props.now == "basic");
const isAdvanced = computed(() => props.now == "advanced");
</script>
<template>
<div
class="flex flex-col items-stretch p-4 gap-10 border-l border-slate-300 bg-slate-100"
>
<button @click="$emit('end-exam')" class="btn btn-warning btn-xl">
Zakończ egzamin
</button>
<div class="flex flex-row gap-6 *:flex-1 w-full">
<div class="flex flex-col gap-1" :class="isBasic ? '' : 'opacity-45'">
<span class="text-lg">Pytania podstawowe</span>
<div
class="info-little-box w-full text-center"
:class="isBasic ? 'font-semibold' : ''"
>
{{ countBasic + 1 }} / {{ dataBasic?.length }}
</div>
</div>
<div class="flex flex-col gap-1" :class="isAdvanced ? '' : 'opacity-45'">
<span class="text-lg">Pytania specjalistyczne</span>
<div
class="info-little-box w-full text-center"
:class="isAdvanced ? 'font-semibold' : ''"
>
{{ countAdvanced + 1 }} / {{ dataAdvanced?.length }}
</div>
</div>
</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"
></progress>
<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"
></progress>
<span class="block set-translate z-10 text-black text-2xl">15s</span>
</div>
</div>
<div class="max-h-[150px] overflow-y-scroll">
{{ result }}
</div>
<div class="flex-1"></div>
<button
@click="$emit('next-question')"
class="btn btn-warning btn-xl"
:disabled="ending"
>
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,108 @@
<script setup lang="ts">
import { range } from "lodash";
const props = defineProps<{
result: ResultEndType;
countBasic: number;
countAdvanced: number;
question: BasicQuestion | AdvancedQuestion | undefined;
questionBasic: BasicQuestion | undefined;
questionAdvanced: AdvancedQuestion | undefined;
now: string | null | undefined;
}>();
const isBasic = computed(() => props.now == "basic");
const isAdvanced = computed(() => props.now == "advanced");
const boxesAmount = computed(() => {
if (isBasic.value) {
return 20;
} else if (isAdvanced.value) {
return 12;
} else {
return 0;
}
});
const countSwitchable = computed(() => {
if (isBasic.value) {
return props.countBasic;
} else if (isAdvanced.value) {
return props.countAdvanced;
} else {
return 0;
}
});
</script>
<template>
<div
class="flex flex-col items-stretch p-4 gap-6 border-l border-slate-300 bg-slate-100"
>
<button @click="$emit('nav', '/')" class="btn btn-warning btn-xl">
Wróć na stronę główną
</button>
<div class="flex flex-row gap-4 *:flex-1 w-full flex-wrap">
<button class="btn btn-info btn-lg" @click="$emit('change-now', 'basic')">
Pytania podstawowe
</button>
<button
class="btn btn-info btn-lg"
@click="$emit('change-now', 'advanced')"
>
Pytania specjalistyczne
</button>
</div>
<div
class="grid grid-cols-[repeat(auto-fit,50px)] gap-2 justify-around w-full"
>
<input
type="radio"
:aria-label="(num + 1).toString()"
class="btn btn-md"
:name="`${now}-chooser`"
v-for="num in range(0, boxesAmount)"
@click="$emit('change-count', num)"
:class="`${
isBasic
? result.basic[num].question?.poprawna_odp.toLowerCase() ==
result.basic[num].chosen_answer
? 'btn-success'
: 'btn-error'
: ''
}${
isAdvanced
? result.advanced[num].question?.poprawna_odp.toLowerCase() ==
result.advanced[num].chosen_answer
? 'btn-success'
: 'btn-error'
: ''
}`"
:checked="countSwitchable == num"
:key="`choose-${num}-${now}`"
/>
</div>
<div class="text-center text-4xl flex flex-col gap-5">
<span class="block">Poprawna odpowiedź</span>
<span class="block">{{ question?.poprawna_odp }}</span>
<span class="block">Zaznaczona odpowiedź</span>
<span class="block">
{{
isBasic
? result.basic[countBasic].chosen_answer.trim() == ""
? "BrakBasic"
: result.basic[countBasic].chosen_answer
: isAdvanced
? result.advanced[countAdvanced].chosen_answer.trim() == ""
? "BrakAdvanced"
: result.advanced[countAdvanced].chosen_answer
: "Błąd"
}}
</span>
</div>
<div class="flex-1"></div>
<button @click="$emit('nav', '/exam')" class="btn btn-warning btn-xl">
Rozpocznij jeszcze raz
</button>
</div>
</template>

View file

@ -1,13 +1,26 @@
<script setup lang="ts">
defineProps<{
import type { Duration } from "date-fns";
const props = defineProps<{
points: number | undefined;
category: string | undefined;
timeRemaining: string | undefined;
timeRemaining?: Duration | undefined;
}>();
const timeRemainingFriendly = computed(() => {
if (typeof props.timeRemaining !== "undefined") {
let seconds = props.timeRemaining.seconds ?? 0;
return `${props.timeRemaining.minutes ?? 0}:${
seconds >= 0 && seconds < 10 ? 0 : ""
}${seconds ?? 0}`;
}
});
</script>
<template>
<div class="flex flex-row gap-4 *:flex *:items-center *:gap-3">
<div
class="flex flex-row gap-4 *:flex *:items-center *:gap-3 border-b p-4 border-slate-300 bg-slate-100"
>
<div>
<span class="block">Wartość punktowa</span>
<div class="info-little-box">
@ -15,12 +28,14 @@ defineProps<{
</div>
</div>
<div>
<span class="block">Aktualna kategoria (implement)</span>
<span class="block">Aktualna kategoria</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 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

@ -1,3 +0,0 @@
<template>
<div><slot /></div>
</template>

10
middleware/exam.ts Normal file
View file

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

8
middleware/result.ts Normal file
View file

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

View file

@ -1,15 +1,25 @@
// 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"],
ssr: true,
css: ["vue-final-modal/style.css"],
imports: {
dirs: ["types/*.ts", "store/*.ts", "types/**/*.ts"],
},
// Transition (later)
// app: {
// pageTransition: { name: "page", mode: "out-in" },
// },
runtimeConfig: {
public: {
cdn_url: "http://pj.netman.ovh/",
cdn_url: process.env.CDN_URL,
},
},
routeRules: {
"/": { prerender: true },
},
});

View file

@ -10,25 +10,27 @@
"postinstall": "nuxt prepare"
},
"dependencies": {
"7.css": "^0.17.0",
"@nuxt/fonts": "0.10.3",
"@nuxt/fonts": "0.11.0",
"@nuxtjs/tailwindcss": "6.13.1",
"@pinia/nuxt": "0.10.1",
"array-shuffle": "^3.0.0",
"daisyui": "^5.0.0",
"date-fns": "^4.1.0",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.40.0",
"nuxt": "^3.15.4",
"lodash": "^4.17.21",
"nuxt": "~3.15.4",
"pg": "^8.13.3",
"pinia": "^3.0.1",
"vue": "latest",
"vue-final-modal": "^4.5.5",
"vue-router": "latest"
},
"packageManager": "pnpm@10.4.1+sha512.c753b6c3ad7afa13af388fa6d808035a008e30ea9993f58c6663e2bc5ff21679aa834db094987129aa4d488b86df57f7b634981b2f827cdcacc698cc0cfb88af",
"devDependencies": {
"@types/lodash": "^4.17.16",
"@types/pg": "^8.11.11",
"drizzle-kit": "^0.30.5",
"tsx": "^4.19.3"
},
"resolutions": {
"nitropack": "2.8.1"
}
}

View file

@ -1,5 +1,32 @@
<script lang="ts" setup>
import "7.css/dist/7.scoped.css";
import { useExamStore } from "~/store/examStore";
import { intervalToDuration, addMinutes, addSeconds, isEqual } from "date-fns";
definePageMeta({ middleware: ["exam"] });
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);
});
const examStore = useExamStore();
useHead({
title: "Pytanie 1/20",
@ -9,13 +36,21 @@ 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);
@ -25,39 +60,73 @@ const now = ref("basic");
const tak_nie_model = ref();
const abc_model = ref();
const ending = ref(false);
async function next() {
if (countBasic.value + 1 < dataBasic.value?.length!) {
questionaries.value.push({
question: question.value,
result.value.basic.push({
question: questionBasic.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 == questionBasic.value?.poprawna_odp?.toLowerCase(),
liczba_pkt: questionBasic.value?.liczba_pkt,
});
tak_nie_model.value = "";
countBasic.value++;
useHead({
title: `Pytanie ${countBasic.value + 1}/${dataBasic.value?.length}`,
});
} else if (countAdvanced.value + 1 < dataAdvanced.value?.length!) {
} else if (countAdvanced.value + 1 <= dataAdvanced.value?.length!) {
if (countAdvanced.value != -1) {
questionaries.value.push({
question: question.value,
result.value.advanced.push({
question: questionAdvanced.value,
chosen_answer: abc_model.value ?? "",
chosen_is_correct:
abc_model.value == question.value?.poprawna_odp?.toLowerCase(),
liczba_pkt: question.value?.liczba_pkt,
abc_model.value ==
questionAdvanced.value?.poprawna_odp?.toLowerCase(),
liczba_pkt: questionAdvanced.value?.liczba_pkt,
});
} else {
now.value = "advanced";
result.value.basic.push({
question: questionBasic.value,
chosen_answer: tak_nie_model.value ?? "",
chosen_is_correct:
tak_nie_model.value ==
questionBasic.value?.poprawna_odp?.toLowerCase(),
liczba_pkt: questionBasic.value?.liczba_pkt,
});
tak_nie_model.value = "";
}
if (countAdvanced.value + 1 < dataAdvanced.value?.length!) {
countAdvanced.value++;
useHead({
title: `Pytanie ${countAdvanced.value + 1}/${
dataAdvanced.value?.length
}`,
});
}
if (countAdvanced.value == dataAdvanced.value?.length! - 1) {
ending.value = true;
}
countAdvanced.value++;
abc_model.value = "";
}
}
async function endExam() {
return;
function endExam() {
loading.value = true;
while (!ending.value) {
next();
}
next();
examStore.setResult(result.value);
examStore.setEnd(true);
while (true) {
if (examStore.result == result.value && examStore.end) {
return navigateTo("/result", { replace: true });
}
}
}
const questionBasic = computed<BasicQuestion | undefined>(() =>
@ -86,27 +155,23 @@ const media = computed(() => {
};
});
const questionaries: Ref<Array<any>> = ref([]);
// onMounted(() => {
// const progresInterval = setInterval(() => {
// progres.value.value = +progres.value.value + 1;
// if (progres.value.value >= 100) {
// clearInterval(progresInterval);
// }
// }, 100);
// });
const result: Ref<ResultEndType> = ref({
basic: [],
advanced: [],
});
const loading = ref(false);
</script>
<template>
<div>
<div v-if="statusBasic === 'success'">
<Loading 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">
<div class="col-span-3 flex flex-col gap-4">
<TopBar
:points="question?.liczba_pkt"
:category="`B`"
:time-remaining="`25:00`"
:category="examStore.category"
:time-remaining="timeRemainingTotal"
/>
<Media :media="media" />
<BasicQuestionBlock
@ -121,58 +186,22 @@ const questionaries: Ref<Array<any>> = ref([]);
/>
</div>
<RightBar
:questionaries="questionaries"
<RightBarExam
:result="result"
:data-basic="dataBasic"
:data-advanced="dataAdvanced"
:count-basic="countBasic"
:count-advanced="countAdvanced"
@next-question="next()"
@end-exam="endExam()"
:now="now"
:ending="ending"
/>
</div>
</div>
<div v-else-if="statusBasic === 'error' || statusAdvanced === 'error'">
An API error occurred: {{ errorBasic }} {{ errorAdvanced }}
</div>
<div v-else>Loading...</div>
<Loading 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,57 @@
<script setup lang="ts">
useHead({
title: "Test na prawo jazdy",
});
const categories = [
"A",
"B",
"C",
"D",
"T",
"AM",
"A1",
"A2",
"B1",
"C1",
"D1",
"PT",
];
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
class="btn btn-xl btn-secondary"
v-for="category in categories"
@click="setAndGo(category)"
>
{{ category }}
</button>
</div>
</div>
<Loading v-else />
</div>
</template>

147
pages/result.vue Normal file
View file

@ -0,0 +1,147 @@
<script setup lang="ts">
import { ModalsContainer, useModal } from "vue-final-modal";
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?.liczba_pkt ?? 0;
}
});
examStore.result.advanced.forEach((answer) => {
if (answer.chosen_is_correct) {
points.value += answer.question?.liczba_pkt ?? 0;
}
});
const resultTrueFalse = ref(points.value >= 68 ? "pozytywny" : "negatywny");
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;
}
});
const tak_nie_model = computed(() =>
resultQuestionBasic.value?.chosen_answer.toLowerCase()
);
const abc_model = computed(() =>
resultQuestionAdvanced.value?.chosen_answer.toLowerCase()
);
const media = computed(() => {
const mediaSplit = question.value?.media?.split(".");
return {
fileType: mediaSplit?.pop()?.toLowerCase(),
fileName: mediaSplit?.join("."),
ogName: question.value?.media,
};
});
const { open, close } = useModal({
component: ResultModal,
attrs: {
title: "Egzamin teorytyczny",
onClose() {
close();
},
onHomepage() {
return navigateTo("/");
},
onNewExam() {
return navigateTo("/exam");
},
},
slots: {
points: `${points.value}`,
resultTrueFalse: resultTrueFalse,
},
});
open();
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;
}
}
function nav(route: string) {
return navigateTo(route);
}
</script>
<template>
<div>
<ModalsContainer />
<div>
<div class="grid grid-cols-4 min-h-dvh">
<div class="col-span-3 flex flex-col gap-4">
<TopBar
:points="question?.liczba_pkt"
:category="examStore.category"
/>
<Media :media="media" />
<BasicQuestionBlock
v-if="now == 'basic'"
:question="questionBasic"
v-model="tak_nie_model"
class="select-none z-[-1]"
/>
<AdvancedQuestionBlock
v-else-if="now == 'advanced'"
:question="questionAdvanced"
v-model="abc_model"
class="select-none z-[-1]"
/>
</div>
<RightBarResult
:result="examStore.result"
:question="question"
:question-basic="questionBasic"
:question-advanced="questionAdvanced"
:count-basic="countBasic"
:count-advanced="countAdvanced"
:now="now"
@change-now="changeNow"
@change-count="changeCount"
@nav="nav"
/>
</div>
</div>
</div>
</template>

View file

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

1724
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

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,111 @@
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 { sql, eq, and, or, isNotNull } from "drizzle-orm";
import { AdvancedQuestion } from "~/types";
import arrayShuffle from "array-shuffle";
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,
async function getFromDb(points: number | string) {
return await db
.select({
id: dane.id,
nr_pytania: dane.nr_pytania,
pytanie: dane.pytanie,
poprawna_odp: dane.poprawna_odp,
media: dane.media,
kategorie: dane.kategorie,
nazwa_media_pjm_tresc_pyt: dane.nazwa_media_pjm_tresc_pyt,
pytanie_eng: dane.pytanie_eng,
pytanie_de: dane.pytanie_de,
pytanie_ua: dane.pytanie_ua,
liczba_pkt: punkty.liczba_pkt,
odp_a: dane.odp_a,
odp_b: dane.odp_b,
odp_c: dane.odp_c,
nazwa_media_pjm_tresc_odp_a: dane.nazwa_media_pjm_tresc_odp_a,
nazwa_media_pjm_tresc_odp_b: dane.nazwa_media_pjm_tresc_odp_b,
nazwa_media_pjm_tresc_odp_c: dane.nazwa_media_pjm_tresc_odp_c,
odp_a_eng: dane.odp_a_eng,
odp_b_eng: dane.odp_b_eng,
odp_c_eng: dane.odp_c_eng,
odp_a_de: dane.odp_a_de,
odp_b_de: dane.odp_b_de,
odp_c_de: dane.odp_c_de,
odp_a_ua: dane.odp_a_ua,
odp_b_ua: dane.odp_b_ua,
odp_c_ua: dane.odp_c_ua,
})
.from(dane)
.innerJoin(punkty, eq(dane.nr_pytania, punkty.nr_pytania))
.where(
and(
isNotNull(dane.odp_a),
isNotNull(dane.odp_b),
isNotNull(dane.odp_c),
isNotNull(dane.odp_a_eng),
isNotNull(dane.odp_b_eng),
isNotNull(dane.odp_c_eng),
isNotNull(dane.odp_a_de),
isNotNull(dane.odp_b_de),
isNotNull(dane.odp_c_de)
)
// 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--;
}
odp_a: dane.odp_a,
odp_b: dane.odp_b,
odp_c: dane.odp_c,
nazwa_media_pjm_tresc_odp_a: dane.nazwa_media_pjm_tresc_odp_a,
nazwa_media_pjm_tresc_odp_b: dane.nazwa_media_pjm_tresc_odp_b,
nazwa_media_pjm_tresc_odp_c: dane.nazwa_media_pjm_tresc_odp_c,
odp_a_eng: dane.odp_a_eng,
odp_b_eng: dane.odp_b_eng,
odp_c_eng: dane.odp_c_eng,
odp_a_de: dane.odp_a_de,
odp_b_de: dane.odp_b_de,
odp_c_de: dane.odp_c_de,
odp_a_ua: dane.odp_a_ua,
odp_b_ua: dane.odp_b_ua,
odp_c_ua: dane.odp_c_ua,
})
.from(dane)
.innerJoin(punkty, eq(dane.nr_pytania, punkty.nr_pytania))
.where(
and(
isNotNull(dane.odp_a),
isNotNull(dane.odp_b),
isNotNull(dane.odp_c),
isNotNull(dane.odp_a_eng),
isNotNull(dane.odp_b_eng),
isNotNull(dane.odp_c_eng),
isNotNull(dane.odp_a_de),
isNotNull(dane.odp_b_de),
isNotNull(dane.odp_c_de),
eq(punkty.liczba_pkt, +points)
)
);
}
return randomizedQuestions;
const query = getQuery(event);
const category = query.category;
const categories = [
"A",
"B",
"C",
"D",
"T",
"AM",
"A1",
"A2",
"B1",
"C1",
"D1",
"PT",
];
if (category == null || category == "") {
throw new Error(
"category argument has to be string (or not to be defined at all)"
);
}
if (!categories.includes(`${category}`)) {
throw new Error(`category argument has to be equal to: ${categories}`);
}
const db = drizzle(process.env.DATABASE_URL!);
const randomizedQuestions: AdvancedQuestion[] = [];
for (let [key, value] of Object.entries({ 1: 2, 2: 4, 3: 6 })) {
const questionsKeyPoints: AdvancedQuestion[] = await getFromDb(key);
const chosenRandomQuestions: AdvancedQuestion[] = [];
const randoms: Array<number> = [];
for (let j = 0; j < value; j++) {
let randomized =
Math.floor(Math.random() * (questionsKeyPoints.length - 1 + 1)) + 0;
while (randoms.includes(randomized)) {
randomized =
Math.floor(Math.random() * (questionsKeyPoints.length - 1 + 1)) + 0;
}
randoms.push(randomized);
if (
questionsKeyPoints[randomized].kategorie
.split(",")
.includes(`${category}`)
) {
chosenRandomQuestions.push(questionsKeyPoints[randomized]);
} else {
j--;
}
}
chosenRandomQuestions.forEach((q) => randomizedQuestions.push(q));
}
return arrayShuffle(randomizedQuestions);
});

View file

@ -2,7 +2,7 @@ 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 { BasicQuestion } from "~/types";
import arrayShuffle from "array-shuffle";
export default defineEventHandler(async (event) => {
@ -35,11 +35,28 @@ export default defineEventHandler(async (event) => {
}
const query = getQuery(event);
const category = query.category;
if (typeof category != "undefined" && typeof category != "string") {
const categories = [
"A",
"B",
"C",
"D",
"T",
"AM",
"A1",
"A2",
"B1",
"C1",
"D1",
"PT",
];
if (category == null || category == "") {
throw new Error(
"category argument has to be string (or not to be defined at all)"
);
}
if (!categories.includes(`${category}`)) {
throw new Error(`category argument has to be equal to: ${categories}`);
}
const db = drizzle(process.env.DATABASE_URL!);
const randomizedQuestions: BasicQuestion[] = [];
@ -60,7 +77,7 @@ export default defineEventHandler(async (event) => {
if (
questionsKeyPoints[randomized].kategorie
.split(",")
.includes(category ?? "B")
.includes(`${category}`)
) {
chosenRandomQuestions.push(questionsKeyPoints[randomized]);
} else {

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

7
tailwind.config.ts Normal file
View file

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

View file

@ -2,28 +2,13 @@ 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;
}
@ -44,3 +29,15 @@ export interface AdvancedQuestion extends BasicQuestion {
odp_b_ua: string | null;
odp_c_ua: string | null;
}
export interface ResultType<T> {
question: T | undefined;
chosen_answer: string;
chosen_is_correct: boolean | undefined;
liczba_pkt: number | undefined;
}
export interface ResultEndType {
basic: ResultType<BasicQuestion>[];
advanced: ResultType<AdvancedQuestion>[];
}