many minor fixes:

nuxtimg, categories composabled, tailwind config in js, remove comments, next question operation, media fit
This commit is contained in:
NetMan 2025-04-28 13:11:07 +02:00
parent 940a93c232
commit c99576617b
20 changed files with 791 additions and 203 deletions

View file

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

View file

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

View file

@ -7,17 +7,6 @@
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;
}

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,35 +0,0 @@
<script setup lang="ts">
const runtimeConfig = useRuntimeConfig();
const cdnUrl = runtimeConfig.public.cdn_url;
const props = defineProps<{
media: string | null | undefined;
}>();
const mediaSplit = computed(() => {
const dotSplit = props.media?.split('.');
const extension = dotSplit?.pop()?.toLowerCase();
return [dotSplit?.join('.'), extension];
});
</script>
<template>
<div
class="select-none z-[-1] flex-1 flex items-stretch w-full justify-center *:object-contain"
>
<!-- Reserved for getting to know the question (20s) in basic questions section
src="~/public/placeholder.svg" -->
<img
v-if="mediaSplit[1] === 'jpg'"
:key="`${media}-photo`"
:src="cdnUrl + media"
alt=""
/>
<video v-else-if="mediaSplit[1] === 'wmv'" :key="`${media}-video`" autoplay>
<source :src="cdnUrl + mediaSplit[0] + '.mp4'" type="video/mp4" />
</video>
<span v-else class="text-4xl font-bold flex items-center justify-center"
>Brak mediów</span
>
</div>
</template>

50
components/MediaBox.vue Normal file
View file

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

View file

@ -20,7 +20,7 @@ const timeRemainingFriendly = computed(() => {
<template>
<div
class="flex flex-row gap-4 *:flex *:items-center *:gap-3 border-b p-4 border-base-300 bg-base-100"
class="flex flex-none flex-row gap-4 *:flex *:items-center *:gap-3 border-b p-4 border-base-300 bg-base-100"
>
<div>
<span class="block">Wartość punktowa</span>

View file

@ -41,7 +41,7 @@ const emit = defineEmits<{
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');
@ -69,7 +69,7 @@ const emit = defineEmits<{
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');
@ -86,3 +86,9 @@ const emit = defineEmits<{
</NuxtLink>
</div>
</template>
<style scoped>
.outline-set-solid {
outline-style: solid;
}
</style>

View file

@ -8,7 +8,7 @@ const answer = defineModel<string | null | undefined>();
<template>
<div
class="flex flex-col gap-6 border-t px-4 py-5 border-base-300 bg-base-100"
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 }}

View file

@ -7,16 +7,14 @@ export default defineNuxtConfig({
'@nuxt/fonts',
'@pinia/nuxt',
'@nuxt/eslint',
'@nuxt/image',
],
ssr: true,
imports: {
dirs: ['types/*.ts', 'store/*.ts', 'types/**/*.ts'],
},
devtools: { enabled: true },
// Transition (later)
// app: {
// pageTransition: { name: "page", mode: "out-in" },
// },
css: ['~/assets/main.css'],
runtimeConfig: {
public: {
cdn_url: process.env.CDN_URL,
@ -36,4 +34,16 @@ export default defineNuxtConfig({
},
},
},
image: {
providers: {
selfhost: {
name: 'selfhost',
provider: '~/providers/selfhost.ts',
options: {
baseUrl: process.env.CDN_URL,
},
},
},
provider: 'selfhost',
},
});

View file

@ -15,10 +15,11 @@
},
"dependencies": {
"@nuxt/fonts": "0.11.1",
"@nuxt/image": "1.10.0",
"@nuxtjs/tailwindcss": "6.13.2",
"@pinia/nuxt": "0.11.0",
"array-shuffle": "^3.0.0",
"daisyui": "^5.0.20",
"daisyui": "^5.0.27",
"date-fns": "^4.1.0",
"dotenv": "^16.5.0",
"drizzle-kit": "^0.31.0",
@ -28,6 +29,7 @@
"nuxt": "~3.16.2",
"pg": "^8.14.1",
"pinia": "^3.0.2",
"ufo": "^1.6.1",
"vue": "latest",
"vue-router": "latest"
},

View file

@ -27,6 +27,13 @@ onMounted(() => {
endExam();
}
}, 1000);
watchEffect(() => {
if (now.value === 'basic')
useHead({ title: `Pytanie ${countBasic.value + 1}/20` });
if (now.value === 'advanced')
useHead({ title: `Pytanie ${countAdvanced.value + 1}/12` });
});
});
const examStore = useExamStore();
@ -78,34 +85,26 @@ const result: Ref<ResultEndType> = ref({
});
async function next() {
function pushVal() {
if (now.value === 'basic' || now.value === 'advanced') {
result.value[now.value].push({
question: question.value,
chosen_answer: answer.value,
chosen_is_correct: answer.value === question.value?.correct_answer,
});
}
answer.value = '';
if (now.value === 'basic' || now.value === 'advanced') {
result.value[now.value].push({
question: question.value,
chosen_answer: answer.value,
chosen_is_correct: answer.value === question.value?.correct_answer,
});
}
answer.value = '';
if (now.value === 'basic') {
pushVal();
countBasic.value++;
useHead({
title: `Pytanie ${countBasic.value + 1}/20`,
});
if (countBasic.value >= 20) {
if (countBasic.value < 19) {
countBasic.value++;
} else {
now.value = 'advanced';
countBasic.value--;
countAdvanced.value++;
}
} else if (now.value === 'advanced') {
pushVal();
countAdvanced.value++;
useHead({
title: `Pytanie ${countAdvanced.value + 1}/12`,
});
if (countAdvanced.value < 11) {
countAdvanced.value++;
}
if (countAdvanced.value >= 11) {
ending.value = true;
}
@ -133,16 +132,16 @@ const loading = ref(false);
<template>
<div>
<!-- as in to transition to the next page -->
<Loading v-if="loading" />
<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">
<div class="col-span-3 flex flex-col">
<BarTop
:points="question?.weight"
:category="examStore.category"
:time-remaining="timeRemainingTotal"
/>
<Media :media="question?.media_url" />
<MediaBox :media-path="question?.media_url" />
<QuestionBasic
v-if="now === 'basic'"
v-model="answer"
@ -168,6 +167,6 @@ const loading = ref(false);
<div v-else-if="statusBasic === 'error' || statusAdvanced === 'error'">
An API error occurred: {{ errorBasic }} {{ errorAdvanced }}
</div>
<Loading v-else />
<LoadingScreen v-else />
</div>
</template>

View file

@ -1,22 +1,11 @@
<script setup lang="ts">
useHead({
title: 'Test na prawo jazdy',
});
import categories from '~/categories';
const categories = [
'A',
'B',
'C',
'D',
'T',
'AM',
'A1',
'A2',
'B1',
'C1',
'D1',
'PT',
];
onMounted(() => {
useHead({
title: 'Test na prawo jazdy',
});
});
const loading = ref(false);
@ -53,6 +42,6 @@ function setAndGo(category: string) {
</button>
</div>
</div>
<Loading v-else />
<LoadingScreen v-else />
</div>
</template>

View file

@ -20,11 +20,13 @@ examStore.result.advanced.forEach((answer) => {
const resultTrueFalse = ref(points.value >= 68 ? 'pozytywny' : 'negatywny');
useHead({
title: `${
String(resultTrueFalse.value[0]).toUpperCase() +
String(resultTrueFalse.value).slice(1)
} (${points.value}/74)`,
onMounted(() => {
useHead({
title: `${
String(resultTrueFalse.value[0]).toUpperCase() +
String(resultTrueFalse.value).slice(1)
} (${points.value}/74)`,
});
});
const countBasic = ref(0);
@ -91,9 +93,9 @@ function changeCount(num: number) {
</ResultModal>
<div>
<div class="grid grid-cols-4 min-h-dvh">
<div class="col-span-3 flex flex-col gap-4">
<div class="col-span-3 flex flex-col">
<BarTop :points="question?.weight" :category="examStore.category" />
<Media :media="question?.media_url" />
<MediaBox :media-path="question?.media_url" />
<QuestionBasic
v-if="now === 'basic'"
v-model="answer"

673
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

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

View file

@ -8,24 +8,12 @@ import {
categories_db,
} from '@/src/db/schema';
import type { AdvancedQuestion } from '~/types';
import categories from '~/categories';
export default defineEventHandler(async (event) => {
const query = getQuery(event);
const category = query.category;
const categories = [
'A',
'B',
'C',
'D',
'T',
'AM',
'A1',
'A2',
'B1',
'C1',
'D1',
'PT',
];
if (category === '' || typeof category !== 'string') {
throw createError({
statusCode: 400,
@ -36,7 +24,7 @@ export default defineEventHandler(async (event) => {
if (!categories.includes(`${category.toUpperCase()}`)) {
throw createError({
statusCode: 400,
statusMessage: `category argument has to be equal to: ${categories}`,
statusMessage: `category argument has to be equal to either: ${categories}`,
});
}

View file

@ -4,24 +4,11 @@ import { sql, eq, and } from 'drizzle-orm';
import arrayShuffle from 'array-shuffle';
import { tasks, questions, categories_db } from '@/src/db/schema';
import type { BasicQuestion } from '~/types';
import categories from '~/categories';
export default defineEventHandler(async (event) => {
const query = getQuery(event);
const category = query.category;
const categories = [
'A',
'B',
'C',
'D',
'T',
'AM',
'A1',
'A2',
'B1',
'C1',
'D1',
'PT',
];
if (category === '' || typeof category !== 'string') {
throw createError({
@ -33,7 +20,7 @@ export default defineEventHandler(async (event) => {
if (!categories.includes(`${category.toUpperCase()}`)) {
throw createError({
statusCode: 400,
statusMessage: `category argument has to be equal to: ${categories}`,
statusMessage: `category argument has to be equal to either: ${categories}`,
});
}

View file

@ -1,4 +1,5 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
plugins: [require('daisyui')],
daisyui: {