From 049eaa18073a4210e891abe28c86bed86362be35 Mon Sep 17 00:00:00 2001 From: Rene Kievits Date: Sat, 18 Apr 2026 22:19:36 +0200 Subject: [PATCH] feat: add sound effects, improved stroke recognition, instant locale switching with scramble text animation - Add SoundManager with Web Audio API synthesized sounds (stroke error, drawing complete, session complete) - Add sound effects toggle in settings (persisted via localStorage) - Improve stroke recognition: curvature similarity, max point deviation, stroke length ratio checks - Loosen thresholds for long strokes and first-stroke positioning - Implement instant locale switching (no save required) - Add ScrambleText component with character-by-character morphing animation - Apply ScrambleText globally across all views, widgets, and dialogs - Add i18n keys for sound effects settings (EN/DE/JA) --- client/src/App.vue | 105 ++++++--- client/src/components/ScrambleText.vue | 79 +++++++ .../components/dashboard/DashboardWidget.vue | 6 +- .../components/dashboard/WidgetAccuracy.vue | 6 +- .../dashboard/WidgetDistribution.vue | 2 +- .../components/dashboard/WidgetForecast.vue | 6 +- .../src/components/dashboard/WidgetGhosts.vue | 2 +- .../dashboard/WidgetGuruMastery.vue | 5 +- .../components/dashboard/WidgetHeatmap.vue | 9 +- .../src/components/dashboard/WidgetStreak.vue | 3 +- .../components/dashboard/WidgetWelcome.vue | 24 +- client/src/main.js | 3 + client/src/plugins/i18n.js | 36 +++ client/src/utils/KanjiController.js | 186 +++++++++++++-- client/src/utils/SoundManager.js | 177 ++++++++++++++ client/src/views/Collection.vue | 37 +-- client/src/views/Lesson.vue | 166 ++++++++++--- client/src/views/Review.vue | 221 ++++++++++++++---- 18 files changed, 907 insertions(+), 166 deletions(-) create mode 100644 client/src/components/ScrambleText.vue create mode 100644 client/src/utils/SoundManager.js diff --git a/client/src/App.vue b/client/src/App.vue index 661902a..63d7b84 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -12,7 +12,8 @@ .d-flex.flex-column.h-100.pa-4 .d-flex.align-center.mb-6.px-2 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-item.mb-2( @@ -22,7 +23,8 @@ ) template(v-slot:prepend) 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( to="/collection" @@ -31,7 +33,8 @@ ) template(v-slot:prepend) 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 @@ -43,7 +46,7 @@ @click="showSettings = true; drawer = false" ) v-icon(start icon="mdi-cog") - | {{ $t('nav.settings') }} + ScrambleText(:text="$t('nav.settings')") v-btn.justify-start.px-4( variant="text" @@ -53,7 +56,7 @@ @click="manualSync" ) v-icon(start icon="mdi-sync") - | {{ $t('nav.sync') }} + ScrambleText(:text="$t('nav.sync')") v-btn.justify-start.px-4.text-red-lighten-2( variant="text" @@ -61,7 +64,7 @@ @click="handleLogout" ) v-icon(start icon="mdi-logout") - | {{ $t('nav.logout') }} + ScrambleText(:text="$t('nav.logout')") .parallax-bg( :style="parallaxStyle" :class="{ 'zooming': viewState === 'zooming' }" @@ -103,13 +106,15 @@ to="/" variant="text" :color="$route.path === '/' ? '#00cec9' : 'grey'" - ) {{ $t('nav.dashboard') }} + ) + ScrambleText(:text="$t('nav.dashboard')") v-btn.mx-1( to="/collection" variant="text" :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") @@ -169,8 +174,10 @@ .login-content img.mb-4(:src="logo" height="64" width="64") - h1.text-h5.font-weight-bold.mb-2 {{ $t('hero.welcome') }} - p.text-grey-lighten-1.text-body-2.mb-6 {{ $t('login.instruction') }} + h1.text-h5.font-weight-bold.mb-2 + 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-model="inputKey" @@ -197,14 +204,17 @@ height="44" :loading="viewState === 'authenticating'" @click="triggerLogin" - ) {{ $t('login.button') }} + ) + ScrambleText(:text="$t('login.button')") v-dialog(v-model="showSettings" max-width="340") 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 - .text-caption.text-grey.mb-2 {{ $t('settings.batchSize') }} + .text-caption.text-grey.mb-2 + ScrambleText(:text="$t('settings.batchSize')") v-slider( v-model="tempBatchSize" :min="5" @@ -215,9 +225,12 @@ track-color="grey-darken-3" ) .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-model="tempDrawingAccuracy" :min="1" @@ -228,11 +241,16 @@ track-color="grey-darken-3" ) .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 {{ $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-glider(:style="{ transform: `translateX(${localeIndex * 100}%)` }") @@ -240,15 +258,29 @@ v-for="l in availableLocales" :key="l" :class="{ 'active': tempLocale === l }" - @click="tempLocale = l" + @click="switchLocale(l)" ) {{ 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-btn.text-white( block color="#2f3542" @click="saveSettings" - ) {{ $t('settings.save') }} + ) + ScrambleText(:text="$t('settings.save')") v-dialog(v-model="showLogoutDialog" max-width="320") 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-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 - | {{ $t('alerts.logoutConfirm') }} + ScrambleText(:text="$t('alerts.logoutConfirm')") v-card-actions.d-flex.gap-2.pa-0.mt-2 v-btn.flex-grow-1.text-capitalize( @@ -267,19 +300,22 @@ color="grey" height="40" @click="showLogoutDialog = false" - ) {{ $t('common.cancel') }} + ) + ScrambleText(:text="$t('common.cancel')") v-btn.flex-grow-1.text-capitalize( color="red-accent-2" variant="flat" height="40" @click="confirmLogout" - ) {{ $t('nav.logout') }} + ) + ScrambleText(:text="$t('nav.logout')") v-snackbar(v-model="snackbar.show" :color="snackbar.color" timeout="3000") | {{ snackbar.text }} 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')") diff --git a/client/src/components/dashboard/DashboardWidget.vue b/client/src/components/dashboard/DashboardWidget.vue index bb760bb..5557329 100644 --- a/client/src/components/dashboard/DashboardWidget.vue +++ b/client/src/components/dashboard/DashboardWidget.vue @@ -14,8 +14,10 @@ div .text-subtitle-1.font-weight-bold.d-flex.align-center.text-white( 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 slot(name="header-right") diff --git a/client/src/components/dashboard/WidgetAccuracy.vue b/client/src/components/dashboard/WidgetAccuracy.vue index b966f52..3e13c15 100644 --- a/client/src/components/dashboard/WidgetAccuracy.vue +++ b/client/src/components/dashboard/WidgetAccuracy.vue @@ -7,9 +7,11 @@ div .text-h3.font-weight-bold.text-white {{ accuracyPercent }}% .text-caption.text-grey.mt-1 - | {{ accuracy?.correct || 0 }} {{ $t('stats.correct') }} + | {{ accuracy?.correct || 0 }} + ScrambleText(:text="$t('stats.correct')") span.mx-1 / - | {{ accuracy?.total || 0 }} {{ $t('stats.total') }} + | {{ accuracy?.total || 0 }} + ScrambleText(:text="$t('stats.total')") v-progress-circular( :model-value="accuracyPercent" diff --git a/client/src/components/dashboard/WidgetDistribution.vue b/client/src/components/dashboard/WidgetDistribution.vue index a160f26..3b08f18 100644 --- a/client/src/components/dashboard/WidgetDistribution.vue +++ b/client/src/components/dashboard/WidgetDistribution.vue @@ -3,7 +3,7 @@ .d-flex.align-center.justify-space-between.mb-4 .text-subtitle-1.font-weight-bold.d-flex.align-center v-icon(color="secondary" start size="small") mdi-chart-bar - | {{ $t('stats.srsDistribution') }} + ScrambleText(:text="$t('stats.srsDistribution')") v-chip.font-weight-bold( size="x-small" color="white" diff --git a/client/src/components/dashboard/WidgetForecast.vue b/client/src/components/dashboard/WidgetForecast.vue index c0edfde..dd99162 100644 --- a/client/src/components/dashboard/WidgetForecast.vue +++ b/client/src/components/dashboard/WidgetForecast.vue @@ -7,7 +7,7 @@ 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") 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( size="small" @@ -15,7 +15,7 @@ ) {{ count }} .fill-height.d-flex.align-center.justify-center.text-grey.text-center.pa-4(v-else) - | {{ $t('stats.noIncoming') }} + ScrambleText(:text="$t('stats.noIncoming')") diff --git a/client/src/views/Lesson.vue b/client/src/views/Lesson.vue index 7927ef7..a98ac27 100644 --- a/client/src/views/Lesson.vue +++ b/client/src/views/Lesson.vue @@ -2,7 +2,8 @@ v-container.page-container-center.fill-height .d-flex.flex-column.justify-center.align-center.fill-height(v-if="loading") 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-else-if="currentItem" @@ -13,23 +14,39 @@ .text-caption.text-grey-darken-1.font-weight-bold | {{ sessionDone + 1 }} / {{ sessionTotal }} v-spacer - v-chip.font-weight-bold( - size="small" - :color="phaseColor" - variant="tonal" - ) {{ phaseLabel }} + .stepper-spacer + + .lesson-stepper.px-4.py-3 + template(v-for="(step, i) in stepperSteps" :key="step.key") + .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-item(value="primer") .d-flex.flex-column.align-center.pa-6 - .hero-wrapper.mb-6 + .hero-wrapper.mb-4 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( - v-if="currentItem.mnemonic") "{{ currentItem.mnemonic }}" - .radical-section(v-if="currentItem.radicals?.length") + + .text-caption.text-grey.text-uppercase.tracking-wide.mb-1 + ScrambleText(:text="$t('lesson.sectionMeaning')") + .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 - |{{ $t('lesson.components') }} + ScrambleText(:text="$t('lesson.components')") .d-flex.flex-wrap.gap-3.justify-center v-sheet.radical-chip( v-for="rad in currentItem.radicals" :key="rad.meaning") @@ -38,25 +55,50 @@ img(v-if="rad.image" :src="rad.image") span.text-h6.font-weight-bold.text-white(v-else) {{ rad.char }} .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)") .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(', ') }} .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(', ') }} v-window-item(value="demo") .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 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") .d-flex.flex-column.align-center.justify-center.h-100.pa-6 - .text-subtitle-1.text-grey.mb-6 - span(v-if="subPhase === 'guided'") {{ $t('lesson.trace') }} - span(v-else) Draw ({{ practiceCount }}/3) + .text-subtitle-1.text-grey.mb-2 + span(v-if="subPhase === 'guided'") + 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( :style="{ opacity: canvasOpacity }" @@ -76,31 +118,51 @@ v-if="phase === 'primer'" @click="phase = 'demo'" color="cyan" rounded="pill" size="large" block - ) {{ $t('lesson.understand') }} + ) + ScrambleText(:text="$t('lesson.understand')") v-btn.action-btn( v-if="phase === 'demo'" @click="startDrawing" 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'") v-btn( 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-icon.mb-6(size="80" color="purple-accent-2") mdi-check-decagram - .text-h4.font-weight-bold.text-white.mb-2 {{ $t('lesson.completeTitle') }} - .text-body-1.text-grey.mb-8 {{ $t('lesson.learned', { n: sessionDone }) }} + .d-flex.flex-column.align-center.mb-6 + v-icon.mb-4(size="80" color="purple-accent-2") mdi-check-decagram + .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( to="/" block color="cyan" height="50" rounded="pill" - ) {{ $t('lesson.backToDashboard') }} + ) + ScrambleText(:text="$t('lesson.backToDashboard')") @@ -115,6 +177,7 @@ import { useRoute } from 'vue-router'; import { useAppStore } from '@/stores/appStore'; import KanjiCanvas from '@/components/kanji/KanjiCanvas.vue'; import KanjiSvgViewer from '@/components/kanji/KanjiSvgViewer.vue'; +import { SoundManager } from '@/utils/SoundManager'; const { t } = useI18n(); const store = useAppStore(); @@ -123,29 +186,49 @@ const currentItem = ref(null); const phase = ref('primer'); const subPhase = ref('guided'); const practiceCount = ref(0); +const practiceTarget = 2; const sessionDone = ref(0); const sessionTotal = ref(0); const canvasOpacity = ref(1); const loading = ref(true); +const learnedItems = ref([]); const canvas = ref(null); const demoViewer = ref(null); -const phaseLabel = computed(() => { - if (phase.value === 'primer') return t('lesson.phasePrimer'); - if (phase.value === 'demo') return t('lesson.phaseDemo'); - return subPhase.value === 'guided' ? t('lesson.phaseGuided') : t('lesson.phasePractice'); +const phaseIndex = computed(() => { + if (phase.value === 'primer') return 0; + if (phase.value === 'demo') return 1; + 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) => { if (val === 'demo') nextTick(() => demoViewer.value?.playAnimation()); }); +function replayDemo() { + demoViewer.value?.playAnimation(); +} + function loadNext() { if (store.lessonQueue.length === 0) { currentItem.value = null; + if (sessionDone.value > 0) SoundManager.playSessionComplete(); return; } [currentItem.value] = store.lessonQueue; @@ -175,7 +258,7 @@ async function handleDrawComplete() { } else { practiceCount.value += 1; - if (practiceCount.value < 3) { + if (practiceCount.value < practiceTarget) { canvasOpacity.value = 0; await new Promise((r) => { setTimeout(r, 300); }); canvas.value?.reset(); @@ -184,9 +267,18 @@ async function handleDrawComplete() { const isRedo = !!route.query.subjectId; 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; store.lessonQueue.shift(); loadNext(); @@ -195,13 +287,15 @@ async function handleDrawComplete() { } function handleMistake() { - if (subPhase.value === 'practice') practiceCount.value = 0; + if (subPhase.value === 'practice') { + practiceCount.value = Math.max(0, practiceCount.value - 1); + } } function useHint() { canvas.value?.showHint(); if (subPhase.value === 'practice') { - practiceCount.value = 0; + practiceCount.value = Math.max(0, practiceCount.value - 1); } } diff --git a/client/src/views/Review.vue b/client/src/views/Review.vue index 4f1be47..5480653 100644 --- a/client/src/views/Review.vue +++ b/client/src/views/Review.vue @@ -3,7 +3,8 @@ v-fade-transition(mode="out-in") .d-flex.flex-column.justify-center.align-center.fill-height(v-if="loading") 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-else-if="currentItem" @@ -23,23 +24,27 @@ size="small" :color="getSRSColor(currentItem.srsLevel)" variant="flat" - ) {{ $t('review.level') }} {{ currentItem.srsLevel }} + ) + ScrambleText(:text="$t('review.level') + ' ' + currentItem.srsLevel") .text-center.mb-6 .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 | {{ currentItem.meaning }} .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") - .label {{ $t('collection.onyomi') }} + .label + ScrambleText(:text="$t('collection.onyomi')") .val.text-cyan-lighten-3 {{ currentItem.onyomi.join(', ') }} .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(', ') }} .review-canvas-area KanjiCanvas( + :key="canvasKey" ref="kanjiCanvasRef" :char="currentItem.char" @complete="handleComplete" @@ -64,18 +69,30 @@ size="small" :disabled="showNext" @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%" - height="48" + min-height="80" color="transparent" ) transition(name="fade-slide" mode="out-in") .status-text.text-h6.font-weight-bold( :key="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-model="progressPercent" @@ -84,23 +101,62 @@ 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" ) - .mb-6 + .d-flex.flex-column.align-center.mb-6 v-avatar(color="rgba(0, 206, 201, 0.1)" size="80") v-icon(size="40" color="primary") mdi-trophy - .text-h4.font-weight-bold.mb-2 {{ $t('review.sessionComplete') }} - .text-body-1.text-grey.mb-8 {{ $t('review.levelup') }} + .text-h4.font-weight-bold.mt-4.mb-2 + ScrambleText(:text="$t('review.sessionComplete')") - v-row.mb-6 - v-col.border-r.border-subtle(cols="6") + v-row.mb-4 + v-col.text-center.border-r.border-subtle(cols="6") .text-h3.font-weight-bold.text-white {{ accuracy }}% - .text-caption.text-grey.text-uppercase.mt-1 {{ $t('stats.accuracy') }} - v-col(cols="6") + .text-caption.text-grey.text-uppercase.mt-1 + ScrambleText(:text="$t('stats.accuracy')") + v-col.text-center(cols="6") .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( to="/" @@ -108,17 +164,21 @@ color="primary" height="50" rounded="lg" - ) {{ $t('review.back') }} + ) + ScrambleText(:text="$t('review.back')") .text-center(v-else) v-icon.mb-4(size="64" color="grey-darken-2") mdi-check-circle-outline - .text-h4.mb-2.text-white {{ $t('review.caughtUp') }} - .text-grey.mb-6 {{ $t('review.noReviews') }} + .text-h4.mb-2.text-white + ScrambleText(:text="$t('review.caughtUp')") + .text-grey.mb-6 + ScrambleText(:text="$t('review.noReviews')") v-btn.font-weight-bold( to="/" color="primary" variant="tonal" - ) {{ $t('review.viewCollection') }} + ) + ScrambleText(:text="$t('review.viewCollection')")