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>
<div>
<div :data-theme="themeStore.theme" class="min-h-dvh">
<NuxtPage />
</div>
</template>

View file

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

View file

@ -4,8 +4,6 @@ import { joinURL } from 'ufo';
const runtimeConfig = useRuntimeConfig();
const cdnUrl = runtimeConfig.public.cdn_url;
const route = useRoute();
const emit = defineEmits(['mediaload']);
const props = defineProps<{
@ -25,10 +23,12 @@ const media = computed(() => {
return { name: dotSplit?.join('.'), type };
});
const video = ref();
const video = useTemplateRef('video');
function onVideoLoad() {
if (route.path === '/exam') {
if (props.phase != 'result') {
const videoPlayInterval = setInterval(() => {
if (video.value) {
video.value.play();
let duration = video.value.duration;
if (isNaN(duration) || duration == Infinity) {
@ -37,6 +37,9 @@ function onVideoLoad() {
setTimeout(() => {
emit('mediaload');
}, duration * 1000);
clearInterval(videoPlayInterval);
}
}, 1000);
}
}
</script>
@ -44,8 +47,11 @@ function onVideoLoad() {
<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]' : ''"
>
<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" />
<NuxtImg
v-else-if="media.type === 'image'"
@ -59,7 +65,7 @@ function onVideoLoad() {
v-else-if="media.type === 'video'"
:key="`${mediaPath}-video`"
ref="video"
:controls="route.path === '/result'"
:controls="phase == 'result'"
@canplaythrough="onVideoLoad()"
>
<source :src="joinURL(cdnUrl, media.name + '.mp4')" type="video/mp4" />

View file

@ -35,7 +35,7 @@ onKeyStroke(['N', 'n'], () => {
<div>
<div class="flex flex-row justify-around">
<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}`"
:ref="`${value}-button`"
:key="`btn_answer_${element}`"
@ -44,7 +44,7 @@ onKeyStroke(['N', 'n'], () => {
name="tak_nie"
:value="value.toString()"
class="btn btn-primary btn-xl"
:aria-label="element"
:aria-label="$t(element).toLocaleUpperCase()"
:class="
phase == 'exam'
? answer == null

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,12 +15,14 @@ const loading = ref(false);
const examStore = useExamStore();
await callOnce(() => examStore.resetExam(), { mode: 'navigation' });
const localePath = useLocalePath();
function setAndGo(category: string) {
loading.value = true;
examStore.setCategory(category);
while (true) {
if (examStore.category === category) {
return navigateTo('/exam');
return navigateTo(localePath('exam'));
}
}
}
@ -31,6 +33,27 @@ function changeLanguage() {
examStore.setLang(langSelect.value);
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>
<template>
@ -50,8 +73,24 @@ function changeLanguage() {
<option value="ua">Ukrainian (Українська)</option>
</select>
</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
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
v-for="category in categories"

View file

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

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