question timers, little fixes to nav/store

This commit is contained in:
NetMan 2025-12-15 20:14:54 +01:00
parent 63121da4b7
commit 97b8d5dab9
12 changed files with 242 additions and 107 deletions

View file

@ -23,8 +23,8 @@ You also need the exam media files from the (Ministry of Infrasture)[https://www
- [x] beautify website (good for now)
- [x] <b>Fixed?</b> Needs testing, but should be fine question-mark? - <b>fix pinia middleware between pages, MAJOR ISSUE - finishing exam sometimes redirects to homepage instead of results, help appreciated</b>
- [x] (scrapped - lazy loading)
- [x] question timers
- [ ] exam (& results?) warning leave message on exit and timer end (and definitely on refresh)
- [ ] question timers
- [ ] i18n - pl, en, de, ua (not all questions are available in ua, api handle)
- UI i18n
- db: examstore add language field, api handle languages

View file

@ -6,8 +6,11 @@ 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(() => {
@ -21,6 +24,21 @@ const media = computed(() => {
}
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>
@ -28,23 +46,26 @@ const media = computed(() => {
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-if="media.type === 'image'"
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`"
:autoplay="route.path === '/exam'"
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"
>Brak mediów</span
>Pytanie bez wizualizacji</span
>
</div>
</template>

View file

@ -5,7 +5,7 @@ onMounted(() => {
myModal.value?.showModal();
});
defineEmits(['again']);
defineEmits(['again', 'home']);
</script>
<template>
@ -21,7 +21,9 @@ defineEmits(['again']);
<div class="*:inline">Punkty: <slot name="points" /> / 74</div>
<div class="*:inline">Wynik: <slot name="resultTrueFalse" /></div>
<div class="flex flex-row gap-2">
<NuxtLink to="/" class="btn btn-soft">Wróć na stronę główną</NuxtLink>
<div class="btn btn-soft" @click="$emit('home')">
Wróć na stronę główną
</div>
<div class="btn btn-outline" @click="$emit('again')">
Rozpocznij jeszcze raz
</div>

View file

@ -4,11 +4,15 @@ defineProps<{
countAdvanced: number;
now: string | null | undefined;
ending: boolean;
setBasic: boolean;
time: number;
phase: string;
}>();
const emit = defineEmits<{
endExam: [];
nextQuestion: [];
nextTime: [];
}>();
</script>
@ -36,38 +40,45 @@ const emit = defineEmits<{
</CurrentQuestionCount>
</div>
<div class="text-center text-xl flex flex-col gap-2">
<div
v-if="phase == 'set-basic'"
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="btn btn-primary" @click="emit('nextTime')">START</div>
<div class="h-full flex-1 relative">
<progress
class="progress progress-warning w-full h-full"
value="50"
max="100"
/>
<span class="block set-translate z-10 text-black text-2xl">20s</span>
:value="time"
max="20"
></progress>
<span class="block set-translate z-10 text-black text-2xl">
{{ time >= 0 ? time : 0 }}s
</span>
</div>
</div>
</div>
<div class="text-center text-xl flex flex-col gap-2">
<div v-else class="text-center text-xl flex flex-col gap-2">
<span>Czas na udzielenie odpowiedzi</span>
<div class="h-9 relative">
<progress
class="progress progress-warning w-full h-full"
value="50"
max="100"
/>
<span class="block set-translate z-10 text-black text-2xl">15s</span>
:value="time"
:max="phase == 'start-basic' ? 15 : 45"
></progress>
<span class="block set-translate z-10 text-black text-2xl">
{{ time >= 0 ? time : 0 }}s
</span>
</div>
</div>
<div class="flex-1" />
<div class="flex-1"></div>
<button
class="btn btn-warning btn-xl"
:disabled="ending"
:disabled="ending || setBasic"
@click="emit('nextQuestion')"
>
Następne pytanie
@ -76,16 +87,12 @@ const emit = defineEmits<{
</template>
<style>
/*.progressive {
animation: progressZapoznanie 20s linear;
.progressive {
width: calc(100% / v-bind('time'));
transition: all 1s linear;
}
@keyframes progressZapoznanie {
0% {
width: 0;
}
100% {
width: 100%;
}
}*/
progress[value]::-webkit-progress-value {
transition: width 0.5s;
}
</style>

View file

@ -12,6 +12,7 @@ const emit = defineEmits<{
changeNow: [value: string];
changeCount: [num: number];
again: [];
home: [];
}>();
</script>
@ -19,9 +20,9 @@ const emit = defineEmits<{
<div
class="flex flex-col items-stretch p-4 gap-6 border-l border-base-300 bg-base-100"
>
<NuxtLink to="/" class="btn btn-warning btn-xl">
<div class="btn btn-warning btn-xl" @click="$emit('home')">
Wróć na stronę główną
</NuxtLink>
</div>
<button class="btn btn-info btn-lg" @click="emit('changeNow', 'basic')">
Pytania podstawowe
@ -38,10 +39,12 @@ const emit = defineEmits<{
class="btn btn-md"
name="chooser"
:class="`${
result.basic[num].question?.correct_answer ===
result.basic[num].chosen_answer
? 'btn-success'
: 'btn-error'
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="
@ -66,10 +69,12 @@ const emit = defineEmits<{
class="btn btn-md"
name="chooser"
:class="`${
result.advanced[num].question?.correct_answer ===
result.advanced[num].chosen_answer
? 'btn-success'
: 'btn-error'
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="

View file

@ -19,6 +19,7 @@
"@nuxt/image": "1.10.0",
"@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",

View file

@ -8,10 +8,15 @@ const advancedStore = useAdvancedStore();
<div class="flex flex-col gap-2 items-start m-2">
<h1 class="text-2xl">Nastąpiła anomalia</h1>
<br />
Kategoria: {{ examStore.category != '' ? examStore.category : '""' }} <br />
Przekierowanie z: {{ useRoute().redirectedFrom ?? '""' }} <br />
Kategoria:
{{ examStore.category != '' ? examStore.category : '""' }}
<br />
Koniec: {{ examStore.end }} <br />
Pytania podstawowe: {{ basicStore.basic }} <br />
Pytania specjalistyczne: {{ advancedStore.advanced }} <br />
Pytania podstawowe: <code class="text-xs">{{ basicStore.basic }}</code>
<br />
Pytania specjalistyczne:
<code class="text-xs">{{ advancedStore.advanced }}</code> <br />
<NuxtLink to="/" class="btn btn-primary"> Wróć na stronę główną </NuxtLink>
</div>
</template>

View file

@ -1,21 +1,14 @@
<script lang="ts" setup>
import {
intervalToDuration,
addMinutes,
addSeconds,
isEqual,
isAfter,
} from 'date-fns';
import { intervalToDuration, addMinutes, addSeconds, isAfter } from 'date-fns';
import { useNow } from '@vueuse/core';
import { useExamStore } from '~/store/examStore';
definePageMeta({
middleware: 'exam',
});
definePageMeta({ middleware: 'exam' });
const examStore = useExamStore();
const basicStore = useBasicStore();
const advancedStore = useAdvancedStore();
// await callOnce(() => examStore.mildReset(), { mode: 'navigation' });
await callOnce(() => examStore.mildReset(), { mode: 'navigation' });
useHead({
title: 'Pytanie 1/20',
@ -23,25 +16,70 @@ useHead({
const now = ref('basic');
const nowTime = ref(new Date());
const nowTime = useNow();
const timeEnd = addMinutes(new Date(), 25);
const timeRemainingTotal = computed(() =>
intervalToDuration({
start: nowTime.value,
end: timeEnd,
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;
}
}),
);
// const timeRemainingQuestion - to implement
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(() => {
const endInterval = setInterval(() => {
nowTime.value = addSeconds(nowTime.value, 1);
if (isEqual(nowTime.value, timeEnd) || isAfter(nowTime.value, timeEnd)) {
clearInterval(endInterval);
endExam();
}
}, 999);
watchEffect(() => {
if (isAfter(nowTime.value, timeEnd)) endExam();
});
watchEffect(() => {
if (now.value === 'basic')
@ -49,6 +87,15 @@ onMounted(() => {
if (now.value === 'advanced')
useHead({ title: `Pytanie ${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();
}
});
});
const {
@ -97,14 +144,17 @@ const result: Ref<ResultEndType> = ref({
});
function next() {
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 = '';
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 {
console.error('next() incorrectly executed - exam has already ended.');
}
if (now.value === 'basic') {
if (countBasic.value + 1 <= 19) {
countBasic.value++;
@ -115,28 +165,33 @@ function next() {
} else if (now.value === 'advanced') {
if (countAdvanced.value + 1 <= 11) {
countAdvanced.value++;
} else if (countAdvanced.value + 1 >= 12) {
}
if (countAdvanced.value + 1 >= 12) {
ending.value = true;
} else {
// error?
}
}
}
function endExam() {
loading.value = true;
while (ending.value == false) next();
examStore.setBasic(result.value.basic);
examStore.setAdvanced(result.value.advanced);
examStore.setEnd(true);
while (true) {
if (
basicStore.basic == result.value.basic &&
advancedStore.advanced == result.value.advanced &&
examStore.end
) {
return navigateTo(`/result`, { replace: 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`);
}
}
@ -153,9 +208,13 @@ const loading = ref(false);
<BarTop
:points="question?.weight"
:category="examStore.category"
:time-remaining="timeRemainingTotal"
:time-remaining="time.total"
/>
<MediaBox
:media-path="question?.media_url"
:phase="time.phase"
@mediaload="onMediaLoad()"
/>
<MediaBox :media-path="question?.media_url" />
<QuestionBasic
v-if="now === 'basic'"
v-model="answer"
@ -174,8 +233,12 @@ const loading = ref(false);
:count-advanced="countAdvanced"
:now="now"
:ending="ending"
@next-question="next()"
:set-basic="time.phase == 'set-basic'"
:time="time.question"
:phase="time.phase"
@next-question="clickNext()"
@end-exam="endExam()"
@next-time="changeQuestionTimeAfterNext()"
/>
</div>
</div>

View file

@ -1,7 +1,5 @@
<script setup lang="ts">
import categories from '~/categories';
import { opis } from '~/categories';
import { wiek } from '~/categories';
import categories, { opis, wiek } from '~/categories';
onMounted(() => {
useHead({
@ -12,8 +10,6 @@ onMounted(() => {
const loading = ref(false);
const examStore = useExamStore();
const basicStore = useBasicStore();
const advancedStore = useAdvancedStore();
await callOnce(() => examStore.resetExam(), { mode: 'navigation' });
function setAndGo(category: string) {

View file

@ -92,13 +92,19 @@ async function again() {
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">
<ResultModal @again="again" @home="home">
<template #title>Egzamin teorytyczny</template>
<template #category>{{ examStore.category }}</template>
<template #points>{{ points }}</template>
@ -108,7 +114,7 @@ async function again() {
<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" />
<MediaBox :media-path="question?.media_url" phase="" />
<QuestionBasic
v-if="now === 'basic'"
v-model="answer"
@ -134,6 +140,7 @@ async function again() {
:now="now"
@change-now="changeNow"
@change-count="changeCount"
@home="home"
@again="again"
>
<template #points>{{ points }}</template>

34
pnpm-lock.yaml generated
View file

@ -23,6 +23,9 @@ importers:
'@pinia/nuxt':
specifier: 0.11.0
version: 0.11.0(magicast@0.3.5)(pinia@3.0.2(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3)))
'@vueuse/core':
specifier: ^14.1.0
version: 14.1.0(vue@3.5.13(typescript@5.8.3))
array-shuffle:
specifier: ^3.0.0
version: 3.0.0
@ -1273,6 +1276,9 @@ packages:
'@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
@ -1498,6 +1504,19 @@ packages:
'@vue/shared@3.5.13':
resolution: {integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==}
'@vueuse/core@14.1.0':
resolution: {integrity: sha512-rgBinKs07hAYyPF834mDTigH7BtPqvZ3Pryuzt1SD/lg5wEcWqvwzXXYGEDb2/cP0Sj5zSvHl3WkmMELr5kfWw==}
peerDependencies:
vue: ^3.5.0
'@vueuse/metadata@14.1.0':
resolution: {integrity: sha512-7hK4g015rWn2PhKcZ99NyT+ZD9sbwm7SGvp7k+k+rKGWnLjS/oQozoIZzWfCewSUeBmnJkIb+CNr7Zc/EyRnnA==}
'@vueuse/shared@14.1.0':
resolution: {integrity: sha512-EcKxtYvn6gx1F8z9J5/rsg3+lTQnvOruQd8fUecW99DCK04BkWD7z5KQ/wTAx+DazyoEE9dJt/zV8OIEQbM6kw==}
peerDependencies:
vue: ^3.5.0
abbrev@3.0.1:
resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==}
engines: {node: ^18.17.0 || >=20.5.0}
@ -6187,6 +6206,8 @@ snapshots:
'@types/resolve@1.20.2': {}
'@types/web-bluetooth@0.0.21': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 22.14.1
@ -6484,6 +6505,19 @@ snapshots:
'@vue/shared@3.5.13': {}
'@vueuse/core@14.1.0(vue@3.5.13(typescript@5.8.3))':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 14.1.0
'@vueuse/shared': 14.1.0(vue@3.5.13(typescript@5.8.3))
vue: 3.5.13(typescript@5.8.3)
'@vueuse/metadata@14.1.0': {}
'@vueuse/shared@14.1.0(vue@3.5.13(typescript@5.8.3))':
dependencies:
vue: 3.5.13(typescript@5.8.3)
abbrev@3.0.1: {}
abort-controller@3.0.0:

View file

@ -38,16 +38,10 @@ export const useExamStore = defineStore('examStore', {
async setEnd(end: boolean) {
this.end = end;
},
async setBasic(basic: ResultType[]) {
useBasicStore().set(basic);
},
async setAdvanced(advanced: ResultType[]) {
useAdvancedStore().set(advanced);
},
async mildReset() {
this.end = false;
this.setBasic([]);
this.setAdvanced([]);
useBasicStore().set([]);
useAdvancedStore().set([]);
},
async resetExam() {
this.category = '';