Files
zen-kanji/client/src/views/Review.vue
T
Crylia 049eaa1807
Release Build / build-docker (push) Successful in 1m9s
Release Build / build-android-and-release (push) Successful in 7m14s
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)
2026-04-18 22:19:36 +02:00

406 lines
12 KiB
Vue

<template lang="pug">
v-container.fill-height.justify-center.pa-4.review-view-container
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
ScrambleText(:text="$t('review.loading')")
v-card.pa-6.rounded-xl.d-flex.flex-column.align-center.review-card(
v-else-if="currentItem"
)
.d-flex.align-center.w-100.mb-6
v-btn.mr-2(
icon="mdi-arrow-left"
variant="text"
color="grey"
to="/"
density="comfortable"
)
.text-caption.text-grey.font-weight-bold
| {{ sessionDone }} / {{ sessionTotal }}
v-spacer
v-chip.font-weight-bold(
size="small"
:color="getSRSColor(currentItem.srsLevel)"
variant="flat"
)
ScrambleText(:text="$t('review.level') + ' ' + currentItem.srsLevel")
.text-center.mb-6
.text-caption.text-grey.text-uppercase.tracking-wide.mb-1
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
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
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"
@mistake="handleMistake"
)
transition(name="scale")
v-btn.next-fab.glow-btn.text-black(
v-if="showNext"
@click="next"
color="primary"
icon="mdi-arrow-right"
size="large"
elevation="8"
)
.mb-4.d-flex.gap-2
v-btn.text-caption.font-weight-bold.opacity-80(
variant="text"
color="amber-lighten-1"
prepend-icon="mdi-lightbulb-outline"
size="small"
:disabled="showNext"
@click="triggerHint"
)
ScrambleText(:text="$t('review.showHint')")
v-sheet.d-flex.flex-column.align-center.justify-center(
width="100%"
min-height="80"
color="transparent"
)
transition(name="fade-slide" mode="out-in")
.status-text.text-h6.font-weight-bold(
:key="statusCode"
:class="getStatusClass(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 }} &rarr; {{ rankChange.level }}
v-progress-linear.mt-4.progress-bar(
v-model="progressPercent"
color="primary"
height="4"
rounded
)
v-card.pa-8.rounded-xl.elevation-10.review-card(
v-else-if="sessionTotal > 0 && store.queue.length === 0"
)
.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.mt-4.mb-2
ScrambleText(:text="$t('review.sessionComplete')")
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
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
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 }} &rarr; {{ r.newLevel }}
v-btn.text-black.font-weight-bold.glow-btn(
to="/"
block
color="primary"
height="50"
rounded="lg"
)
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
ScrambleText(:text="$t('review.caughtUp')")
.text-grey.mb-6
ScrambleText(:text="$t('review.noReviews')")
v-btn.font-weight-bold(
to="/"
color="primary"
variant="tonal"
)
ScrambleText(:text="$t('review.viewCollection')")
</template>
<script setup>
/* eslint-disable no-unused-vars */
import {
ref, onMounted, computed, watch,
} from 'vue';
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 currentItem = ref(null);
const statusCode = ref('draw');
const showNext = ref(false);
const isFailure = ref(false);
const kanjiCanvasRef = ref(null);
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;
return Math.round((sessionCorrect.value / sessionDone.value) * 100);
});
const progressPercent = computed(() => {
if (sessionTotal.value === 0) return 0;
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;
}
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';
await store.fetchQueue(mode);
if (sessionTotal.value === 0 && store.queue.length > 0) {
sessionTotal.value = store.queue.length;
}
loadNext();
loading.value = false;
});
async function resetSession() {
loading.value = true;
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);
if (store.queue.length > 0) sessionTotal.value = store.queue.length;
loadNext();
loading.value = false;
}
watch(() => store.batchSize, async () => {
resetSession();
});
function triggerHint() {
if (!kanjiCanvasRef.value) return;
isFailure.value = true;
statusCode.value = 'hint';
kanjiCanvasRef.value.drawGuide(true);
}
function handleMistake(isHint) {
if (isHint) {
isFailure.value = true;
statusCode.value = 'hint';
} else {
statusCode.value = 'tryAgain';
}
}
function handleComplete() {
statusCode.value = 'correct';
showNext.value = true;
}
async function next() {
if (!currentItem.value) return;
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;
const index = store.queue.findIndex((i) => i._id === itemId);
if (index !== -1) {
store.queue.splice(index, 1);
}
}
loadNext();
}
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';
};
const getStatusClass = (status) => {
switch (status) {
case 'hint': return 'text-red-lighten-1';
case 'correct': return 'text-teal-accent-3';
default: return 'text-grey-lighten-1';
}
};
</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>