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')")