ui i18n english, fix locale routes, theme toggle

minor:
- change background for category list for dark theme compatibility
- try to 'fix' video playback errors in console
This commit is contained in:
NetMan 2025-12-16 09:32:27 +01:00
parent 19508f1148
commit ccccf038d9
15 changed files with 205 additions and 101 deletions

11
app.vue
View file

@ -1,5 +1,14 @@
<script setup lang="ts">
const examStore = useExamStore();
const { setLocale } = useI18n();
setLocale(examStore.lang as 'pl' | 'en' | 'de' | 'ua');
const themeStore = useThemeStore();
</script>
<template> <template>
<div> <div :data-theme="themeStore.theme" class="min-h-dvh">
<NuxtPage /> <NuxtPage />
</div> </div>
</template> </template>

View file

@ -21,8 +21,12 @@ watchEffect(() => {
<h1 class="text-[1.5rem]">{{ $t('examEnd') }}</h1> <h1 class="text-[1.5rem]">{{ $t('examEnd') }}</h1>
<div class="*:inline">{{ $t('doYouReallyWantToEndExam') }}</div> <div class="*:inline">{{ $t('doYouReallyWantToEndExam') }}</div>
<div class="flex flex-row gap-2 justify-around"> <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-success" @click="emit('endExam')">
<div class="btn btn-lg btn-error" @click="endModal?.close()">Nie</div> {{ $t('yes') }}
</div>
<div class="btn btn-lg btn-error" @click="endModal?.close()">
{{ $t('no') }}
</div>
</div> </div>
</div> </div>
</dialog> </dialog>

View file

@ -4,8 +4,6 @@ import { joinURL } from 'ufo';
const runtimeConfig = useRuntimeConfig(); const runtimeConfig = useRuntimeConfig();
const cdnUrl = runtimeConfig.public.cdn_url; const cdnUrl = runtimeConfig.public.cdn_url;
const route = useRoute();
const emit = defineEmits(['mediaload']); const emit = defineEmits(['mediaload']);
const props = defineProps<{ const props = defineProps<{
@ -25,18 +23,23 @@ const media = computed(() => {
return { name: dotSplit?.join('.'), type }; return { name: dotSplit?.join('.'), type };
}); });
const video = ref(); const video = useTemplateRef('video');
function onVideoLoad() { function onVideoLoad() {
if (route.path === '/exam') { if (props.phase != 'result') {
video.value.play(); const videoPlayInterval = setInterval(() => {
let duration = video.value.duration; if (video.value) {
if (isNaN(duration) || duration == Infinity) { video.value.play();
duration = 0; let duration = video.value.duration;
} if (isNaN(duration) || duration == Infinity) {
setTimeout(() => { duration = 0;
emit('mediaload'); }
}, duration * 1000); setTimeout(() => {
emit('mediaload');
}, duration * 1000);
clearInterval(videoPlayInterval);
}
}, 1000);
} }
} }
</script> </script>
@ -44,8 +47,11 @@ function onVideoLoad() {
<template> <template>
<div <div
class="select-none flex-auto w-full *:object-contain *:w-full *:h-full *:max-h-full relative *:absolute" class="select-none flex-auto w-full *:object-contain *:w-full *:h-full *:max-h-full relative *:absolute"
:class="route.path === '/exam' ? 'z-[-1]' : ''"
> >
<div
class="w-full h-full opacity-0"
:class="phase != 'result' ? 'z-10' : 'z-[-10]'"
></div>
<img v-if="phase == 'set-basic'" src="/placeholder.svg" alt="placeholder" /> <img v-if="phase == 'set-basic'" src="/placeholder.svg" alt="placeholder" />
<NuxtImg <NuxtImg
v-else-if="media.type === 'image'" v-else-if="media.type === 'image'"
@ -59,7 +65,7 @@ function onVideoLoad() {
v-else-if="media.type === 'video'" v-else-if="media.type === 'video'"
:key="`${mediaPath}-video`" :key="`${mediaPath}-video`"
ref="video" ref="video"
:controls="route.path === '/result'" :controls="phase == 'result'"
@canplaythrough="onVideoLoad()" @canplaythrough="onVideoLoad()"
> >
<source :src="joinURL(cdnUrl, media.name + '.mp4')" type="video/mp4" /> <source :src="joinURL(cdnUrl, media.name + '.mp4')" type="video/mp4" />

View file

@ -35,7 +35,7 @@ onKeyStroke(['N', 'n'], () => {
<div> <div>
<div class="flex flex-row justify-around"> <div class="flex flex-row justify-around">
<input <input
v-for="[element, value] of Object.entries({ TAK: true, NIE: false })" v-for="[element, value] of Object.entries({ yes: true, no: false })"
:id="`odp_${element}`" :id="`odp_${element}`"
:ref="`${value}-button`" :ref="`${value}-button`"
:key="`btn_answer_${element}`" :key="`btn_answer_${element}`"
@ -44,7 +44,7 @@ onKeyStroke(['N', 'n'], () => {
name="tak_nie" name="tak_nie"
:value="value.toString()" :value="value.toString()"
class="btn btn-primary btn-xl" class="btn btn-primary btn-xl"
:aria-label="element" :aria-label="$t(element).toLocaleUpperCase()"
:class=" :class="
phase == 'exam' phase == 'exam'
? answer == null ? answer == null

View file

@ -12,6 +12,12 @@
"B": "B", "B": "B",
"C": "C" "C": "C"
}, },
"yes": "Tak",
"no": "Nie",
"theme": "Motyw",
"light": "Jasny",
"dark": "Ciemny",
"auto": "Automatyczny",
"endExam": "Zakończ egzamin", "endExam": "Zakończ egzamin",
"examEnd": "Koniec egzaminu", "examEnd": "Koniec egzaminu",
"basicQuestions": "Pytania podstawowe", "basicQuestions": "Pytania podstawowe",

View file

@ -1,73 +1,79 @@
{ {
"mainTitle": "Test na prawo jazdy", "mainTitle": "Driving exam",
"loading": "Ładowanie", "loading": "Loading",
"keybinds": "Skróty klawiszowe", "keybinds": "Keybinds",
"bindedKeys": { "bindedKeys": {
"S": "niebieski przycisk start", "S": "blue \"start\" button",
"D": "następne pytanie", "D": "next question",
"X": "zakończ egzamin", "X": "finish exam",
"T / Y": "Tak", "T / Y": "Yes",
"N": "Nie", "N": "No",
"A": "A", "A": "A",
"B": "B", "B": "B",
"C": "C" "C": "C"
}, },
"endExam": "Zakończ egzamin", "yes": "Yes",
"examEnd": "Koniec egzaminu", "no": "No",
"basicQuestions": "Pytania podstawowe", "theme": "Theme",
"advancedQuestions": "Pytania specjalistyczne", "light": "Light",
"timeToGetAcquaintedWithTheQuestion": "Czas na zapoznanie się z pytaniem", "dark": "Dark",
"timeForAnswer": "Czas na udzielenie odpowiedzi", "auto": "Auto",
"endExam": "Finish exam",
"examEnd": "Exam finished",
"basicQuestions": "Basic questions",
"advancedQuestions": "Advanced questions",
"timeToGetAcquaintedWithTheQuestion": "Time to get acquainted with the question",
"timeForAnswer": "Time to answer",
"startBtn": "START", "startBtn": "START",
"second": "s", "second": "s",
"nextQuestion": "Następne pytanie", "nextQuestion": "Next question",
"goBackToHomePage": "Wróć na stronę główną", "goBackToHomePage": "Back to homepage",
"points": "Punkty", "points": "Points",
"pointValue": "Wartość punktowa", "pointValue": "Point value",
"currentCategory": "Aktualna kategoria", "currentCategory": "Current category",
"timeToExamEnd": "Czas do końca egzaminu", "timeToExamEnd": "Time until the exam's finished",
"result": "Wynik", "result": "Result",
"startAgain": "Rozpocznij jeszcze raz", "startAgain": "Start again",
"viewAnswers": "Przejrzyj odpowiedzi", "viewAnswers": "View answers",
"doYouReallyWantToEndExam": "Czy na pewno chcesz zakończyć egzamin?", "doYouReallyWantToEndExam": "Are you sure you want to finish the exam?",
"questionWithoutVisual": "Pytanie bez wizualizacji", "questionWithoutVisual": "Question without visualization",
"categoryWord": "Kategoria", "categoryWord": "Category",
"anAnomalyHasOccured": "Nastąpiła anomalia", "anAnomalyHasOccured": "An anomaly has occured",
"redirectFrom": "Przekierowanie z", "redirectFrom": "Redirecting from",
"end": "Koniec", "end": "End",
"question": "Pytanie", "question": "Question",
"anAPIErrorOccured": "Wystąpił błąd z API", "anAPIErrorOccured": "An API error has occured",
"positive": "pozytywny", "positive": "positive",
"negative": "negatywny", "negative": "negative",
"theoreticalExam": "Egzamin teorytyczny", "theoreticalExam": "Theoretical exam",
"category": { "category": {
"description": { "description": {
"A": "motocykle bez ograniczeń mocy", "A": "motorcycle without limited power",
"B": "⭐ samochody osobowe do 3,5 t", "B": "⭐ passenger cars up to 3,5 t",
"C": "pojazdy ciężarowe powyżej 3,5 t", "C": "trucks above 3,5 t",
"D": "autobusy", "D": "buses",
"T": "ciągniki rolnicze i pojazdy wolnobieżne", "T": "agricultural tractor and low-speed vehicles",
"AM": "motorowery i lekkie czterokołowce", "AM": "motobikes and light quadricycles",
"A1": "motocykle do 125 cm³ i 11 kW", "A1": "motorcycles up to 125 cm³ and 11 kW",
"A2": "motocykle do 35 kW", "A2": "motorcycles up to 35 kW",
"B1": "czterokołowce (np. quady)", "B1": "quadricycles (e.g quads)",
"C1": "pojazdy od 3,5 t do 7,5 t", "C1": "vehicles from 3,5 t to 7,5 t",
"D1": "autobusy do 16 pasażerów", "D1": "buses up to 16 passengers",
"PT": "tramwaje" "PT": "trams"
}, },
"age": { "age": {
"A": "(24 lata; lub 20 lat jeśli masz kat. A2 min. 2 lata)", "A": "(24 years; or 20 years if you've had category A2 for at least 2 years)",
"B": "(18 lat)", "B": "(18 years)",
"C": "(21 lat; lub 18 lat z kwalifikacją wstępną)", "C": "(21 years; or 18 years with preliminary qualification)",
"D": "(24 lata; lub 21 lat z kwalifikacją wstępną)", "D": "(24 years; or 21 years with preliminary qualification)",
"T": "(16 lat)", "T": "(16 years)",
"AM": "(14 lat)", "AM": "(14 years)",
"A1": "(16 lat)", "A1": "(16 years)",
"A2": "(18 lat)", "A2": "(18 years)",
"B1": "(16 lat)", "B1": "(16 years)",
"C1": "(18 lat)", "C1": "(18 years)",
"D1": "(21 lat; lub 18 lat z kwalifikacją wstępną)", "D1": "(21 years; or 18 years with preliminary qualification)",
"PT": "(21 lat)" "PT": "(21 years)"
} }
} }
} }

View file

@ -12,6 +12,12 @@
"B": "B", "B": "B",
"C": "C" "C": "C"
}, },
"yes": "Tak",
"no": "Nie",
"theme": "Motyw",
"light": "Jasny",
"dark": "Ciemny",
"auto": "Automatyczny",
"endExam": "Zakończ egzamin", "endExam": "Zakończ egzamin",
"examEnd": "Koniec egzaminu", "examEnd": "Koniec egzaminu",
"basicQuestions": "Pytania podstawowe", "basicQuestions": "Pytania podstawowe",

View file

@ -12,6 +12,12 @@
"B": "B", "B": "B",
"C": "C" "C": "C"
}, },
"yes": "Tak",
"no": "Nie",
"theme": "Motyw",
"light": "Jasny",
"dark": "Ciemny",
"auto": "Automatyczny",
"endExam": "Zakończ egzamin", "endExam": "Zakończ egzamin",
"examEnd": "Koniec egzaminu", "examEnd": "Koniec egzaminu",
"basicQuestions": "Pytania podstawowe", "basicQuestions": "Pytania podstawowe",

View file

@ -1,9 +1,11 @@
export default defineNuxtRouteMiddleware(async () => { export default defineNuxtRouteMiddleware(async () => {
const examStore = useExamStore(); const examStore = useExamStore();
const localePath = useLocalePath();
if (examStore.end) { if (examStore.end) {
return '/result'; return localePath('result');
} }
if (examStore.category === '') { if (examStore.category === '') {
return '/anomaly'; return localePath('anomaly');
} }
}); });

View file

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

View file

@ -18,7 +18,7 @@ const advancedStore = useAdvancedStore();
<br /> <br />
{{ $t('advancedQuestions') }}: {{ $t('advancedQuestions') }}:
<code class="text-xs">{{ advancedStore.advanced }}</code> <br /> <code class="text-xs">{{ advancedStore.advanced }}</code> <br />
<NuxtLink to="/" class="btn btn-primary"> <NuxtLink :to="$localePath('index')" class="btn btn-primary">
{{ $t('goBackToHomePage') }} {{ $t('goBackToHomePage') }}
</NuxtLink> </NuxtLink>
</div> </div>

View file

@ -183,6 +183,8 @@ function next() {
} }
} }
const localePath = useLocalePath();
function endExam() { function endExam() {
loading.value = true; loading.value = true;
while (ending.value == false) { while (ending.value == false) {
@ -200,9 +202,9 @@ function endExam() {
advancedStore.advanced == result.value.advanced && advancedStore.advanced == result.value.advanced &&
examStore.end examStore.end
) { ) {
return navigateTo(`/result`, { replace: true }); return navigateTo(localePath(`result`), { replace: true });
} else { } else {
return navigateTo(`/anomaly`); return navigateTo(localePath(`anomaly`));
} }
} }

View file

@ -15,12 +15,14 @@ const loading = ref(false);
const examStore = useExamStore(); const examStore = useExamStore();
await callOnce(() => examStore.resetExam(), { mode: 'navigation' }); await callOnce(() => examStore.resetExam(), { mode: 'navigation' });
const localePath = useLocalePath();
function setAndGo(category: string) { function setAndGo(category: string) {
loading.value = true; loading.value = true;
examStore.setCategory(category); examStore.setCategory(category);
while (true) { while (true) {
if (examStore.category === category) { if (examStore.category === category) {
return navigateTo('/exam'); return navigateTo(localePath('exam'));
} }
} }
} }
@ -31,6 +33,27 @@ function changeLanguage() {
examStore.setLang(langSelect.value); examStore.setLang(langSelect.value);
setLocale(langSelect.value as 'pl' | 'en' | 'de' | 'ua'); setLocale(langSelect.value as 'pl' | 'en' | 'de' | 'ua');
} }
const dark = ref(false);
function getTheme() {
if (
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
dark.value = true;
} else {
dark.value = false;
}
}
watchEffect(getTheme);
const themeStore = useThemeStore();
function themeAuto() {
themeStore.set('');
}
</script> </script>
<template> <template>
@ -50,8 +73,24 @@ function changeLanguage() {
<option value="ua">Ukrainian (Українська)</option> <option value="ua">Ukrainian (Українська)</option>
</select> </select>
</div> </div>
<div class="flex gap-2">
<label class="flex cursor-pointer gap-2 text-lg items-center">
{{ $t('theme') }}: {{ $t('light') }}
<input
v-model="dark"
type="checkbox"
value="dark"
class="toggle theme-controller"
@change="themeStore.set(dark ? 'dark' : 'light')"
/>
{{ $t('dark') }}
</label>
<div class="btn btn-soft btn-sm" @click="themeAuto()">
{{ $t('auto') }}
</div>
</div>
<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" class="flex flex-col flex-wrap gap-2 items-start p-4 bg-base-300 border-1 border-slate-500 rounded-xl w-fit"
> >
<div <div
v-for="category in categories" v-for="category in categories"

View file

@ -89,16 +89,18 @@ function changeCount(num: number) {
} }
} }
const localePath = useLocalePath();
async function again() { async function again() {
loading.value = true; loading.value = true;
await examStore.mildReset(); await examStore.mildReset();
return await navigateTo('/exam'); return await navigateTo(localePath('exam'));
} }
async function home() { async function home() {
loading.value = true; loading.value = true;
await examStore.resetExam(); await examStore.resetExam();
return await navigateTo('/'); return await navigateTo(localePath('index'));
} }
</script> </script>
@ -117,20 +119,22 @@ async function home() {
<div class="col-span-3 flex flex-col"> <div class="col-span-3 flex flex-col">
<BarTop :points="question?.weight" :category="examStore.category" /> <BarTop :points="question?.weight" :category="examStore.category" />
<MediaBox :media-path="question?.media_url" phase="" /> <MediaBox :media-path="question?.media_url" phase="" />
<QuestionBasic <div>
v-if="now === 'basic'" <QuestionBasic
v-model="answer" v-if="now === 'basic'"
:question="questionBasic" v-model="answer"
phase="result" :question="questionBasic"
class="select-none z-[-1]" phase="result"
/> class="select-none z-[-1]"
<QuestionAdvanced />
v-else-if="now === 'advanced'" <QuestionAdvanced
v-model="answer" v-else-if="now === 'advanced'"
:question="questionAdvanced" v-model="answer"
phase="result" :question="questionAdvanced"
class="select-none z-[-1]" phase="result"
/> class="select-none z-[-1]"
/>
</div>
</div> </div>
<BarRightResult <BarRightResult
:result="{ :result="{

13
store/themeStore.ts Normal file
View file

@ -0,0 +1,13 @@
export const useThemeStore = defineStore('themeStore', {
state: () => ({
theme: '',
}),
actions: {
async set(theme: string) {
this.theme = theme;
},
},
persist: {
storage: piniaPluginPersistedstate.localStorage(),
},
});