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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user