Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 049eaa1807 |
+75
-30
@@ -12,7 +12,8 @@
|
|||||||
.d-flex.flex-column.h-100.pa-4
|
.d-flex.flex-column.h-100.pa-4
|
||||||
.d-flex.align-center.mb-6.px-2
|
.d-flex.align-center.mb-6.px-2
|
||||||
img.mr-3(:src="logo" height="32" width="32" alt="Logo")
|
img.mr-3(:src="logo" height="32" width="32" alt="Logo")
|
||||||
span.text-h6.font-weight-bold {{ $t('nav.menu') }}
|
span.text-h6.font-weight-bold
|
||||||
|
ScrambleText(:text="$t('nav.menu')")
|
||||||
|
|
||||||
v-list.pa-0(nav bg-color="transparent")
|
v-list.pa-0(nav bg-color="transparent")
|
||||||
v-list-item.mb-2(
|
v-list-item.mb-2(
|
||||||
@@ -22,7 +23,8 @@
|
|||||||
)
|
)
|
||||||
template(v-slot:prepend)
|
template(v-slot:prepend)
|
||||||
v-icon(color="#00cec9" icon="mdi-view-dashboard")
|
v-icon(color="#00cec9" icon="mdi-view-dashboard")
|
||||||
v-list-item-title.font-weight-bold {{ $t('nav.dashboard') }}
|
v-list-item-title.font-weight-bold
|
||||||
|
ScrambleText(:text="$t('nav.dashboard')")
|
||||||
|
|
||||||
v-list-item.mb-2(
|
v-list-item.mb-2(
|
||||||
to="/collection"
|
to="/collection"
|
||||||
@@ -31,7 +33,8 @@
|
|||||||
)
|
)
|
||||||
template(v-slot:prepend)
|
template(v-slot:prepend)
|
||||||
v-icon(color="#00cec9" icon="mdi-bookshelf")
|
v-icon(color="#00cec9" icon="mdi-bookshelf")
|
||||||
v-list-item-title.font-weight-bold {{ $t('nav.collection') }}
|
v-list-item-title.font-weight-bold
|
||||||
|
ScrambleText(:text="$t('nav.collection')")
|
||||||
|
|
||||||
v-divider.my-4.border-subtle
|
v-divider.my-4.border-subtle
|
||||||
|
|
||||||
@@ -43,7 +46,7 @@
|
|||||||
@click="showSettings = true; drawer = false"
|
@click="showSettings = true; drawer = false"
|
||||||
)
|
)
|
||||||
v-icon(start icon="mdi-cog")
|
v-icon(start icon="mdi-cog")
|
||||||
| {{ $t('nav.settings') }}
|
ScrambleText(:text="$t('nav.settings')")
|
||||||
|
|
||||||
v-btn.justify-start.px-4(
|
v-btn.justify-start.px-4(
|
||||||
variant="text"
|
variant="text"
|
||||||
@@ -53,7 +56,7 @@
|
|||||||
@click="manualSync"
|
@click="manualSync"
|
||||||
)
|
)
|
||||||
v-icon(start icon="mdi-sync")
|
v-icon(start icon="mdi-sync")
|
||||||
| {{ $t('nav.sync') }}
|
ScrambleText(:text="$t('nav.sync')")
|
||||||
|
|
||||||
v-btn.justify-start.px-4.text-red-lighten-2(
|
v-btn.justify-start.px-4.text-red-lighten-2(
|
||||||
variant="text"
|
variant="text"
|
||||||
@@ -61,7 +64,7 @@
|
|||||||
@click="handleLogout"
|
@click="handleLogout"
|
||||||
)
|
)
|
||||||
v-icon(start icon="mdi-logout")
|
v-icon(start icon="mdi-logout")
|
||||||
| {{ $t('nav.logout') }}
|
ScrambleText(:text="$t('nav.logout')")
|
||||||
.parallax-bg(
|
.parallax-bg(
|
||||||
:style="parallaxStyle"
|
:style="parallaxStyle"
|
||||||
:class="{ 'zooming': viewState === 'zooming' }"
|
:class="{ 'zooming': viewState === 'zooming' }"
|
||||||
@@ -103,13 +106,15 @@
|
|||||||
to="/"
|
to="/"
|
||||||
variant="text"
|
variant="text"
|
||||||
:color="$route.path === '/' ? '#00cec9' : 'grey'"
|
:color="$route.path === '/' ? '#00cec9' : 'grey'"
|
||||||
) {{ $t('nav.dashboard') }}
|
)
|
||||||
|
ScrambleText(:text="$t('nav.dashboard')")
|
||||||
|
|
||||||
v-btn.mx-1(
|
v-btn.mx-1(
|
||||||
to="/collection"
|
to="/collection"
|
||||||
variant="text"
|
variant="text"
|
||||||
:color="$route.path === '/collection' ? '#00cec9' : 'grey'"
|
:color="$route.path === '/collection' ? '#00cec9' : 'grey'"
|
||||||
) {{ $t('nav.collection') }}
|
)
|
||||||
|
ScrambleText(:text="$t('nav.collection')")
|
||||||
|
|
||||||
v-divider.mx-2.my-auto(vertical length="20" color="grey-darken-2")
|
v-divider.mx-2.my-auto(vertical length="20" color="grey-darken-2")
|
||||||
|
|
||||||
@@ -169,8 +174,10 @@
|
|||||||
.login-content
|
.login-content
|
||||||
img.mb-4(:src="logo" height="64" width="64")
|
img.mb-4(:src="logo" height="64" width="64")
|
||||||
|
|
||||||
h1.text-h5.font-weight-bold.mb-2 {{ $t('hero.welcome') }}
|
h1.text-h5.font-weight-bold.mb-2
|
||||||
p.text-grey-lighten-1.text-body-2.mb-6 {{ $t('login.instruction') }}
|
ScrambleText(:text="$t('hero.welcome')")
|
||||||
|
p.text-grey-lighten-1.text-body-2.mb-6
|
||||||
|
ScrambleText(:text="$t('login.instruction')")
|
||||||
|
|
||||||
v-text-field.mb-2(
|
v-text-field.mb-2(
|
||||||
v-model="inputKey"
|
v-model="inputKey"
|
||||||
@@ -197,14 +204,17 @@
|
|||||||
height="44"
|
height="44"
|
||||||
:loading="viewState === 'authenticating'"
|
:loading="viewState === 'authenticating'"
|
||||||
@click="triggerLogin"
|
@click="triggerLogin"
|
||||||
) {{ $t('login.button') }}
|
)
|
||||||
|
ScrambleText(:text="$t('login.button')")
|
||||||
|
|
||||||
v-dialog(v-model="showSettings" max-width="340")
|
v-dialog(v-model="showSettings" max-width="340")
|
||||||
v-card.rounded-xl.pa-4.border-subtle(color="#1e1e24")
|
v-card.rounded-xl.pa-4.border-subtle(color="#1e1e24")
|
||||||
v-card-title.text-center.font-weight-bold {{ $t('settings.title') }}
|
v-card-title.text-center.font-weight-bold
|
||||||
|
ScrambleText(:text="$t('settings.title')")
|
||||||
|
|
||||||
v-card-text.pt-4
|
v-card-text.pt-4
|
||||||
.text-caption.text-grey.mb-2 {{ $t('settings.batchSize') }}
|
.text-caption.text-grey.mb-2
|
||||||
|
ScrambleText(:text="$t('settings.batchSize')")
|
||||||
v-slider(
|
v-slider(
|
||||||
v-model="tempBatchSize"
|
v-model="tempBatchSize"
|
||||||
:min="5"
|
:min="5"
|
||||||
@@ -215,9 +225,12 @@
|
|||||||
track-color="grey-darken-3"
|
track-color="grey-darken-3"
|
||||||
)
|
)
|
||||||
.text-center.text-h5.font-weight-bold.text-teal-accent-3.mb-6
|
.text-center.text-h5.font-weight-bold.text-teal-accent-3.mb-6
|
||||||
| {{ tempBatchSize }} {{ $t('settings.items') }}
|
| {{ tempBatchSize }}
|
||||||
|
|
|
||||||
|
ScrambleText(:text="$t('settings.items')")
|
||||||
|
|
||||||
.text-caption.text-grey.mb-2 {{ $t('settings.drawingTolerance') }}
|
.text-caption.text-grey.mb-2
|
||||||
|
ScrambleText(:text="$t('settings.drawingTolerance')")
|
||||||
v-slider(
|
v-slider(
|
||||||
v-model="tempDrawingAccuracy"
|
v-model="tempDrawingAccuracy"
|
||||||
:min="1"
|
:min="1"
|
||||||
@@ -228,11 +241,16 @@
|
|||||||
track-color="grey-darken-3"
|
track-color="grey-darken-3"
|
||||||
)
|
)
|
||||||
.d-flex.justify-space-between.text-caption.text-grey-lighten-1.mb-6.px-1
|
.d-flex.justify-space-between.text-caption.text-grey-lighten-1.mb-6.px-1
|
||||||
span {{ $t('settings.strict') }} (5)
|
span
|
||||||
|
ScrambleText(:text="$t('settings.strict')")
|
||||||
|
| (5)
|
||||||
span.font-weight-bold.text-body-1(color="#00cec9") {{ tempDrawingAccuracy }}
|
span.font-weight-bold.text-body-1(color="#00cec9") {{ tempDrawingAccuracy }}
|
||||||
span {{ $t('settings.loose') }} (20)
|
span
|
||||||
|
ScrambleText(:text="$t('settings.loose')")
|
||||||
|
| (20)
|
||||||
|
|
||||||
.text-caption.text-grey.mb-2 {{ $t('settings.language') }}
|
.text-caption.text-grey.mb-2
|
||||||
|
ScrambleText(:text="$t('settings.language')")
|
||||||
|
|
||||||
.lang-switch-wrapper.border-subtle.mb-2
|
.lang-switch-wrapper.border-subtle.mb-2
|
||||||
.lang-glider(:style="{ transform: `translateX(${localeIndex * 100}%)` }")
|
.lang-glider(:style="{ transform: `translateX(${localeIndex * 100}%)` }")
|
||||||
@@ -240,15 +258,29 @@
|
|||||||
v-for="l in availableLocales"
|
v-for="l in availableLocales"
|
||||||
:key="l"
|
:key="l"
|
||||||
:class="{ 'active': tempLocale === l }"
|
:class="{ 'active': tempLocale === l }"
|
||||||
@click="tempLocale = l"
|
@click="switchLocale(l)"
|
||||||
) {{ l.toUpperCase() }}
|
) {{ l.toUpperCase() }}
|
||||||
|
|
||||||
|
.d-flex.align-center.justify-space-between.mt-4.px-1
|
||||||
|
.text-caption.text-grey
|
||||||
|
ScrambleText(:text="$t('settings.soundEffects')")
|
||||||
|
v-switch(
|
||||||
|
v-model="soundEnabled"
|
||||||
|
color="cyan"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
inset
|
||||||
|
)
|
||||||
|
template(v-slot:label)
|
||||||
|
ScrambleText(:text="soundEnabled ? $t('settings.on') : $t('settings.off')")
|
||||||
|
|
||||||
v-card-actions.mt-2
|
v-card-actions.mt-2
|
||||||
v-btn.text-white(
|
v-btn.text-white(
|
||||||
block
|
block
|
||||||
color="#2f3542"
|
color="#2f3542"
|
||||||
@click="saveSettings"
|
@click="saveSettings"
|
||||||
) {{ $t('settings.save') }}
|
)
|
||||||
|
ScrambleText(:text="$t('settings.save')")
|
||||||
|
|
||||||
v-dialog(v-model="showLogoutDialog" max-width="320")
|
v-dialog(v-model="showLogoutDialog" max-width="320")
|
||||||
v-card.rounded-xl.pa-4.border-subtle(color="#1e1e24")
|
v-card.rounded-xl.pa-4.border-subtle(color="#1e1e24")
|
||||||
@@ -256,10 +288,11 @@
|
|||||||
v-avatar.mb-3(color="red-darken-4" size="48")
|
v-avatar.mb-3(color="red-darken-4" size="48")
|
||||||
v-icon(icon="mdi-logout" size="24" color="red-lighten-1")
|
v-icon(icon="mdi-logout" size="24" color="red-lighten-1")
|
||||||
|
|
||||||
h3.text-h6.font-weight-bold.mb-2 {{ $t('nav.logout') }}?
|
h3.text-h6.font-weight-bold.mb-2
|
||||||
|
ScrambleText(:text="$t('nav.logout') + '?'")
|
||||||
|
|
||||||
p.text-body-2.text-grey-lighten-1.px-2.mb-4
|
p.text-body-2.text-grey-lighten-1.px-2.mb-4
|
||||||
| {{ $t('alerts.logoutConfirm') }}
|
ScrambleText(:text="$t('alerts.logoutConfirm')")
|
||||||
|
|
||||||
v-card-actions.d-flex.gap-2.pa-0.mt-2
|
v-card-actions.d-flex.gap-2.pa-0.mt-2
|
||||||
v-btn.flex-grow-1.text-capitalize(
|
v-btn.flex-grow-1.text-capitalize(
|
||||||
@@ -267,19 +300,22 @@
|
|||||||
color="grey"
|
color="grey"
|
||||||
height="40"
|
height="40"
|
||||||
@click="showLogoutDialog = false"
|
@click="showLogoutDialog = false"
|
||||||
) {{ $t('common.cancel') }}
|
)
|
||||||
|
ScrambleText(:text="$t('common.cancel')")
|
||||||
|
|
||||||
v-btn.flex-grow-1.text-capitalize(
|
v-btn.flex-grow-1.text-capitalize(
|
||||||
color="red-accent-2"
|
color="red-accent-2"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
height="40"
|
height="40"
|
||||||
@click="confirmLogout"
|
@click="confirmLogout"
|
||||||
) {{ $t('nav.logout') }}
|
)
|
||||||
|
ScrambleText(:text="$t('nav.logout')")
|
||||||
|
|
||||||
v-snackbar(v-model="snackbar.show" :color="snackbar.color" timeout="3000")
|
v-snackbar(v-model="snackbar.show" :color="snackbar.color" timeout="3000")
|
||||||
| {{ snackbar.text }}
|
| {{ snackbar.text }}
|
||||||
template(v-slot:actions)
|
template(v-slot:actions)
|
||||||
v-btn(variant="text" @click="snackbar.show = false") {{ $t('common.close') }}
|
v-btn(variant="text" @click="snackbar.show = false")
|
||||||
|
ScrambleText(:text="$t('common.close')")
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -291,6 +327,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { App as CapacitorApp } from '@capacitor/app';
|
import { App as CapacitorApp } from '@capacitor/app';
|
||||||
import { useAppStore } from '@/stores/appStore';
|
import { useAppStore } from '@/stores/appStore';
|
||||||
import { checkForUpdates } from '@/utils/autoUpdate';
|
import { checkForUpdates } from '@/utils/autoUpdate';
|
||||||
|
import { SoundManager } from '@/utils/SoundManager';
|
||||||
import logo from '@/assets/icon.svg';
|
import logo from '@/assets/icon.svg';
|
||||||
|
|
||||||
const drawer = ref(false);
|
const drawer = ref(false);
|
||||||
@@ -313,6 +350,9 @@ const tempDrawingAccuracy = ref(store.drawingAccuracy);
|
|||||||
const availableLocales = ['en', 'de', 'ja'];
|
const availableLocales = ['en', 'de', 'ja'];
|
||||||
const tempLocale = ref(locale.value);
|
const tempLocale = ref(locale.value);
|
||||||
const localeIndex = computed(() => availableLocales.indexOf(tempLocale.value));
|
const localeIndex = computed(() => availableLocales.indexOf(tempLocale.value));
|
||||||
|
const soundEnabled = ref(true);
|
||||||
|
|
||||||
|
watch(inputKey, () => { errorMsg.value = ''; });
|
||||||
|
|
||||||
const backgroundKanjis = [
|
const backgroundKanjis = [
|
||||||
{
|
{
|
||||||
@@ -391,6 +431,7 @@ const handleMouseMove = (e) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
SoundManager.init();
|
||||||
CapacitorApp.addListener('appStateChange', ({ isActive }) => {
|
CapacitorApp.addListener('appStateChange', ({ isActive }) => {
|
||||||
if (isActive) checkForUpdates();
|
if (isActive) checkForUpdates();
|
||||||
});
|
});
|
||||||
@@ -421,6 +462,7 @@ watch(showSettings, (isOpen) => {
|
|||||||
tempBatchSize.value = store.batchSize;
|
tempBatchSize.value = store.batchSize;
|
||||||
tempDrawingAccuracy.value = store.drawingAccuracy;
|
tempDrawingAccuracy.value = store.drawingAccuracy;
|
||||||
tempLocale.value = locale.value;
|
tempLocale.value = locale.value;
|
||||||
|
soundEnabled.value = !SoundManager.isMuted;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -464,20 +506,23 @@ async function triggerLogin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function switchLocale(l) {
|
||||||
|
tempLocale.value = l;
|
||||||
|
locale.value = l;
|
||||||
|
localStorage.setItem('zen_locale', l);
|
||||||
|
}
|
||||||
|
|
||||||
function saveSettings() {
|
function saveSettings() {
|
||||||
showSettings.value = false;
|
showSettings.value = false;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (locale.value !== tempLocale.value) {
|
|
||||||
locale.value = tempLocale.value;
|
|
||||||
localStorage.setItem('zen_locale', locale.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
store.saveSettings({
|
store.saveSettings({
|
||||||
batchSize: tempBatchSize.value,
|
batchSize: tempBatchSize.value,
|
||||||
drawingAccuracy: tempDrawingAccuracy.value,
|
drawingAccuracy: tempDrawingAccuracy.value,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
SoundManager.setMuted(!soundEnabled.value);
|
||||||
|
|
||||||
store.fetchQueue();
|
store.fetchQueue();
|
||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<template lang="pug">
|
||||||
|
span {{ displayText }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, onBeforeUnmount } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
text: { type: String, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayText = ref(props.text);
|
||||||
|
let frameId = null;
|
||||||
|
|
||||||
|
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZアイウエオカキクケコ漢字書道';
|
||||||
|
const DURATION = 400; // total animation time in ms
|
||||||
|
const STEPS_PER_CHAR = 3; // how many random chars before settling
|
||||||
|
|
||||||
|
function scrambleTo(newText) {
|
||||||
|
if (frameId) cancelAnimationFrame(frameId);
|
||||||
|
|
||||||
|
const oldText = displayText.value;
|
||||||
|
const maxLen = Math.max(oldText.length, newText.length);
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
// Each character position settles at a staggered time
|
||||||
|
const charDelay = DURATION / (maxLen || 1);
|
||||||
|
|
||||||
|
const tick = (now) => {
|
||||||
|
const elapsed = now - startTime;
|
||||||
|
let result = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < maxLen; i++) {
|
||||||
|
const charSettleTime = i * charDelay;
|
||||||
|
const charElapsed = elapsed - charSettleTime;
|
||||||
|
|
||||||
|
if (i >= newText.length && elapsed > charSettleTime + charDelay) {
|
||||||
|
// This position should disappear — don't add anything
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (charElapsed >= charDelay) {
|
||||||
|
// This character has settled — show final
|
||||||
|
if (i < newText.length) result += newText[i];
|
||||||
|
} else if (charElapsed > 0) {
|
||||||
|
// This character is still scrambling
|
||||||
|
const step = Math.floor((charElapsed / charDelay) * STEPS_PER_CHAR);
|
||||||
|
if (step >= STEPS_PER_CHAR - 1 && i < newText.length) {
|
||||||
|
result += newText[i];
|
||||||
|
} else {
|
||||||
|
result += CHARS[Math.floor(Math.random() * CHARS.length)];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Hasn't started yet — show old char or space
|
||||||
|
result += i < oldText.length ? oldText[i] : ' ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
displayText.value = result;
|
||||||
|
|
||||||
|
if (elapsed < DURATION + charDelay) {
|
||||||
|
frameId = requestAnimationFrame(tick);
|
||||||
|
} else {
|
||||||
|
displayText.value = newText;
|
||||||
|
frameId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
frameId = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.text, (newVal) => {
|
||||||
|
scrambleTo(newVal);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (frameId) cancelAnimationFrame(frameId);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -14,8 +14,10 @@
|
|||||||
div
|
div
|
||||||
.text-subtitle-1.font-weight-bold.d-flex.align-center.text-white(
|
.text-subtitle-1.font-weight-bold.d-flex.align-center.text-white(
|
||||||
style="line-height: 1.2"
|
style="line-height: 1.2"
|
||||||
) {{ title }}
|
)
|
||||||
.text-caption.text-grey(v-if="subtitle") {{ subtitle }}
|
ScrambleText(:text="title")
|
||||||
|
.text-caption.text-grey(v-if="subtitle")
|
||||||
|
ScrambleText(:text="subtitle")
|
||||||
|
|
||||||
div.d-flex.align-center
|
div.d-flex.align-center
|
||||||
slot(name="header-right")
|
slot(name="header-right")
|
||||||
|
|||||||
@@ -7,9 +7,11 @@
|
|||||||
div
|
div
|
||||||
.text-h3.font-weight-bold.text-white {{ accuracyPercent }}%
|
.text-h3.font-weight-bold.text-white {{ accuracyPercent }}%
|
||||||
.text-caption.text-grey.mt-1
|
.text-caption.text-grey.mt-1
|
||||||
| {{ accuracy?.correct || 0 }} {{ $t('stats.correct') }}
|
| {{ accuracy?.correct || 0 }}
|
||||||
|
ScrambleText(:text="$t('stats.correct')")
|
||||||
span.mx-1 /
|
span.mx-1 /
|
||||||
| {{ accuracy?.total || 0 }} {{ $t('stats.total') }}
|
| {{ accuracy?.total || 0 }}
|
||||||
|
ScrambleText(:text="$t('stats.total')")
|
||||||
|
|
||||||
v-progress-circular(
|
v-progress-circular(
|
||||||
:model-value="accuracyPercent"
|
:model-value="accuracyPercent"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
.d-flex.align-center.justify-space-between.mb-4
|
.d-flex.align-center.justify-space-between.mb-4
|
||||||
.text-subtitle-1.font-weight-bold.d-flex.align-center
|
.text-subtitle-1.font-weight-bold.d-flex.align-center
|
||||||
v-icon(color="secondary" start size="small") mdi-chart-bar
|
v-icon(color="secondary" start size="small") mdi-chart-bar
|
||||||
| {{ $t('stats.srsDistribution') }}
|
ScrambleText(:text="$t('stats.srsDistribution')")
|
||||||
v-chip.font-weight-bold(
|
v-chip.font-weight-bold(
|
||||||
size="x-small"
|
size="x-small"
|
||||||
color="white"
|
color="white"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
div(v-for="(count, hour) in forecast" :key="hour")
|
div(v-for="(count, hour) in forecast" :key="hour")
|
||||||
.d-flex.justify-space-between.align-center.mb-2.py-2.border-b-subtle(v-if="count > 0")
|
.d-flex.justify-space-between.align-center.mb-2.py-2.border-b-subtle(v-if="count > 0")
|
||||||
span.text-body-2.text-grey-lighten-1
|
span.text-body-2.text-grey-lighten-1
|
||||||
| {{ hour === 0 ? $t('stats.availableNow') : $t('stats.inHours', { n: hour }, hour) }}
|
ScrambleText(:text="hour === 0 ? $t('stats.availableNow') : $t('stats.inHours', { n: hour }, hour)")
|
||||||
|
|
||||||
v-chip.font-weight-bold.text-primary(
|
v-chip.font-weight-bold.text-primary(
|
||||||
size="small"
|
size="small"
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
) {{ count }}
|
) {{ count }}
|
||||||
|
|
||||||
.fill-height.d-flex.align-center.justify-center.text-grey.text-center.pa-4(v-else)
|
.fill-height.d-flex.align-center.justify-center.text-grey.text-center.pa-4(v-else)
|
||||||
| {{ $t('stats.noIncoming') }}
|
ScrambleText(:text="$t('stats.noIncoming')")
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -24,7 +24,7 @@ import { computed } from 'vue';
|
|||||||
import DashboardWidget from './DashboardWidget.vue';
|
import DashboardWidget from './DashboardWidget.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
forecast: { type: Object, default: () => ({}) },
|
forecast: { type: Array, default: () => [] },
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasUpcoming = computed(() => props.forecast && props.forecast.some((c) => c > 0));
|
const hasUpcoming = computed(() => props.forecast && props.forecast.some((c) => c > 0));
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
) {{ ghost.accuracy }}%
|
) {{ ghost.accuracy }}%
|
||||||
|
|
||||||
.text-center.py-2.text-caption.text-grey(v-else)
|
.text-center.py-2.text-caption.text-grey(v-else)
|
||||||
| {{ $t('stats.noGhosts') }}
|
ScrambleText(:text="$t('stats.noGhosts')")
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
)
|
)
|
||||||
|
|
||||||
.text-caption.text-medium-emphasis.mt-2.text-right
|
.text-caption.text-medium-emphasis.mt-2.text-right
|
||||||
| {{ masteredCount }} / {{ totalItems }} {{ $t('stats.items') }}
|
| {{ masteredCount }} / {{ totalItems }}
|
||||||
|
ScrambleText(:text="$t('stats.items')")
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -33,7 +34,7 @@ const totalItems = computed(
|
|||||||
() => Object.values(props.distribution || {}).reduce((a, b) => a + b, 0),
|
() => Object.values(props.distribution || {}).reduce((a, b) => a + b, 0),
|
||||||
);
|
);
|
||||||
const masteredCount = computed(
|
const masteredCount = computed(
|
||||||
() => (props.distribution || {})[6] || 0,
|
() => [6, 7, 8, 9, 10].reduce((sum, lvl) => sum + ((props.distribution || {})[lvl] || 0), 0),
|
||||||
);
|
);
|
||||||
const masteryPercent = computed(
|
const masteryPercent = computed(
|
||||||
() => (totalItems.value === 0 ? 0 : Math.round((masteredCount.value / totalItems.value) * 100)),
|
() => (totalItems.value === 0 ? 0 : Math.round((masteredCount.value / totalItems.value) * 100)),
|
||||||
|
|||||||
@@ -5,13 +5,15 @@
|
|||||||
)
|
)
|
||||||
template(#header-right)
|
template(#header-right)
|
||||||
.legend-container
|
.legend-container
|
||||||
span.text-caption.text-medium-emphasis.mr-1 {{ $t('stats.less') }}
|
span.text-caption.text-medium-emphasis.mr-1
|
||||||
|
ScrambleText(:text="$t('stats.less')")
|
||||||
.legend-box.level-0
|
.legend-box.level-0
|
||||||
.legend-box.level-1
|
.legend-box.level-1
|
||||||
.legend-box.level-2
|
.legend-box.level-2
|
||||||
.legend-box.level-3
|
.legend-box.level-3
|
||||||
.legend-box.level-4
|
.legend-box.level-4
|
||||||
span.text-caption.text-medium-emphasis.ml-1 {{ $t('stats.more') }}
|
span.text-caption.text-medium-emphasis.ml-1
|
||||||
|
ScrambleText(:text="$t('stats.more')")
|
||||||
|
|
||||||
.heatmap-container
|
.heatmap-container
|
||||||
.heatmap-year
|
.heatmap-year
|
||||||
@@ -25,7 +27,8 @@
|
|||||||
)
|
)
|
||||||
.text-center
|
.text-center
|
||||||
.font-weight-bold {{ formatDate(day.date) }}
|
.font-weight-bold {{ formatDate(day.date) }}
|
||||||
div {{ $t('stats.reviewsCount', { count: day.count }) }}
|
div
|
||||||
|
ScrambleText(:text="$t('stats.reviewsCount', { count: day.count })")
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
.d-flex.align-end.mb-3
|
.d-flex.align-end.mb-3
|
||||||
.text-h3.font-weight-bold.text-white.mr-2(style="line-height: 1")
|
.text-h3.font-weight-bold.text-white.mr-2(style="line-height: 1")
|
||||||
| {{ streak?.current || 0 }}
|
| {{ streak?.current || 0 }}
|
||||||
.text-body-1.text-grey.mb-1 {{ $t('stats.days') }}
|
.text-body-1.text-grey.mb-1
|
||||||
|
ScrambleText(:text="$t('stats.days')")
|
||||||
|
|
||||||
.d-flex.justify-space-between.align-center.px-1
|
.d-flex.justify-space-between.align-center.px-1
|
||||||
.d-flex.flex-column.align-center(
|
.d-flex.flex-column.align-center(
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
.grid-area-full.text-center.mb-8.zen-wrapper
|
.grid-area-full.text-center.mb-8.zen-wrapper
|
||||||
.text-h3.font-weight-bold.mb-2.text-white.letter-spacing-small {{ $t('hero.welcome') }}
|
.text-h3.font-weight-bold.mb-2.text-white.letter-spacing-small
|
||||||
|
ScrambleText(:text="$t('hero.welcome')")
|
||||||
.text-subtitle-1.text-grey-lighten-1.mb-10(
|
.text-subtitle-1.text-grey-lighten-1.mb-10(
|
||||||
style="max-width: 600px; margin-inline: auto;"
|
style="max-width: 600px; margin-inline: auto;"
|
||||||
) {{ $t('hero.subtitle') }}
|
)
|
||||||
|
ScrambleText(:text="$t('hero.subtitle')")
|
||||||
|
|
||||||
.d-flex.justify-center.align-stretch.flex-wrap.ga-6.mb-8
|
.d-flex.justify-center.align-stretch.flex-wrap.ga-6.mb-8
|
||||||
|
|
||||||
@@ -21,9 +23,9 @@
|
|||||||
|
|
||||||
.d-flex.flex-column.align-start.mr-auto
|
.d-flex.flex-column.align-start.mr-auto
|
||||||
.text-purple-lighten-1.text-h6.font-weight-bold.line-height-tight
|
.text-purple-lighten-1.text-h6.font-weight-bold.line-height-tight
|
||||||
| {{ $t('hero.lessons') }}
|
ScrambleText(:text="$t('hero.lessons')")
|
||||||
.text-purple-accent-1.text-caption.font-weight-regular.opacity-80
|
.text-purple-accent-1.text-caption.font-weight-regular.opacity-80
|
||||||
| {{ $t('hero.newContent') }}
|
ScrambleText(:text="$t('hero.newContent')")
|
||||||
|
|
||||||
.zen-counter.bg-purple-accent-2.text-black.ml-4
|
.zen-counter.bg-purple-accent-2.text-black.ml-4
|
||||||
| {{ lessonCount }}
|
| {{ lessonCount }}
|
||||||
@@ -43,9 +45,9 @@
|
|||||||
|
|
||||||
.d-flex.flex-column.align-start.mr-auto
|
.d-flex.flex-column.align-start.mr-auto
|
||||||
.text-cyan-lighten-1.text-h6.font-weight-bold.line-height-tight
|
.text-cyan-lighten-1.text-h6.font-weight-bold.line-height-tight
|
||||||
| {{ queueLength > 0 ? $t('hero.start') : $t('hero.noReviews') }}
|
ScrambleText(:text="queueLength > 0 ? $t('hero.start') : $t('hero.noReviews')")
|
||||||
.text-cyan-accent-1.text-caption.font-weight-regular.opacity-80
|
.text-cyan-accent-1.text-caption.font-weight-regular.opacity-80
|
||||||
| {{ queueLength > 0 ? $t('hero.readyToReview') : $t('hero.allCaughtUp') }}
|
ScrambleText(:text="queueLength > 0 ? $t('hero.readyToReview') : $t('hero.allCaughtUp')")
|
||||||
|
|
||||||
v-fade-transition
|
v-fade-transition
|
||||||
.zen-counter.bg-cyan-accent-2.text-black.ml-4(v-if="queueLength > 0")
|
.zen-counter.bg-cyan-accent-2.text-black.ml-4(v-if="queueLength > 0")
|
||||||
@@ -59,14 +61,16 @@
|
|||||||
variant="text"
|
variant="text"
|
||||||
prepend-icon="mdi-sort-ascending"
|
prepend-icon="mdi-sort-ascending"
|
||||||
:ripple="false"
|
:ripple="false"
|
||||||
) {{ $t('hero.prioritize', { count: lowerLevelCount }) }}
|
)
|
||||||
|
ScrambleText(:text="$t('hero.prioritize', { count: lowerLevelCount })")
|
||||||
|
|
||||||
v-fade-transition
|
v-fade-transition
|
||||||
.text-caption.text-grey-darken-1.font-weight-medium.d-flex.align-center.mt-2(
|
.text-caption.text-grey-darken-1.font-weight-medium.d-flex.align-center.mt-2(
|
||||||
v-if="queueLength === 0 && !hasLowerLevels && nextReviewTime"
|
v-if="queueLength === 0 && !hasLowerLevels && nextReviewTime"
|
||||||
)
|
)
|
||||||
v-icon.mr-2(size="small" icon="mdi-clock-outline")
|
v-icon.mr-2(size="small" icon="mdi-clock-outline")
|
||||||
span {{ $t('hero.nextIn') }} {{ nextReviewTime }}
|
span
|
||||||
|
ScrambleText(:text="$t('hero.nextIn') + ' ' + nextReviewTime")
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -79,14 +83,14 @@ const props = defineProps({
|
|||||||
lessonCount: { type: Number, default: 0 },
|
lessonCount: { type: Number, default: 0 },
|
||||||
hasLowerLevels: { type: Boolean, default: false },
|
hasLowerLevels: { type: Boolean, default: false },
|
||||||
lowerLevelCount: { type: Number, default: 0 },
|
lowerLevelCount: { type: Number, default: 0 },
|
||||||
forecast: { type: Object, default: () => ({}) },
|
forecast: { type: Array, default: () => [] },
|
||||||
});
|
});
|
||||||
defineEmits(['start']);
|
defineEmits(['start']);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const nextReviewTime = computed(() => {
|
const nextReviewTime = computed(() => {
|
||||||
if (!props.forecast || Object.keys(props.forecast).length === 0) return null;
|
if (!props.forecast || props.forecast.length === 0) return null;
|
||||||
try {
|
try {
|
||||||
const idx = props.forecast.findIndex((c) => c > 0);
|
const idx = props.forecast.findIndex((c) => c > 0);
|
||||||
if (idx === -1) return null;
|
if (idx === -1) return null;
|
||||||
|
|||||||
@@ -51,8 +51,11 @@ const vuetify = createVuetify({
|
|||||||
icons: { defaultSet: 'mdi', aliases, sets: { mdi } },
|
icons: { defaultSet: 'mdi', aliases, sets: { mdi } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import ScrambleText from '@/components/ScrambleText.vue';
|
||||||
|
|
||||||
app.use(pinia);
|
app.use(pinia);
|
||||||
app.use(router);
|
app.use(router);
|
||||||
app.use(vuetify);
|
app.use(vuetify);
|
||||||
app.use(i18n);
|
app.use(i18n);
|
||||||
|
app.component('ScrambleText', ScrambleText);
|
||||||
app.mount('#app');
|
app.mount('#app');
|
||||||
|
|||||||
@@ -44,8 +44,13 @@ const messages = {
|
|||||||
learned: "You've learned {n} new kanji.",
|
learned: "You've learned {n} new kanji.",
|
||||||
components: 'Components',
|
components: 'Components',
|
||||||
observe: 'Observe Stroke Order',
|
observe: 'Observe Stroke Order',
|
||||||
|
replay: 'Replay',
|
||||||
trace: 'Trace',
|
trace: 'Trace',
|
||||||
drawStep: 'Draw ({n}/{total})',
|
drawStep: 'Draw ({n}/{total})',
|
||||||
|
sectionMeaning: 'Meaning',
|
||||||
|
sectionMnemonic: 'Memory Aid',
|
||||||
|
sectionReadings: 'Readings',
|
||||||
|
learnedKanji: 'Kanji Learned',
|
||||||
backToDashboard: 'Back to Dashboard',
|
backToDashboard: 'Back to Dashboard',
|
||||||
},
|
},
|
||||||
hero: {
|
hero: {
|
||||||
@@ -70,6 +75,7 @@ const messages = {
|
|||||||
next24: 'Next 24h',
|
next24: 'Next 24h',
|
||||||
availableNow: 'Available Now',
|
availableNow: 'Available Now',
|
||||||
inHours: 'In {n} hour | In {n} hours',
|
inHours: 'In {n} hour | In {n} hours',
|
||||||
|
inDays: 'In {n} day | In {n} days',
|
||||||
noIncoming: 'No reviews incoming for 24 hours.',
|
noIncoming: 'No reviews incoming for 24 hours.',
|
||||||
items: 'items',
|
items: 'items',
|
||||||
reviewsCount: '{count} reviews',
|
reviewsCount: '{count} reviews',
|
||||||
@@ -96,6 +102,9 @@ const messages = {
|
|||||||
strict: 'Strict',
|
strict: 'Strict',
|
||||||
loose: 'Loose',
|
loose: 'Loose',
|
||||||
save: 'Save & Close',
|
save: 'Save & Close',
|
||||||
|
soundEffects: 'Sound Effects',
|
||||||
|
on: 'On',
|
||||||
|
off: 'Off',
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
meaning: 'Meaning',
|
meaning: 'Meaning',
|
||||||
@@ -109,6 +118,9 @@ const messages = {
|
|||||||
next: 'NEXT',
|
next: 'NEXT',
|
||||||
sessionComplete: 'Session Complete!',
|
sessionComplete: 'Session Complete!',
|
||||||
levelup: 'You leveled up your Kanji skills.',
|
levelup: 'You leveled up your Kanji skills.',
|
||||||
|
levelsUp: '{n} leveled up',
|
||||||
|
levelsDown: '{n} leveled down',
|
||||||
|
results: 'Results',
|
||||||
back: 'Back to Collection',
|
back: 'Back to Collection',
|
||||||
caughtUp: 'All Caught Up!',
|
caughtUp: 'All Caught Up!',
|
||||||
noReviews: 'No reviews available right now.',
|
noReviews: 'No reviews available right now.',
|
||||||
@@ -174,8 +186,13 @@ const messages = {
|
|||||||
learned: 'Du hast {n} neue Kanji gelernt.',
|
learned: 'Du hast {n} neue Kanji gelernt.',
|
||||||
components: 'Komponenten',
|
components: 'Komponenten',
|
||||||
observe: 'Strichfolge beobachten',
|
observe: 'Strichfolge beobachten',
|
||||||
|
replay: 'Wiederholen',
|
||||||
trace: 'Nachzeichnen',
|
trace: 'Nachzeichnen',
|
||||||
drawStep: 'Zeichnen ({n}/{total})',
|
drawStep: 'Zeichnen ({n}/{total})',
|
||||||
|
sectionMeaning: 'Bedeutung',
|
||||||
|
sectionMnemonic: 'Merkhilfe',
|
||||||
|
sectionReadings: 'Lesungen',
|
||||||
|
learnedKanji: 'Gelernte Kanji',
|
||||||
backToDashboard: 'Zurück zur Übersicht',
|
backToDashboard: 'Zurück zur Übersicht',
|
||||||
},
|
},
|
||||||
hero: {
|
hero: {
|
||||||
@@ -200,6 +217,7 @@ const messages = {
|
|||||||
next24: 'Nächste 24h',
|
next24: 'Nächste 24h',
|
||||||
availableNow: 'Jetzt verfügbar',
|
availableNow: 'Jetzt verfügbar',
|
||||||
inHours: 'In {n} Stunde | In {n} Stunden',
|
inHours: 'In {n} Stunde | In {n} Stunden',
|
||||||
|
inDays: 'In {n} Tag | In {n} Tagen',
|
||||||
noIncoming: 'Keine Reviews in den nächsten 24h.',
|
noIncoming: 'Keine Reviews in den nächsten 24h.',
|
||||||
items: 'Einträge',
|
items: 'Einträge',
|
||||||
reviewsCount: '{count} Reviews',
|
reviewsCount: '{count} Reviews',
|
||||||
@@ -226,6 +244,9 @@ const messages = {
|
|||||||
strict: 'Strikt',
|
strict: 'Strikt',
|
||||||
loose: 'Locker',
|
loose: 'Locker',
|
||||||
save: 'Speichern & Schließen',
|
save: 'Speichern & Schließen',
|
||||||
|
soundEffects: 'Soundeffekte',
|
||||||
|
on: 'An',
|
||||||
|
off: 'Aus',
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
meaning: 'Bedeutung',
|
meaning: 'Bedeutung',
|
||||||
@@ -239,6 +260,9 @@ const messages = {
|
|||||||
next: 'WEITER',
|
next: 'WEITER',
|
||||||
sessionComplete: 'Sitzung beendet!',
|
sessionComplete: 'Sitzung beendet!',
|
||||||
levelup: 'Du hast deine Kanji-Skills verbessert.',
|
levelup: 'Du hast deine Kanji-Skills verbessert.',
|
||||||
|
levelsUp: '{n} aufgestiegen',
|
||||||
|
levelsDown: '{n} abgestiegen',
|
||||||
|
results: 'Ergebnisse',
|
||||||
back: 'Zurück zur Sammlung',
|
back: 'Zurück zur Sammlung',
|
||||||
caughtUp: 'Alles erledigt!',
|
caughtUp: 'Alles erledigt!',
|
||||||
noReviews: 'Gerade keine Reviews verfügbar.',
|
noReviews: 'Gerade keine Reviews verfügbar.',
|
||||||
@@ -304,8 +328,13 @@ const messages = {
|
|||||||
learned: '{n}個の新しい漢字を覚えました。',
|
learned: '{n}個の新しい漢字を覚えました。',
|
||||||
components: '構成要素',
|
components: '構成要素',
|
||||||
observe: '書き順を見る',
|
observe: '書き順を見る',
|
||||||
|
replay: 'もう一度',
|
||||||
trace: 'なぞる',
|
trace: 'なぞる',
|
||||||
drawStep: '書く ({n}/{total})',
|
drawStep: '書く ({n}/{total})',
|
||||||
|
sectionMeaning: '意味',
|
||||||
|
sectionMnemonic: '覚え方',
|
||||||
|
sectionReadings: '読み方',
|
||||||
|
learnedKanji: '学んだ漢字',
|
||||||
backToDashboard: 'ダッシュボードに戻る',
|
backToDashboard: 'ダッシュボードに戻る',
|
||||||
},
|
},
|
||||||
hero: {
|
hero: {
|
||||||
@@ -330,6 +359,7 @@ const messages = {
|
|||||||
next24: '今後24時間',
|
next24: '今後24時間',
|
||||||
availableNow: '今すぐ可能',
|
availableNow: '今すぐ可能',
|
||||||
inHours: '{n}時間後',
|
inHours: '{n}時間後',
|
||||||
|
inDays: '{n}日後',
|
||||||
noIncoming: '24時間以内のレビューはありません。',
|
noIncoming: '24時間以内のレビューはありません。',
|
||||||
items: '個',
|
items: '個',
|
||||||
reviewsCount: '{count} レビュー',
|
reviewsCount: '{count} レビュー',
|
||||||
@@ -356,6 +386,9 @@ const messages = {
|
|||||||
strict: '厳しい',
|
strict: '厳しい',
|
||||||
loose: '甘い',
|
loose: '甘い',
|
||||||
save: '保存して閉じる',
|
save: '保存して閉じる',
|
||||||
|
soundEffects: '効果音',
|
||||||
|
on: 'オン',
|
||||||
|
off: 'オフ',
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
meaning: '意味',
|
meaning: '意味',
|
||||||
@@ -369,6 +402,9 @@ const messages = {
|
|||||||
next: '次へ',
|
next: '次へ',
|
||||||
sessionComplete: 'セッション完了!',
|
sessionComplete: 'セッション完了!',
|
||||||
levelup: '漢字力がアップしました。',
|
levelup: '漢字力がアップしました。',
|
||||||
|
levelsUp: '{n}個昇格',
|
||||||
|
levelsDown: '{n}個降格',
|
||||||
|
results: '結果',
|
||||||
back: 'コレクションに戻る',
|
back: 'コレクションに戻る',
|
||||||
caughtUp: '完了しました!',
|
caughtUp: '完了しました!',
|
||||||
noReviews: '現在レビューするものはありません。',
|
noReviews: '現在レビューするものはありません。',
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { SoundManager } from './SoundManager.js';
|
||||||
|
|
||||||
export const KANJI_CONSTANTS = {
|
export const KANJI_CONSTANTS = {
|
||||||
BASE_SIZE: 109,
|
BASE_SIZE: 109,
|
||||||
SVG_NS: 'http://www.w3.org/2000/svg',
|
SVG_NS: 'http://www.w3.org/2000/svg',
|
||||||
@@ -11,7 +13,7 @@ export const KANJI_CONSTANTS = {
|
|||||||
SAMPLE_POINTS: 60,
|
SAMPLE_POINTS: 60,
|
||||||
|
|
||||||
VALIDATION: {
|
VALIDATION: {
|
||||||
SAMPLES: 10,
|
SAMPLES: 20,
|
||||||
},
|
},
|
||||||
|
|
||||||
COLORS: {
|
COLORS: {
|
||||||
@@ -323,12 +325,16 @@ export class KanjiController {
|
|||||||
this.clearCanvas(this.ctx.hint);
|
this.clearCanvas(this.ctx.hint);
|
||||||
|
|
||||||
if (this.currentStrokeIdx >= this.paths.length) {
|
if (this.currentStrokeIdx >= this.paths.length) {
|
||||||
|
SoundManager.playDrawingComplete();
|
||||||
this.onComplete();
|
this.onComplete();
|
||||||
} else if (this.autoHint) {
|
} else {
|
||||||
this.showHint();
|
if (this.autoHint) {
|
||||||
|
this.showHint();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
SoundManager.playStrokeError();
|
||||||
this.mistakes += 1;
|
this.mistakes += 1;
|
||||||
this.animateErrorFade(this.userPath, () => {
|
this.animateErrorFade(this.userPath, () => {
|
||||||
this.redrawAllPerfectStrokes();
|
this.redrawAllPerfectStrokes();
|
||||||
@@ -346,27 +352,171 @@ export class KanjiController {
|
|||||||
const len = pathEl.getTotalLength();
|
const len = pathEl.getTotalLength();
|
||||||
const { SAMPLES } = KANJI_CONSTANTS.VALIDATION;
|
const { SAMPLES } = KANJI_CONSTANTS.VALIDATION;
|
||||||
|
|
||||||
const avgDistThreshold = this.accuracy * 0.8;
|
|
||||||
const startEndThreshold = this.accuracy * 2.5;
|
|
||||||
|
|
||||||
const targetStart = pathEl.getPointAtLength(0);
|
|
||||||
const targetEnd = pathEl.getPointAtLength(len);
|
|
||||||
|
|
||||||
const dist = (p1, p2) => Math.hypot(p1.x - p2.x, p1.y - p2.y);
|
const dist = (p1, p2) => Math.hypot(p1.x - p2.x, p1.y - p2.y);
|
||||||
|
|
||||||
if (dist(userPts[0], targetStart) > startEndThreshold) return false;
|
// ── 1. Start / end point check ──────────────────
|
||||||
if (dist(userPts[userPts.length - 1], targetEnd) > startEndThreshold) return false;
|
const targetStart = pathEl.getPointAtLength(0);
|
||||||
|
const targetEnd = pathEl.getPointAtLength(len);
|
||||||
|
const userStart = userPts[0];
|
||||||
|
const userEnd = userPts[userPts.length - 1];
|
||||||
|
|
||||||
|
const isFirstStroke = this.currentStrokeIdx === 0;
|
||||||
|
const startThreshold = this.accuracy * (isFirstStroke ? 3.5 : 2.5);
|
||||||
|
// End tolerance scales with stroke length — long strokes get more forgiveness
|
||||||
|
const lengthBonus = Math.min(len / 30, 1.5);
|
||||||
|
const endThreshold = this.accuracy * (2.5 + lengthBonus);
|
||||||
|
|
||||||
|
if (dist(userStart, targetStart) > startThreshold) return false;
|
||||||
|
if (dist(userEnd, targetEnd) > endThreshold) return false;
|
||||||
|
|
||||||
|
// ── 2. Overall direction check ──────────────────
|
||||||
|
const userAngle = Math.atan2(userEnd.y - userStart.y, userEnd.x - userStart.x);
|
||||||
|
const targetAngle = Math.atan2(targetEnd.y - targetStart.y, targetEnd.x - targetStart.x);
|
||||||
|
const angleDiff = KanjiController.normalizeAngle(userAngle - targetAngle);
|
||||||
|
|
||||||
|
// Reject if direction is off by more than 90 degrees
|
||||||
|
if (angleDiff > Math.PI / 2) return false;
|
||||||
|
|
||||||
|
// ── 3. Orientation classification guard ─────────
|
||||||
|
const userOrientation = KanjiController.classifyOrientation(userStart, userEnd);
|
||||||
|
const targetOrientation = KanjiController.classifyOrientation(targetStart, targetEnd);
|
||||||
|
|
||||||
|
// Only block if both strokes are clearly H or V (not diagonal or dot)
|
||||||
|
if (userOrientation !== 'other' && targetOrientation !== 'other'
|
||||||
|
&& userOrientation !== targetOrientation) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Sequential sample comparison ─────────────
|
||||||
|
const userResampled = KanjiController.resamplePoints(userPts, SAMPLES + 1);
|
||||||
let totalError = 0;
|
let totalError = 0;
|
||||||
const sampleCount = SAMPLES + 1;
|
let maxError = 0;
|
||||||
|
|
||||||
totalError = Array.from({ length: sampleCount }).reduce((acc, _, i) => {
|
for (let i = 0; i <= SAMPLES; i++) {
|
||||||
const targetPt = pathEl.getPointAtLength((i / SAMPLES) * len);
|
const t = i / SAMPLES;
|
||||||
const minD = userPts.reduce((min, up) => Math.min(min, dist(targetPt, up)), Infinity);
|
const targetPt = pathEl.getPointAtLength(t * len);
|
||||||
return acc + minD;
|
const userPt = userResampled[i];
|
||||||
}, 0);
|
const d = dist(targetPt, userPt);
|
||||||
|
totalError += d;
|
||||||
|
if (d > maxError) maxError = d;
|
||||||
|
}
|
||||||
|
|
||||||
return (totalError / sampleCount) < avgDistThreshold;
|
const avgError = totalError / (SAMPLES + 1);
|
||||||
|
const avgDistThreshold = this.accuracy * 1.0;
|
||||||
|
const maxDistThreshold = this.accuracy * 3.5;
|
||||||
|
|
||||||
|
if (avgError > avgDistThreshold) return false;
|
||||||
|
// No single point should be wildly off — catches "right average, wrong shape"
|
||||||
|
if (maxError > maxDistThreshold) return false;
|
||||||
|
|
||||||
|
// ── 4b. Stroke length ratio ─────────────────────
|
||||||
|
let userLen = 0;
|
||||||
|
for (let i = 1; i < userPts.length; i++) {
|
||||||
|
userLen += dist(userPts[i - 1], userPts[i]);
|
||||||
|
}
|
||||||
|
const lengthRatio = userLen / len;
|
||||||
|
// User stroke should be between 30% and 300% of target length
|
||||||
|
// Long straight strokes especially benefit from the upper bound
|
||||||
|
if (lengthRatio < 0.3 || lengthRatio > 3.0) return false;
|
||||||
|
|
||||||
|
// ── 4c. Curvature similarity ────────────────────
|
||||||
|
const targetCurvature = KanjiController.measureCurvature(
|
||||||
|
userResampled.map((_, i) => {
|
||||||
|
const t = i / SAMPLES;
|
||||||
|
const pt = pathEl.getPointAtLength(t * len);
|
||||||
|
return { x: pt.x, y: pt.y };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const userCurvature = KanjiController.measureCurvature(userPts);
|
||||||
|
|
||||||
|
// If target is clearly curved but user drew straight (or vice versa)
|
||||||
|
const curvatureDiff = Math.abs(targetCurvature - userCurvature);
|
||||||
|
// Scale threshold: strict = 0.2, loose = 0.5
|
||||||
|
const curvatureThreshold = 0.15 + (this.accuracy / 20) * 0.35;
|
||||||
|
if (curvatureDiff > curvatureThreshold) return false;
|
||||||
|
|
||||||
|
// ── 5. Endpoint direction check (hook detection) ─
|
||||||
|
const tailFraction = 0.2;
|
||||||
|
const targetTailStart = pathEl.getPointAtLength(len * (1 - tailFraction));
|
||||||
|
const targetTailAngle = Math.atan2(
|
||||||
|
targetEnd.y - targetTailStart.y,
|
||||||
|
targetEnd.x - targetTailStart.x,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if target has a significant direction change at the end (hook)
|
||||||
|
const mainTargetAngle = Math.atan2(
|
||||||
|
targetEnd.y - targetStart.y,
|
||||||
|
targetEnd.x - targetStart.x,
|
||||||
|
);
|
||||||
|
const targetHookAngle = KanjiController.normalizeAngle(targetTailAngle - mainTargetAngle);
|
||||||
|
const hasHook = targetHookAngle > Math.PI / 6; // > 30 degrees = hook
|
||||||
|
|
||||||
|
if (hasHook) {
|
||||||
|
// User must also have a direction change in the last portion
|
||||||
|
const tailIdx = Math.max(0, Math.floor(userPts.length * (1 - tailFraction)));
|
||||||
|
const userTailStart = userPts[tailIdx];
|
||||||
|
const userTailAngle = Math.atan2(
|
||||||
|
userEnd.y - userTailStart.y,
|
||||||
|
userEnd.x - userTailStart.x,
|
||||||
|
);
|
||||||
|
const mainUserAngle = Math.atan2(
|
||||||
|
userEnd.y - userStart.y,
|
||||||
|
userEnd.x - userStart.x,
|
||||||
|
);
|
||||||
|
const userHookAngle = KanjiController.normalizeAngle(userTailAngle - mainUserAngle);
|
||||||
|
|
||||||
|
// User's hook should be at least half as pronounced as the target's
|
||||||
|
// but use a generous threshold scaled by accuracy
|
||||||
|
const hookThreshold = Math.max(Math.PI / 12, targetHookAngle * 0.4);
|
||||||
|
if (userHookAngle < hookThreshold) {
|
||||||
|
// Only reject on strict accuracy (< 12), forgive on loose settings
|
||||||
|
if (this.accuracy < 12) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Static helpers for stroke validation ────────────
|
||||||
|
|
||||||
|
static normalizeAngle(angle) {
|
||||||
|
let a = Math.abs(angle);
|
||||||
|
if (a > Math.PI) a = 2 * Math.PI - a;
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
static classifyOrientation(start, end) {
|
||||||
|
const dx = Math.abs(end.x - start.x);
|
||||||
|
const dy = Math.abs(end.y - start.y);
|
||||||
|
const totalDist = Math.hypot(dx, dy);
|
||||||
|
|
||||||
|
// Very short strokes (dots) — don't classify
|
||||||
|
if (totalDist < 5) return 'other';
|
||||||
|
|
||||||
|
const ratio = dx / (dy || 0.001);
|
||||||
|
if (ratio > 2.5) return 'horizontal';
|
||||||
|
if (ratio < 0.4) return 'vertical';
|
||||||
|
return 'other'; // diagonal or ambiguous
|
||||||
|
}
|
||||||
|
|
||||||
|
static measureCurvature(points) {
|
||||||
|
if (points.length < 3) return 0;
|
||||||
|
|
||||||
|
// Curvature = ratio of actual path length to straight-line distance
|
||||||
|
// A perfectly straight line = 0, a semicircle ≈ 0.57
|
||||||
|
let pathLen = 0;
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
pathLen += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
|
||||||
|
}
|
||||||
|
|
||||||
|
const straightDist = Math.hypot(
|
||||||
|
points[points.length - 1].x - points[0].x,
|
||||||
|
points[points.length - 1].y - points[0].y,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (straightDist < 1) return 0; // dot-like stroke
|
||||||
|
// Returns 0 for straight, >0 for curved (ratio - 1)
|
||||||
|
return Math.max(0, (pathLen / straightDist) - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
animateErrorFade(userPath, onComplete) {
|
animateErrorFade(userPath, onComplete) {
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* SoundManager — Web Audio API synthesized sounds for ZenKanji.
|
||||||
|
*
|
||||||
|
* All sounds are generated programmatically (no external files).
|
||||||
|
* Designed to be subtle, smooth, and non-annoying.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let audioCtx = null;
|
||||||
|
let muted = false;
|
||||||
|
const MASTER_VOLUME = 0.15;
|
||||||
|
|
||||||
|
function getCtx() {
|
||||||
|
if (!audioCtx) {
|
||||||
|
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
}
|
||||||
|
return audioCtx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureResumed() {
|
||||||
|
const ctx = getCtx();
|
||||||
|
if (ctx.state === 'suspended') ctx.resume();
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a soft "pop" — stroke accepted.
|
||||||
|
* Short sine wave with quick pitch drop, very gentle.
|
||||||
|
*/
|
||||||
|
function playStrokeCorrect() {
|
||||||
|
if (muted) return;
|
||||||
|
const ctx = ensureResumed();
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(880, now);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(440, now + 0.08);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(MASTER_VOLUME * 0.6, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
|
||||||
|
osc.start(now);
|
||||||
|
osc.stop(now + 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a soft low thud — stroke rejected.
|
||||||
|
* Very short, low frequency, barely audible. Not jarring.
|
||||||
|
*/
|
||||||
|
function playStrokeError() {
|
||||||
|
if (muted) return;
|
||||||
|
const ctx = ensureResumed();
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(200, now);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(120, now + 0.15);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(MASTER_VOLUME * 0.4, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
|
||||||
|
osc.start(now);
|
||||||
|
osc.stop(now + 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a pleasant ascending two-tone chime — drawing complete (all strokes done).
|
||||||
|
*/
|
||||||
|
function playDrawingComplete() {
|
||||||
|
if (muted) return;
|
||||||
|
const ctx = ensureResumed();
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
|
||||||
|
[523.25, 659.25].forEach((freq, i) => {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.value = freq;
|
||||||
|
|
||||||
|
const start = now + i * 0.1;
|
||||||
|
gain.gain.setValueAtTime(0, start);
|
||||||
|
gain.gain.linearRampToValueAtTime(MASTER_VOLUME * 0.7, start + 0.03);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, start + 0.3);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
|
||||||
|
osc.start(start);
|
||||||
|
osc.stop(start + 0.3);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a gentle three-tone ascending arpeggio — session/lesson complete.
|
||||||
|
* C5 → E5 → G5, soft and satisfying.
|
||||||
|
*/
|
||||||
|
function playSessionComplete() {
|
||||||
|
if (muted) return;
|
||||||
|
const ctx = ensureResumed();
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
|
||||||
|
[523.25, 659.25, 783.99].forEach((freq, i) => {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = 'triangle';
|
||||||
|
osc.frequency.value = freq;
|
||||||
|
|
||||||
|
const start = now + i * 0.12;
|
||||||
|
gain.gain.setValueAtTime(0, start);
|
||||||
|
gain.gain.linearRampToValueAtTime(MASTER_VOLUME * 0.8, start + 0.04);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, start + 0.5);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
|
||||||
|
osc.start(start);
|
||||||
|
osc.stop(start + 0.5);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a subtle click — button press / UI interaction.
|
||||||
|
*/
|
||||||
|
function playClick() {
|
||||||
|
if (muted) return;
|
||||||
|
const ctx = ensureResumed();
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.value = 1000;
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(MASTER_VOLUME * 0.3, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
|
||||||
|
osc.start(now);
|
||||||
|
osc.stop(now + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SoundManager = {
|
||||||
|
playStrokeCorrect,
|
||||||
|
playStrokeError,
|
||||||
|
playDrawingComplete,
|
||||||
|
playSessionComplete,
|
||||||
|
playClick,
|
||||||
|
|
||||||
|
get isMuted() {
|
||||||
|
return muted;
|
||||||
|
},
|
||||||
|
|
||||||
|
setMuted(val) {
|
||||||
|
muted = val;
|
||||||
|
localStorage.setItem('zen_sound_muted', val ? '1' : '0');
|
||||||
|
},
|
||||||
|
|
||||||
|
init() {
|
||||||
|
muted = localStorage.getItem('zen_sound_muted') === '1';
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -13,14 +13,17 @@
|
|||||||
clearable
|
clearable
|
||||||
)
|
)
|
||||||
|
|
||||||
.text-center.mt-10(v-if="loading") {{ $t('collection.loading') }}
|
.text-center.mt-10(v-if="loading")
|
||||||
|
ScrambleText(:text="$t('collection.loading')")
|
||||||
|
|
||||||
.text-center.mt-10.text-grey-lighten-1(
|
.text-center.mt-10.text-grey-lighten-1(
|
||||||
v-else-if="!loading && Object.keys(groupedItems).length === 0"
|
v-else-if="!loading && Object.keys(groupedItems).length === 0"
|
||||||
)
|
)
|
||||||
v-icon.mb-4(size="64" color="grey-darken-2") mdi-text-search-variant
|
v-icon.mb-4(size="64" color="grey-darken-2") mdi-text-search-variant
|
||||||
.text-h6 {{ $t('collection.noMatches') }}
|
.text-h6
|
||||||
.text-body-2 {{ $t('collection.tryDifferent') }}
|
ScrambleText(:text="$t('collection.noMatches')")
|
||||||
|
.text-body-2
|
||||||
|
ScrambleText(:text="$t('collection.tryDifferent')")
|
||||||
|
|
||||||
.mb-6.fade-slide-up(
|
.mb-6.fade-slide-up(
|
||||||
v-else
|
v-else
|
||||||
@@ -28,7 +31,7 @@
|
|||||||
:key="level"
|
:key="level"
|
||||||
)
|
)
|
||||||
.text-subtitle-2.text-grey.mb-2.font-weight-bold.ml-1
|
.text-subtitle-2.text-grey.mb-2.font-weight-bold.ml-1
|
||||||
| {{ $t('collection.levelHeader') }} {{ level }}
|
ScrambleText(:text="$t('collection.levelHeader') + ' ' + level")
|
||||||
|
|
||||||
.kanji-grid
|
.kanji-grid
|
||||||
.kanji-card(
|
.kanji-card(
|
||||||
@@ -76,7 +79,8 @@
|
|||||||
)
|
)
|
||||||
v-card.pa-4.pt-6.text-center.rounded-xl.border-subtle(color="#1e1e24")
|
v-card.pa-4.pt-6.text-center.rounded-xl.border-subtle(color="#1e1e24")
|
||||||
.d-flex.justify-space-between.align-center.px-2.mb-2
|
.d-flex.justify-space-between.align-center.px-2.mb-2
|
||||||
.text-caption.text-grey {{ $t('review.level') }} {{ selectedItem?.level }}
|
.text-caption.text-grey
|
||||||
|
ScrambleText(:text="$t('review.level') + ' ' + selectedItem?.level")
|
||||||
v-chip.font-weight-bold(
|
v-chip.font-weight-bold(
|
||||||
size="x-small"
|
size="x-small"
|
||||||
:color="getSRSColor(selectedItem?.srsLevel)"
|
:color="getSRSColor(selectedItem?.srsLevel)"
|
||||||
@@ -94,7 +98,7 @@
|
|||||||
|
|
||||||
.radical-section.mb-4(v-if="selectedItem?.radicals?.length")
|
.radical-section.mb-4(v-if="selectedItem?.radicals?.length")
|
||||||
.text-caption.text-grey-darken-1.text-uppercase.mb-3.font-weight-bold
|
.text-caption.text-grey-darken-1.text-uppercase.mb-3.font-weight-bold
|
||||||
|{{ $t('lesson.components') }}
|
ScrambleText(:text="$t('lesson.components')")
|
||||||
.d-flex.flex-wrap.gap-3.justify-center
|
.d-flex.flex-wrap.gap-3.justify-center
|
||||||
v-sheet.radical-chip(
|
v-sheet.radical-chip(
|
||||||
v-for="rad in selectedItem.radicals" :key="rad.meaning")
|
v-for="rad in selectedItem.radicals" :key="rad.meaning")
|
||||||
@@ -106,20 +110,24 @@
|
|||||||
|
|
||||||
.readings-container.mb-4
|
.readings-container.mb-4
|
||||||
.reading-group(v-if="hasReading(selectedItem?.onyomi)")
|
.reading-group(v-if="hasReading(selectedItem?.onyomi)")
|
||||||
.reading-label {{ $t('collection.onyomi') }}
|
.reading-label
|
||||||
|
ScrambleText(:text="$t('collection.onyomi')")
|
||||||
.reading-value {{ selectedItem?.onyomi.join(', ') }}
|
.reading-value {{ selectedItem?.onyomi.join(', ') }}
|
||||||
|
|
||||||
.reading-group(v-if="hasReading(selectedItem?.kunyomi)")
|
.reading-group(v-if="hasReading(selectedItem?.kunyomi)")
|
||||||
.reading-label {{ $t('collection.kunyomi') }}
|
.reading-label
|
||||||
|
ScrambleText(:text="$t('collection.kunyomi')")
|
||||||
.reading-value {{ selectedItem?.kunyomi.join(', ') }}
|
.reading-value {{ selectedItem?.kunyomi.join(', ') }}
|
||||||
|
|
||||||
.reading-group(v-if="hasReading(selectedItem?.nanori)")
|
.reading-group(v-if="hasReading(selectedItem?.nanori)")
|
||||||
.reading-label {{ $t('collection.nanori') }}
|
.reading-label
|
||||||
|
ScrambleText(:text="$t('collection.nanori')")
|
||||||
.reading-value {{ selectedItem?.nanori.join(', ') }}
|
.reading-value {{ selectedItem?.nanori.join(', ') }}
|
||||||
|
|
||||||
.px-2.mb-4(v-if="selectedItem?.stats?.total > 0")
|
.px-2.mb-4(v-if="selectedItem?.stats?.total > 0")
|
||||||
.d-flex.justify-space-between.align-center.mb-1
|
.d-flex.justify-space-between.align-center.mb-1
|
||||||
.text-caption.text-grey {{ $t('stats.accuracy') }}
|
.text-caption.text-grey
|
||||||
|
ScrambleText(:text="$t('stats.accuracy')")
|
||||||
.text-caption.font-weight-bold {{ accuracyStats.text }}
|
.text-caption.font-weight-bold {{ accuracyStats.text }}
|
||||||
v-progress-linear(
|
v-progress-linear(
|
||||||
:model-value="accuracyStats.percent"
|
:model-value="accuracyStats.percent"
|
||||||
@@ -134,16 +142,15 @@
|
|||||||
block
|
block
|
||||||
color="#2f3542"
|
color="#2f3542"
|
||||||
@click="showModal = false"
|
@click="showModal = false"
|
||||||
) {{ $t('collection.close') }}
|
)
|
||||||
|
ScrambleText(:text="$t('collection.close')")
|
||||||
v-col.pl-2(cols="6")
|
v-col.pl-2(cols="6")
|
||||||
v-btn.text-black(
|
v-btn.text-black(
|
||||||
block
|
block
|
||||||
color="purple-accent-2"
|
color="purple-accent-2"
|
||||||
@click="redoLesson"
|
@click="redoLesson"
|
||||||
)
|
)
|
||||||
| {{ selectedItem?.srsLevel === 0
|
ScrambleText(:text="selectedItem?.srsLevel === 0 ? $t('collection.startLesson') : $t('collection.redoLesson')")
|
||||||
| ? $t('collection.startLesson')
|
|
||||||
| : $t('collection.redoLesson') }}
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -264,6 +271,6 @@ const getNextReviewText = (dateStr, srsLvl) => {
|
|||||||
if (diffHrs < 24) return t('stats.inHours', { n: diffHrs }, diffHrs);
|
if (diffHrs < 24) return t('stats.inHours', { n: diffHrs }, diffHrs);
|
||||||
|
|
||||||
const diffDays = Math.floor(diffHrs / 24);
|
const diffDays = Math.floor(diffHrs / 24);
|
||||||
return `Next: ${diffDays}d`;
|
return t('stats.inDays', { n: diffDays }, diffDays);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+130
-36
@@ -2,7 +2,8 @@
|
|||||||
v-container.page-container-center.fill-height
|
v-container.page-container-center.fill-height
|
||||||
.d-flex.flex-column.justify-center.align-center.fill-height(v-if="loading")
|
.d-flex.flex-column.justify-center.align-center.fill-height(v-if="loading")
|
||||||
v-progress-circular(indeterminate color="primary" size="64")
|
v-progress-circular(indeterminate color="primary" size="64")
|
||||||
.text-body-1.text-grey.mt-4 {{ $t('review.loading') }}
|
.text-body-1.text-grey.mt-4
|
||||||
|
ScrambleText(:text="$t('review.loading')")
|
||||||
|
|
||||||
v-card.lesson-card(
|
v-card.lesson-card(
|
||||||
v-else-if="currentItem"
|
v-else-if="currentItem"
|
||||||
@@ -13,23 +14,39 @@
|
|||||||
.text-caption.text-grey-darken-1.font-weight-bold
|
.text-caption.text-grey-darken-1.font-weight-bold
|
||||||
| {{ sessionDone + 1 }} / {{ sessionTotal }}
|
| {{ sessionDone + 1 }} / {{ sessionTotal }}
|
||||||
v-spacer
|
v-spacer
|
||||||
v-chip.font-weight-bold(
|
.stepper-spacer
|
||||||
size="small"
|
|
||||||
:color="phaseColor"
|
.lesson-stepper.px-4.py-3
|
||||||
variant="tonal"
|
template(v-for="(step, i) in stepperSteps" :key="step.key")
|
||||||
) {{ phaseLabel }}
|
.lesson-stepper__step(:class="{ 'is-active': step.active, 'is-done': step.done }")
|
||||||
|
.lesson-stepper__dot
|
||||||
|
v-icon(v-if="step.done" size="14" color="white") mdi-check
|
||||||
|
span(v-else) {{ i + 1 }}
|
||||||
|
.lesson-stepper__label {{ step.label }}
|
||||||
|
.lesson-stepper__line(
|
||||||
|
v-if="i < stepperSteps.length - 1"
|
||||||
|
:class="{ 'is-done': stepperSteps[i + 1]?.done || stepperSteps[i + 1]?.active }"
|
||||||
|
)
|
||||||
|
|
||||||
v-window.flex-grow-1(v-model="phase")
|
v-window.flex-grow-1(v-model="phase")
|
||||||
v-window-item(value="primer")
|
v-window-item(value="primer")
|
||||||
.d-flex.flex-column.align-center.pa-6
|
.d-flex.flex-column.align-center.pa-6
|
||||||
.hero-wrapper.mb-6
|
.hero-wrapper.mb-4
|
||||||
KanjiSvgViewer(:char="currentItem.char" mode="hero")
|
KanjiSvgViewer(:char="currentItem.char" mode="hero")
|
||||||
.text-h4.font-weight-bold.text-white.mb-2 {{ currentItem.meaning }}
|
|
||||||
.text-body-1.text-grey.text-center.mb-8(
|
.text-caption.text-grey.text-uppercase.tracking-wide.mb-1
|
||||||
v-if="currentItem.mnemonic") "{{ currentItem.mnemonic }}"
|
ScrambleText(:text="$t('lesson.sectionMeaning')")
|
||||||
.radical-section(v-if="currentItem.radicals?.length")
|
.text-h4.font-weight-bold.text-white.mb-4 {{ currentItem.meaning }}
|
||||||
|
|
||||||
|
.mnemonic-box.pa-4.rounded-lg.mb-5(v-if="currentItem.mnemonic")
|
||||||
|
.text-caption.text-grey.text-uppercase.tracking-wide.mb-2.font-weight-bold
|
||||||
|
v-icon.mr-1(size="14" color="amber") mdi-lightbulb-outline
|
||||||
|
ScrambleText(:text="$t('lesson.sectionMnemonic')")
|
||||||
|
.text-body-2.text-grey-lighten-1.font-italic "{{ currentItem.mnemonic }}"
|
||||||
|
|
||||||
|
.radical-section.mb-5(v-if="currentItem.radicals?.length")
|
||||||
.text-caption.text-grey-darken-1.text-uppercase.mb-3.font-weight-bold
|
.text-caption.text-grey-darken-1.text-uppercase.mb-3.font-weight-bold
|
||||||
|{{ $t('lesson.components') }}
|
ScrambleText(:text="$t('lesson.components')")
|
||||||
.d-flex.flex-wrap.gap-3.justify-center
|
.d-flex.flex-wrap.gap-3.justify-center
|
||||||
v-sheet.radical-chip(
|
v-sheet.radical-chip(
|
||||||
v-for="rad in currentItem.radicals" :key="rad.meaning")
|
v-for="rad in currentItem.radicals" :key="rad.meaning")
|
||||||
@@ -38,25 +55,50 @@
|
|||||||
img(v-if="rad.image" :src="rad.image")
|
img(v-if="rad.image" :src="rad.image")
|
||||||
span.text-h6.font-weight-bold.text-white(v-else) {{ rad.char }}
|
span.text-h6.font-weight-bold.text-white(v-else) {{ rad.char }}
|
||||||
.text-caption.text-grey-lighten-1 {{ rad.meaning }}
|
.text-caption.text-grey-lighten-1 {{ rad.meaning }}
|
||||||
|
|
||||||
|
.text-caption.text-grey.text-uppercase.tracking-wide.mb-3.font-weight-bold.text-center(
|
||||||
|
v-if="(currentItem.onyomi && currentItem.onyomi.length > 0) || (currentItem.kunyomi && currentItem.kunyomi.length > 0)"
|
||||||
|
)
|
||||||
|
ScrambleText(:text="$t('lesson.sectionReadings')")
|
||||||
.readings-grid(v-if="(currentItem.onyomi && currentItem.onyomi.length > 0) || (currentItem.kunyomi && currentItem.kunyomi.length > 0)")
|
.readings-grid(v-if="(currentItem.onyomi && currentItem.onyomi.length > 0) || (currentItem.kunyomi && currentItem.kunyomi.length > 0)")
|
||||||
.reading-box(v-if="currentItem.onyomi && currentItem.onyomi.length > 0")
|
.reading-box(v-if="currentItem.onyomi && currentItem.onyomi.length > 0")
|
||||||
.label {{ $t('collection.onyomi') }}
|
.label
|
||||||
|
ScrambleText(:text="$t('collection.onyomi')")
|
||||||
.val.text-cyan-lighten-3 {{ currentItem.onyomi.join(', ') }}
|
.val.text-cyan-lighten-3 {{ currentItem.onyomi.join(', ') }}
|
||||||
.reading-box(v-if="currentItem.kunyomi && currentItem.kunyomi.length > 0")
|
.reading-box(v-if="currentItem.kunyomi && currentItem.kunyomi.length > 0")
|
||||||
.label {{ $t('collection.kunyomi') }}
|
.label
|
||||||
|
ScrambleText(:text="$t('collection.kunyomi')")
|
||||||
.val.text-pink-lighten-3 {{ currentItem.kunyomi.join(', ') }}
|
.val.text-pink-lighten-3 {{ currentItem.kunyomi.join(', ') }}
|
||||||
|
|
||||||
v-window-item(value="demo")
|
v-window-item(value="demo")
|
||||||
.d-flex.flex-column.align-center.justify-center.h-100.pa-6
|
.d-flex.flex-column.align-center.justify-center.h-100.pa-6
|
||||||
.text-subtitle-1.text-grey.mb-6 {{ $t('lesson.observe') }}
|
.text-subtitle-1.text-grey.mb-6
|
||||||
|
ScrambleText(:text="$t('lesson.observe')")
|
||||||
.canvas-wrapper.lesson-canvas-wrapper
|
.canvas-wrapper.lesson-canvas-wrapper
|
||||||
KanjiSvgViewer(ref="demoViewer" :char="currentItem.char" mode="animate")
|
KanjiSvgViewer(ref="demoViewer" :char="currentItem.char" mode="animate")
|
||||||
|
v-btn.mt-4(
|
||||||
|
variant="text"
|
||||||
|
color="cyan"
|
||||||
|
prepend-icon="mdi-replay"
|
||||||
|
size="small"
|
||||||
|
@click="replayDemo"
|
||||||
|
)
|
||||||
|
ScrambleText(:text="$t('lesson.replay')")
|
||||||
|
|
||||||
v-window-item(value="drawing")
|
v-window-item(value="drawing")
|
||||||
.d-flex.flex-column.align-center.justify-center.h-100.pa-6
|
.d-flex.flex-column.align-center.justify-center.h-100.pa-6
|
||||||
.text-subtitle-1.text-grey.mb-6
|
.text-subtitle-1.text-grey.mb-2
|
||||||
span(v-if="subPhase === 'guided'") {{ $t('lesson.trace') }}
|
span(v-if="subPhase === 'guided'")
|
||||||
span(v-else) Draw ({{ practiceCount }}/3)
|
ScrambleText(:text="$t('lesson.trace')")
|
||||||
|
span(v-else)
|
||||||
|
ScrambleText(:text="$t('lesson.drawStep', { n: Math.min(practiceCount + 1, practiceTarget), total: practiceTarget })")
|
||||||
|
|
||||||
|
.practice-dots.mb-4(v-if="subPhase === 'practice'")
|
||||||
|
.practice-dot(
|
||||||
|
v-for="i in practiceTarget"
|
||||||
|
:key="i"
|
||||||
|
:class="{ 'is-filled': i <= practiceCount }"
|
||||||
|
)
|
||||||
|
|
||||||
.transition-opacity(
|
.transition-opacity(
|
||||||
:style="{ opacity: canvasOpacity }"
|
:style="{ opacity: canvasOpacity }"
|
||||||
@@ -76,31 +118,51 @@
|
|||||||
v-if="phase === 'primer'"
|
v-if="phase === 'primer'"
|
||||||
@click="phase = 'demo'"
|
@click="phase = 'demo'"
|
||||||
color="cyan" rounded="pill" size="large" block
|
color="cyan" rounded="pill" size="large" block
|
||||||
) {{ $t('lesson.understand') }}
|
)
|
||||||
|
ScrambleText(:text="$t('lesson.understand')")
|
||||||
v-btn.action-btn(
|
v-btn.action-btn(
|
||||||
v-if="phase === 'demo'"
|
v-if="phase === 'demo'"
|
||||||
@click="startDrawing"
|
@click="startDrawing"
|
||||||
color="cyan" rounded="pill" size="large" block
|
color="cyan" rounded="pill" size="large" block
|
||||||
) {{ $t('lesson.ready') }}
|
)
|
||||||
|
ScrambleText(:text="$t('lesson.ready')")
|
||||||
.d-flex.justify-center.gap-4.mt-2(v-if="phase === 'drawing' && subPhase === 'practice'")
|
.d-flex.justify-center.gap-4.mt-2(v-if="phase === 'drawing' && subPhase === 'practice'")
|
||||||
v-btn(
|
v-btn(
|
||||||
variant="text" color="amber" @click="useHint"
|
variant="text" color="amber" @click="useHint"
|
||||||
) {{ $t('lesson.hintAction') }}
|
)
|
||||||
v-btn(variant="text" color="grey" @click="phase='demo'") {{ $t('lesson.watchAgain') }}
|
ScrambleText(:text="$t('lesson.hintAction')")
|
||||||
|
v-btn(variant="text" color="grey" @click="phase='demo'")
|
||||||
|
ScrambleText(:text="$t('lesson.watchAgain')")
|
||||||
|
|
||||||
v-card.text-center.pa-8.lesson-card(
|
v-card.pa-8.lesson-card(
|
||||||
v-else
|
v-else
|
||||||
)
|
)
|
||||||
v-icon.mb-6(size="80" color="purple-accent-2") mdi-check-decagram
|
.d-flex.flex-column.align-center.mb-6
|
||||||
.text-h4.font-weight-bold.text-white.mb-2 {{ $t('lesson.completeTitle') }}
|
v-icon.mb-4(size="80" color="purple-accent-2") mdi-check-decagram
|
||||||
.text-body-1.text-grey.mb-8 {{ $t('lesson.learned', { n: sessionDone }) }}
|
.text-h4.font-weight-bold.text-white.mb-2
|
||||||
|
ScrambleText(:text="$t('lesson.completeTitle')")
|
||||||
|
.text-body-1.text-grey.mb-2
|
||||||
|
ScrambleText(:text="$t('lesson.learned', { n: sessionDone })")
|
||||||
|
|
||||||
|
.text-caption.text-grey.text-uppercase.tracking-wide.mb-3.text-left.w-100.font-weight-bold
|
||||||
|
ScrambleText(:text="$t('lesson.learnedKanji')")
|
||||||
|
|
||||||
|
.lesson-summary-grid.w-100.mb-6
|
||||||
|
.lesson-summary-item(
|
||||||
|
v-for="item in learnedItems"
|
||||||
|
:key="item.char"
|
||||||
|
)
|
||||||
|
.lesson-summary-char {{ item.char }}
|
||||||
|
.lesson-summary-meaning.text-grey {{ item.meaning }}
|
||||||
|
|
||||||
v-btn.glow-btn.text-black.font-weight-bold(
|
v-btn.glow-btn.text-black.font-weight-bold(
|
||||||
to="/"
|
to="/"
|
||||||
block
|
block
|
||||||
color="cyan"
|
color="cyan"
|
||||||
height="50"
|
height="50"
|
||||||
rounded="pill"
|
rounded="pill"
|
||||||
) {{ $t('lesson.backToDashboard') }}
|
)
|
||||||
|
ScrambleText(:text="$t('lesson.backToDashboard')")
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -115,6 +177,7 @@ import { useRoute } from 'vue-router';
|
|||||||
import { useAppStore } from '@/stores/appStore';
|
import { useAppStore } from '@/stores/appStore';
|
||||||
import KanjiCanvas from '@/components/kanji/KanjiCanvas.vue';
|
import KanjiCanvas from '@/components/kanji/KanjiCanvas.vue';
|
||||||
import KanjiSvgViewer from '@/components/kanji/KanjiSvgViewer.vue';
|
import KanjiSvgViewer from '@/components/kanji/KanjiSvgViewer.vue';
|
||||||
|
import { SoundManager } from '@/utils/SoundManager';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const store = useAppStore();
|
const store = useAppStore();
|
||||||
@@ -123,29 +186,49 @@ const currentItem = ref(null);
|
|||||||
const phase = ref('primer');
|
const phase = ref('primer');
|
||||||
const subPhase = ref('guided');
|
const subPhase = ref('guided');
|
||||||
const practiceCount = ref(0);
|
const practiceCount = ref(0);
|
||||||
|
const practiceTarget = 2;
|
||||||
const sessionDone = ref(0);
|
const sessionDone = ref(0);
|
||||||
const sessionTotal = ref(0);
|
const sessionTotal = ref(0);
|
||||||
const canvasOpacity = ref(1);
|
const canvasOpacity = ref(1);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
const learnedItems = ref([]);
|
||||||
|
|
||||||
const canvas = ref(null);
|
const canvas = ref(null);
|
||||||
const demoViewer = ref(null);
|
const demoViewer = ref(null);
|
||||||
|
|
||||||
const phaseLabel = computed(() => {
|
const phaseIndex = computed(() => {
|
||||||
if (phase.value === 'primer') return t('lesson.phasePrimer');
|
if (phase.value === 'primer') return 0;
|
||||||
if (phase.value === 'demo') return t('lesson.phaseDemo');
|
if (phase.value === 'demo') return 1;
|
||||||
return subPhase.value === 'guided' ? t('lesson.phaseGuided') : t('lesson.phasePractice');
|
if (phase.value === 'drawing' && subPhase.value === 'guided') return 2;
|
||||||
|
return 3;
|
||||||
});
|
});
|
||||||
|
|
||||||
const phaseColor = computed(() => (phase.value === 'primer' ? 'blue' : 'purple'));
|
const stepperSteps = computed(() => {
|
||||||
|
const steps = [
|
||||||
|
{ key: 'primer', label: t('lesson.phasePrimer') },
|
||||||
|
{ key: 'demo', label: t('lesson.phaseDemo') },
|
||||||
|
{ key: 'guided', label: t('lesson.phaseGuided') },
|
||||||
|
{ key: 'practice', label: t('lesson.phasePractice') },
|
||||||
|
];
|
||||||
|
return steps.map((s, i) => ({
|
||||||
|
...s,
|
||||||
|
active: i === phaseIndex.value,
|
||||||
|
done: i < phaseIndex.value,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
watch(phase, (val) => {
|
watch(phase, (val) => {
|
||||||
if (val === 'demo') nextTick(() => demoViewer.value?.playAnimation());
|
if (val === 'demo') nextTick(() => demoViewer.value?.playAnimation());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function replayDemo() {
|
||||||
|
demoViewer.value?.playAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
function loadNext() {
|
function loadNext() {
|
||||||
if (store.lessonQueue.length === 0) {
|
if (store.lessonQueue.length === 0) {
|
||||||
currentItem.value = null;
|
currentItem.value = null;
|
||||||
|
if (sessionDone.value > 0) SoundManager.playSessionComplete();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
[currentItem.value] = store.lessonQueue;
|
[currentItem.value] = store.lessonQueue;
|
||||||
@@ -175,7 +258,7 @@ async function handleDrawComplete() {
|
|||||||
} else {
|
} else {
|
||||||
practiceCount.value += 1;
|
practiceCount.value += 1;
|
||||||
|
|
||||||
if (practiceCount.value < 3) {
|
if (practiceCount.value < practiceTarget) {
|
||||||
canvasOpacity.value = 0;
|
canvasOpacity.value = 0;
|
||||||
await new Promise((r) => { setTimeout(r, 300); });
|
await new Promise((r) => { setTimeout(r, 300); });
|
||||||
canvas.value?.reset();
|
canvas.value?.reset();
|
||||||
@@ -184,9 +267,18 @@ async function handleDrawComplete() {
|
|||||||
const isRedo = !!route.query.subjectId;
|
const isRedo = !!route.query.subjectId;
|
||||||
|
|
||||||
if (!isRedo) {
|
if (!isRedo) {
|
||||||
await store.submitLesson(currentItem.value.wkSubjectId);
|
try {
|
||||||
|
await store.submitLesson(currentItem.value.wkSubjectId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to submit lesson:', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
learnedItems.value.push({
|
||||||
|
char: currentItem.value.char,
|
||||||
|
meaning: currentItem.value.meaning,
|
||||||
|
});
|
||||||
|
|
||||||
sessionDone.value += 1;
|
sessionDone.value += 1;
|
||||||
store.lessonQueue.shift();
|
store.lessonQueue.shift();
|
||||||
loadNext();
|
loadNext();
|
||||||
@@ -195,13 +287,15 @@ async function handleDrawComplete() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleMistake() {
|
function handleMistake() {
|
||||||
if (subPhase.value === 'practice') practiceCount.value = 0;
|
if (subPhase.value === 'practice') {
|
||||||
|
practiceCount.value = Math.max(0, practiceCount.value - 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function useHint() {
|
function useHint() {
|
||||||
canvas.value?.showHint();
|
canvas.value?.showHint();
|
||||||
if (subPhase.value === 'practice') {
|
if (subPhase.value === 'practice') {
|
||||||
practiceCount.value = 0;
|
practiceCount.value = Math.max(0, practiceCount.value - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+179
-42
@@ -3,7 +3,8 @@
|
|||||||
v-fade-transition(mode="out-in")
|
v-fade-transition(mode="out-in")
|
||||||
.d-flex.flex-column.justify-center.align-center.fill-height(v-if="loading")
|
.d-flex.flex-column.justify-center.align-center.fill-height(v-if="loading")
|
||||||
v-progress-circular(indeterminate color="primary" size="64")
|
v-progress-circular(indeterminate color="primary" size="64")
|
||||||
.text-body-1.text-grey.mt-4 {{ $t('review.loading') }}
|
.text-body-1.text-grey.mt-4
|
||||||
|
ScrambleText(:text="$t('review.loading')")
|
||||||
|
|
||||||
v-card.pa-6.rounded-xl.d-flex.flex-column.align-center.review-card(
|
v-card.pa-6.rounded-xl.d-flex.flex-column.align-center.review-card(
|
||||||
v-else-if="currentItem"
|
v-else-if="currentItem"
|
||||||
@@ -23,23 +24,27 @@
|
|||||||
size="small"
|
size="small"
|
||||||
:color="getSRSColor(currentItem.srsLevel)"
|
:color="getSRSColor(currentItem.srsLevel)"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
) {{ $t('review.level') }} {{ currentItem.srsLevel }}
|
)
|
||||||
|
ScrambleText(:text="$t('review.level') + ' ' + currentItem.srsLevel")
|
||||||
|
|
||||||
.text-center.mb-6
|
.text-center.mb-6
|
||||||
.text-caption.text-grey.text-uppercase.tracking-wide.mb-1
|
.text-caption.text-grey.text-uppercase.tracking-wide.mb-1
|
||||||
| {{ $t('review.meaning') }}
|
ScrambleText(:text="$t('review.meaning')")
|
||||||
.text-h3.font-weight-bold.text-white.text-shadow.mb-4
|
.text-h3.font-weight-bold.text-white.text-shadow.mb-4
|
||||||
| {{ currentItem.meaning }}
|
| {{ currentItem.meaning }}
|
||||||
.readings-grid(v-if="(currentItem.onyomi && currentItem.onyomi.length > 0) || (currentItem.kunyomi && currentItem.kunyomi.length > 0)")
|
.readings-grid(v-if="(currentItem.onyomi && currentItem.onyomi.length > 0) || (currentItem.kunyomi && currentItem.kunyomi.length > 0)")
|
||||||
.reading-box.reading-box--small(v-if="currentItem.onyomi && currentItem.onyomi.length > 0")
|
.reading-box.reading-box--small(v-if="currentItem.onyomi && currentItem.onyomi.length > 0")
|
||||||
.label {{ $t('collection.onyomi') }}
|
.label
|
||||||
|
ScrambleText(:text="$t('collection.onyomi')")
|
||||||
.val.text-cyan-lighten-3 {{ currentItem.onyomi.join(', ') }}
|
.val.text-cyan-lighten-3 {{ currentItem.onyomi.join(', ') }}
|
||||||
.reading-box.reading-box--small(v-if="currentItem.kunyomi && currentItem.kunyomi.length > 0")
|
.reading-box.reading-box--small(v-if="currentItem.kunyomi && currentItem.kunyomi.length > 0")
|
||||||
.label {{ $t('collection.kunyomi') }}
|
.label
|
||||||
|
ScrambleText(:text="$t('collection.kunyomi')")
|
||||||
.val.text-pink-lighten-3 {{ currentItem.kunyomi.join(', ') }}
|
.val.text-pink-lighten-3 {{ currentItem.kunyomi.join(', ') }}
|
||||||
|
|
||||||
.review-canvas-area
|
.review-canvas-area
|
||||||
KanjiCanvas(
|
KanjiCanvas(
|
||||||
|
:key="canvasKey"
|
||||||
ref="kanjiCanvasRef"
|
ref="kanjiCanvasRef"
|
||||||
:char="currentItem.char"
|
:char="currentItem.char"
|
||||||
@complete="handleComplete"
|
@complete="handleComplete"
|
||||||
@@ -64,18 +69,30 @@
|
|||||||
size="small"
|
size="small"
|
||||||
:disabled="showNext"
|
:disabled="showNext"
|
||||||
@click="triggerHint"
|
@click="triggerHint"
|
||||||
) {{ $t('review.showHint') }}
|
)
|
||||||
|
ScrambleText(:text="$t('review.showHint')")
|
||||||
|
|
||||||
v-sheet.d-flex.align-center.justify-center(
|
v-sheet.d-flex.flex-column.align-center.justify-center(
|
||||||
width="100%"
|
width="100%"
|
||||||
height="48"
|
min-height="80"
|
||||||
color="transparent"
|
color="transparent"
|
||||||
)
|
)
|
||||||
transition(name="fade-slide" mode="out-in")
|
transition(name="fade-slide" mode="out-in")
|
||||||
.status-text.text-h6.font-weight-bold(
|
.status-text.text-h6.font-weight-bold(
|
||||||
:key="statusCode"
|
:key="statusCode"
|
||||||
:class="getStatusClass(statusCode)"
|
:class="getStatusClass(statusCode)"
|
||||||
) {{ $t('review.' + statusCode) }}
|
)
|
||||||
|
ScrambleText(:text="$t('review.' + statusCode)")
|
||||||
|
|
||||||
|
transition(name="scale")
|
||||||
|
v-chip.mt-2.font-weight-bold.elevation-2(
|
||||||
|
v-if="showNext && rankChange"
|
||||||
|
:color="rankChange.type === 'up' ? 'teal-accent-4' : 'red-darken-1'"
|
||||||
|
variant="flat"
|
||||||
|
size="small"
|
||||||
|
)
|
||||||
|
v-icon(start size="14") {{ rankChange.type === 'up' ? 'mdi-arrow-up-thick' : 'mdi-arrow-down-thick' }}
|
||||||
|
| Level {{ currentItem.srsLevel }} → {{ rankChange.level }}
|
||||||
|
|
||||||
v-progress-linear.mt-4.progress-bar(
|
v-progress-linear.mt-4.progress-bar(
|
||||||
v-model="progressPercent"
|
v-model="progressPercent"
|
||||||
@@ -84,23 +101,62 @@
|
|||||||
rounded
|
rounded
|
||||||
)
|
)
|
||||||
|
|
||||||
v-card.pa-8.rounded-xl.elevation-10.text-center.review-card(
|
v-card.pa-8.rounded-xl.elevation-10.review-card(
|
||||||
v-else-if="sessionTotal > 0 && store.queue.length === 0"
|
v-else-if="sessionTotal > 0 && store.queue.length === 0"
|
||||||
)
|
)
|
||||||
.mb-6
|
.d-flex.flex-column.align-center.mb-6
|
||||||
v-avatar(color="rgba(0, 206, 201, 0.1)" size="80")
|
v-avatar(color="rgba(0, 206, 201, 0.1)" size="80")
|
||||||
v-icon(size="40" color="primary") mdi-trophy
|
v-icon(size="40" color="primary") mdi-trophy
|
||||||
|
|
||||||
.text-h4.font-weight-bold.mb-2 {{ $t('review.sessionComplete') }}
|
.text-h4.font-weight-bold.mt-4.mb-2
|
||||||
.text-body-1.text-grey.mb-8 {{ $t('review.levelup') }}
|
ScrambleText(:text="$t('review.sessionComplete')")
|
||||||
|
|
||||||
v-row.mb-6
|
v-row.mb-4
|
||||||
v-col.border-r.border-subtle(cols="6")
|
v-col.text-center.border-r.border-subtle(cols="6")
|
||||||
.text-h3.font-weight-bold.text-white {{ accuracy }}%
|
.text-h3.font-weight-bold.text-white {{ accuracy }}%
|
||||||
.text-caption.text-grey.text-uppercase.mt-1 {{ $t('stats.accuracy') }}
|
.text-caption.text-grey.text-uppercase.mt-1
|
||||||
v-col(cols="6")
|
ScrambleText(:text="$t('stats.accuracy')")
|
||||||
|
v-col.text-center(cols="6")
|
||||||
.text-h3.font-weight-bold.text-white {{ sessionCorrect }}
|
.text-h3.font-weight-bold.text-white {{ sessionCorrect }}
|
||||||
.text-caption.text-grey.text-uppercase.mt-1 {{ $t('stats.correct') }}
|
.text-caption.text-grey.text-uppercase.mt-1
|
||||||
|
ScrambleText(:text="$t('stats.correct')")
|
||||||
|
|
||||||
|
.d-flex.justify-center.ga-4.mb-6(v-if="levelUpCount > 0 || levelDownCount > 0")
|
||||||
|
v-chip.font-weight-bold(
|
||||||
|
v-if="levelUpCount > 0"
|
||||||
|
color="teal-accent-4"
|
||||||
|
variant="tonal"
|
||||||
|
size="small"
|
||||||
|
prepend-icon="mdi-arrow-up-thick"
|
||||||
|
)
|
||||||
|
ScrambleText(:text="$t('review.levelsUp', { n: levelUpCount })")
|
||||||
|
v-chip.font-weight-bold(
|
||||||
|
v-if="levelDownCount > 0"
|
||||||
|
color="red-darken-1"
|
||||||
|
variant="tonal"
|
||||||
|
size="small"
|
||||||
|
prepend-icon="mdi-arrow-down-thick"
|
||||||
|
)
|
||||||
|
ScrambleText(:text="$t('review.levelsDown', { n: levelDownCount })")
|
||||||
|
|
||||||
|
.text-caption.text-grey.text-uppercase.tracking-wide.mb-3.text-left.w-100
|
||||||
|
ScrambleText(:text="$t('review.results')")
|
||||||
|
|
||||||
|
.result-list.w-100.mb-6
|
||||||
|
.result-item(
|
||||||
|
v-for="(r, i) in sessionResults"
|
||||||
|
:key="i"
|
||||||
|
)
|
||||||
|
.result-char {{ r.char }}
|
||||||
|
.result-meaning.text-grey {{ r.meaning }}
|
||||||
|
v-spacer
|
||||||
|
v-chip.font-weight-bold.mr-2(
|
||||||
|
:color="r.success ? 'teal-accent-4' : 'red-darken-1'"
|
||||||
|
variant="tonal"
|
||||||
|
size="x-small"
|
||||||
|
)
|
||||||
|
v-icon(start size="12") {{ r.success ? 'mdi-check' : 'mdi-close' }}
|
||||||
|
| {{ r.oldLevel }} → {{ r.newLevel }}
|
||||||
|
|
||||||
v-btn.text-black.font-weight-bold.glow-btn(
|
v-btn.text-black.font-weight-bold.glow-btn(
|
||||||
to="/"
|
to="/"
|
||||||
@@ -108,17 +164,21 @@
|
|||||||
color="primary"
|
color="primary"
|
||||||
height="50"
|
height="50"
|
||||||
rounded="lg"
|
rounded="lg"
|
||||||
) {{ $t('review.back') }}
|
)
|
||||||
|
ScrambleText(:text="$t('review.back')")
|
||||||
|
|
||||||
.text-center(v-else)
|
.text-center(v-else)
|
||||||
v-icon.mb-4(size="64" color="grey-darken-2") mdi-check-circle-outline
|
v-icon.mb-4(size="64" color="grey-darken-2") mdi-check-circle-outline
|
||||||
.text-h4.mb-2.text-white {{ $t('review.caughtUp') }}
|
.text-h4.mb-2.text-white
|
||||||
.text-grey.mb-6 {{ $t('review.noReviews') }}
|
ScrambleText(:text="$t('review.caughtUp')")
|
||||||
|
.text-grey.mb-6
|
||||||
|
ScrambleText(:text="$t('review.noReviews')")
|
||||||
v-btn.font-weight-bold(
|
v-btn.font-weight-bold(
|
||||||
to="/"
|
to="/"
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
) {{ $t('review.viewCollection') }}
|
)
|
||||||
|
ScrambleText(:text="$t('review.viewCollection')")
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -126,13 +186,13 @@
|
|||||||
import {
|
import {
|
||||||
ref, onMounted, computed, watch,
|
ref, onMounted, computed, watch,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useAppStore } from '@/stores/appStore';
|
import { useAppStore } from '@/stores/appStore';
|
||||||
import KanjiCanvas from '@/components/kanji/KanjiCanvas.vue';
|
import KanjiCanvas from '@/components/kanji/KanjiCanvas.vue';
|
||||||
|
import { SoundManager } from '@/utils/SoundManager';
|
||||||
|
|
||||||
const store = useAppStore();
|
const store = useAppStore();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const currentItem = ref(null);
|
const currentItem = ref(null);
|
||||||
const statusCode = ref('draw');
|
const statusCode = ref('draw');
|
||||||
@@ -144,6 +204,7 @@ const loading = ref(true);
|
|||||||
const sessionTotal = ref(0);
|
const sessionTotal = ref(0);
|
||||||
const sessionDone = ref(0);
|
const sessionDone = ref(0);
|
||||||
const sessionCorrect = ref(0);
|
const sessionCorrect = ref(0);
|
||||||
|
const sessionResults = ref([]);
|
||||||
|
|
||||||
const accuracy = computed(() => {
|
const accuracy = computed(() => {
|
||||||
if (sessionDone.value === 0) return 100;
|
if (sessionDone.value === 0) return 100;
|
||||||
@@ -155,19 +216,49 @@ const progressPercent = computed(() => {
|
|||||||
return (sessionDone.value / sessionTotal.value) * 100;
|
return (sessionDone.value / sessionTotal.value) * 100;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const levelUpCount = computed(() => sessionResults.value.filter((r) => r.success).length);
|
||||||
|
const levelDownCount = computed(() => sessionResults.value.filter((r) => !r.success).length);
|
||||||
|
|
||||||
|
const submittedItems = ref(new Set());
|
||||||
|
const canvasKey = ref(0);
|
||||||
|
|
||||||
function loadNext() {
|
function loadNext() {
|
||||||
if (store.queue.length === 0) {
|
if (store.queue.length === 0) {
|
||||||
currentItem.value = null;
|
currentItem.value = null;
|
||||||
|
if (sessionTotal.value > 0) SoundManager.playSessionComplete();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const idx = Math.floor(Math.random() * store.queue.length);
|
|
||||||
|
let idx = Math.floor(Math.random() * store.queue.length);
|
||||||
|
|
||||||
|
if (store.queue.length > 1
|
||||||
|
&& currentItem.value
|
||||||
|
&& store.queue[idx]._id === currentItem.value._id
|
||||||
|
) idx = (idx + 1) % store.queue.length;
|
||||||
|
|
||||||
currentItem.value = store.queue[idx];
|
currentItem.value = store.queue[idx];
|
||||||
|
|
||||||
statusCode.value = 'draw';
|
statusCode.value = 'draw';
|
||||||
showNext.value = false;
|
showNext.value = false;
|
||||||
isFailure.value = false;
|
isFailure.value = false;
|
||||||
|
canvasKey.value += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rankChange = computed(() => {
|
||||||
|
if (!currentItem.value || !showNext.value) return null;
|
||||||
|
|
||||||
|
if (submittedItems.value.has(currentItem.value._id)) return null;
|
||||||
|
|
||||||
|
const current = currentItem.value.srsLevel;
|
||||||
|
|
||||||
|
if (!isFailure.value) {
|
||||||
|
if (current >= 10) return null;
|
||||||
|
return { type: 'up', level: current + 1 };
|
||||||
|
}
|
||||||
|
if (current <= 1) return null;
|
||||||
|
return { type: 'down', level: current - 1 };
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
const mode = route.query.mode || 'shuffled';
|
const mode = route.query.mode || 'shuffled';
|
||||||
@@ -185,6 +276,8 @@ async function resetSession() {
|
|||||||
sessionDone.value = 0;
|
sessionDone.value = 0;
|
||||||
sessionCorrect.value = 0;
|
sessionCorrect.value = 0;
|
||||||
sessionTotal.value = 0;
|
sessionTotal.value = 0;
|
||||||
|
sessionResults.value = [];
|
||||||
|
submittedItems.value.clear();
|
||||||
currentItem.value = null;
|
currentItem.value = null;
|
||||||
const mode = route.query.mode || 'shuffled';
|
const mode = route.query.mode || 'shuffled';
|
||||||
await store.fetchQueue(mode);
|
await store.fetchQueue(mode);
|
||||||
@@ -223,31 +316,42 @@ function handleComplete() {
|
|||||||
async function next() {
|
async function next() {
|
||||||
if (!currentItem.value) return;
|
if (!currentItem.value) return;
|
||||||
|
|
||||||
await store.submitReview(currentItem.value.wkSubjectId, !isFailure.value);
|
const itemId = currentItem.value._id;
|
||||||
|
const isFirstAttempt = !submittedItems.value.has(itemId);
|
||||||
|
|
||||||
sessionDone.value += 1;
|
if (isFirstAttempt) {
|
||||||
if (!isFailure.value) sessionCorrect.value += 1;
|
const oldLevel = currentItem.value.srsLevel;
|
||||||
// eslint-disable-next-line no-underscore-dangle
|
const result = await store.submitReview(currentItem.value.wkSubjectId, !isFailure.value);
|
||||||
const index = store.queue.findIndex((i) => i._id === currentItem.value._id);
|
submittedItems.value.add(itemId);
|
||||||
if (index !== -1) {
|
|
||||||
store.queue.splice(index, 1);
|
const newLevel = result?.srsLevel ?? oldLevel;
|
||||||
|
sessionResults.value.push({
|
||||||
|
char: currentItem.value.char,
|
||||||
|
meaning: currentItem.value.meaning,
|
||||||
|
success: !isFailure.value,
|
||||||
|
oldLevel,
|
||||||
|
newLevel,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isFailure.value) {
|
||||||
|
sessionCorrect.value += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFailure.value) {
|
||||||
|
sessionDone.value += 1;
|
||||||
|
const index = store.queue.findIndex((i) => i._id === itemId);
|
||||||
|
if (index !== -1) {
|
||||||
|
store.queue.splice(index, 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadNext();
|
loadNext();
|
||||||
}
|
}
|
||||||
|
|
||||||
function redoLesson() {
|
|
||||||
if (!currentItem.value) return;
|
|
||||||
router.push({
|
|
||||||
path: '/lesson',
|
|
||||||
query: { subjectId: currentItem.value.wkSubjectId },
|
|
||||||
state: { item: JSON.parse(JSON.stringify(currentItem.value)) },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const getSRSColor = (lvl) => {
|
const getSRSColor = (lvl) => {
|
||||||
const colors = {
|
const colors = {
|
||||||
1: '#ff7675', 2: '#fdcb6e', 3: '#55efc4', 4: '#0984e3', 5: '#a29bfe', 6: '#6c5ce7',
|
1: '#ff7675', 2: '#fdcb6e', 3: '#55efc4', 4: '#0984e3', 5: '#a29bfe', 6: '#6c5ce7',
|
||||||
|
7: '#00cec9', 8: '#fd79a8', 9: '#e84393', 10: '#ffd700',
|
||||||
};
|
};
|
||||||
return colors[lvl] || 'grey';
|
return colors[lvl] || 'grey';
|
||||||
};
|
};
|
||||||
@@ -262,7 +366,40 @@ const getStatusClass = (status) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@use '@/styles/abstracts' as *;
|
||||||
|
|
||||||
.review-view-container {
|
.review-view-container {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.result-list {
|
||||||
|
max-height: 16rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
@include scrollbar;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
background: $bg-glass-subtle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
border-bottom: $border-width-sm solid $color-border;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-char {
|
||||||
|
font-size: $font-lg;
|
||||||
|
font-weight: $weight-bold;
|
||||||
|
min-width: 2.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-meaning {
|
||||||
|
font-size: $font-sm;
|
||||||
|
margin-left: $spacing-sm;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user