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 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 ```bash
pnpm install 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: # To-do:
- [x] re-forge database structure (good for now) - [x] re-forge database structure (good for now)
- [ ] db: script for processing, share appropriate files
- [x] choose category (good for now) - [x] choose category (good for now)
- [ ] beautify website
- [ ] better answer click recognition
- [x] come up with how to show results appropriately - [x] come up with how to show results appropriately
- [ ] i18n - pl, en, de, ua (not all questions are not available in ua, api handle) - [x] db: script for processing, share appropriate files
- [ ] exam (maybe also results?) warning leave message on exit (refresh) - [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 - [ ] 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 ## Development Server

View file

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

View file

@ -7,17 +7,6 @@
transform: translate(-50%, -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 { .info-little-box {
@apply inline-block px-[15px] py-[8px] bg-blue-500 text-white font-bold rounded-md; @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> <template>
<div <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> <div>
<span class="block">Wartość punktowa</span> <span class="block">Wartość punktowa</span>

View file

@ -41,7 +41,7 @@ const emit = defineEmits<{
result.basic[num].chosen_answer result.basic[num].chosen_answer
? 'btn-success' ? 'btn-success'
: 'btn-error' : 'btn-error'
}`" } ${now === 'basic' && countBasic === num ? 'outline-set-solid outline-2' : ''}`"
:checked="now === 'basic' ? countBasic === num : false" :checked="now === 'basic' ? countBasic === num : false"
@click=" @click="
emit('changeNow', 'basic'); emit('changeNow', 'basic');
@ -69,7 +69,7 @@ const emit = defineEmits<{
result.advanced[num].chosen_answer result.advanced[num].chosen_answer
? 'btn-success' ? 'btn-success'
: 'btn-error' : 'btn-error'
}`" } ${now === 'advanced' && countAdvanced === num ? 'outline-set-solid outline-2' : ''}`"
:checked="now === 'advanced' ? countAdvanced === num : false" :checked="now === 'advanced' ? countAdvanced === num : false"
@click=" @click="
emit('changeNow', 'advanced'); emit('changeNow', 'advanced');
@ -86,3 +86,9 @@ const emit = defineEmits<{
</NuxtLink> </NuxtLink>
</div> </div>
</template> </template>
<style scoped>
.outline-set-solid {
outline-style: solid;
}
</style>

View file

@ -8,7 +8,7 @@ const answer = defineModel<string | null | undefined>();
<template> <template>
<div <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"> <div class="text-xl">
{{ question?.text }} {{ question?.text }}

View file

@ -7,16 +7,14 @@ export default defineNuxtConfig({
'@nuxt/fonts', '@nuxt/fonts',
'@pinia/nuxt', '@pinia/nuxt',
'@nuxt/eslint', '@nuxt/eslint',
'@nuxt/image',
], ],
ssr: true, ssr: true,
imports: { imports: {
dirs: ['types/*.ts', 'store/*.ts', 'types/**/*.ts'], dirs: ['types/*.ts', 'store/*.ts', 'types/**/*.ts'],
}, },
devtools: { enabled: true }, devtools: { enabled: true },
// Transition (later) css: ['~/assets/main.css'],
// app: {
// pageTransition: { name: "page", mode: "out-in" },
// },
runtimeConfig: { runtimeConfig: {
public: { public: {
cdn_url: process.env.CDN_URL, 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": { "dependencies": {
"@nuxt/fonts": "0.11.1", "@nuxt/fonts": "0.11.1",
"@nuxt/image": "1.10.0",
"@nuxtjs/tailwindcss": "6.13.2", "@nuxtjs/tailwindcss": "6.13.2",
"@pinia/nuxt": "0.11.0", "@pinia/nuxt": "0.11.0",
"array-shuffle": "^3.0.0", "array-shuffle": "^3.0.0",
"daisyui": "^5.0.20", "daisyui": "^5.0.27",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"drizzle-kit": "^0.31.0", "drizzle-kit": "^0.31.0",
@ -28,6 +29,7 @@
"nuxt": "~3.16.2", "nuxt": "~3.16.2",
"pg": "^8.14.1", "pg": "^8.14.1",
"pinia": "^3.0.2", "pinia": "^3.0.2",
"ufo": "^1.6.1",
"vue": "latest", "vue": "latest",
"vue-router": "latest" "vue-router": "latest"
}, },

View file

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

View file

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

View file

@ -20,11 +20,13 @@ examStore.result.advanced.forEach((answer) => {
const resultTrueFalse = ref(points.value >= 68 ? 'pozytywny' : 'negatywny'); const resultTrueFalse = ref(points.value >= 68 ? 'pozytywny' : 'negatywny');
useHead({ onMounted(() => {
title: `${ useHead({
String(resultTrueFalse.value[0]).toUpperCase() + title: `${
String(resultTrueFalse.value).slice(1) String(resultTrueFalse.value[0]).toUpperCase() +
} (${points.value}/74)`, String(resultTrueFalse.value).slice(1)
} (${points.value}/74)`,
});
}); });
const countBasic = ref(0); const countBasic = ref(0);
@ -91,9 +93,9 @@ function changeCount(num: number) {
</ResultModal> </ResultModal>
<div> <div>
<div class="grid grid-cols-4 min-h-dvh"> <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" /> <BarTop :points="question?.weight" :category="examStore.category" />
<Media :media="question?.media_url" /> <MediaBox :media-path="question?.media_url" />
<QuestionBasic <QuestionBasic
v-if="now === 'basic'" v-if="now === 'basic'"
v-model="answer" 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, categories_db,
} from '@/src/db/schema'; } from '@/src/db/schema';
import type { AdvancedQuestion } from '~/types'; import type { AdvancedQuestion } from '~/types';
import categories from '~/categories';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const query = getQuery(event); const query = getQuery(event);
const category = query.category; const category = query.category;
const categories = [
'A',
'B',
'C',
'D',
'T',
'AM',
'A1',
'A2',
'B1',
'C1',
'D1',
'PT',
];
if (category === '' || typeof category !== 'string') { if (category === '' || typeof category !== 'string') {
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
@ -36,7 +24,7 @@ export default defineEventHandler(async (event) => {
if (!categories.includes(`${category.toUpperCase()}`)) { if (!categories.includes(`${category.toUpperCase()}`)) {
throw createError({ throw createError({
statusCode: 400, 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 arrayShuffle from 'array-shuffle';
import { tasks, questions, categories_db } from '@/src/db/schema'; import { tasks, questions, categories_db } from '@/src/db/schema';
import type { BasicQuestion } from '~/types'; import type { BasicQuestion } from '~/types';
import categories from '~/categories';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const query = getQuery(event); const query = getQuery(event);
const category = query.category; const category = query.category;
const categories = [
'A',
'B',
'C',
'D',
'T',
'AM',
'A1',
'A2',
'B1',
'C1',
'D1',
'PT',
];
if (category === '' || typeof category !== 'string') { if (category === '' || typeof category !== 'string') {
throw createError({ throw createError({
@ -33,7 +20,7 @@ export default defineEventHandler(async (event) => {
if (!categories.includes(`${category.toUpperCase()}`)) { if (!categories.includes(`${category.toUpperCase()}`)) {
throw createError({ throw createError({
statusCode: 400, 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} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
plugins: [require('daisyui')], plugins: [require('daisyui')],
daisyui: { daisyui: {