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)
This commit is contained in:
+75
-30
@@ -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')")
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -291,6 +327,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { App as CapacitorApp } from '@capacitor/app';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import { checkForUpdates } from '@/utils/autoUpdate';
|
||||
import { SoundManager } from '@/utils/SoundManager';
|
||||
import logo from '@/assets/icon.svg';
|
||||
|
||||
const drawer = ref(false);
|
||||
@@ -313,6 +350,9 @@ const tempDrawingAccuracy = ref(store.drawingAccuracy);
|
||||
const availableLocales = ['en', 'de', 'ja'];
|
||||
const tempLocale = ref(locale.value);
|
||||
const localeIndex = computed(() => availableLocales.indexOf(tempLocale.value));
|
||||
const soundEnabled = ref(true);
|
||||
|
||||
watch(inputKey, () => { errorMsg.value = ''; });
|
||||
|
||||
const backgroundKanjis = [
|
||||
{
|
||||
@@ -391,6 +431,7 @@ const handleMouseMove = (e) => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
SoundManager.init();
|
||||
CapacitorApp.addListener('appStateChange', ({ isActive }) => {
|
||||
if (isActive) checkForUpdates();
|
||||
});
|
||||
@@ -421,6 +462,7 @@ watch(showSettings, (isOpen) => {
|
||||
tempBatchSize.value = store.batchSize;
|
||||
tempDrawingAccuracy.value = store.drawingAccuracy;
|
||||
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() {
|
||||
showSettings.value = false;
|
||||
|
||||
setTimeout(() => {
|
||||
if (locale.value !== tempLocale.value) {
|
||||
locale.value = tempLocale.value;
|
||||
localStorage.setItem('zen_locale', locale.value);
|
||||
}
|
||||
|
||||
store.saveSettings({
|
||||
batchSize: tempBatchSize.value,
|
||||
drawingAccuracy: tempDrawingAccuracy.value,
|
||||
});
|
||||
|
||||
SoundManager.setMuted(!soundEnabled.value);
|
||||
|
||||
store.fetchQueue();
|
||||
}, 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
|
||||
.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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')")
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -24,7 +24,7 @@ import { computed } from 'vue';
|
||||
import DashboardWidget from './DashboardWidget.vue';
|
||||
|
||||
const props = defineProps({
|
||||
forecast: { type: Object, default: () => ({}) },
|
||||
forecast: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const hasUpcoming = computed(() => props.forecast && props.forecast.some((c) => c > 0));
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
) {{ ghost.accuracy }}%
|
||||
|
||||
.text-center.py-2.text-caption.text-grey(v-else)
|
||||
| {{ $t('stats.noGhosts') }}
|
||||
ScrambleText(:text="$t('stats.noGhosts')")
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
)
|
||||
|
||||
.text-caption.text-medium-emphasis.mt-2.text-right
|
||||
| {{ masteredCount }} / {{ totalItems }} {{ $t('stats.items') }}
|
||||
| {{ masteredCount }} / {{ totalItems }}
|
||||
ScrambleText(:text="$t('stats.items')")
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -33,7 +34,7 @@ const totalItems = computed(
|
||||
() => Object.values(props.distribution || {}).reduce((a, b) => a + b, 0),
|
||||
);
|
||||
const masteredCount = computed(
|
||||
() => (props.distribution || {})[6] || 0,
|
||||
() => [6, 7, 8, 9, 10].reduce((sum, lvl) => sum + ((props.distribution || {})[lvl] || 0), 0),
|
||||
);
|
||||
const masteryPercent = computed(
|
||||
() => (totalItems.value === 0 ? 0 : Math.round((masteredCount.value / totalItems.value) * 100)),
|
||||
|
||||
@@ -5,13 +5,15 @@
|
||||
)
|
||||
template(#header-right)
|
||||
.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-1
|
||||
.legend-box.level-2
|
||||
.legend-box.level-3
|
||||
.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-year
|
||||
@@ -25,7 +27,8 @@
|
||||
)
|
||||
.text-center
|
||||
.font-weight-bold {{ formatDate(day.date) }}
|
||||
div {{ $t('stats.reviewsCount', { count: day.count }) }}
|
||||
div
|
||||
ScrambleText(:text="$t('stats.reviewsCount', { count: day.count })")
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
.d-flex.align-end.mb-3
|
||||
.text-h3.font-weight-bold.text-white.mr-2(style="line-height: 1")
|
||||
| {{ 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.flex-column.align-center(
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<template lang="pug">
|
||||
.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(
|
||||
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
|
||||
|
||||
@@ -21,9 +23,9 @@
|
||||
|
||||
.d-flex.flex-column.align-start.mr-auto
|
||||
.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
|
||||
| {{ $t('hero.newContent') }}
|
||||
ScrambleText(:text="$t('hero.newContent')")
|
||||
|
||||
.zen-counter.bg-purple-accent-2.text-black.ml-4
|
||||
| {{ lessonCount }}
|
||||
@@ -43,9 +45,9 @@
|
||||
|
||||
.d-flex.flex-column.align-start.mr-auto
|
||||
.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
|
||||
| {{ queueLength > 0 ? $t('hero.readyToReview') : $t('hero.allCaughtUp') }}
|
||||
ScrambleText(:text="queueLength > 0 ? $t('hero.readyToReview') : $t('hero.allCaughtUp')")
|
||||
|
||||
v-fade-transition
|
||||
.zen-counter.bg-cyan-accent-2.text-black.ml-4(v-if="queueLength > 0")
|
||||
@@ -59,14 +61,16 @@
|
||||
variant="text"
|
||||
prepend-icon="mdi-sort-ascending"
|
||||
:ripple="false"
|
||||
) {{ $t('hero.prioritize', { count: lowerLevelCount }) }}
|
||||
)
|
||||
ScrambleText(:text="$t('hero.prioritize', { count: lowerLevelCount })")
|
||||
|
||||
v-fade-transition
|
||||
.text-caption.text-grey-darken-1.font-weight-medium.d-flex.align-center.mt-2(
|
||||
v-if="queueLength === 0 && !hasLowerLevels && nextReviewTime"
|
||||
)
|
||||
v-icon.mr-2(size="small" icon="mdi-clock-outline")
|
||||
span {{ $t('hero.nextIn') }} {{ nextReviewTime }}
|
||||
span
|
||||
ScrambleText(:text="$t('hero.nextIn') + ' ' + nextReviewTime")
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -79,14 +83,14 @@ const props = defineProps({
|
||||
lessonCount: { type: Number, default: 0 },
|
||||
hasLowerLevels: { type: Boolean, default: false },
|
||||
lowerLevelCount: { type: Number, default: 0 },
|
||||
forecast: { type: Object, default: () => ({}) },
|
||||
forecast: { type: Array, default: () => [] },
|
||||
});
|
||||
defineEmits(['start']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const nextReviewTime = computed(() => {
|
||||
if (!props.forecast || Object.keys(props.forecast).length === 0) return null;
|
||||
if (!props.forecast || props.forecast.length === 0) return null;
|
||||
try {
|
||||
const idx = props.forecast.findIndex((c) => c > 0);
|
||||
if (idx === -1) return null;
|
||||
|
||||
@@ -51,8 +51,11 @@ const vuetify = createVuetify({
|
||||
icons: { defaultSet: 'mdi', aliases, sets: { mdi } },
|
||||
});
|
||||
|
||||
import ScrambleText from '@/components/ScrambleText.vue';
|
||||
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
app.use(vuetify);
|
||||
app.use(i18n);
|
||||
app.component('ScrambleText', ScrambleText);
|
||||
app.mount('#app');
|
||||
|
||||
@@ -44,8 +44,13 @@ const messages = {
|
||||
learned: "You've learned {n} new kanji.",
|
||||
components: 'Components',
|
||||
observe: 'Observe Stroke Order',
|
||||
replay: 'Replay',
|
||||
trace: 'Trace',
|
||||
drawStep: 'Draw ({n}/{total})',
|
||||
sectionMeaning: 'Meaning',
|
||||
sectionMnemonic: 'Memory Aid',
|
||||
sectionReadings: 'Readings',
|
||||
learnedKanji: 'Kanji Learned',
|
||||
backToDashboard: 'Back to Dashboard',
|
||||
},
|
||||
hero: {
|
||||
@@ -70,6 +75,7 @@ const messages = {
|
||||
next24: 'Next 24h',
|
||||
availableNow: 'Available Now',
|
||||
inHours: 'In {n} hour | In {n} hours',
|
||||
inDays: 'In {n} day | In {n} days',
|
||||
noIncoming: 'No reviews incoming for 24 hours.',
|
||||
items: 'items',
|
||||
reviewsCount: '{count} reviews',
|
||||
@@ -96,6 +102,9 @@ const messages = {
|
||||
strict: 'Strict',
|
||||
loose: 'Loose',
|
||||
save: 'Save & Close',
|
||||
soundEffects: 'Sound Effects',
|
||||
on: 'On',
|
||||
off: 'Off',
|
||||
},
|
||||
review: {
|
||||
meaning: 'Meaning',
|
||||
@@ -109,6 +118,9 @@ const messages = {
|
||||
next: 'NEXT',
|
||||
sessionComplete: 'Session Complete!',
|
||||
levelup: 'You leveled up your Kanji skills.',
|
||||
levelsUp: '{n} leveled up',
|
||||
levelsDown: '{n} leveled down',
|
||||
results: 'Results',
|
||||
back: 'Back to Collection',
|
||||
caughtUp: 'All Caught Up!',
|
||||
noReviews: 'No reviews available right now.',
|
||||
@@ -174,8 +186,13 @@ const messages = {
|
||||
learned: 'Du hast {n} neue Kanji gelernt.',
|
||||
components: 'Komponenten',
|
||||
observe: 'Strichfolge beobachten',
|
||||
replay: 'Wiederholen',
|
||||
trace: 'Nachzeichnen',
|
||||
drawStep: 'Zeichnen ({n}/{total})',
|
||||
sectionMeaning: 'Bedeutung',
|
||||
sectionMnemonic: 'Merkhilfe',
|
||||
sectionReadings: 'Lesungen',
|
||||
learnedKanji: 'Gelernte Kanji',
|
||||
backToDashboard: 'Zurück zur Übersicht',
|
||||
},
|
||||
hero: {
|
||||
@@ -200,6 +217,7 @@ const messages = {
|
||||
next24: 'Nächste 24h',
|
||||
availableNow: 'Jetzt verfügbar',
|
||||
inHours: 'In {n} Stunde | In {n} Stunden',
|
||||
inDays: 'In {n} Tag | In {n} Tagen',
|
||||
noIncoming: 'Keine Reviews in den nächsten 24h.',
|
||||
items: 'Einträge',
|
||||
reviewsCount: '{count} Reviews',
|
||||
@@ -226,6 +244,9 @@ const messages = {
|
||||
strict: 'Strikt',
|
||||
loose: 'Locker',
|
||||
save: 'Speichern & Schließen',
|
||||
soundEffects: 'Soundeffekte',
|
||||
on: 'An',
|
||||
off: 'Aus',
|
||||
},
|
||||
review: {
|
||||
meaning: 'Bedeutung',
|
||||
@@ -239,6 +260,9 @@ const messages = {
|
||||
next: 'WEITER',
|
||||
sessionComplete: 'Sitzung beendet!',
|
||||
levelup: 'Du hast deine Kanji-Skills verbessert.',
|
||||
levelsUp: '{n} aufgestiegen',
|
||||
levelsDown: '{n} abgestiegen',
|
||||
results: 'Ergebnisse',
|
||||
back: 'Zurück zur Sammlung',
|
||||
caughtUp: 'Alles erledigt!',
|
||||
noReviews: 'Gerade keine Reviews verfügbar.',
|
||||
@@ -304,8 +328,13 @@ const messages = {
|
||||
learned: '{n}個の新しい漢字を覚えました。',
|
||||
components: '構成要素',
|
||||
observe: '書き順を見る',
|
||||
replay: 'もう一度',
|
||||
trace: 'なぞる',
|
||||
drawStep: '書く ({n}/{total})',
|
||||
sectionMeaning: '意味',
|
||||
sectionMnemonic: '覚え方',
|
||||
sectionReadings: '読み方',
|
||||
learnedKanji: '学んだ漢字',
|
||||
backToDashboard: 'ダッシュボードに戻る',
|
||||
},
|
||||
hero: {
|
||||
@@ -330,6 +359,7 @@ const messages = {
|
||||
next24: '今後24時間',
|
||||
availableNow: '今すぐ可能',
|
||||
inHours: '{n}時間後',
|
||||
inDays: '{n}日後',
|
||||
noIncoming: '24時間以内のレビューはありません。',
|
||||
items: '個',
|
||||
reviewsCount: '{count} レビュー',
|
||||
@@ -356,6 +386,9 @@ const messages = {
|
||||
strict: '厳しい',
|
||||
loose: '甘い',
|
||||
save: '保存して閉じる',
|
||||
soundEffects: '効果音',
|
||||
on: 'オン',
|
||||
off: 'オフ',
|
||||
},
|
||||
review: {
|
||||
meaning: '意味',
|
||||
@@ -369,6 +402,9 @@ const messages = {
|
||||
next: '次へ',
|
||||
sessionComplete: 'セッション完了!',
|
||||
levelup: '漢字力がアップしました。',
|
||||
levelsUp: '{n}個昇格',
|
||||
levelsDown: '{n}個降格',
|
||||
results: '結果',
|
||||
back: 'コレクションに戻る',
|
||||
caughtUp: '完了しました!',
|
||||
noReviews: '現在レビューするものはありません。',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { SoundManager } from './SoundManager.js';
|
||||
|
||||
export const KANJI_CONSTANTS = {
|
||||
BASE_SIZE: 109,
|
||||
SVG_NS: 'http://www.w3.org/2000/svg',
|
||||
@@ -11,7 +13,7 @@ export const KANJI_CONSTANTS = {
|
||||
SAMPLE_POINTS: 60,
|
||||
|
||||
VALIDATION: {
|
||||
SAMPLES: 10,
|
||||
SAMPLES: 20,
|
||||
},
|
||||
|
||||
COLORS: {
|
||||
@@ -323,12 +325,16 @@ export class KanjiController {
|
||||
this.clearCanvas(this.ctx.hint);
|
||||
|
||||
if (this.currentStrokeIdx >= this.paths.length) {
|
||||
SoundManager.playDrawingComplete();
|
||||
this.onComplete();
|
||||
} else if (this.autoHint) {
|
||||
} else {
|
||||
if (this.autoHint) {
|
||||
this.showHint();
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
SoundManager.playStrokeError();
|
||||
this.mistakes += 1;
|
||||
this.animateErrorFade(this.userPath, () => {
|
||||
this.redrawAllPerfectStrokes();
|
||||
@@ -346,27 +352,171 @@ export class KanjiController {
|
||||
const len = pathEl.getTotalLength();
|
||||
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);
|
||||
|
||||
if (dist(userPts[0], targetStart) > startEndThreshold) return false;
|
||||
if (dist(userPts[userPts.length - 1], targetEnd) > startEndThreshold) return false;
|
||||
// ── 1. Start / end point check ──────────────────
|
||||
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;
|
||||
const sampleCount = SAMPLES + 1;
|
||||
let maxError = 0;
|
||||
|
||||
totalError = Array.from({ length: sampleCount }).reduce((acc, _, i) => {
|
||||
const targetPt = pathEl.getPointAtLength((i / SAMPLES) * len);
|
||||
const minD = userPts.reduce((min, up) => Math.min(min, dist(targetPt, up)), Infinity);
|
||||
return acc + minD;
|
||||
}, 0);
|
||||
for (let i = 0; i <= SAMPLES; i++) {
|
||||
const t = i / SAMPLES;
|
||||
const targetPt = pathEl.getPointAtLength(t * len);
|
||||
const userPt = userResampled[i];
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
.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(
|
||||
v-else-if="!loading && Object.keys(groupedItems).length === 0"
|
||||
)
|
||||
v-icon.mb-4(size="64" color="grey-darken-2") mdi-text-search-variant
|
||||
.text-h6 {{ $t('collection.noMatches') }}
|
||||
.text-body-2 {{ $t('collection.tryDifferent') }}
|
||||
.text-h6
|
||||
ScrambleText(:text="$t('collection.noMatches')")
|
||||
.text-body-2
|
||||
ScrambleText(:text="$t('collection.tryDifferent')")
|
||||
|
||||
.mb-6.fade-slide-up(
|
||||
v-else
|
||||
@@ -28,7 +31,7 @@
|
||||
:key="level"
|
||||
)
|
||||
.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-card(
|
||||
@@ -76,7 +79,8 @@
|
||||
)
|
||||
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
|
||||
.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(
|
||||
size="x-small"
|
||||
:color="getSRSColor(selectedItem?.srsLevel)"
|
||||
@@ -94,7 +98,7 @@
|
||||
|
||||
.radical-section.mb-4(v-if="selectedItem?.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 selectedItem.radicals" :key="rad.meaning")
|
||||
@@ -106,20 +110,24 @@
|
||||
|
||||
.readings-container.mb-4
|
||||
.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-group(v-if="hasReading(selectedItem?.kunyomi)")
|
||||
.reading-label {{ $t('collection.kunyomi') }}
|
||||
.reading-label
|
||||
ScrambleText(:text="$t('collection.kunyomi')")
|
||||
.reading-value {{ selectedItem?.kunyomi.join(', ') }}
|
||||
|
||||
.reading-group(v-if="hasReading(selectedItem?.nanori)")
|
||||
.reading-label {{ $t('collection.nanori') }}
|
||||
.reading-label
|
||||
ScrambleText(:text="$t('collection.nanori')")
|
||||
.reading-value {{ selectedItem?.nanori.join(', ') }}
|
||||
|
||||
.px-2.mb-4(v-if="selectedItem?.stats?.total > 0")
|
||||
.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 }}
|
||||
v-progress-linear(
|
||||
:model-value="accuracyStats.percent"
|
||||
@@ -134,16 +142,15 @@
|
||||
block
|
||||
color="#2f3542"
|
||||
@click="showModal = false"
|
||||
) {{ $t('collection.close') }}
|
||||
)
|
||||
ScrambleText(:text="$t('collection.close')")
|
||||
v-col.pl-2(cols="6")
|
||||
v-btn.text-black(
|
||||
block
|
||||
color="purple-accent-2"
|
||||
@click="redoLesson"
|
||||
)
|
||||
| {{ selectedItem?.srsLevel === 0
|
||||
| ? $t('collection.startLesson')
|
||||
| : $t('collection.redoLesson') }}
|
||||
ScrambleText(:text="selectedItem?.srsLevel === 0 ? $t('collection.startLesson') : $t('collection.redoLesson')")
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -264,6 +271,6 @@ const getNextReviewText = (dateStr, srsLvl) => {
|
||||
if (diffHrs < 24) return t('stats.inHours', { n: diffHrs }, diffHrs);
|
||||
|
||||
const diffDays = Math.floor(diffHrs / 24);
|
||||
return `Next: ${diffDays}d`;
|
||||
return t('stats.inDays', { n: diffDays }, diffDays);
|
||||
};
|
||||
</script>
|
||||
|
||||
+129
-35
@@ -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')")
|
||||
|
||||
</template>
|
||||
|
||||
@@ -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,8 +267,17 @@ async function handleDrawComplete() {
|
||||
const isRedo = !!route.query.subjectId;
|
||||
|
||||
if (!isRedo) {
|
||||
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();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+176
-39
@@ -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')")
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -126,13 +186,13 @@
|
||||
import {
|
||||
ref, onMounted, computed, watch,
|
||||
} from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import KanjiCanvas from '@/components/kanji/KanjiCanvas.vue';
|
||||
import { SoundManager } from '@/utils/SoundManager';
|
||||
|
||||
const store = useAppStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const currentItem = ref(null);
|
||||
const statusCode = ref('draw');
|
||||
@@ -144,6 +204,7 @@ const loading = ref(true);
|
||||
const sessionTotal = ref(0);
|
||||
const sessionDone = ref(0);
|
||||
const sessionCorrect = ref(0);
|
||||
const sessionResults = ref([]);
|
||||
|
||||
const accuracy = computed(() => {
|
||||
if (sessionDone.value === 0) return 100;
|
||||
@@ -155,19 +216,49 @@ const progressPercent = computed(() => {
|
||||
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() {
|
||||
if (store.queue.length === 0) {
|
||||
currentItem.value = null;
|
||||
if (sessionTotal.value > 0) SoundManager.playSessionComplete();
|
||||
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];
|
||||
|
||||
statusCode.value = 'draw';
|
||||
showNext.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 () => {
|
||||
loading.value = true;
|
||||
const mode = route.query.mode || 'shuffled';
|
||||
@@ -185,6 +276,8 @@ async function resetSession() {
|
||||
sessionDone.value = 0;
|
||||
sessionCorrect.value = 0;
|
||||
sessionTotal.value = 0;
|
||||
sessionResults.value = [];
|
||||
submittedItems.value.clear();
|
||||
currentItem.value = null;
|
||||
const mode = route.query.mode || 'shuffled';
|
||||
await store.fetchQueue(mode);
|
||||
@@ -223,31 +316,42 @@ function handleComplete() {
|
||||
async function next() {
|
||||
if (!currentItem.value) return;
|
||||
|
||||
await store.submitReview(currentItem.value.wkSubjectId, !isFailure.value);
|
||||
const itemId = currentItem.value._id;
|
||||
const isFirstAttempt = !submittedItems.value.has(itemId);
|
||||
|
||||
if (isFirstAttempt) {
|
||||
const oldLevel = currentItem.value.srsLevel;
|
||||
const result = await store.submitReview(currentItem.value.wkSubjectId, !isFailure.value);
|
||||
submittedItems.value.add(itemId);
|
||||
|
||||
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;
|
||||
if (!isFailure.value) sessionCorrect.value += 1;
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
const index = store.queue.findIndex((i) => i._id === currentItem.value._id);
|
||||
const index = store.queue.findIndex((i) => i._id === itemId);
|
||||
if (index !== -1) {
|
||||
store.queue.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
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 colors = {
|
||||
1: '#ff7675', 2: '#fdcb6e', 3: '#55efc4', 4: '#0984e3', 5: '#a29bfe', 6: '#6c5ce7',
|
||||
7: '#00cec9', 8: '#fd79a8', 9: '#e84393', 10: '#ffd700',
|
||||
};
|
||||
return colors[lvl] || 'grey';
|
||||
};
|
||||
@@ -262,7 +366,40 @@ const getStatusClass = (status) => {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '@/styles/abstracts' as *;
|
||||
|
||||
.review-view-container {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user