add new lesson mode and started code refraction
This commit is contained in:
@@ -62,7 +62,7 @@
|
||||
v-icon(start icon="mdi-logout")
|
||||
| {{ $t('nav.logout') }}
|
||||
|
||||
v-app-bar.px-2.app-bar-blur(
|
||||
v-app-bar.px-2.app-bar-blur.safe-area-header(
|
||||
flat
|
||||
color="rgba(30, 30, 36, 0.8)"
|
||||
border="b"
|
||||
@@ -189,10 +189,10 @@
|
||||
.text-center.text-h5.font-weight-bold.text-teal-accent-3.mb-6
|
||||
| {{ tempBatchSize }} {{ $t('settings.items') }}
|
||||
|
||||
.text-caption.text-grey.mb-2 Drawing Tolerance
|
||||
.text-caption.text-grey.mb-2 {{ $t('settings.drawingTolerance') }}
|
||||
v-slider(
|
||||
v-model="tempDrawingAccuracy"
|
||||
:min="5"
|
||||
:min="1"
|
||||
:max="20"
|
||||
:step="1"
|
||||
thumb-label
|
||||
@@ -200,9 +200,9 @@
|
||||
track-color="grey-darken-3"
|
||||
)
|
||||
.d-flex.justify-space-between.text-caption.text-grey-lighten-1.mb-6.px-1
|
||||
span Strict (5)
|
||||
span {{ $t('settings.strict') }} (5)
|
||||
span.font-weight-bold.text-body-1(color="#00cec9") {{ tempDrawingAccuracy }}
|
||||
span Loose (20)
|
||||
span {{ $t('settings.loose') }} (20)
|
||||
|
||||
.text-caption.text-grey.mb-2 {{ $t('settings.language') }}
|
||||
v-btn-toggle.d-flex.w-100.border-subtle(
|
||||
@@ -255,9 +255,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { ref, watch, onMounted } from 'vue';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import logo from '@/assets/icon.svg';
|
||||
|
||||
const drawer = ref(false);
|
||||
@@ -276,73 +277,86 @@ const tempBatchSize = ref(store.batchSize);
|
||||
const tempDrawingAccuracy = ref(store.drawingAccuracy);
|
||||
|
||||
onMounted(() => {
|
||||
if (store.token) {
|
||||
store.fetchStats();
|
||||
}
|
||||
if (store.token) {
|
||||
store.fetchStats();
|
||||
}
|
||||
});
|
||||
|
||||
watch(showSettings, (isOpen) => {
|
||||
if (isOpen) {
|
||||
tempBatchSize.value = store.batchSize;
|
||||
tempDrawingAccuracy.value = store.drawingAccuracy;
|
||||
}
|
||||
if (isOpen) {
|
||||
tempBatchSize.value = store.batchSize;
|
||||
tempDrawingAccuracy.value = store.drawingAccuracy;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleLogin() {
|
||||
if (!inputKey.value) return;
|
||||
loggingIn.value = true;
|
||||
errorMsg.value = '';
|
||||
|
||||
try {
|
||||
const result = await store.login(inputKey.value.trim());
|
||||
if (result.user && !result.user.lastSync) {
|
||||
manualSync();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
errorMsg.value = e.message || t('login.failed');
|
||||
} finally {
|
||||
loggingIn.value = false;
|
||||
}
|
||||
async function manualSync() {
|
||||
syncing.value = true;
|
||||
try {
|
||||
const result = await store.sync();
|
||||
snackbar.value = { show: true, text: t('alerts.syncSuccess', { count: result.count }), color: 'success' };
|
||||
await store.fetchQueue();
|
||||
await store.fetchStats();
|
||||
await store.fetchCollection();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
snackbar.value = { show: true, text: t('alerts.syncFailed'), color: 'error' };
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function manualSync() {
|
||||
syncing.value = true;
|
||||
try {
|
||||
const result = await store.sync();
|
||||
snackbar.value = { show: true, text: t('alerts.syncSuccess', { count: result.count }), color: 'success' };
|
||||
await store.fetchQueue();
|
||||
await store.fetchStats();
|
||||
await store.fetchCollection();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
snackbar.value = { show: true, text: t('alerts.syncFailed'), color: 'error' };
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
async function handleLogin() {
|
||||
if (!inputKey.value) return;
|
||||
loggingIn.value = true;
|
||||
errorMsg.value = '';
|
||||
|
||||
try {
|
||||
const result = await store.login(inputKey.value.trim());
|
||||
if (result.user && !result.user.lastSync) {
|
||||
manualSync();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
errorMsg.value = e.message || t('login.failed');
|
||||
} finally {
|
||||
loggingIn.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
store.saveSettings({
|
||||
batchSize: tempBatchSize.value,
|
||||
drawingAccuracy: tempDrawingAccuracy.value
|
||||
});
|
||||
store.saveSettings({
|
||||
batchSize: tempBatchSize.value,
|
||||
drawingAccuracy: tempDrawingAccuracy.value,
|
||||
});
|
||||
|
||||
localStorage.setItem('zen_locale', locale.value);
|
||||
localStorage.setItem('zen_locale', locale.value);
|
||||
|
||||
showSettings.value = false;
|
||||
store.fetchQueue();
|
||||
showSettings.value = false;
|
||||
store.fetchQueue();
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
showLogoutDialog.value = true;
|
||||
drawer.value = false;
|
||||
showLogoutDialog.value = true;
|
||||
drawer.value = false;
|
||||
}
|
||||
function confirmLogout() {
|
||||
store.logout();
|
||||
inputKey.value = '';
|
||||
showLogoutDialog.value = false;
|
||||
store.logout();
|
||||
inputKey.value = '';
|
||||
showLogoutDialog.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/pages/_app.scss"></style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.safe-area-header {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
|
||||
height: auto !important;
|
||||
|
||||
:deep(.v-toolbar__content) {
|
||||
min-height: 64px;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
42
client/src/components/dashboard/DashboardWidget.vue
Normal file
42
client/src/components/dashboard/DashboardWidget.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template lang="pug">
|
||||
v-card.pa-4.widget-card.rounded-xl.border-subtle.d-flex.flex-column(
|
||||
color="#1e1e24"
|
||||
flat
|
||||
)
|
||||
.d-flex.justify-space-between.align-center.mb-3(v-if="hasHeader")
|
||||
.d-flex.align-center
|
||||
v-icon.mr-2(
|
||||
v-if="icon"
|
||||
:color="iconColor"
|
||||
size="small"
|
||||
) {{ icon }}
|
||||
|
||||
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 }}
|
||||
|
||||
div.d-flex.align-center
|
||||
slot(name="header-right")
|
||||
|
||||
.widget-content.flex-grow-1.d-flex.flex-column
|
||||
slot
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, default: '' },
|
||||
subtitle: { type: String, default: '' },
|
||||
icon: { type: String, default: '' },
|
||||
iconColor: { type: String, default: 'secondary' },
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const hasHeader = computed(() => !!props.title || !!props.icon || !!props.subtitle);
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>
|
||||
@@ -1,37 +1,38 @@
|
||||
<template lang="pug">
|
||||
v-card.pa-4.rounded-xl.border-subtle.d-flex.align-center.justify-space-between(color="#1e1e24")
|
||||
div
|
||||
.text-subtitle-2.text-grey {{ $t('stats.accuracy') }}
|
||||
.text-h3.font-weight-bold.text-white {{ accuracyPercent }}%
|
||||
.text-caption.text-grey
|
||||
| {{ accuracy?.correct || 0 }} {{ $t('stats.correct') }} / {{ accuracy?.total || 0 }} {{ $t('stats.total') }}
|
||||
DashboardWidget(
|
||||
:title="$t('stats.accuracy')"
|
||||
icon="mdi-bullseye-arrow"
|
||||
)
|
||||
.d-flex.align-center.justify-space-between.flex-grow-1
|
||||
div
|
||||
.text-h3.font-weight-bold.text-white {{ accuracyPercent }}%
|
||||
.text-caption.text-grey.mt-1
|
||||
| {{ accuracy?.correct || 0 }} {{ $t('stats.correct') }}
|
||||
span.mx-1 /
|
||||
| {{ accuracy?.total || 0 }} {{ $t('stats.total') }}
|
||||
|
||||
v-progress-circular(
|
||||
:model-value="accuracyPercent"
|
||||
color="#00cec9"
|
||||
size="100"
|
||||
width="10"
|
||||
bg-color="grey-darken-3"
|
||||
)
|
||||
v-icon(color="#00cec9" size="large") mdi-bullseye-arrow
|
||||
v-progress-circular(
|
||||
:model-value="accuracyPercent"
|
||||
color="primary"
|
||||
size="80"
|
||||
width="8"
|
||||
bg-color="grey-darken-3"
|
||||
)
|
||||
span.text-caption.font-weight-bold {{ accuracyPercent }}%
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { computed } from 'vue';
|
||||
import DashboardWidget from './DashboardWidget.vue';
|
||||
|
||||
const props = defineProps({
|
||||
accuracy: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
correct: 0,
|
||||
total: 0
|
||||
})
|
||||
}
|
||||
accuracy: { type: Object, default: () => ({ correct: 0, total: 0 }) },
|
||||
});
|
||||
|
||||
const accuracyPercent = computed(() => {
|
||||
if (!props.accuracy || !props.accuracy.total) return 100;
|
||||
return Math.round((props.accuracy.correct / props.accuracy.total) * 100);
|
||||
if (!props.accuracy || !props.accuracy.total) return 100;
|
||||
return Math.round((props.accuracy.correct / props.accuracy.total) * 100);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template lang="pug">
|
||||
v-card.pa-5.rounded-xl.border-subtle.d-flex.flex-column.flex-grow-1(color="#1e1e24")
|
||||
v-card.widget-card.pa-5.d-flex.flex-column.flex-grow-1(flat)
|
||||
.d-flex.align-center.justify-space-between.mb-4
|
||||
.text-subtitle-1.font-weight-bold.d-flex.align-center
|
||||
v-icon(color="#ffeaa7" start size="small") mdi-chart-bar
|
||||
v-icon(color="secondary" start size="small") mdi-chart-bar
|
||||
| {{ $t('stats.srsDistribution') }}
|
||||
v-chip.font-weight-bold(
|
||||
size="x-small"
|
||||
@@ -10,7 +10,7 @@
|
||||
variant="tonal"
|
||||
) {{ totalItems }}
|
||||
|
||||
.srs-chart-container.d-flex.justify-space-between.align-end.px-2.gap-2.flex-grow-1
|
||||
.srs-chart-container.d-flex.justify-space-between.align-end.px-2.flex-grow-1
|
||||
.d-flex.flex-column.align-center.flex-grow-1.srs-column(
|
||||
v-for="lvl in 10"
|
||||
:key="lvl"
|
||||
@@ -20,11 +20,13 @@
|
||||
) {{ getCount(lvl) }}
|
||||
|
||||
.srs-track
|
||||
.srs-fill(:style="{\
|
||||
height: getBarHeight(getCount(lvl)) + '%',\
|
||||
background: getSRSColor(lvl),\
|
||||
boxShadow: getCount(lvl) > 0 ? `0 0 20px ${getSRSColor(lvl)}30` : 'none'\
|
||||
}")
|
||||
.srs-fill(
|
||||
:class="'bg-srs-' + lvl"
|
||||
:style="{\
|
||||
height: getBarHeight(getCount(lvl)) + '%',\
|
||||
'--shadow-color': 'var(--srs-' + lvl + ')'\
|
||||
}"
|
||||
)
|
||||
|
||||
.text-caption.text-grey-darken-1.font-weight-bold.mt-3(
|
||||
style="font-size: 10px !important;"
|
||||
@@ -32,41 +34,29 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
distribution: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
distribution: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const totalItems = computed(() => Object.values(props.distribution || {}).reduce((a, b) => a + b, 0));
|
||||
|
||||
const totalItems = computed(
|
||||
() => Object.values(props.distribution || {}).reduce((a, b) => a + b, 0),
|
||||
);
|
||||
const getCount = (lvl) => props.distribution?.[lvl] || 0;
|
||||
|
||||
const getBarHeight = (count) => {
|
||||
const max = Math.max(...Object.values(props.distribution || {}), 1);
|
||||
if (count > 0 && (count / max) * 100 < 4) return 4;
|
||||
return (count / max) * 100;
|
||||
};
|
||||
|
||||
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] || '#444';
|
||||
const max = Math.max(...Object.values(props.distribution || {}), 1);
|
||||
if (count > 0 && (count / max) * 100 < 4) return 4;
|
||||
return (count / max) * 100;
|
||||
};
|
||||
|
||||
const toRoman = (num) => {
|
||||
const lookup = {
|
||||
1: 'I', 2: 'II', 3: 'III', 4: 'IV', 5: 'V',
|
||||
6: 'VI', 7: 'VII', 8: 'VIII', 9: 'IX', 10: 'X'
|
||||
};
|
||||
return lookup[num] || num;
|
||||
const lookup = {
|
||||
1: 'I', 2: 'II', 3: 'III', 4: 'IV', 5: 'V', 6: 'VI', 7: 'VII', 8: 'VIII', 9: 'IX', 10: 'X',
|
||||
};
|
||||
return lookup[num] || num;
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
<template lang="pug">
|
||||
v-card.pa-4.rounded-xl.border-subtle.d-flex.flex-column.flex-grow-1(color="#1e1e24")
|
||||
.text-subtitle-1.font-weight-bold.mb-3.d-flex.align-center
|
||||
v-icon(color="#ffeaa7" start) mdi-clock-outline
|
||||
| {{ $t('stats.next24') }}
|
||||
|
||||
DashboardWidget(
|
||||
:title="$t('stats.next24')"
|
||||
icon="mdi-clock-outline"
|
||||
)
|
||||
.forecast-list.flex-grow-1(v-if="hasUpcoming")
|
||||
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) }}
|
||||
v-chip.font-weight-bold(
|
||||
|
||||
v-chip.font-weight-bold.text-primary(
|
||||
size="small"
|
||||
color="#2f3542"
|
||||
style="color: #00cec9 !important;"
|
||||
color="surface-light"
|
||||
) {{ count }}
|
||||
|
||||
.fill-height.d-flex.align-center.justify-center.text-grey.text-center.pa-4(v-else)
|
||||
@@ -20,22 +19,15 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { computed } from 'vue';
|
||||
import DashboardWidget from './DashboardWidget.vue';
|
||||
|
||||
const props = defineProps({
|
||||
forecast: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
forecast: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const hasUpcoming = computed(() => {
|
||||
return props.forecast && props.forecast.some(c => c > 0);
|
||||
});
|
||||
const hasUpcoming = computed(() => props.forecast && props.forecast.some((c) => c > 0));
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/components/_widgets.scss" scoped>
|
||||
.border-b-subtle {
|
||||
border-bottom: 1px solid rgb(255 255 255 / 5%);
|
||||
}
|
||||
</style>
|
||||
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>
|
||||
|
||||
@@ -1,31 +1,38 @@
|
||||
<template lang="pug">
|
||||
v-card.pa-4.rounded-xl.border-subtle(color="#1e1e24")
|
||||
.d-flex.justify-space-between.align-center.mb-3
|
||||
.d-flex.align-center
|
||||
v-icon(color="#ff7675" start size="small") mdi-ghost
|
||||
div
|
||||
.text-subtitle-2.text-white {{ $t('stats.ghostTitle') }}
|
||||
.text-caption.text-grey {{ $t('stats.ghostSubtitle') }}
|
||||
DashboardWidget(
|
||||
:title="$t('stats.ghostTitle')"
|
||||
:subtitle="$t('stats.ghostSubtitle')"
|
||||
icon="mdi-ghost"
|
||||
icon-color="var(--srs-1)"
|
||||
)
|
||||
.grid-wrapper(style="display: grid; grid-template-columns: minmax(0, 1fr); width: 100%;")
|
||||
.d-flex.gap-2.overflow-x-auto.pb-2(
|
||||
v-if="ghosts && ghosts.length > 0"
|
||||
style="scrollbar-width: thin; max-width: 100%;"
|
||||
)
|
||||
.ghost-card(
|
||||
v-for="ghost in ghosts"
|
||||
:key="ghost._id"
|
||||
style="min-width: 80px; flex-shrink: 0;"
|
||||
)
|
||||
.text-h6.font-weight-bold.text-white.mb-1 {{ ghost.char }}
|
||||
|
||||
.d-flex.justify-space-between.gap-2(v-if="ghosts && ghosts.length > 0")
|
||||
.ghost-card.flex-grow-1(v-for="ghost in ghosts" :key="ghost._id")
|
||||
.text-h6.font-weight-bold.text-white.mb-1 {{ ghost.char }}
|
||||
v-chip.font-weight-bold.text-black.w-100.justify-center(
|
||||
size="x-small"
|
||||
color="red-accent-2"
|
||||
variant="flat"
|
||||
) {{ ghost.accuracy }}%
|
||||
v-chip.font-weight-bold.w-100.justify-center(
|
||||
size="x-small"
|
||||
class="bg-srs-1 text-black"
|
||||
variant="flat"
|
||||
) {{ ghost.accuracy }}%
|
||||
|
||||
.text-center.py-2.text-caption.text-grey(v-else)
|
||||
| {{ $t('stats.noGhosts') }}
|
||||
.text-center.py-2.text-caption.text-grey(v-else)
|
||||
| {{ $t('stats.noGhosts') }}
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import DashboardWidget from './DashboardWidget.vue';
|
||||
|
||||
const props = defineProps({
|
||||
ghosts: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
ghosts: { type: Array, default: () => [] },
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,45 +1,43 @@
|
||||
<template lang="pug">
|
||||
v-card.widget-card.pa-5.rounded-xl.d-flex.flex-column.justify-center(color="#1e1e24" flat)
|
||||
.d-flex.justify-space-between.align-center.mb-2
|
||||
.d-flex.align-center
|
||||
v-icon(color="secondary" start size="small") mdi-trophy-outline
|
||||
span.text-subtitle-2.font-weight-bold {{ $t('stats.mastery') }}
|
||||
DashboardWidget(
|
||||
:title="$t('stats.mastery')"
|
||||
icon="mdi-trophy-outline"
|
||||
)
|
||||
template(#header-right)
|
||||
.text-subtitle-2.text-white.font-weight-bold {{ masteryPercent }}%
|
||||
|
||||
v-progress-linear(
|
||||
:model-value="masteryPercent"
|
||||
color="primary"
|
||||
height="8"
|
||||
rounded
|
||||
bg-color="grey-darken-3"
|
||||
striped
|
||||
)
|
||||
.d-flex.flex-column.justify-center.flex-grow-1
|
||||
v-progress-linear(
|
||||
:model-value="masteryPercent"
|
||||
color="primary"
|
||||
height="8"
|
||||
rounded
|
||||
bg-color="grey-darken-3"
|
||||
striped
|
||||
)
|
||||
|
||||
.text-caption.text-medium-emphasis.mt-2.text-right
|
||||
| {{ masteredCount }} / {{ totalItems }} {{ $t('stats.items') }}
|
||||
.text-caption.text-medium-emphasis.mt-2.text-right
|
||||
| {{ masteredCount }} / {{ totalItems }} {{ $t('stats.items') }}
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { computed } from 'vue';
|
||||
import DashboardWidget from './DashboardWidget.vue';
|
||||
|
||||
const props = defineProps({
|
||||
distribution: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
distribution: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const totalItems = computed(() => Object.values(props.distribution || {}).reduce((a, b) => a + b, 0));
|
||||
|
||||
const masteredCount = computed(() => {
|
||||
const dist = props.distribution || {};
|
||||
return (dist[6] || 0);
|
||||
});
|
||||
|
||||
const masteryPercent = computed(() => {
|
||||
if (totalItems.value === 0) return 0;
|
||||
return Math.round((masteredCount.value / totalItems.value) * 100);
|
||||
});
|
||||
const totalItems = computed(
|
||||
() => Object.values(props.distribution || {}).reduce((a, b) => a + b, 0),
|
||||
);
|
||||
const masteredCount = computed(
|
||||
() => (props.distribution || {})[6] || 0,
|
||||
);
|
||||
const masteryPercent = computed(
|
||||
() => (totalItems.value === 0 ? 0 : Math.round((masteredCount.value / totalItems.value) * 100)),
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<template lang="pug">
|
||||
v-card.widget-card.pa-4.rounded-xl(color="#1e1e24" flat)
|
||||
.d-flex.flex-wrap.justify-space-between.align-center.mb-4.gap-2
|
||||
.text-subtitle-1.font-weight-bold.d-flex.align-center
|
||||
v-icon(color="secondary" start) mdi-calendar-check
|
||||
| {{ $t('stats.consistency') }}
|
||||
|
||||
DashboardWidget(
|
||||
:title="$t('stats.consistency')"
|
||||
icon="mdi-calendar-check"
|
||||
)
|
||||
template(#header-right)
|
||||
.legend-container
|
||||
span.text-caption.text-medium-emphasis.mr-1 {{ $t('stats.less') }}
|
||||
.legend-box.level-0
|
||||
@@ -22,10 +21,7 @@
|
||||
template(v-slot:activator="{ props }")
|
||||
.heatmap-cell(
|
||||
v-bind="props"
|
||||
:class="[\
|
||||
isToday(day.date) ? 'today-cell' : '',\
|
||||
getHeatmapClass(day.count)\
|
||||
]"
|
||||
:class="[isToday(day.date) ? 'today-cell' : '', getHeatmapClass(day.count)]"
|
||||
)
|
||||
.text-center
|
||||
.font-weight-bold {{ formatDate(day.date) }}
|
||||
@@ -33,53 +29,50 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const { locale } = useI18n();
|
||||
import DashboardWidget from './DashboardWidget.vue';
|
||||
|
||||
const { locale } = useI18n();
|
||||
const props = defineProps({
|
||||
heatmapData: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
heatmapData: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const weeks = computed(() => {
|
||||
const data = props.heatmapData || {};
|
||||
const w = [];
|
||||
const today = new Date();
|
||||
const data = props.heatmapData || {};
|
||||
const w = [];
|
||||
const today = new Date();
|
||||
|
||||
const startDate = new Date(today);
|
||||
startDate.setDate(today.getDate() - (52 * 7));
|
||||
const dayOfWeek = startDate.getDay();
|
||||
startDate.setDate(startDate.getDate() - dayOfWeek);
|
||||
const startDate = new Date(today);
|
||||
startDate.setDate(today.getDate() - (52 * 7));
|
||||
startDate.setDate(startDate.getDate() - startDate.getDay());
|
||||
|
||||
let currentWeek = [];
|
||||
for (let i = 0; i < 371; i++) {
|
||||
const d = new Date(startDate);
|
||||
d.setDate(startDate.getDate() + i);
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
let currentWeek = [];
|
||||
for (let i = 0; i < 371; i += 1) {
|
||||
const d = new Date(startDate);
|
||||
d.setDate(startDate.getDate() + i);
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
|
||||
currentWeek.push({
|
||||
date: dateStr,
|
||||
count: data[dateStr] || 0
|
||||
});
|
||||
currentWeek.push({
|
||||
date: dateStr,
|
||||
count: data[dateStr] || 0,
|
||||
});
|
||||
|
||||
if (currentWeek.length === 7) {
|
||||
w.push(currentWeek);
|
||||
currentWeek = [];
|
||||
}
|
||||
}
|
||||
return w;
|
||||
if (currentWeek.length === 7) {
|
||||
w.push(currentWeek);
|
||||
currentWeek = [];
|
||||
}
|
||||
}
|
||||
return w;
|
||||
});
|
||||
|
||||
const getHeatmapClass = (count) => {
|
||||
if (count === 0) return 'level-0';
|
||||
if (count <= 5) return 'level-1';
|
||||
if (count <= 10) return 'level-2';
|
||||
if (count <= 20) return 'level-3';
|
||||
return 'level-4';
|
||||
if (count === 0) return 'level-0';
|
||||
if (count <= 5) return 'level-1';
|
||||
if (count <= 10) return 'level-2';
|
||||
if (count <= 20) return 'level-3';
|
||||
return 'level-4';
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => new Date(dateStr).toLocaleDateString(locale.value, { month: 'short', day: 'numeric' });
|
||||
|
||||
@@ -1,58 +1,61 @@
|
||||
<template lang="pug">
|
||||
v-card.pa-4.rounded-xl.border-subtle(color="#1e1e24")
|
||||
.d-flex.justify-space-between.align-start.mb-2
|
||||
div
|
||||
.text-subtitle-2.text-grey {{ $t('stats.streakTitle') }}
|
||||
.d-flex.align-center
|
||||
.text-h3.font-weight-bold.text-white.mr-2 {{ streak?.current || 0 }}
|
||||
.text-h6.text-grey {{ $t('stats.days') }}
|
||||
DashboardWidget(
|
||||
:title="$t('stats.streakTitle')"
|
||||
icon="mdi-fire"
|
||||
icon-color="var(--srs-1)"
|
||||
)
|
||||
template(#header-right)
|
||||
v-tooltip(location="start" :text="shieldTooltip")
|
||||
template(v-slot:activator="{ props }")
|
||||
v-avatar.streak-shield-avatar(
|
||||
v-bind="props"
|
||||
size="32"
|
||||
:class="streak?.shield?.ready ? 'text-primary' : 'text-grey'"
|
||||
)
|
||||
v-icon(size="small")
|
||||
| {{ streak?.shield?.ready ? 'mdi-shield-check' : 'mdi-shield-off-outline' }}
|
||||
|
||||
.text-center
|
||||
v-tooltip(
|
||||
location="start"
|
||||
:text="streak?.shield?.ready ? $t('stats.shieldActive') : $t('stats.shieldCooldown', { n: streak?.shield?.cooldown })"
|
||||
.d-flex.flex-column.justify-space-between.flex-grow-1
|
||||
.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') }}
|
||||
|
||||
.d-flex.justify-space-between.align-center.px-1
|
||||
.d-flex.flex-column.align-center(
|
||||
v-for="(day, idx) in (streak?.history || [])"
|
||||
:key="idx"
|
||||
)
|
||||
template(v-slot:activator="{ props }")
|
||||
v-avatar(
|
||||
v-bind="props"
|
||||
size="48"
|
||||
:color="streak?.shield?.ready ? 'rgba(0, 206, 201, 0.1)' : 'rgba(255, 255, 255, 0.05)'"
|
||||
style="border: 1px solid;"
|
||||
:style="{ borderColor: streak?.shield?.ready ? '#00cec9' : '#555' }"
|
||||
)
|
||||
v-icon(:color="streak?.shield?.ready ? '#00cec9' : 'grey'")
|
||||
| {{ streak?.shield?.ready ? 'mdi-shield-check' : 'mdi-shield-off-outline' }}
|
||||
|
||||
.d-flex.justify-space-between.align-center.mt-2.px-1
|
||||
.d-flex.flex-column.align-center(
|
||||
v-for="(day, idx) in (streak?.history || [])"
|
||||
:key="idx"
|
||||
)
|
||||
.streak-dot.mb-1(:class="{ 'active': day.active }")
|
||||
v-icon(v-if="day.active" size="12" color="black") mdi-check
|
||||
.text-grey.text-uppercase(style="font-size: 10px;")
|
||||
| {{ getDayLabel(day.date) }}
|
||||
.streak-dot.mb-1(:class="{ 'active': day.active }")
|
||||
v-icon(v-if="day.active" size="12" color="black") mdi-check
|
||||
.text-grey.text-uppercase.streak-day-label
|
||||
| {{ getDayLabel(day.date) }}
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import DashboardWidget from './DashboardWidget.vue';
|
||||
|
||||
const props = defineProps({
|
||||
streak: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({
|
||||
current: 0,
|
||||
history: [],
|
||||
shield: { ready: false, cooldown: 0 }
|
||||
})
|
||||
}
|
||||
streak: {
|
||||
type: Object,
|
||||
default: () => ({ current: 0, history: [], shield: { ready: false, cooldown: 0 } }),
|
||||
},
|
||||
});
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const shieldTooltip = computed(() => {
|
||||
const shield = props.streak?.shield;
|
||||
if (shield?.ready) return t('stats.shieldActive');
|
||||
return t('stats.shieldCooldown', { n: shield?.cooldown || 0 });
|
||||
});
|
||||
const { locale } = useI18n();
|
||||
|
||||
const getDayLabel = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
return new Date(dateStr).toLocaleDateString(locale.value, { weekday: 'short' });
|
||||
if (!dateStr) return '';
|
||||
return new Date(dateStr).toLocaleDateString(locale.value, { weekday: 'short' });
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -3,11 +3,24 @@
|
||||
.text-h2.font-weight-bold.mb-2 {{ $t('hero.welcome') }}
|
||||
.text-h5.text-grey.mb-8 {{ $t('hero.subtitle') }}
|
||||
|
||||
.d-flex.justify-center.align-center.flex-column
|
||||
v-btn.text-h5.font-weight-bold.text-black.glow-btn(
|
||||
.d-flex.justify-center.align-center.flex-column.gap-4
|
||||
v-btn.text-h5.font-weight-bold.text-black.glow-btn.welcome-btn(
|
||||
v-if="lessonCount > 0"
|
||||
to="/lesson"
|
||||
rounded="xl"
|
||||
color="purple-accent-2"
|
||||
class="mb-3"
|
||||
)
|
||||
v-icon(size="32" start) mdi-school
|
||||
| {{ $t('hero.lessons') }}
|
||||
v-chip.ml-3.font-weight-bold(
|
||||
color="#1e1e24"
|
||||
variant="flat"
|
||||
size="default"
|
||||
style="color: white !important;"
|
||||
) {{ lessonCount }}
|
||||
v-btn.text-h5.font-weight-bold.text-black.glow-btn.welcome-btn(
|
||||
@click="$emit('start', 'shuffled')"
|
||||
height="80"
|
||||
width="280"
|
||||
rounded="xl"
|
||||
color="#00cec9"
|
||||
:disabled="queueLength === 0"
|
||||
@@ -38,39 +51,25 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
queueLength: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
hasLowerLevels: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
lowerLevelCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
forecast: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
queueLength: { type: Number, required: true },
|
||||
lessonCount: { type: Number, default: 0 },
|
||||
hasLowerLevels: { type: Boolean, default: false },
|
||||
lowerLevelCount: { type: Number, default: 0 },
|
||||
forecast: { type: Object, default: () => ({}) },
|
||||
});
|
||||
defineEmits(['start']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const nextReviewTime = computed(() => {
|
||||
if (!props.forecast) return "a while";
|
||||
|
||||
const idx = props.forecast.findIndex(c => c > 0);
|
||||
|
||||
if (idx === -1) return "a while";
|
||||
return idx === 0 ? t('hero.now') : t('stats.inHours', { n: idx }, idx);
|
||||
if (!props.forecast) return 'a while';
|
||||
const idx = props.forecast.findIndex((c) => c > 0);
|
||||
if (idx === -1) return 'a while';
|
||||
return idx === 0 ? t('hero.now') : t('stats.inHours', { n: idx }, idx);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/components/_buttons.scss" scoped></style>
|
||||
|
||||
@@ -1,247 +1,140 @@
|
||||
<template lang="pug">
|
||||
.canvas-container
|
||||
.loading-text(v-if="loading") {{ $t('review.loading') }}
|
||||
|
||||
.canvas-wrapper(ref="wrapper")
|
||||
canvas(
|
||||
ref="bgCanvas"
|
||||
:width="CANVAS_SIZE"
|
||||
:height="CANVAS_SIZE"
|
||||
)
|
||||
canvas(
|
||||
ref="snapCanvas"
|
||||
:width="CANVAS_SIZE"
|
||||
:height="CANVAS_SIZE"
|
||||
)
|
||||
.canvas-container(
|
||||
style="touch-action: none; user-select: none; -webkit-user-select: none; overscroll-behavior: none;"
|
||||
)
|
||||
.canvas-wrapper(
|
||||
ref="wrapper"
|
||||
:class="{ 'shake': isShaking }"
|
||||
:style="{ width: size + 'px', height: size + 'px', touchAction: 'none', userSelect: 'none', overscrollBehavior: 'none' }"
|
||||
)
|
||||
canvas(ref="bgCanvas")
|
||||
canvas(ref="hintCanvas")
|
||||
canvas(
|
||||
ref="drawCanvas"
|
||||
:width="CANVAS_SIZE"
|
||||
:height="CANVAS_SIZE"
|
||||
@mousedown="startDraw"
|
||||
@mousemove="draw"
|
||||
@mouseup="endDraw"
|
||||
@mouseleave="endDraw"
|
||||
@touchstart.prevent="startDraw"
|
||||
@touchmove.prevent="draw"
|
||||
@touchend.prevent="endDraw"
|
||||
style="touch-action: none; user-select: none; -webkit-user-select: none; overscroll-behavior: none;"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
@pointerleave="handlePointerUp"
|
||||
@pointercancel="handlePointerUp"
|
||||
@touchstart.prevent.stop
|
||||
@touchmove.prevent.stop
|
||||
@touchend.prevent.stop
|
||||
@touchcancel.prevent.stop
|
||||
@contextmenu.prevent
|
||||
)
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
import {
|
||||
ref, onMounted, watch, onBeforeUnmount,
|
||||
} from 'vue';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import { KanjiController } from '@/utils/KanjiController';
|
||||
|
||||
const props = defineProps({
|
||||
char: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
const emit = defineEmits(['complete', 'mistake']);
|
||||
const store = useAppStore();
|
||||
|
||||
const KANJI_SIZE = 109;
|
||||
const CANVAS_SIZE = 300;
|
||||
const SCALE = CANVAS_SIZE / KANJI_SIZE;
|
||||
const props = defineProps({
|
||||
char: String,
|
||||
autoHint: Boolean,
|
||||
size: { type: Number, default: 300 },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['complete', 'mistake']);
|
||||
|
||||
const wrapper = ref(null);
|
||||
const bgCanvas = ref(null);
|
||||
const snapCanvas = ref(null);
|
||||
const hintCanvas = ref(null);
|
||||
const drawCanvas = ref(null);
|
||||
let ctxBg, ctxSnap, ctxDraw;
|
||||
const isShaking = ref(false);
|
||||
|
||||
const kanjiPaths = ref([]);
|
||||
const currentStrokeIndex = ref(0);
|
||||
const failureCount = ref(0);
|
||||
const loading = ref(false);
|
||||
let isDrawing = false;
|
||||
let userPath = [];
|
||||
let controller = null;
|
||||
|
||||
function getPoint(e) {
|
||||
if (!drawCanvas.value) return { x: 0, y: 0 };
|
||||
const rect = drawCanvas.value.getBoundingClientRect();
|
||||
return {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
};
|
||||
}
|
||||
|
||||
function handlePointerDown(e) {
|
||||
if (!controller) return;
|
||||
if (e.cancelable) e.preventDefault();
|
||||
|
||||
e.target.setPointerCapture(e.pointerId);
|
||||
|
||||
controller.startStroke(getPoint(e));
|
||||
}
|
||||
|
||||
function handlePointerMove(e) {
|
||||
if (!controller) return;
|
||||
if (e.cancelable) e.preventDefault();
|
||||
controller.moveStroke(getPoint(e));
|
||||
}
|
||||
|
||||
function handlePointerUp(e) {
|
||||
if (!controller) return;
|
||||
if (e.cancelable) e.preventDefault();
|
||||
|
||||
e.target.releasePointerCapture(e.pointerId);
|
||||
|
||||
controller.endStroke();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initContexts();
|
||||
if (props.char) loadKanji(props.char);
|
||||
controller = new KanjiController({
|
||||
size: props.size,
|
||||
accuracy: store.drawingAccuracy,
|
||||
onComplete: () => emit('complete'),
|
||||
onMistake: (needsHint) => {
|
||||
isShaking.value = true;
|
||||
setTimeout(() => { isShaking.value = false; }, 400);
|
||||
emit('mistake', needsHint);
|
||||
},
|
||||
});
|
||||
|
||||
if (bgCanvas.value && hintCanvas.value && drawCanvas.value) {
|
||||
controller.mount({
|
||||
bg: bgCanvas.value,
|
||||
hint: hintCanvas.value,
|
||||
draw: drawCanvas.value,
|
||||
});
|
||||
}
|
||||
|
||||
if (props.char) {
|
||||
controller.loadChar(props.char, props.autoHint);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
controller = null;
|
||||
});
|
||||
|
||||
watch(() => props.char, (newChar) => {
|
||||
if (newChar) loadKanji(newChar);
|
||||
if (controller && newChar) {
|
||||
controller.loadChar(newChar, props.autoHint);
|
||||
}
|
||||
});
|
||||
|
||||
function initContexts() {
|
||||
ctxBg = bgCanvas.value.getContext('2d');
|
||||
ctxSnap = snapCanvas.value.getContext('2d');
|
||||
ctxDraw = drawCanvas.value.getContext('2d');
|
||||
watch(() => props.autoHint, (shouldHint) => {
|
||||
if (!controller) return;
|
||||
if (shouldHint) controller.showHint();
|
||||
});
|
||||
|
||||
[ctxBg, ctxSnap, ctxDraw].forEach(ctx => {
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.scale(SCALE, SCALE);
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
});
|
||||
}
|
||||
watch(() => props.size, (newSize) => {
|
||||
if (controller) controller.resize(newSize);
|
||||
});
|
||||
|
||||
async function loadKanji(char) {
|
||||
reset();
|
||||
loading.value = true;
|
||||
const hex = char.charCodeAt(0).toString(16).padStart(5, '0');
|
||||
watch(() => store.drawingAccuracy, (newVal) => {
|
||||
if (controller) controller.setAccuracy(newVal);
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://raw.githubusercontent.com/KanjiVG/kanjivg/master/kanji/${hex}.svg`);
|
||||
const txt = await res.text();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(txt, "image/svg+xml");
|
||||
kanjiPaths.value = Array.from(doc.getElementsByTagName("path")).map(p => p.getAttribute("d"));
|
||||
|
||||
drawGuide();
|
||||
} catch (e) {
|
||||
console.error("Failed to load KanjiVG data", e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
currentStrokeIndex.value = 0;
|
||||
failureCount.value = 0;
|
||||
kanjiPaths.value = [];
|
||||
|
||||
[ctxBg, ctxSnap, ctxDraw].forEach(ctx => {
|
||||
ctx.clearRect(0, 0, KANJI_SIZE, KANJI_SIZE);
|
||||
});
|
||||
}
|
||||
|
||||
function getCoords(e) {
|
||||
const rect = drawCanvas.value.getBoundingClientRect();
|
||||
const cx = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const cy = e.touches ? e.touches[0].clientY : e.clientY;
|
||||
|
||||
return {
|
||||
x: (cx - rect.left) / SCALE,
|
||||
y: (cy - rect.top) / SCALE
|
||||
};
|
||||
}
|
||||
|
||||
function startDraw(e) {
|
||||
if (currentStrokeIndex.value >= kanjiPaths.value.length) return;
|
||||
isDrawing = true;
|
||||
userPath = [];
|
||||
|
||||
const p = getCoords(e);
|
||||
userPath.push(p);
|
||||
|
||||
ctxDraw.beginPath();
|
||||
ctxDraw.moveTo(p.x, p.y);
|
||||
ctxDraw.strokeStyle = '#ff7675';
|
||||
ctxDraw.lineWidth = 4;
|
||||
}
|
||||
|
||||
function draw(e) {
|
||||
if (!isDrawing) return;
|
||||
const p = getCoords(e);
|
||||
userPath.push(p);
|
||||
ctxDraw.lineTo(p.x, p.y);
|
||||
ctxDraw.stroke();
|
||||
}
|
||||
|
||||
function endDraw() {
|
||||
if (!isDrawing) return;
|
||||
isDrawing = false;
|
||||
|
||||
const targetD = kanjiPaths.value[currentStrokeIndex.value];
|
||||
|
||||
if (checkMatch(userPath, targetD)) {
|
||||
ctxSnap.strokeStyle = '#00cec9';
|
||||
ctxSnap.lineWidth = 4;
|
||||
ctxSnap.stroke(new Path2D(targetD));
|
||||
|
||||
currentStrokeIndex.value++;
|
||||
failureCount.value = 0;
|
||||
ctxDraw.clearRect(0, 0, KANJI_SIZE, KANJI_SIZE);
|
||||
|
||||
if (currentStrokeIndex.value >= kanjiPaths.value.length) {
|
||||
emit('complete');
|
||||
} else {
|
||||
drawGuide();
|
||||
}
|
||||
} else {
|
||||
failureCount.value++;
|
||||
ctxDraw.clearRect(0, 0, KANJI_SIZE, KANJI_SIZE);
|
||||
|
||||
if (failureCount.value >= 3) {
|
||||
drawGuide(true);
|
||||
emit('mistake', true);
|
||||
} else {
|
||||
emit('mistake', false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkMatch(userPts, targetD) {
|
||||
if (userPts.length < 5) return false;
|
||||
|
||||
const tempPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
tempPath.setAttribute("d", targetD);
|
||||
const len = tempPath.getTotalLength();
|
||||
const targetEnd = tempPath.getPointAtLength(len);
|
||||
const userEnd = userPts[userPts.length - 1];
|
||||
|
||||
const threshold = store.drawingAccuracy || 10;
|
||||
|
||||
const dist = (p1, p2) => Math.hypot(p1.x - p2.x, p1.y - p2.y);
|
||||
if (dist(userEnd, targetEnd) > threshold * 3.0) return false;
|
||||
|
||||
let totalError = 0;
|
||||
const samples = 10;
|
||||
for (let i = 0; i <= samples; i++) {
|
||||
const pt = tempPath.getPointAtLength((i / samples) * len);
|
||||
let min = Infinity;
|
||||
for (let p of userPts) min = Math.min(min, dist(pt, p));
|
||||
totalError += min;
|
||||
}
|
||||
return (totalError / (samples + 1)) < threshold;
|
||||
}
|
||||
|
||||
function drawGuide(showHint = false) {
|
||||
ctxBg.clearRect(0, 0, KANJI_SIZE, KANJI_SIZE);
|
||||
|
||||
if (!showHint) return;
|
||||
|
||||
const d = kanjiPaths.value[currentStrokeIndex.value];
|
||||
if (!d) return;
|
||||
|
||||
ctxBg.strokeStyle = '#57606f';
|
||||
ctxBg.lineWidth = 3;
|
||||
ctxBg.setLineDash([5, 5]);
|
||||
ctxBg.stroke(new Path2D(d));
|
||||
ctxBg.setLineDash([]);
|
||||
|
||||
const tempPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
tempPath.setAttribute("d", d);
|
||||
const len = tempPath.getTotalLength();
|
||||
const mid = tempPath.getPointAtLength(len / 2);
|
||||
const prev = tempPath.getPointAtLength(Math.max(0, (len / 2) - 1));
|
||||
const angle = Math.atan2(mid.y - prev.y, mid.x - prev.x);
|
||||
|
||||
ctxBg.save();
|
||||
ctxBg.translate(mid.x, mid.y);
|
||||
ctxBg.rotate(angle);
|
||||
|
||||
ctxBg.strokeStyle = 'rgba(255, 234, 167, 0.7)';
|
||||
ctxBg.lineWidth = 2;
|
||||
ctxBg.lineCap = 'round';
|
||||
ctxBg.lineJoin = 'round';
|
||||
|
||||
ctxBg.beginPath();
|
||||
ctxBg.moveTo(-7, 0);
|
||||
ctxBg.lineTo(2, 0);
|
||||
ctxBg.moveTo(-1, -3);
|
||||
ctxBg.lineTo(2, 0);
|
||||
ctxBg.lineTo(-1, 3);
|
||||
ctxBg.stroke();
|
||||
|
||||
ctxBg.restore();
|
||||
}
|
||||
|
||||
defineExpose({ drawGuide });
|
||||
defineExpose({
|
||||
reset: () => controller?.reset(),
|
||||
showHint: () => controller?.showHint(),
|
||||
drawGuide: () => controller?.showHint(),
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/components/_kanji.scss" scoped></style>
|
||||
|
||||
@@ -1,138 +1,151 @@
|
||||
<template lang="pug">
|
||||
.svg-container(:class="{ loading: loading }")
|
||||
.svg-container(
|
||||
:class="{ 'canvas-mode': mode === 'animate', 'hero-mode': mode === 'hero' }"
|
||||
)
|
||||
svg.kanji-svg(
|
||||
v-if="!loading"
|
||||
v-show="!loading"
|
||||
viewBox="0 0 109 109"
|
||||
width="100%"
|
||||
height="100%"
|
||||
)
|
||||
g(v-if="mode === 'animate'")
|
||||
path.stroke-ghost(
|
||||
v-for="(stroke, i) in strokes"
|
||||
:key="'ghost-'+i"
|
||||
:d="stroke.d"
|
||||
)
|
||||
|
||||
g(v-for="(stroke, i) in strokes" :key="i")
|
||||
path.stroke-path(
|
||||
:d="stroke.d"
|
||||
:class="{\
|
||||
'animating': isPlaying && currentStrokeIdx === i,\
|
||||
'hidden': isPlaying && currentStrokeIdx < i,\
|
||||
'drawn': isPlaying && currentStrokeIdx > i\
|
||||
}"
|
||||
:style="{\
|
||||
'--len': stroke.len,\
|
||||
'--duration': (stroke.len * 0.02) + 's'\
|
||||
}"
|
||||
:class="getStrokeClass(i)"
|
||||
:style="getStrokeStyle(stroke)"
|
||||
)
|
||||
|
||||
g(v-show="!isPlaying || currentStrokeIdx > i")
|
||||
circle.stroke-start-circle(
|
||||
v-if="stroke.start"
|
||||
:cx="stroke.start.x"
|
||||
:cy="stroke.start.y"
|
||||
r="3.5"
|
||||
)
|
||||
g(v-if="mode === 'animate' && (!isPlaying || currentStrokeIdx > -1)")
|
||||
g(v-for="(stroke, i) in strokes" :key="'anno-'+i")
|
||||
g(v-show="!isPlaying || currentStrokeIdx >= i")
|
||||
path.stroke-arrow-line(
|
||||
v-if="isPlaying && currentStrokeIdx === i && stroke.arrow"
|
||||
d="M -7 0 L 2 0 M -1 -3 L 2 0 L -1 3"
|
||||
:transform="getArrowTransform(stroke.arrow)"
|
||||
)
|
||||
|
||||
text.stroke-number(
|
||||
v-if="stroke.start"
|
||||
:x="stroke.start.x"
|
||||
:y="stroke.start.y + 0.5"
|
||||
) {{ i + 1 }}
|
||||
|
||||
path.stroke-arrow-line(
|
||||
v-if="stroke.arrow"
|
||||
d="M -7 0 L 2 0 M -1 -3 L 2 0 L -1 3"
|
||||
:transform="`translate(${stroke.arrow.x}, ${stroke.arrow.y}) rotate(${stroke.arrow.angle})`"
|
||||
)
|
||||
|
||||
.loading-spinner(v-else) {{ $t('review.loading') }}
|
||||
g.stroke-badge-group(
|
||||
v-if="stroke.start"
|
||||
:transform="`translate(${stroke.start.x}, ${stroke.start.y})`"
|
||||
)
|
||||
circle.stroke-badge-bg(r="4")
|
||||
text.stroke-badge-text(
|
||||
dy="0.5"
|
||||
) {{ i + 1 }}
|
||||
|
||||
button.play-btn(
|
||||
v-if="!loading && !isPlaying"
|
||||
@click="playAnimation"
|
||||
v-if="mode === 'animate' && !loading && !isPlaying"
|
||||
@click.stop="playAnimation"
|
||||
)
|
||||
svg(viewBox="0 0 24 24" fill="currentColor")
|
||||
path(d="M8 5v14l11-7z")
|
||||
path(d="M8,5.14V19.14L19,12.14L8,5.14Z")
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
import { ref, watch, onMounted } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
char: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
char: { type: String, required: true },
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'animate',
|
||||
validator: (v) => ['hero', 'animate'].includes(v),
|
||||
},
|
||||
});
|
||||
|
||||
const strokes = ref([]);
|
||||
const loading = ref(true);
|
||||
const isPlaying = ref(false);
|
||||
const currentStrokeIdx = ref(-1);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.char) loadData(props.char);
|
||||
});
|
||||
|
||||
watch(() => props.char, (newChar) => {
|
||||
if (newChar) loadData(newChar);
|
||||
});
|
||||
|
||||
async function loadData(char) {
|
||||
loading.value = true;
|
||||
isPlaying.value = false;
|
||||
strokes.value = [];
|
||||
loading.value = true;
|
||||
isPlaying.value = false;
|
||||
strokes.value = [];
|
||||
const hex = char.charCodeAt(0).toString(16).padStart(5, '0');
|
||||
|
||||
const hex = char.charCodeAt(0).toString(16).padStart(5, '0');
|
||||
try {
|
||||
const res = await fetch(`https://raw.githubusercontent.com/KanjiVG/kanjivg/master/kanji/${hex}.svg`);
|
||||
const txt = await res.text();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(txt, "image/svg+xml");
|
||||
try {
|
||||
const baseUrl = 'https://raw.githubusercontent.com/KanjiVG/kanjivg/master/kanji';
|
||||
const res = await fetch(`${baseUrl}/${hex}.svg`);
|
||||
|
||||
const rawPaths = Array.from(doc.getElementsByTagName("path")).map(p => p.getAttribute("d"));
|
||||
const txt = await res.text();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(txt, 'image/svg+xml');
|
||||
const rawPaths = Array.from(doc.getElementsByTagName('path')).map((p) => p.getAttribute('d'));
|
||||
|
||||
strokes.value = rawPaths.map(d => {
|
||||
const data = { d, start: null, arrow: null, len: 0 };
|
||||
strokes.value = rawPaths.map((d) => {
|
||||
const data = {
|
||||
d, start: null, arrow: null, len: 0, duration: 0,
|
||||
};
|
||||
const tempPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
tempPath.setAttribute('d', d);
|
||||
try { data.len = tempPath.getTotalLength(); } catch (e) { data.len = 100; }
|
||||
data.duration = Math.floor(data.len * 20);
|
||||
|
||||
const tempPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
tempPath.setAttribute("d", d);
|
||||
try {
|
||||
data.len = tempPath.getTotalLength();
|
||||
} catch (e) { data.len = 100; }
|
||||
const startMatch = d.match(/[Mm]\s*([\d.]+)[,\s]([\d.]+)/);
|
||||
if (startMatch) data.start = { x: parseFloat(startMatch[1]), y: parseFloat(startMatch[2]) };
|
||||
|
||||
const startMatch = d.match(/[Mm]\s*([\d.]+)[,\s]([\d.]+)/);
|
||||
if (startMatch) {
|
||||
data.start = { x: parseFloat(startMatch[1]), y: parseFloat(startMatch[2]) };
|
||||
}
|
||||
try {
|
||||
const mid = tempPath.getPointAtLength(data.len / 2);
|
||||
const prev = tempPath.getPointAtLength(Math.max(0, (data.len / 2) - 1));
|
||||
const angle = Math.atan2(mid.y - prev.y, mid.x - prev.x) * (180 / Math.PI);
|
||||
data.arrow = { x: mid.x, y: mid.y, angle };
|
||||
} catch (e) { console.error(e); }
|
||||
return data;
|
||||
});
|
||||
} catch (e) { console.error(e); } finally { loading.value = false; }
|
||||
}
|
||||
|
||||
try {
|
||||
const mid = tempPath.getPointAtLength(data.len / 2);
|
||||
const prev = tempPath.getPointAtLength(Math.max(0, (data.len / 2) - 1));
|
||||
const angle = Math.atan2(mid.y - prev.y, mid.x - prev.x) * (180 / Math.PI);
|
||||
data.arrow = { x: mid.x, y: mid.y, angle };
|
||||
} catch (e) { console.error(e) }
|
||||
function getArrowTransform(arrow) {
|
||||
if (!arrow) return '';
|
||||
return `translate(${arrow.x}, ${arrow.y}) rotate(${arrow.angle})`;
|
||||
}
|
||||
|
||||
return data;
|
||||
});
|
||||
function getStrokeClass(i) {
|
||||
if (props.mode === 'hero') return 'drawn';
|
||||
|
||||
} catch (e) {
|
||||
console.error("SVG Load Failed", e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
if (isPlaying.value) {
|
||||
if (currentStrokeIdx.value === i) return 'animating';
|
||||
if (currentStrokeIdx.value > i) return 'drawn';
|
||||
return 'hidden';
|
||||
}
|
||||
return 'drawn';
|
||||
}
|
||||
|
||||
function getStrokeStyle(stroke) {
|
||||
if (props.mode === 'hero') return {};
|
||||
return { '--len': stroke.len, '--duration': `${stroke.duration}ms` };
|
||||
}
|
||||
|
||||
async function playAnimation() {
|
||||
if (isPlaying.value) return;
|
||||
isPlaying.value = true;
|
||||
currentStrokeIdx.value = -1;
|
||||
if (isPlaying.value) return;
|
||||
isPlaying.value = true;
|
||||
currentStrokeIdx.value = -1;
|
||||
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
await new Promise((r) => { setTimeout(r, 200); });
|
||||
|
||||
for (let i = 0; i < strokes.value.length; i++) {
|
||||
currentStrokeIdx.value = i;
|
||||
const duration = strokes.value[i].len * 20;
|
||||
await new Promise(r => setTimeout(r, duration + 100));
|
||||
}
|
||||
for (let i = 0; i < strokes.value.length; i += 1) {
|
||||
currentStrokeIdx.value = i;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((r) => { setTimeout(r, strokes.value[i].duration); });
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((r) => { setTimeout(r, 100); });
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
isPlaying.value = false;
|
||||
await new Promise((r) => { setTimeout(r, 500); });
|
||||
isPlaying.value = false;
|
||||
}
|
||||
|
||||
defineExpose({ playAnimation });
|
||||
onMounted(() => { if (props.char) loadData(props.char); });
|
||||
watch(() => props.char, (n) => { if (n) loadData(n); });
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/components/_kanji.scss" scoped></style>
|
||||
|
||||
@@ -1,49 +1,58 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import i18n from '@/plugins/i18n'
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
|
||||
import '@/styles/main.scss'
|
||||
import '@/styles/main.scss';
|
||||
|
||||
import 'vuetify/styles'
|
||||
import { createVuetify } from 'vuetify'
|
||||
import * as components from 'vuetify/components'
|
||||
import * as directives from 'vuetify/directives'
|
||||
import { aliases, mdi } from 'vuetify/iconsets/mdi'
|
||||
import '@mdi/font/css/materialdesignicons.css'
|
||||
import 'vuetify/styles';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import * as components from 'vuetify/components';
|
||||
import * as directives from 'vuetify/directives';
|
||||
import { aliases, mdi } from 'vuetify/iconsets/mdi';
|
||||
import i18n from '@/plugins/i18n';
|
||||
import '@mdi/font/css/materialdesignicons.css';
|
||||
|
||||
import App from './App.vue'
|
||||
import Dashboard from './views/Dashboard.vue'
|
||||
import Collection from './views/Collection.vue'
|
||||
import Review from './views/Review.vue'
|
||||
import App from './App.vue';
|
||||
import Dashboard from './views/Dashboard.vue';
|
||||
import Collection from './views/Collection.vue';
|
||||
import Review from './views/Review.vue';
|
||||
import Lesson from './views/Lesson.vue';
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: Dashboard },
|
||||
{ path: '/dashboard', component: Dashboard },
|
||||
{ path: '/collection', component: Collection },
|
||||
{ path: '/review', component: Review }
|
||||
]
|
||||
})
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: Dashboard },
|
||||
{ path: '/dashboard', component: Dashboard },
|
||||
{ path: '/collection', component: Collection },
|
||||
{ path: '/review', component: Review },
|
||||
{ path: '/lesson', component: Lesson },
|
||||
],
|
||||
});
|
||||
|
||||
const vuetify = createVuetify({
|
||||
components,
|
||||
directives,
|
||||
theme: {
|
||||
defaultTheme: 'dark',
|
||||
themes: {
|
||||
dark: { colors: { primary: '#00cec9', secondary: '#ffeaa7' } }
|
||||
}
|
||||
},
|
||||
icons: { defaultSet: 'mdi', aliases, sets: { mdi } }
|
||||
})
|
||||
components,
|
||||
directives,
|
||||
theme: {
|
||||
defaultTheme: 'dark',
|
||||
themes: {
|
||||
dark: {
|
||||
colors: {
|
||||
primary: '#00cec9',
|
||||
secondary: '#ffeaa7',
|
||||
surface: '#1e1e24',
|
||||
background: '#121212',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
icons: { defaultSet: 'mdi', aliases, sets: { mdi } },
|
||||
});
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(vuetify)
|
||||
app.use(i18n)
|
||||
app.mount('#app')
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
app.use(vuetify);
|
||||
app.use(i18n);
|
||||
app.mount('#app');
|
||||
|
||||
@@ -1,306 +1,396 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { createI18n } from 'vue-i18n';
|
||||
|
||||
const messages = {
|
||||
en: {
|
||||
common: {
|
||||
close: "Close",
|
||||
cancel: "Cancel"
|
||||
},
|
||||
nav: {
|
||||
dashboard: "Dashboard",
|
||||
review: "Review",
|
||||
collection: "Collection",
|
||||
settings: "Settings",
|
||||
sync: "Sync",
|
||||
logout: "Logout",
|
||||
menu: "Menu"
|
||||
},
|
||||
login: {
|
||||
instruction: "Enter your WaniKani V2 API Key to login",
|
||||
placeholder: "Paste key here...",
|
||||
button: "Login",
|
||||
failed: "Login failed. Is server running?"
|
||||
},
|
||||
alerts: {
|
||||
syncSuccess: "Sync complete! Collection: {count}",
|
||||
syncFailed: "Sync failed.",
|
||||
logoutConfirm: "Are you sure you want to log out? This will end your session.",
|
||||
},
|
||||
hero: {
|
||||
welcome: "Welcome Back",
|
||||
subtitle: "Your mind is ready.",
|
||||
start: "Start Review",
|
||||
noReviews: "No Reviews",
|
||||
nextIn: "Next review in",
|
||||
now: "now",
|
||||
prioritize: "Prioritize Lower Levels ({count})"
|
||||
},
|
||||
stats: {
|
||||
mastery: "Mastery (Guru+)",
|
||||
srsDistribution: "SRS Levels",
|
||||
accuracy: "Global Accuracy",
|
||||
correct: "Correct",
|
||||
total: "Total",
|
||||
next24: "Next 24h",
|
||||
availableNow: "Available Now",
|
||||
inHours: "In {n} hour | In {n} hours",
|
||||
noIncoming: "No reviews incoming for 24 hours.",
|
||||
items: "items",
|
||||
reviewsCount: "{count} reviews",
|
||||
en: {
|
||||
common: {
|
||||
close: 'Close',
|
||||
cancel: 'Cancel',
|
||||
},
|
||||
nav: {
|
||||
dashboard: 'Dashboard',
|
||||
review: 'Review',
|
||||
collection: 'Collection',
|
||||
settings: 'Settings',
|
||||
sync: 'Sync',
|
||||
logout: 'Logout',
|
||||
menu: 'Menu',
|
||||
},
|
||||
login: {
|
||||
instruction: 'Enter your WaniKani V2 API Key to login',
|
||||
placeholder: 'Paste key here...',
|
||||
button: 'Login',
|
||||
failed: 'Login failed. Is server running?',
|
||||
},
|
||||
alerts: {
|
||||
syncSuccess: 'Sync complete! Collection: {count}',
|
||||
syncFailed: 'Sync failed.',
|
||||
logoutConfirm: 'Are you sure you want to log out? This will end your session.',
|
||||
},
|
||||
lesson: {
|
||||
phasePrimer: 'Study',
|
||||
phaseDemo: 'Observation',
|
||||
phaseGuided: 'Guided',
|
||||
phasePractice: 'Recall',
|
||||
understand: 'I Understand',
|
||||
ready: 'Ready to Draw',
|
||||
startPractice: 'Start Practice',
|
||||
continue: 'Continue',
|
||||
streak: 'Correct: {n} / {total}',
|
||||
hint: 'Show Hint (Resets Streak)',
|
||||
hintAction: 'Hint',
|
||||
watchAgain: 'Watch Again',
|
||||
completeTitle: 'Lesson Complete!',
|
||||
completeBody: 'You have unlocked new Kanji for review.',
|
||||
learned: "You've learned {n} new kanji.",
|
||||
components: 'Components',
|
||||
observe: 'Observe Stroke Order',
|
||||
trace: 'Trace',
|
||||
drawStep: 'Draw ({n}/{total})',
|
||||
backToDashboard: 'Back to Dashboard',
|
||||
},
|
||||
hero: {
|
||||
lessons: 'Lessons',
|
||||
welcome: 'Welcome Back',
|
||||
subtitle: 'Your mind is ready.',
|
||||
start: 'Start Review',
|
||||
noReviews: 'No Reviews',
|
||||
nextIn: 'Next review in',
|
||||
now: 'now',
|
||||
prioritize: 'Prioritize Lower Levels ({count})',
|
||||
},
|
||||
stats: {
|
||||
mastery: 'Mastery (Guru+)',
|
||||
srsDistribution: 'SRS Levels',
|
||||
accuracy: 'Global Accuracy',
|
||||
correct: 'Correct',
|
||||
total: 'Total',
|
||||
next24: 'Next 24h',
|
||||
availableNow: 'Available Now',
|
||||
inHours: 'In {n} hour | In {n} hours',
|
||||
noIncoming: 'No reviews incoming for 24 hours.',
|
||||
items: 'items',
|
||||
reviewsCount: '{count} reviews',
|
||||
|
||||
consistency: "Study Consistency",
|
||||
less: "Less",
|
||||
more: "More",
|
||||
consistency: 'Study Consistency',
|
||||
less: 'Less',
|
||||
more: 'More',
|
||||
|
||||
streakTitle: "Study Streak",
|
||||
days: "days",
|
||||
shieldActive: "Zen Shield Active: Protects streak if you miss 1 day.",
|
||||
shieldCooldown: "Regenerating: {n} days left",
|
||||
streakTitle: 'Study Streak',
|
||||
days: 'days',
|
||||
shieldActive: 'Zen Shield Active: Protects streak if you miss 1 day.',
|
||||
shieldCooldown: 'Regenerating: {n} days left',
|
||||
|
||||
ghostTitle: "Ghost Items",
|
||||
ghostSubtitle: "Lowest Accuracy",
|
||||
noGhosts: "No ghosts found! Keep it up."
|
||||
},
|
||||
settings: {
|
||||
title: "Settings",
|
||||
batchSize: "Review Batch Size",
|
||||
items: "Items",
|
||||
language: "Language",
|
||||
save: "Save & Close"
|
||||
},
|
||||
review: {
|
||||
meaning: "Meaning",
|
||||
level: "Level",
|
||||
draw: "Draw correctly",
|
||||
hint: "Hint Shown",
|
||||
tryAgain: "Try again",
|
||||
correct: "Correct!",
|
||||
next: "NEXT",
|
||||
sessionComplete: "Session Complete!",
|
||||
levelup: "You leveled up your Kanji skills.",
|
||||
back: "Back to Collection",
|
||||
caughtUp: "All Caught Up!",
|
||||
noReviews: "No reviews available right now.",
|
||||
viewCollection: "View Collection",
|
||||
queue: "Session queue:",
|
||||
loading: "Loading Kanji...",
|
||||
},
|
||||
collection: {
|
||||
searchLabel: "Search Kanji, Meaning, or Reading...",
|
||||
placeholder: "e.g. 'water', 'mizu', '水'",
|
||||
loading: "Loading Collection...",
|
||||
noMatches: "No matches found",
|
||||
tryDifferent: "Try searching for a different meaning or reading.",
|
||||
levelHeader: "LEVEL",
|
||||
onyomi: "On'yomi",
|
||||
kunyomi: "Kun'yomi",
|
||||
nanori: "Nanori",
|
||||
close: "Close"
|
||||
}
|
||||
},
|
||||
de: {
|
||||
common: {
|
||||
close: "Schließen",
|
||||
cancel: "Abbrechen"
|
||||
},
|
||||
nav: {
|
||||
dashboard: "Übersicht",
|
||||
review: "Lernen",
|
||||
collection: "Sammlung",
|
||||
settings: "Einstellungen",
|
||||
sync: "Sync",
|
||||
logout: "Abmelden",
|
||||
menu: "Menü"
|
||||
},
|
||||
login: {
|
||||
instruction: "Gib deinen WaniKani V2 API Key ein",
|
||||
placeholder: "Key hier einfügen...",
|
||||
button: "Anmelden",
|
||||
failed: "Login fehlgeschlagen. Läuft der Server?"
|
||||
},
|
||||
alerts: {
|
||||
syncSuccess: "Sync fertig! Sammlung: {count}",
|
||||
syncFailed: "Sync fehlgeschlagen.",
|
||||
logoutConfirm: "Möchtest du dich wirklich abmelden? Deine Sitzung wird beendet.",
|
||||
},
|
||||
hero: {
|
||||
welcome: "Willkommen zurück",
|
||||
subtitle: "Dein Geist ist bereit.",
|
||||
start: "Starten",
|
||||
noReviews: "Alles erledigt",
|
||||
nextIn: "Nächste Review in",
|
||||
now: "jetzt",
|
||||
prioritize: "Niedrige Stufen zuerst ({count})"
|
||||
},
|
||||
stats: {
|
||||
mastery: "Meisterschaft (Guru+)",
|
||||
srsDistribution: "SRS Verteilung",
|
||||
accuracy: "Genauigkeit",
|
||||
correct: "Richtig",
|
||||
total: "Gesamt",
|
||||
next24: "Nächste 24h",
|
||||
availableNow: "Jetzt verfügbar",
|
||||
inHours: "In {n} Stunde | In {n} Stunden",
|
||||
noIncoming: "Keine Reviews in den nächsten 24h.",
|
||||
items: "Einträge",
|
||||
reviewsCount: "{count} Reviews",
|
||||
ghostTitle: 'Ghost Items',
|
||||
ghostSubtitle: 'Lowest Accuracy',
|
||||
noGhosts: 'No ghosts found! Keep it up.',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
batchSize: 'Review Batch Size',
|
||||
items: 'Items',
|
||||
language: 'Language',
|
||||
drawingTolerance: 'Drawing Tolerance',
|
||||
strict: 'Strict',
|
||||
loose: 'Loose',
|
||||
save: 'Save & Close',
|
||||
},
|
||||
review: {
|
||||
meaning: 'Meaning',
|
||||
level: 'Level',
|
||||
draw: 'Draw correctly',
|
||||
hint: 'Hint Shown',
|
||||
showHint: 'Show Hint',
|
||||
redoLesson: 'Redo Lesson',
|
||||
tryAgain: 'Try again',
|
||||
correct: 'Correct!',
|
||||
next: 'NEXT',
|
||||
sessionComplete: 'Session Complete!',
|
||||
levelup: 'You leveled up your Kanji skills.',
|
||||
back: 'Back to Collection',
|
||||
caughtUp: 'All Caught Up!',
|
||||
noReviews: 'No reviews available right now.',
|
||||
viewCollection: 'View Collection',
|
||||
queue: 'Session queue:',
|
||||
loading: 'Loading Kanji...',
|
||||
},
|
||||
collection: {
|
||||
searchLabel: 'Search Kanji, Meaning, or Reading...',
|
||||
placeholder: "e.g. 'water', 'mizu', '水'",
|
||||
loading: 'Loading Collection...',
|
||||
noMatches: 'No matches found',
|
||||
tryDifferent: 'Try searching for a different meaning or reading.',
|
||||
levelHeader: 'LEVEL',
|
||||
onyomi: "On'yomi",
|
||||
kunyomi: "Kun'yomi",
|
||||
nanori: 'Nanori',
|
||||
close: 'Close',
|
||||
startLesson: 'Start Lesson',
|
||||
redoLesson: 'Redo Lesson',
|
||||
},
|
||||
},
|
||||
de: {
|
||||
common: {
|
||||
close: 'Schließen',
|
||||
cancel: 'Abbrechen',
|
||||
},
|
||||
nav: {
|
||||
dashboard: 'Übersicht',
|
||||
review: 'Lernen',
|
||||
collection: 'Sammlung',
|
||||
settings: 'Einstellungen',
|
||||
sync: 'Sync',
|
||||
logout: 'Abmelden',
|
||||
menu: 'Menü',
|
||||
},
|
||||
login: {
|
||||
instruction: 'Gib deinen WaniKani V2 API Key ein',
|
||||
placeholder: 'Key hier einfügen...',
|
||||
button: 'Anmelden',
|
||||
failed: 'Login fehlgeschlagen. Läuft der Server?',
|
||||
},
|
||||
alerts: {
|
||||
syncSuccess: 'Sync fertig! Sammlung: {count}',
|
||||
syncFailed: 'Sync fehlgeschlagen.',
|
||||
logoutConfirm: 'Möchtest du dich wirklich abmelden? Deine Sitzung wird beendet.',
|
||||
},
|
||||
lesson: {
|
||||
phasePrimer: 'Lernen',
|
||||
phaseDemo: 'Beobachtung',
|
||||
phaseGuided: 'Geführt',
|
||||
phasePractice: 'Abruf',
|
||||
understand: 'Verstanden',
|
||||
ready: 'Bereit zum Zeichnen',
|
||||
startPractice: 'Üben Starten',
|
||||
continue: 'Weiter',
|
||||
streak: 'Richtig: {n} / {total}',
|
||||
hint: 'Hinweis (Setzt Serie zurück)',
|
||||
hintAction: 'Hinweis',
|
||||
watchAgain: 'Nochmal ansehen',
|
||||
completeTitle: 'Lektion Fertig!',
|
||||
completeBody: 'Neue Kanji für Reviews freigeschaltet.',
|
||||
learned: 'Du hast {n} neue Kanji gelernt.',
|
||||
components: 'Komponenten',
|
||||
observe: 'Strichfolge beobachten',
|
||||
trace: 'Nachzeichnen',
|
||||
drawStep: 'Zeichnen ({n}/{total})',
|
||||
backToDashboard: 'Zurück zur Übersicht',
|
||||
},
|
||||
hero: {
|
||||
lessons: 'Lektionen',
|
||||
welcome: 'Willkommen zurück',
|
||||
subtitle: 'Dein Geist ist bereit.',
|
||||
start: 'Starten',
|
||||
noReviews: 'Alles erledigt',
|
||||
nextIn: 'Nächste Review in',
|
||||
now: 'jetzt',
|
||||
prioritize: 'Niedrige Stufen zuerst ({count})',
|
||||
},
|
||||
stats: {
|
||||
mastery: 'Meisterschaft (Guru+)',
|
||||
srsDistribution: 'SRS Verteilung',
|
||||
accuracy: 'Genauigkeit',
|
||||
correct: 'Richtig',
|
||||
total: 'Gesamt',
|
||||
next24: 'Nächste 24h',
|
||||
availableNow: 'Jetzt verfügbar',
|
||||
inHours: 'In {n} Stunde | In {n} Stunden',
|
||||
noIncoming: 'Keine Reviews in den nächsten 24h.',
|
||||
items: 'Einträge',
|
||||
reviewsCount: '{count} Reviews',
|
||||
|
||||
consistency: "Lern-Konstanz",
|
||||
less: "Weniger",
|
||||
more: "Mehr",
|
||||
consistency: 'Lern-Konstanz',
|
||||
less: 'Weniger',
|
||||
more: 'Mehr',
|
||||
|
||||
streakTitle: "Lern-Serie",
|
||||
days: "Tage",
|
||||
shieldActive: "Zen-Schild Aktiv: Schützt dich bei einem verpassten Tag.",
|
||||
shieldCooldown: "Regeneriert: noch {n} Tage",
|
||||
streakTitle: 'Lern-Serie',
|
||||
days: 'Tage',
|
||||
shieldActive: 'Zen-Schild Aktiv: Schützt dich bei einem verpassten Tag.',
|
||||
shieldCooldown: 'Regeneriert: noch {n} Tage',
|
||||
|
||||
ghostTitle: "Geister-Items",
|
||||
ghostSubtitle: "Niedrigste Genauigkeit",
|
||||
noGhosts: "Keine Geister gefunden! Weiter so."
|
||||
},
|
||||
settings: {
|
||||
title: "Einstellungen",
|
||||
batchSize: "Anzahl pro Sitzung",
|
||||
items: "Einträge",
|
||||
language: "Sprache",
|
||||
save: "Speichern & Schließen"
|
||||
},
|
||||
review: {
|
||||
meaning: "Bedeutung",
|
||||
level: "Stufe",
|
||||
draw: "Zeichne das Kanji",
|
||||
hint: "Hinweis angezeigt",
|
||||
tryAgain: "Nochmal versuchen",
|
||||
correct: "Richtig!",
|
||||
next: "WEITER",
|
||||
sessionComplete: "Sitzung beendet!",
|
||||
levelup: "Du hast deine Kanji-Skills verbessert.",
|
||||
back: "Zurück zur Sammlung",
|
||||
caughtUp: "Alles erledigt!",
|
||||
noReviews: "Gerade keine Reviews verfügbar.",
|
||||
viewCollection: "Zur Sammlung",
|
||||
queue: "Verbleibend:",
|
||||
loading: "Lade Kanji...",
|
||||
},
|
||||
collection: {
|
||||
searchLabel: "Suche Kanji, Bedeutung oder Lesung...",
|
||||
placeholder: "z.B. 'Wasser', 'mizu'",
|
||||
loading: "Lade Sammlung...",
|
||||
noMatches: "Keine Treffer",
|
||||
tryDifferent: "Versuche einen anderen Suchbegriff.",
|
||||
levelHeader: "STUFE",
|
||||
onyomi: "On'yomi",
|
||||
kunyomi: "Kun'yomi",
|
||||
nanori: "Nanori",
|
||||
close: "Schließen"
|
||||
}
|
||||
},
|
||||
ja: {
|
||||
common: {
|
||||
close: "閉じる",
|
||||
cancel: "キャンセル"
|
||||
},
|
||||
nav: {
|
||||
dashboard: "ダッシュボード",
|
||||
review: "復習",
|
||||
collection: "コレクション",
|
||||
settings: "設定",
|
||||
sync: "同期",
|
||||
logout: "ログアウト",
|
||||
menu: "メニュー"
|
||||
},
|
||||
login: {
|
||||
instruction: "WaniKani V2 APIキーを入力してください",
|
||||
placeholder: "キーを貼り付け...",
|
||||
button: "ログイン",
|
||||
failed: "ログイン失敗。サーバーは起動していますか?"
|
||||
},
|
||||
alerts: {
|
||||
syncSuccess: "同期完了! コレクション: {count}",
|
||||
syncFailed: "同期に失敗しました。",
|
||||
logoutConfirm: "ログアウトしてもよろしいですか?セッションが終了します。",
|
||||
},
|
||||
hero: {
|
||||
welcome: "お帰りなさい",
|
||||
subtitle: "準備は完了です。",
|
||||
start: "復習開始",
|
||||
noReviews: "レビューなし",
|
||||
nextIn: "次の復習まで",
|
||||
now: "今",
|
||||
prioritize: "低レベルを優先 ({count})"
|
||||
},
|
||||
stats: {
|
||||
mastery: "習得度 (Guru+)",
|
||||
srsDistribution: "SRS分布",
|
||||
accuracy: "正解率",
|
||||
correct: "正解",
|
||||
total: "合計",
|
||||
next24: "今後24時間",
|
||||
availableNow: "今すぐ可能",
|
||||
inHours: "{n}時間後",
|
||||
noIncoming: "24時間以内のレビューはありません。",
|
||||
items: "個",
|
||||
reviewsCount: "{count} レビュー",
|
||||
ghostTitle: 'Geister-Items',
|
||||
ghostSubtitle: 'Niedrigste Genauigkeit',
|
||||
noGhosts: 'Keine Geister gefunden! Weiter so.',
|
||||
},
|
||||
settings: {
|
||||
title: 'Einstellungen',
|
||||
batchSize: 'Anzahl pro Sitzung',
|
||||
items: 'Einträge',
|
||||
language: 'Sprache',
|
||||
drawingTolerance: 'Zeichentoleranz',
|
||||
strict: 'Strikt',
|
||||
loose: 'Locker',
|
||||
save: 'Speichern & Schließen',
|
||||
},
|
||||
review: {
|
||||
meaning: 'Bedeutung',
|
||||
level: 'Stufe',
|
||||
draw: 'Zeichne das Kanji',
|
||||
hint: 'Hinweis angezeigt',
|
||||
showHint: 'Hinweis zeigen',
|
||||
redoLesson: 'Lektion wiederholen',
|
||||
tryAgain: 'Nochmal versuchen',
|
||||
correct: 'Richtig!',
|
||||
next: 'WEITER',
|
||||
sessionComplete: 'Sitzung beendet!',
|
||||
levelup: 'Du hast deine Kanji-Skills verbessert.',
|
||||
back: 'Zurück zur Sammlung',
|
||||
caughtUp: 'Alles erledigt!',
|
||||
noReviews: 'Gerade keine Reviews verfügbar.',
|
||||
viewCollection: 'Zur Sammlung',
|
||||
queue: 'Verbleibend:',
|
||||
loading: 'Lade Kanji...',
|
||||
},
|
||||
collection: {
|
||||
searchLabel: 'Suche Kanji, Bedeutung oder Lesung...',
|
||||
placeholder: "z.B. 'Wasser', 'mizu'",
|
||||
loading: 'Lade Sammlung...',
|
||||
noMatches: 'Keine Treffer',
|
||||
tryDifferent: 'Versuche einen anderen Suchbegriff.',
|
||||
levelHeader: 'STUFE',
|
||||
onyomi: "On'yomi",
|
||||
kunyomi: "Kun'yomi",
|
||||
nanori: 'Nanori',
|
||||
close: 'Schließen',
|
||||
startLesson: 'Lektion starten',
|
||||
redoLesson: 'Lektion wiederholen',
|
||||
},
|
||||
},
|
||||
ja: {
|
||||
common: {
|
||||
close: '閉じる',
|
||||
cancel: 'キャンセル',
|
||||
},
|
||||
nav: {
|
||||
dashboard: 'ダッシュボード',
|
||||
review: '復習',
|
||||
collection: 'コレクション',
|
||||
settings: '設定',
|
||||
sync: '同期',
|
||||
logout: 'ログアウト',
|
||||
menu: 'メニュー',
|
||||
},
|
||||
login: {
|
||||
instruction: 'WaniKani V2 APIキーを入力してください',
|
||||
placeholder: 'キーを貼り付け...',
|
||||
button: 'ログイン',
|
||||
failed: 'ログイン失敗。サーバーは起動していますか?',
|
||||
},
|
||||
alerts: {
|
||||
syncSuccess: '同期完了! コレクション: {count}',
|
||||
syncFailed: '同期に失敗しました。',
|
||||
logoutConfirm: 'ログアウトしてもよろしいですか?セッションが終了します。',
|
||||
},
|
||||
lesson: {
|
||||
phasePrimer: '学習',
|
||||
phaseDemo: '観察',
|
||||
phaseGuided: 'ガイド',
|
||||
phasePractice: '想起',
|
||||
understand: '理解した',
|
||||
ready: '描いてみる',
|
||||
startPractice: '練習開始',
|
||||
continue: '次へ',
|
||||
streak: '正解: {n} / {total}',
|
||||
hint: 'ヒント (連勝リセット)',
|
||||
hintAction: 'ヒント',
|
||||
watchAgain: 'もう一度見る',
|
||||
completeTitle: 'レッスン完了!',
|
||||
completeBody: '新しい漢字がレビューに追加されました。',
|
||||
learned: '{n}個の新しい漢字を覚えました。',
|
||||
components: '構成要素',
|
||||
observe: '書き順を見る',
|
||||
trace: 'なぞる',
|
||||
drawStep: '書く ({n}/{total})',
|
||||
backToDashboard: 'ダッシュボードに戻る',
|
||||
},
|
||||
hero: {
|
||||
lessons: 'レッスン',
|
||||
welcome: 'お帰りなさい',
|
||||
subtitle: '準備は完了です。',
|
||||
start: '復習開始',
|
||||
noReviews: 'レビューなし',
|
||||
nextIn: '次の復習まで',
|
||||
now: '今',
|
||||
prioritize: '低レベルを優先 ({count})',
|
||||
},
|
||||
stats: {
|
||||
mastery: '習得度 (Guru+)',
|
||||
srsDistribution: 'SRS分布',
|
||||
accuracy: '正解率',
|
||||
correct: '正解',
|
||||
total: '合計',
|
||||
next24: '今後24時間',
|
||||
availableNow: '今すぐ可能',
|
||||
inHours: '{n}時間後',
|
||||
noIncoming: '24時間以内のレビューはありません。',
|
||||
items: '個',
|
||||
reviewsCount: '{count} レビュー',
|
||||
|
||||
consistency: "学習の一貫性",
|
||||
less: "少",
|
||||
more: "多",
|
||||
consistency: '学習の一貫性',
|
||||
less: '少',
|
||||
more: '多',
|
||||
|
||||
streakTitle: "連続学習日数",
|
||||
days: "日",
|
||||
shieldActive: "Zenシールド有効: 1日休んでもストリークを守ります。",
|
||||
shieldCooldown: "再チャージ中: 残り{n}日",
|
||||
streakTitle: '連続学習日数',
|
||||
days: '日',
|
||||
shieldActive: 'Zenシールド有効: 1日休んでもストリークを守ります。',
|
||||
shieldCooldown: '再チャージ中: 残り{n}日',
|
||||
|
||||
ghostTitle: "苦手なアイテム",
|
||||
ghostSubtitle: "正解率が低い",
|
||||
noGhosts: "苦手なアイテムはありません!"
|
||||
},
|
||||
settings: {
|
||||
title: "設定",
|
||||
batchSize: "1回の復習数",
|
||||
items: "個",
|
||||
language: "言語 (Language)",
|
||||
save: "保存して閉じる"
|
||||
},
|
||||
review: {
|
||||
meaning: "意味",
|
||||
level: "レベル",
|
||||
draw: "正しく描いてください",
|
||||
hint: "ヒント表示",
|
||||
tryAgain: "もう一度",
|
||||
correct: "正解!",
|
||||
next: "次へ",
|
||||
sessionComplete: "セッション完了!",
|
||||
levelup: "漢字力がアップしました。",
|
||||
back: "コレクションに戻る",
|
||||
caughtUp: "完了しました!",
|
||||
noReviews: "現在レビューするものはありません。",
|
||||
viewCollection: "コレクションを見る",
|
||||
queue: "残り:",
|
||||
loading: "漢字を読み込み中...",
|
||||
},
|
||||
collection: {
|
||||
searchLabel: "漢字、意味、読みで検索...",
|
||||
placeholder: "例: '水', 'mizu'",
|
||||
loading: "読み込み中...",
|
||||
noMatches: "見つかりませんでした",
|
||||
tryDifferent: "別のキーワードで検索してください。",
|
||||
levelHeader: "レベル",
|
||||
onyomi: "音読み",
|
||||
kunyomi: "訓読み",
|
||||
nanori: "名乗り",
|
||||
close: "閉じる"
|
||||
}
|
||||
}
|
||||
}
|
||||
ghostTitle: '苦手なアイテム',
|
||||
ghostSubtitle: '正解率が低い',
|
||||
noGhosts: '苦手なアイテムはありません!',
|
||||
},
|
||||
settings: {
|
||||
title: '設定',
|
||||
batchSize: '1回の復習数',
|
||||
items: '個',
|
||||
language: '言語 (Language)',
|
||||
drawingTolerance: '描画許容範囲',
|
||||
strict: '厳しい',
|
||||
loose: '甘い',
|
||||
save: '保存して閉じる',
|
||||
},
|
||||
review: {
|
||||
meaning: '意味',
|
||||
level: 'レベル',
|
||||
draw: '正しく描いてください',
|
||||
hint: 'ヒント表示',
|
||||
showHint: 'ヒントを表示',
|
||||
redoLesson: 'レッスンをやり直す',
|
||||
tryAgain: 'もう一度',
|
||||
correct: '正解!',
|
||||
next: '次へ',
|
||||
sessionComplete: 'セッション完了!',
|
||||
levelup: '漢字力がアップしました。',
|
||||
back: 'コレクションに戻る',
|
||||
caughtUp: '完了しました!',
|
||||
noReviews: '現在レビューするものはありません。',
|
||||
viewCollection: 'コレクションを見る',
|
||||
queue: '残り:',
|
||||
loading: '漢字を読み込み中...',
|
||||
},
|
||||
collection: {
|
||||
searchLabel: '漢字、意味、読みで検索...',
|
||||
placeholder: "例: '水', 'mizu'",
|
||||
loading: '読み込み中...',
|
||||
noMatches: '見つかりませんでした',
|
||||
tryDifferent: '別のキーワードで検索してください。',
|
||||
levelHeader: 'レベル',
|
||||
onyomi: '音読み',
|
||||
kunyomi: '訓読み',
|
||||
nanori: '名乗り',
|
||||
close: '閉じる',
|
||||
startLesson: 'レッスン開始',
|
||||
redoLesson: 'レッスンをやり直す',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const savedLocale = localStorage.getItem('zen_locale') || 'en'
|
||||
const savedLocale = localStorage.getItem('zen_locale') || 'en';
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: savedLocale,
|
||||
fallbackLocale: 'en',
|
||||
messages
|
||||
})
|
||||
legacy: false,
|
||||
locale: savedLocale,
|
||||
fallbackLocale: 'en',
|
||||
messages,
|
||||
});
|
||||
|
||||
export default i18n
|
||||
export default i18n;
|
||||
|
||||
@@ -3,134 +3,200 @@ import { defineStore } from 'pinia';
|
||||
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||
|
||||
export const useAppStore = defineStore('app', {
|
||||
state: () => ({
|
||||
token: localStorage.getItem('zen_token') || '',
|
||||
user: null,
|
||||
queue: [],
|
||||
collection: [],
|
||||
stats: {
|
||||
distribution: {},
|
||||
forecast: [],
|
||||
queueLength: 0,
|
||||
streak: {},
|
||||
accuracy: {},
|
||||
ghosts: []
|
||||
},
|
||||
batchSize: 20,
|
||||
drawingAccuracy: 10,
|
||||
loading: false
|
||||
}),
|
||||
state: () => ({
|
||||
token: localStorage.getItem('zen_token') || '',
|
||||
user: null,
|
||||
queue: [],
|
||||
lessonQueue: [],
|
||||
collection: [],
|
||||
stats: {
|
||||
distribution: {},
|
||||
forecast: [],
|
||||
queueLength: 0,
|
||||
lessonCount: 0,
|
||||
streak: {},
|
||||
accuracy: {},
|
||||
ghosts: [],
|
||||
},
|
||||
batchSize: parseInt(localStorage.getItem('zen_batch_size'), 10) || 20,
|
||||
drawingAccuracy: parseInt(localStorage.getItem('zen_drawing_accuracy'), 10) || 10,
|
||||
loading: false,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async login(apiKey) {
|
||||
const res = await fetch(`${BASE_URL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ apiKey })
|
||||
});
|
||||
actions: {
|
||||
async login(apiKey) {
|
||||
const res = await fetch(`${BASE_URL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ apiKey }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Login failed');
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Login failed');
|
||||
|
||||
this.token = data.token;
|
||||
this.user = data.user;
|
||||
this.token = data.token;
|
||||
this.user = data.user;
|
||||
|
||||
if (data.user.settings) {
|
||||
this.batchSize = data.user.settings.batchSize || 20;
|
||||
this.drawingAccuracy = data.user.settings.drawingAccuracy || 10;
|
||||
}
|
||||
if (data.user.settings) {
|
||||
this.batchSize = data.user.settings.batchSize || 20;
|
||||
this.drawingAccuracy = data.user.settings.drawingAccuracy || 10;
|
||||
|
||||
localStorage.setItem('zen_token', data.token);
|
||||
// Persist settings to local storage on login
|
||||
localStorage.setItem('zen_batch_size', this.batchSize);
|
||||
localStorage.setItem('zen_drawing_accuracy', this.drawingAccuracy);
|
||||
}
|
||||
|
||||
await this.fetchStats();
|
||||
return data;
|
||||
},
|
||||
localStorage.setItem('zen_token', data.token);
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
if (this.token) {
|
||||
await fetch(`${BASE_URL}/api/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders()
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Logout error:", e);
|
||||
} finally {
|
||||
this.clearData();
|
||||
}
|
||||
},
|
||||
await this.fetchStats();
|
||||
return data;
|
||||
},
|
||||
|
||||
clearData() {
|
||||
this.token = '';
|
||||
this.user = null;
|
||||
this.queue = [];
|
||||
this.stats = {};
|
||||
localStorage.removeItem('zen_token');
|
||||
},
|
||||
async logout() {
|
||||
try {
|
||||
if (this.token) {
|
||||
await fetch(`${BASE_URL}/api/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Logout error:', e);
|
||||
} finally {
|
||||
this.clearData();
|
||||
}
|
||||
},
|
||||
|
||||
getHeaders() {
|
||||
return {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
},
|
||||
clearData() {
|
||||
this.token = '';
|
||||
this.user = null;
|
||||
this.queue = [];
|
||||
this.stats = {};
|
||||
localStorage.removeItem('zen_token');
|
||||
localStorage.removeItem('zen_batch_size');
|
||||
localStorage.removeItem('zen_drawing_accuracy');
|
||||
},
|
||||
|
||||
async sync() {
|
||||
const res = await fetch(`${BASE_URL}/api/sync`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
return data;
|
||||
},
|
||||
getHeaders() {
|
||||
return {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
},
|
||||
|
||||
async fetchStats() {
|
||||
if (!this.token) return;
|
||||
const res = await fetch(`${BASE_URL}/api/stats`, { headers: this.getHeaders() });
|
||||
if (res.status === 401) return this.logout();
|
||||
const data = await res.json();
|
||||
this.stats = data;
|
||||
return data;
|
||||
},
|
||||
async sync() {
|
||||
const res = await fetch(`${BASE_URL}/api/sync`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
return data;
|
||||
},
|
||||
|
||||
async fetchQueue(sortMode = 'shuffled') {
|
||||
if (!this.token) return;
|
||||
const res = await fetch(`${BASE_URL}/api/queue?limit=${this.batchSize}&sort=${sortMode}`, {
|
||||
headers: this.getHeaders()
|
||||
});
|
||||
if (res.status === 401) return this.logout();
|
||||
this.queue = await res.json();
|
||||
},
|
||||
async fetchStats() {
|
||||
if (!this.token) return null;
|
||||
|
||||
async fetchCollection() {
|
||||
if (!this.token) return;
|
||||
const res = await fetch(`${BASE_URL}/api/collection`, { headers: this.getHeaders() });
|
||||
if (res.status === 401) return this.logout();
|
||||
this.collection = await res.json();
|
||||
},
|
||||
const res = await fetch(`${BASE_URL}/api/stats`, { headers: this.getHeaders() });
|
||||
if (res.status === 401) {
|
||||
await this.logout();
|
||||
return null;
|
||||
}
|
||||
|
||||
async submitReview(subjectId, success) {
|
||||
const res = await fetch(`${BASE_URL}/api/review`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify({ subjectId, success })
|
||||
});
|
||||
if (res.status === 401) return this.logout();
|
||||
return await res.json();
|
||||
},
|
||||
const data = await res.json();
|
||||
this.stats = data;
|
||||
return data;
|
||||
},
|
||||
|
||||
async saveSettings(settings) {
|
||||
if (settings.batchSize) this.batchSize = settings.batchSize;
|
||||
if (settings.drawingAccuracy) this.drawingAccuracy = settings.drawingAccuracy;
|
||||
async fetchQueue(sortMode = 'shuffled') {
|
||||
if (!this.token) return;
|
||||
|
||||
await fetch(`${BASE_URL}/api/settings`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
}
|
||||
}
|
||||
const res = await fetch(`${BASE_URL}/api/queue?limit=${this.batchSize}&sort=${sortMode}`, {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
await this.logout();
|
||||
return;
|
||||
}
|
||||
|
||||
this.queue = await res.json();
|
||||
},
|
||||
|
||||
async fetchLessonQueue() {
|
||||
if (!this.token) return;
|
||||
|
||||
const res = await fetch(`${BASE_URL}/api/lessons?limit=${this.batchSize}`, {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
await this.logout();
|
||||
return;
|
||||
}
|
||||
|
||||
this.lessonQueue = await res.json();
|
||||
},
|
||||
|
||||
async fetchCollection() {
|
||||
if (!this.token) return;
|
||||
|
||||
const res = await fetch(`${BASE_URL}/api/collection`, { headers: this.getHeaders() });
|
||||
|
||||
if (res.status === 401) {
|
||||
await this.logout();
|
||||
return;
|
||||
}
|
||||
|
||||
this.collection = await res.json();
|
||||
},
|
||||
|
||||
async submitReview(subjectId, success) {
|
||||
const res = await fetch(`${BASE_URL}/api/review`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify({ subjectId, success }),
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
await this.logout();
|
||||
return null;
|
||||
}
|
||||
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async submitLesson(subjectId) {
|
||||
const res = await fetch(`${BASE_URL}/api/lesson`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify({ subjectId }),
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
await this.logout();
|
||||
return null;
|
||||
}
|
||||
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async saveSettings(settings) {
|
||||
if (settings.batchSize !== undefined) {
|
||||
this.batchSize = settings.batchSize;
|
||||
localStorage.setItem('zen_batch_size', settings.batchSize);
|
||||
}
|
||||
if (settings.drawingAccuracy !== undefined) {
|
||||
this.drawingAccuracy = settings.drawingAccuracy;
|
||||
localStorage.setItem('zen_drawing_accuracy', settings.drawingAccuracy);
|
||||
}
|
||||
|
||||
await fetch(`${BASE_URL}/api/settings`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,13 +6,32 @@ $color-surface-light: hsl(221deg 17% 22%);
|
||||
$color-text-white: hsl(0deg 0% 100%);
|
||||
$color-text-grey: hsl(213deg 14% 70%);
|
||||
$color-border: hsl(0deg 0% 100% / 8%);
|
||||
$color-zinc-900: #18181b;
|
||||
$color-reading-box: rgb(255 255 255 / 3%);
|
||||
$color-stroke-drawn: #a1a1aa;
|
||||
$color-srs-1: hsl(0deg 100% 73%);
|
||||
$color-srs-2: hsl(39deg 98% 71%);
|
||||
$color-srs-3: hsl(163deg 85% 64%);
|
||||
$color-srs-4: hsl(206deg 92% 46%);
|
||||
$color-srs-5: hsl(244deg 100% 82%);
|
||||
$color-srs-6: hsl(247deg 72% 63%);
|
||||
$color-srs-7: $color-primary;
|
||||
$color-srs-8: hsl(339deg 97% 73%);
|
||||
$color-srs-9: hsl(331deg 78% 59%);
|
||||
$color-srs-10: hsl(51deg 100% 50%);
|
||||
$color-danger: hsl(0deg 85% 65%);
|
||||
$color-success: hsl(160deg 80% 45%);
|
||||
$color-stroke-inactive: hsl(0deg 0% 33%);
|
||||
$color-dot-base: hsl(0deg 0% 27%);
|
||||
$srs-colors: (
|
||||
1: $color-srs-1,
|
||||
2: $color-srs-2,
|
||||
3: $color-srs-3,
|
||||
4: $color-srs-4,
|
||||
5: $color-srs-5,
|
||||
6: $color-srs-6,
|
||||
7: $color-srs-7,
|
||||
8: $color-srs-8,
|
||||
9: $color-srs-9,
|
||||
10: $color-srs-10
|
||||
);
|
||||
|
||||
@@ -18,6 +18,9 @@ $z-dropdown: 50;
|
||||
$z-modal: 100;
|
||||
$z-tooltip: 200;
|
||||
$z-above: 2;
|
||||
|
||||
// Neue Z-Indizes aus dem Refactoring
|
||||
$z-play-btn: 10;
|
||||
$size-canvas: 300px;
|
||||
$size-kanji-preview: 200px;
|
||||
$size-icon-btn: 32px;
|
||||
@@ -26,12 +29,6 @@ $stroke-width-main: 3px;
|
||||
$stroke-width-arrow: 2px;
|
||||
$font-size-svg-number: 5px;
|
||||
$radius-xs: 2px;
|
||||
$radius-sm: 4px;
|
||||
$radius-md: 8px;
|
||||
$radius-lg: 12px;
|
||||
$radius-xl: 24px;
|
||||
$radius-pill: 999px;
|
||||
$radius-circle: 50%;
|
||||
$size-legend-box: 12px;
|
||||
$size-streak-dot: 24px;
|
||||
$size-srs-track: 24px;
|
||||
@@ -44,3 +41,14 @@ $padding-page-x: 24px;
|
||||
$breakpoint-md: 960px;
|
||||
$offset-fab: 20px;
|
||||
$size-heatmap-cell-height: 10px;
|
||||
$stroke-width-kanji: 6px;
|
||||
$opacity-kanji-hint: 0.5;
|
||||
$dash-kanji-hint: 10px 15px;
|
||||
|
||||
// --- NEUE VARIABLEN (Wichtig!) ---
|
||||
$size-hero-wrapper: 140px;
|
||||
$size-lesson-card-width: 450px;
|
||||
$size-lesson-card-min-height: 500px;
|
||||
$size-avatar-small: 32px;
|
||||
$size-button-large-height: 70px;
|
||||
$size-button-large-width: 280px;
|
||||
|
||||
@@ -1,23 +1,53 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
@use '../abstracts/mixins' as *;
|
||||
|
||||
@mixin grid-overlay {
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border-color: $color-border;
|
||||
border-style: dashed;
|
||||
border-width: 0;
|
||||
z-index: $z-normal;
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: 50%;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
border-top-width: 1px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
left: 50%;
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
border-left-width: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
position: relative;
|
||||
|
||||
@include flex-center;
|
||||
|
||||
margin-bottom: $spacing-xl;
|
||||
}
|
||||
|
||||
// The main drawing canvas wrapper
|
||||
.canvas-wrapper {
|
||||
position: relative;
|
||||
width: $size-canvas;
|
||||
height: $size-canvas;
|
||||
background: $color-surface;
|
||||
border-radius: $radius-lg;
|
||||
background: rgba($color-surface, 0.95);
|
||||
border: $border-width-md solid $color-surface-light;
|
||||
position: relative;
|
||||
cursor: crosshair;
|
||||
border: $border-subtle;
|
||||
box-shadow: $shadow-inset;
|
||||
overflow: hidden;
|
||||
cursor: crosshair;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
canvas {
|
||||
position: absolute;
|
||||
@@ -26,39 +56,66 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
touch-action: none;
|
||||
z-index: $z-above;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
position: absolute;
|
||||
color: $color-text-grey;
|
||||
z-index: $z-sticky;
|
||||
font-size: $font-sm;
|
||||
font-weight: $weight-medium;
|
||||
letter-spacing: $tracking-wide;
|
||||
// The SVG Viewer wrapper (Review/Lesson/Collection)
|
||||
.svg-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
// --- Unified Styles to match Canvas ---
|
||||
background: $color-surface;
|
||||
border-radius: $radius-lg; // Matches canvas-wrapper
|
||||
border: $border-subtle; // Matches canvas-wrapper
|
||||
box-shadow: $shadow-inset; // Matches canvas-wrapper
|
||||
// -------------------------------------
|
||||
|
||||
@include grid-overlay;
|
||||
|
||||
&.hero-mode {
|
||||
background: linear-gradient(145deg, $color-surface, #18181b);
|
||||
border: 2px solid $color-border;
|
||||
box-shadow: 0 4px 20px rgb(0 0 0 / 40%);
|
||||
|
||||
.stroke-path.drawn {
|
||||
stroke: $color-text-white;
|
||||
filter: drop-shadow(0 0 2px rgb(255 255 255 / 25%));
|
||||
}
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.svg-container {
|
||||
width: $size-kanji-preview;
|
||||
height: $size-kanji-preview;
|
||||
margin: 0 auto $spacing-lg;
|
||||
background: rgba($color-bg-dark, 0.2);
|
||||
border-radius: $radius-md;
|
||||
border: $border-subtle;
|
||||
position: relative;
|
||||
|
||||
@include flex-center;
|
||||
|
||||
overflow: hidden;
|
||||
.kanji-svg {
|
||||
z-index: $z-above;
|
||||
display: block;
|
||||
padding: 12%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.stroke-path {
|
||||
fill: none;
|
||||
stroke: $color-stroke-inactive;
|
||||
stroke-width: $stroke-width-main;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
transition: stroke $duration-normal;
|
||||
stroke-width: $stroke-width-main;
|
||||
transition:
|
||||
stroke 0.3s ease,
|
||||
opacity 0.3s ease;
|
||||
|
||||
&.drawn {
|
||||
stroke: $color-stroke-drawn;
|
||||
}
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
@@ -70,10 +127,75 @@
|
||||
stroke-dashoffset: var(--len);
|
||||
animation: draw-stroke var(--duration) linear forwards;
|
||||
}
|
||||
}
|
||||
|
||||
&.drawn {
|
||||
stroke: $color-text-white;
|
||||
opacity: 1;
|
||||
.stroke-ghost {
|
||||
fill: none;
|
||||
stroke: $color-stroke-inactive;
|
||||
stroke-width: $stroke-width-main;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
opacity: 0.5;
|
||||
stroke-dasharray: 10 15;
|
||||
}
|
||||
|
||||
.stroke-badge-group {
|
||||
filter: drop-shadow(0 1px 2px rgb(0 0 0 / 50%));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stroke-badge-bg {
|
||||
fill: $color-primary;
|
||||
opacity: 0.9;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.stroke-badge-text {
|
||||
fill: $color-bg-dark;
|
||||
font-size: $font-size-svg-number;
|
||||
font-family: sans-serif;
|
||||
font-weight: $weight-black;
|
||||
user-select: none;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: central;
|
||||
}
|
||||
|
||||
.stroke-arrow-line {
|
||||
fill: none;
|
||||
stroke: $color-primary;
|
||||
stroke-width: 1.5px;
|
||||
vector-effect: non-scaling-stroke;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 0 2px rgb(0 0 0 / 50%));
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: $size-icon-btn;
|
||||
height: $size-icon-btn;
|
||||
border-radius: $radius-circle;
|
||||
background: rgb(255 255 255 / 10%);
|
||||
border: 1px solid rgba($color-primary, 0.3);
|
||||
color: $color-primary;
|
||||
|
||||
@include flex-center;
|
||||
|
||||
cursor: pointer;
|
||||
z-index: $z-play-btn;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
background: rgba($color-primary, 0.2);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,65 +205,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
.stroke-start-circle {
|
||||
fill: $color-srs-1;
|
||||
}
|
||||
|
||||
.stroke-number {
|
||||
fill: $color-text-white;
|
||||
font-size: $font-size-svg-number;
|
||||
font-family: $font-family-sans;
|
||||
font-weight: $weight-bold;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: middle;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stroke-arrow-line {
|
||||
fill: none;
|
||||
stroke: rgba($color-secondary, 0.7);
|
||||
stroke-width: $stroke-width-arrow;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
color: $color-text-grey;
|
||||
font-size: $font-sm;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
position: absolute;
|
||||
top: $spacing-sm;
|
||||
right: $spacing-sm;
|
||||
background: rgba($color-bg-dark, 0.3);
|
||||
border: $border-width-sm solid rgba($color-primary, 0.5);
|
||||
color: $color-primary;
|
||||
border-radius: $radius-circle;
|
||||
width: $size-icon-btn;
|
||||
height: $size-icon-btn;
|
||||
|
||||
@include flex-center;
|
||||
|
||||
cursor: pointer;
|
||||
transition: all $duration-fast ease;
|
||||
z-index: $z-sticky;
|
||||
backdrop-filter: $blur-sm;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
background: $color-primary;
|
||||
color: $color-bg-dark;
|
||||
border-color: $color-primary;
|
||||
box-shadow: $shadow-glow-base;
|
||||
@keyframes shake-x {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
20% {
|
||||
transform: translateX(-6px);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: $size-icon-small;
|
||||
height: $size-icon-small;
|
||||
40% {
|
||||
transform: translateX(6px);
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: translateX(-6px);
|
||||
}
|
||||
|
||||
80% {
|
||||
transform: translateX(6px);
|
||||
}
|
||||
}
|
||||
|
||||
.shake {
|
||||
animation: shake-x 0.4s ease-in-out;
|
||||
border-color: $color-danger !important;
|
||||
}
|
||||
|
||||
@@ -6,26 +6,27 @@
|
||||
@include card-base;
|
||||
|
||||
border: $border-subtle;
|
||||
background-color: $color-surface !important;
|
||||
border-radius: $radius-xl !important;
|
||||
}
|
||||
|
||||
.level-0 {
|
||||
background-color: $bg-glass-subtle;
|
||||
.welcome-btn {
|
||||
height: $size-button-large-height !important;
|
||||
width: $size-button-large-width !important;
|
||||
|
||||
.v-chip {
|
||||
color: white !important;
|
||||
background-color: $color-surface !important;
|
||||
}
|
||||
}
|
||||
|
||||
.level-1 {
|
||||
background-color: color.mix($color-primary, $color-surface, 25%);
|
||||
.streak-shield-avatar {
|
||||
border: 1px solid currentcolor;
|
||||
background: rgb(255 255 255 / 5%);
|
||||
}
|
||||
|
||||
.level-2 {
|
||||
background-color: color.mix($color-primary, $color-surface, 50%);
|
||||
}
|
||||
|
||||
.level-3 {
|
||||
background-color: $color-primary;
|
||||
}
|
||||
|
||||
.level-4 {
|
||||
background-color: color.scale($color-primary, $lightness: 40%);
|
||||
.streak-day-label {
|
||||
font-size: 9px !important;
|
||||
}
|
||||
|
||||
.heatmap-container {
|
||||
@@ -72,6 +73,26 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.level-0 {
|
||||
background-color: $bg-glass-subtle;
|
||||
}
|
||||
|
||||
.level-1 {
|
||||
background-color: color.mix($color-primary, $color-surface, 25%);
|
||||
}
|
||||
|
||||
.level-2 {
|
||||
background-color: color.mix($color-primary, $color-surface, 50%);
|
||||
}
|
||||
|
||||
.level-3 {
|
||||
background-color: $color-primary;
|
||||
}
|
||||
|
||||
.level-4 {
|
||||
background-color: color.scale($color-primary, $lightness: 40%);
|
||||
}
|
||||
|
||||
.legend-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -153,22 +174,16 @@
|
||||
@include scrollbar;
|
||||
}
|
||||
|
||||
.gap-1 {
|
||||
gap: $spacing-xs;
|
||||
.srs-chart-container {
|
||||
gap: 8px;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.border-subtle {
|
||||
border: $border-subtle;
|
||||
}
|
||||
|
||||
.border-b-subtle {
|
||||
border-bottom: $border-subtle;
|
||||
}
|
||||
|
||||
.border-t-subtle {
|
||||
border-top: $border-subtle;
|
||||
.srs-track {
|
||||
@media (max-width: 400px) {
|
||||
width: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,3 +8,4 @@
|
||||
@use 'pages/dashboard';
|
||||
@use 'pages/review';
|
||||
@use 'pages/collection';
|
||||
@use 'pages/lesson';
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
:root {
|
||||
--v-theme-background: #{$color-bg-dark};
|
||||
--color-surface: #{$color-surface};
|
||||
|
||||
@each $level, $color in $srs-colors {
|
||||
--srs-#{$level}: #{$color};
|
||||
}
|
||||
}
|
||||
|
||||
body,
|
||||
@@ -59,3 +64,13 @@ html,
|
||||
opacity: $opacity-hover;
|
||||
}
|
||||
}
|
||||
|
||||
@each $level, $color in $srs-colors {
|
||||
.text-srs-#{$level} {
|
||||
color: var(--srs-#{$level}) !important;
|
||||
}
|
||||
|
||||
.bg-srs-#{$level} {
|
||||
background-color: var(--srs-#{$level}) !important;
|
||||
}
|
||||
}
|
||||
|
||||
78
client/src/styles/pages/_lesson.scss
Normal file
78
client/src/styles/pages/_lesson.scss
Normal file
@@ -0,0 +1,78 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
@use '../abstracts/mixins' as *;
|
||||
|
||||
.page-container-center {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: $spacing-md;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.lesson-card {
|
||||
width: 100%;
|
||||
max-width: $size-lesson-card-width;
|
||||
border-radius: $radius-xl !important;
|
||||
border: $border-subtle;
|
||||
background-color: $color-surface !important;
|
||||
min-height: $size-lesson-card-min-height;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hero-wrapper {
|
||||
width: $size-hero-wrapper;
|
||||
height: $size-hero-wrapper;
|
||||
}
|
||||
|
||||
.radical-section {
|
||||
width: 100%;
|
||||
background: $color-zinc-900;
|
||||
border-radius: $radius-md;
|
||||
padding: $spacing-md;
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.radical-chip {
|
||||
border-radius: $radius-md;
|
||||
border: $border-subtle;
|
||||
background-color: #27272a;
|
||||
|
||||
img {
|
||||
filter: invert(1);
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.readings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $spacing-lg;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.reading-box {
|
||||
background: $color-reading-box;
|
||||
padding: $spacing-lg;
|
||||
border-radius: $radius-lg;
|
||||
text-align: center;
|
||||
|
||||
.label {
|
||||
font-size: $font-xs;
|
||||
color: #666;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: $spacing-xs;
|
||||
}
|
||||
|
||||
.val {
|
||||
font-size: 1.1rem;
|
||||
font-weight: $weight-bold;
|
||||
}
|
||||
}
|
||||
|
||||
.lesson-canvas-wrapper {
|
||||
margin: 0 auto;
|
||||
}
|
||||
@@ -1,21 +1,25 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
@use '../abstracts/mixins' as *;
|
||||
|
||||
.review-card {
|
||||
border: $border-subtle;
|
||||
background-color: $color-surface !important;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.text-shadow {
|
||||
text-shadow: $text-shadow;
|
||||
}
|
||||
|
||||
.canvas-wrapper {
|
||||
.review-canvas-area {
|
||||
position: relative;
|
||||
width: $size-canvas;
|
||||
height: $size-canvas;
|
||||
border-radius: $radius-lg;
|
||||
background: $bg-glass-dark;
|
||||
box-shadow: $shadow-inset;
|
||||
margin-bottom: $spacing-sm;
|
||||
|
||||
.next-fab {
|
||||
position: absolute;
|
||||
bottom: -#{$offset-fab};
|
||||
right: -#{$offset-fab};
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: $z-sticky;
|
||||
}
|
||||
}
|
||||
@@ -45,3 +49,15 @@
|
||||
opacity: 0;
|
||||
transform: translateY(-#{$dist-slide-sm});
|
||||
}
|
||||
|
||||
.opacity-80 {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
411
client/src/utils/KanjiController.js
Normal file
411
client/src/utils/KanjiController.js
Normal file
@@ -0,0 +1,411 @@
|
||||
export const KANJI_CONSTANTS = {
|
||||
BASE_SIZE: 109,
|
||||
SVG_NS: 'http://www.w3.org/2000/svg',
|
||||
API_URL: 'https://raw.githubusercontent.com/KanjiVG/kanjivg/master/kanji',
|
||||
|
||||
STROKE_WIDTH_BASE: 6,
|
||||
DASH_ARRAY_GRID: [5, 5],
|
||||
DASH_ARRAY_HINT: [10, 15],
|
||||
|
||||
ANIMATION_DURATION: 300,
|
||||
SAMPLE_POINTS: 60,
|
||||
|
||||
VALIDATION: {
|
||||
SAMPLES: 10,
|
||||
},
|
||||
|
||||
COLORS: {
|
||||
USER: { r: 255, g: 118, b: 117 },
|
||||
FINAL: { r: 0, g: 206, b: 201 },
|
||||
HINT: '#3f3f46',
|
||||
GRID: 'rgba(255, 255, 255, 0.08)',
|
||||
},
|
||||
};
|
||||
|
||||
export class KanjiController {
|
||||
constructor(options = {}) {
|
||||
this.size = options.size || 300;
|
||||
this.accuracy = options.accuracy || 10;
|
||||
this.onComplete = options.onComplete || (() => {});
|
||||
this.onMistake = options.onMistake || (() => {});
|
||||
|
||||
this.scale = this.size / KANJI_CONSTANTS.BASE_SIZE;
|
||||
this.paths = [];
|
||||
this.currentStrokeIdx = 0;
|
||||
this.mistakes = 0;
|
||||
this.userPath = [];
|
||||
this.isDrawing = false;
|
||||
this.isAnimating = false;
|
||||
|
||||
this.ctx = { bg: null, hint: null, draw: null };
|
||||
}
|
||||
|
||||
static createPathElement(d) {
|
||||
const path = document.createElementNS(KANJI_CONSTANTS.SVG_NS, 'path');
|
||||
path.setAttribute('d', d);
|
||||
return path;
|
||||
}
|
||||
|
||||
static setContextDefaults(ctx) {
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineWidth = KANJI_CONSTANTS.STROKE_WIDTH_BASE;
|
||||
}
|
||||
|
||||
static resamplePoints(points, count) {
|
||||
if (!points || points.length === 0) return [];
|
||||
|
||||
let totalLen = 0;
|
||||
const dists = [0];
|
||||
points.slice(1).forEach((p, i) => {
|
||||
const prev = points[i];
|
||||
const d = Math.hypot(p.x - prev.x, p.y - prev.y);
|
||||
totalLen += d;
|
||||
dists.push(totalLen);
|
||||
});
|
||||
|
||||
const step = totalLen / (count - 1);
|
||||
|
||||
return Array.from({ length: count }).map((_, i) => {
|
||||
const targetDist = i * step;
|
||||
let idx = dists.findIndex((d) => d >= targetDist);
|
||||
if (idx === -1) idx = dists.length - 1;
|
||||
if (idx > 0) idx -= 1;
|
||||
|
||||
if (idx >= points.length - 1) {
|
||||
return points[points.length - 1];
|
||||
}
|
||||
|
||||
const dStart = dists[idx];
|
||||
const dEnd = dists[idx + 1];
|
||||
const segmentLen = dEnd - dStart;
|
||||
const t = segmentLen === 0 ? 0 : (targetDist - dStart) / segmentLen;
|
||||
|
||||
const p1 = points[idx];
|
||||
const p2 = points[idx + 1];
|
||||
|
||||
return {
|
||||
x: p1.x + (p2.x - p1.x) * t,
|
||||
y: p1.y + (p2.y - p1.y) * t,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
mount(canvasRefs) {
|
||||
this.ctx.bg = canvasRefs.bg.getContext('2d');
|
||||
this.ctx.hint = canvasRefs.hint.getContext('2d');
|
||||
this.ctx.draw = canvasRefs.draw.getContext('2d');
|
||||
this.resize(this.size);
|
||||
}
|
||||
|
||||
setAccuracy(val) {
|
||||
this.accuracy = val;
|
||||
}
|
||||
|
||||
resize(newSize) {
|
||||
this.size = newSize;
|
||||
this.scale = this.size / KANJI_CONSTANTS.BASE_SIZE;
|
||||
|
||||
Object.values(this.ctx).forEach((ctx) => {
|
||||
if (ctx && ctx.canvas) {
|
||||
ctx.canvas.width = this.size;
|
||||
ctx.canvas.height = this.size;
|
||||
KanjiController.setContextDefaults(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
this.drawGrid();
|
||||
if (this.paths.length) {
|
||||
this.redrawAllPerfectStrokes();
|
||||
}
|
||||
}
|
||||
|
||||
async loadChar(char, autoHint = false) {
|
||||
this.reset();
|
||||
const hex = char.charCodeAt(0).toString(16).padStart(5, '0');
|
||||
|
||||
try {
|
||||
const res = await fetch(`${KANJI_CONSTANTS.API_URL}/${hex}.svg`);
|
||||
const txt = await res.text();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(txt, 'image/svg+xml');
|
||||
this.paths = Array.from(doc.getElementsByTagName('path')).map((p) => p.getAttribute('d'));
|
||||
|
||||
this.drawGrid();
|
||||
if (autoHint) this.showHint();
|
||||
} catch (e) {
|
||||
console.error('Failed to load Kanji:', e);
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.currentStrokeIdx = 0;
|
||||
this.mistakes = 0;
|
||||
this.isAnimating = false;
|
||||
this.userPath = [];
|
||||
|
||||
this.clearCanvas(this.ctx.draw);
|
||||
this.clearCanvas(this.ctx.hint);
|
||||
this.drawGrid();
|
||||
this.resetDrawStyle();
|
||||
}
|
||||
|
||||
startStroke(point) {
|
||||
if (this.currentStrokeIdx >= this.paths.length || this.isAnimating) return;
|
||||
|
||||
this.isDrawing = true;
|
||||
this.userPath = [point];
|
||||
|
||||
this.ctx.draw.beginPath();
|
||||
this.ctx.draw.moveTo(point.x, point.y);
|
||||
this.resetDrawStyle();
|
||||
}
|
||||
|
||||
moveStroke(point) {
|
||||
if (!this.isDrawing) return;
|
||||
this.userPath.push(point);
|
||||
this.ctx.draw.lineTo(point.x, point.y);
|
||||
this.ctx.draw.stroke();
|
||||
}
|
||||
|
||||
endStroke() {
|
||||
if (!this.isDrawing) return;
|
||||
this.isDrawing = false;
|
||||
this.validateStroke();
|
||||
}
|
||||
|
||||
drawGrid() {
|
||||
if (!this.ctx.bg) return;
|
||||
const ctx = this.ctx.bg;
|
||||
this.clearCanvas(ctx);
|
||||
|
||||
ctx.strokeStyle = KANJI_CONSTANTS.COLORS.GRID;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash(KANJI_CONSTANTS.DASH_ARRAY_GRID);
|
||||
|
||||
const s = this.size;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(s / 2, 0); ctx.lineTo(s / 2, s);
|
||||
ctx.moveTo(0, s / 2); ctx.lineTo(s, s / 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
showHint() {
|
||||
this.clearCanvas(this.ctx.hint);
|
||||
if (this.currentStrokeIdx >= this.paths.length) return;
|
||||
|
||||
const d = this.paths[this.currentStrokeIdx];
|
||||
const pathEl = KanjiController.createPathElement(d);
|
||||
const len = pathEl.getTotalLength();
|
||||
|
||||
const ctx = this.ctx.hint;
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = KANJI_CONSTANTS.COLORS.HINT;
|
||||
ctx.setLineDash(KANJI_CONSTANTS.DASH_ARRAY_HINT);
|
||||
|
||||
const step = 5;
|
||||
const count = Math.floor(len / step) + 1;
|
||||
|
||||
Array.from({ length: count }).forEach((_, i) => {
|
||||
const dist = Math.min(i * step, len);
|
||||
const pt = pathEl.getPointAtLength(dist);
|
||||
if (i === 0) ctx.moveTo(pt.x * this.scale, pt.y * this.scale);
|
||||
else ctx.lineTo(pt.x * this.scale, pt.y * this.scale);
|
||||
});
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
redrawAllPerfectStrokes(includeCurrent = false) {
|
||||
const ctx = this.ctx.draw;
|
||||
this.clearCanvas(ctx);
|
||||
ctx.save();
|
||||
ctx.scale(this.scale, this.scale);
|
||||
|
||||
const { r, g, b } = KANJI_CONSTANTS.COLORS.FINAL;
|
||||
ctx.strokeStyle = `rgb(${r}, ${g}, ${b})`;
|
||||
ctx.lineWidth = KANJI_CONSTANTS.STROKE_WIDTH_BASE / this.scale;
|
||||
ctx.setLineDash([]);
|
||||
|
||||
const limit = includeCurrent ? this.currentStrokeIdx + 1 : this.currentStrokeIdx;
|
||||
this.paths.slice(0, limit).forEach((d) => {
|
||||
ctx.stroke(new Path2D(d));
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
|
||||
if (!this.isAnimating) this.resetDrawStyle();
|
||||
}
|
||||
|
||||
clearCanvas(ctx) {
|
||||
if (ctx) ctx.clearRect(0, 0, this.size, this.size);
|
||||
}
|
||||
|
||||
resetDrawStyle() {
|
||||
const { r, g, b } = KANJI_CONSTANTS.COLORS.USER;
|
||||
if (this.ctx.draw) {
|
||||
this.ctx.draw.strokeStyle = `rgb(${r}, ${g}, ${b})`;
|
||||
this.ctx.draw.lineWidth = KANJI_CONSTANTS.STROKE_WIDTH_BASE;
|
||||
this.ctx.draw.setLineDash([]);
|
||||
}
|
||||
}
|
||||
|
||||
validateStroke() {
|
||||
const targetD = this.paths[this.currentStrokeIdx];
|
||||
const userNormalized = this.userPath.map((p) => ({
|
||||
x: p.x / this.scale,
|
||||
y: p.y / this.scale,
|
||||
}));
|
||||
|
||||
if (this.checkMatch(userNormalized, targetD)) {
|
||||
this.animateMorph(this.userPath, targetD, () => {
|
||||
this.currentStrokeIdx += 1;
|
||||
this.mistakes = 0;
|
||||
this.redrawAllPerfectStrokes();
|
||||
|
||||
if (this.currentStrokeIdx >= this.paths.length) {
|
||||
this.onComplete();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.mistakes += 1;
|
||||
this.animateErrorFade(this.userPath, () => {
|
||||
this.redrawAllPerfectStrokes();
|
||||
const needsHint = this.mistakes >= 3;
|
||||
if (needsHint) this.showHint();
|
||||
this.onMistake(needsHint);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
checkMatch(userPts, targetD) {
|
||||
if (userPts.length < 3) return false;
|
||||
|
||||
const pathEl = KanjiController.createPathElement(targetD);
|
||||
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;
|
||||
|
||||
let totalError = 0;
|
||||
const sampleCount = SAMPLES + 1;
|
||||
|
||||
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);
|
||||
|
||||
return (totalError / sampleCount) < avgDistThreshold;
|
||||
}
|
||||
|
||||
animateErrorFade(userPath, onComplete) {
|
||||
this.isAnimating = true;
|
||||
const startTime = performance.now();
|
||||
const DURATION = 300;
|
||||
|
||||
const tick = (now) => {
|
||||
const elapsed = now - startTime;
|
||||
const progress = Math.min(elapsed / DURATION, 1);
|
||||
const opacity = 1 - progress;
|
||||
|
||||
this.redrawAllPerfectStrokes();
|
||||
|
||||
if (opacity > 0) {
|
||||
const ctx = this.ctx.draw;
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
|
||||
const { r, g, b } = KANJI_CONSTANTS.COLORS.USER;
|
||||
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
ctx.lineWidth = KANJI_CONSTANTS.STROKE_WIDTH_BASE;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
if (userPath.length > 0) {
|
||||
ctx.moveTo(userPath[0].x, userPath[0].y);
|
||||
userPath.slice(1).forEach((p) => ctx.lineTo(p.x, p.y));
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(tick);
|
||||
} else {
|
||||
this.isAnimating = false;
|
||||
this.resetDrawStyle();
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
animateMorph(userPoints, targetD, onComplete) {
|
||||
this.isAnimating = true;
|
||||
const targetPoints = this.getSvgPoints(targetD, KANJI_CONSTANTS.SAMPLE_POINTS);
|
||||
const startPoints = KanjiController.resamplePoints(userPoints, KANJI_CONSTANTS.SAMPLE_POINTS);
|
||||
|
||||
const startTime = performance.now();
|
||||
const { USER, FINAL } = KANJI_CONSTANTS.COLORS;
|
||||
|
||||
const tick = (now) => {
|
||||
const elapsed = now - startTime;
|
||||
const progress = Math.min(elapsed / KANJI_CONSTANTS.ANIMATION_DURATION, 1);
|
||||
const ease = 1 - (1 - progress) ** 3;
|
||||
|
||||
this.redrawAllPerfectStrokes(false);
|
||||
|
||||
const r = USER.r + (FINAL.r - USER.r) * ease;
|
||||
const g = USER.g + (FINAL.g - USER.g) * ease;
|
||||
const b = USER.b + (FINAL.b - USER.b) * ease;
|
||||
|
||||
const ctx = this.ctx.draw;
|
||||
ctx.strokeStyle = `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`;
|
||||
ctx.lineWidth = KANJI_CONSTANTS.STROKE_WIDTH_BASE;
|
||||
ctx.beginPath();
|
||||
|
||||
Array.from({ length: KANJI_CONSTANTS.SAMPLE_POINTS }).forEach((_, i) => {
|
||||
const sx = startPoints[i].x;
|
||||
const sy = startPoints[i].y;
|
||||
const ex = targetPoints[i].x;
|
||||
const ey = targetPoints[i].y;
|
||||
|
||||
const cx = sx + (ex - sx) * ease;
|
||||
const cy = sy + (ey - sy) * ease;
|
||||
|
||||
if (i === 0) ctx.moveTo(cx, cy);
|
||||
else ctx.lineTo(cx, cy);
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(tick);
|
||||
} else {
|
||||
this.isAnimating = false;
|
||||
this.resetDrawStyle();
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
getSvgPoints(d, count) {
|
||||
const path = KanjiController.createPathElement(d);
|
||||
const len = path.getTotalLength();
|
||||
|
||||
return Array.from({ length: count }).map((_, i) => {
|
||||
const pt = path.getPointAtLength((i / (count - 1)) * len);
|
||||
return { x: pt.x * this.scale, y: pt.y * this.scale };
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@
|
||||
.text-h2.font-weight-bold.mb-1 {{ selectedItem?.char }}
|
||||
.text-subtitle-1.text-grey-lighten-1.text-capitalize.mb-4 {{ selectedItem?.meaning }}
|
||||
|
||||
KanjiSvgViewer(
|
||||
KanjiSvgViewer.mb-4(
|
||||
v-if="showModal && selectedItem"
|
||||
:char="selectedItem.char"
|
||||
)
|
||||
@@ -97,21 +97,35 @@
|
||||
.reading-label {{ $t('collection.nanori') }}
|
||||
.reading-value {{ selectedItem?.nanori.join(', ') }}
|
||||
|
||||
v-btn.text-white(
|
||||
block
|
||||
color="#2f3542"
|
||||
@click="showModal = false"
|
||||
) {{ $t('collection.close') }}
|
||||
v-row.px-2.pb-2
|
||||
v-col.pr-2(cols="6")
|
||||
v-btn.text-white(
|
||||
block
|
||||
color="#2f3542"
|
||||
@click="showModal = false"
|
||||
) {{ $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') }}
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import KanjiSvgViewer from '@/components/kanji/KanjiSvgViewer.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useAppStore();
|
||||
const router = useRouter();
|
||||
|
||||
const loading = ref(true);
|
||||
const showModal = ref(false);
|
||||
@@ -119,62 +133,77 @@ const selectedItem = ref(null);
|
||||
const searchQuery = ref('');
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchCollection();
|
||||
loading.value = false;
|
||||
await store.fetchCollection();
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
const filteredCollection = computed(() => {
|
||||
if (!searchQuery.value) return store.collection;
|
||||
const q = searchQuery.value.toLowerCase().trim();
|
||||
if (!searchQuery.value) return store.collection;
|
||||
const q = searchQuery.value.toLowerCase().trim();
|
||||
|
||||
return store.collection.filter(item => {
|
||||
if (item.meaning && item.meaning.toLowerCase().includes(q)) return true;
|
||||
if (item.char && item.char.includes(q)) return true;
|
||||
if (item.onyomi && item.onyomi.some(r => r.includes(q))) return true;
|
||||
if (item.kunyomi && item.kunyomi.some(r => r.includes(q))) return true;
|
||||
if (item.nanori && item.nanori.some(r => r.includes(q))) return true;
|
||||
return false;
|
||||
});
|
||||
return store.collection.filter((item) => {
|
||||
if (item.meaning && item.meaning.toLowerCase().includes(q)) return true;
|
||||
if (item.char && item.char.includes(q)) return true;
|
||||
if (item.onyomi && item.onyomi.some((r) => r.includes(q))) return true;
|
||||
if (item.kunyomi && item.kunyomi.some((r) => r.includes(q))) return true;
|
||||
if (item.nanori && item.nanori.some((r) => r.includes(q))) return true;
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
const groupedItems = computed(() => {
|
||||
const groups = {};
|
||||
filteredCollection.value.forEach(i => {
|
||||
if (!groups[i.level]) groups[i.level] = [];
|
||||
groups[i.level].push(i);
|
||||
});
|
||||
return groups;
|
||||
const groups = {};
|
||||
filteredCollection.value.forEach((i) => {
|
||||
if (!groups[i.level]) groups[i.level] = [];
|
||||
groups[i.level].push(i);
|
||||
});
|
||||
return groups;
|
||||
});
|
||||
|
||||
const SRS_COLORS = {
|
||||
1: '#ff7675', 2: '#fdcb6e', 3: '#55efc4',
|
||||
4: '#0984e3', 5: '#a29bfe', 6: '#6c5ce7',
|
||||
7: '#00cec9', 8: '#fd79a8', 9: '#e84393',
|
||||
10: '#ffd700'
|
||||
1: '#ff7675',
|
||||
2: '#fdcb6e',
|
||||
3: '#55efc4',
|
||||
4: '#0984e3',
|
||||
5: '#a29bfe',
|
||||
6: '#6c5ce7',
|
||||
7: '#00cec9',
|
||||
8: '#fd79a8',
|
||||
9: '#e84393',
|
||||
10: '#ffd700',
|
||||
};
|
||||
|
||||
const getSRSColor = (lvl) => SRS_COLORS[lvl] || '#444';
|
||||
|
||||
const openDetail = (item) => {
|
||||
selectedItem.value = item;
|
||||
showModal.value = true;
|
||||
selectedItem.value = item;
|
||||
showModal.value = true;
|
||||
};
|
||||
|
||||
const redoLesson = () => {
|
||||
if (!selectedItem.value) return;
|
||||
router.push({
|
||||
path: '/lesson',
|
||||
query: { subjectId: selectedItem.value.wkSubjectId },
|
||||
state: { item: JSON.parse(JSON.stringify(selectedItem.value)) },
|
||||
});
|
||||
};
|
||||
|
||||
const hasReading = (arr) => arr && arr.length > 0;
|
||||
|
||||
const getNextReviewText = (dateStr, srsLvl) => {
|
||||
if (srsLvl >= 10) return 'COMPLETE';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
if (date <= now) return t('stats.availableNow');
|
||||
if (srsLvl >= 10) return 'COMPLETE';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
if (date <= now) return t('stats.availableNow');
|
||||
|
||||
const diffMs = date - now;
|
||||
const diffHrs = Math.floor(diffMs / 3600000);
|
||||
const diffMs = date - now;
|
||||
const diffHrs = Math.floor(diffMs / 3600000);
|
||||
|
||||
if (diffHrs < 24) return t('stats.inHours', { n: diffHrs }, diffHrs);
|
||||
if (diffHrs < 24) return t('stats.inHours', { n: diffHrs }, diffHrs);
|
||||
|
||||
const diffDays = Math.floor(diffHrs / 24);
|
||||
return `Next: ${diffDays}d`;
|
||||
const diffDays = Math.floor(diffHrs / 24);
|
||||
return `Next: ${diffDays}d`;
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
.dashboard-layout.fade-in
|
||||
WidgetWelcome(
|
||||
:queue-length="stats.queueLength"
|
||||
:lesson-count="stats.lessonCount"
|
||||
:has-lower-levels="stats.hasLowerLevels"
|
||||
:lower-level-count="stats.lowerLevelCount"
|
||||
:forecast="stats.forecast"
|
||||
@@ -31,9 +32,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
|
||||
import WidgetWelcome from '@/components/dashboard/WidgetWelcome.vue';
|
||||
import WidgetHeatmap from '@/components/dashboard/WidgetHeatmap.vue';
|
||||
@@ -49,32 +51,33 @@ const router = useRouter();
|
||||
const loading = ref(true);
|
||||
|
||||
const stats = ref({
|
||||
queueLength: 0,
|
||||
hasLowerLevels: false,
|
||||
lowerLevelCount: 0,
|
||||
distribution: {},
|
||||
forecast: [],
|
||||
ghosts: [],
|
||||
accuracy: { total: 0, correct: 0 },
|
||||
heatmap: {},
|
||||
streak: { current: 0, history: [], shield: { ready: false } }
|
||||
queueLength: 0,
|
||||
lessonCount: 0,
|
||||
hasLowerLevels: false,
|
||||
lowerLevelCount: 0,
|
||||
distribution: {},
|
||||
forecast: [],
|
||||
ghosts: [],
|
||||
accuracy: { total: 0, correct: 0 },
|
||||
heatmap: {},
|
||||
streak: { current: 0, history: [], shield: { ready: false } },
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await store.fetchStats();
|
||||
if (data) {
|
||||
stats.value = { ...stats.value, ...data };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Dashboard Load Error:", e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
try {
|
||||
const data = await store.fetchStats();
|
||||
if (data) {
|
||||
stats.value = { ...stats.value, ...data };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Dashboard Load Error:', e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function handleStart(mode) {
|
||||
router.push({ path: '/review', query: { mode: mode } });
|
||||
router.push({ path: '/review', query: { mode } });
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
194
client/src/views/Lesson.vue
Normal file
194
client/src/views/Lesson.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<template lang="pug">
|
||||
v-container.page-container-center
|
||||
|
||||
v-card.lesson-card(
|
||||
v-if="currentItem"
|
||||
elevation="0"
|
||||
)
|
||||
.d-flex.align-center.pa-4.border-b-subtle
|
||||
v-btn(icon="mdi-close" variant="text" to="/dashboard" color="grey")
|
||||
v-spacer
|
||||
.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 }}
|
||||
|
||||
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
|
||||
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-darken-1.text-uppercase.mb-3.font-weight-bold
|
||||
|{{ $t('lesson.components') }}
|
||||
.d-flex.flex-wrap.gap-3.justify-center
|
||||
v-sheet.radical-chip(
|
||||
v-for="rad in currentItem.radicals" :key="rad.meaning")
|
||||
.d-flex.align-center.gap-2.px-3.py-1
|
||||
v-avatar(size="24" rounded="0")
|
||||
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 }}
|
||||
.readings-grid
|
||||
.reading-box
|
||||
.label {{ $t('collection.onyomi') }}
|
||||
.val.text-cyan-lighten-3 {{ currentItem.onyomi[0] || '-' }}
|
||||
.reading-box
|
||||
.label {{ $t('collection.kunyomi') }}
|
||||
.val.text-pink-lighten-3 {{ currentItem.kunyomi[0] || '-' }}
|
||||
|
||||
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') }}
|
||||
.canvas-wrapper.lesson-canvas-wrapper
|
||||
KanjiSvgViewer(ref="demoViewer" :char="currentItem.char" mode="animate")
|
||||
|
||||
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)
|
||||
|
||||
.transition-opacity(
|
||||
:style="{ opacity: canvasOpacity }"
|
||||
style="transition: opacity 0.3s ease;"
|
||||
)
|
||||
KanjiCanvas(
|
||||
ref="canvas"
|
||||
:char="currentItem.char"
|
||||
:auto-hint="subPhase === 'guided'"
|
||||
:size="300"
|
||||
@complete="handleDrawComplete"
|
||||
@mistake="handleMistake"
|
||||
)
|
||||
|
||||
.pa-6
|
||||
v-btn.action-btn(
|
||||
v-if="phase === 'primer'"
|
||||
@click="phase = 'demo'"
|
||||
color="cyan" rounded="pill" size="large" block
|
||||
) {{ $t('lesson.understand') }}
|
||||
v-btn.action-btn(
|
||||
v-if="phase === 'demo'"
|
||||
@click="startDrawing"
|
||||
color="cyan" rounded="pill" size="large" block
|
||||
) {{ $t('lesson.ready') }}
|
||||
.d-flex.justify-center.gap-4.mt-2(v-if="phase === 'drawing' && subPhase === 'practice'")
|
||||
v-btn(
|
||||
variant="text" color="amber" @click="canvas?.showHint()"
|
||||
) {{ $t('lesson.hintAction') }}
|
||||
v-btn(variant="text" color="grey" @click="phase='demo'") {{ $t('lesson.watchAgain') }}
|
||||
|
||||
v-card.text-center.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 }) }}
|
||||
v-btn.glow-btn.text-black.font-weight-bold(
|
||||
to="/dashboard"
|
||||
block
|
||||
color="cyan"
|
||||
height="50"
|
||||
rounded="pill"
|
||||
) {{ $t('lesson.backToDashboard') }}
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
import {
|
||||
ref, computed, onMounted, nextTick, watch,
|
||||
} from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import KanjiCanvas from '@/components/kanji/KanjiCanvas.vue';
|
||||
import KanjiSvgViewer from '@/components/kanji/KanjiSvgViewer.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useAppStore();
|
||||
const currentItem = ref(null);
|
||||
const phase = ref('primer');
|
||||
const subPhase = ref('guided');
|
||||
const practiceCount = ref(0);
|
||||
const sessionDone = ref(0);
|
||||
const sessionTotal = ref(0);
|
||||
const canvasOpacity = ref(1);
|
||||
|
||||
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 phaseColor = computed(() => (phase.value === 'primer' ? 'blue' : 'purple'));
|
||||
|
||||
watch(phase, (val) => {
|
||||
if (val === 'demo') nextTick(() => demoViewer.value?.playAnimation());
|
||||
});
|
||||
|
||||
function loadNext() {
|
||||
if (store.lessonQueue.length === 0) {
|
||||
currentItem.value = null;
|
||||
return;
|
||||
}
|
||||
[currentItem.value] = store.lessonQueue;
|
||||
phase.value = 'primer';
|
||||
subPhase.value = 'guided';
|
||||
practiceCount.value = 0;
|
||||
}
|
||||
|
||||
function startDrawing() {
|
||||
phase.value = 'drawing';
|
||||
subPhase.value = 'guided';
|
||||
nextTick(() => canvas.value?.reset());
|
||||
}
|
||||
|
||||
async function handleDrawComplete() {
|
||||
await new Promise((r) => { setTimeout(r, 400); });
|
||||
|
||||
if (subPhase.value === 'guided') {
|
||||
canvasOpacity.value = 0;
|
||||
await new Promise((r) => { setTimeout(r, 300); });
|
||||
|
||||
subPhase.value = 'practice';
|
||||
canvas.value?.reset();
|
||||
|
||||
canvasOpacity.value = 1;
|
||||
} else {
|
||||
practiceCount.value += 1;
|
||||
|
||||
if (practiceCount.value < 3) {
|
||||
canvasOpacity.value = 0;
|
||||
await new Promise((r) => { setTimeout(r, 300); });
|
||||
canvas.value?.reset();
|
||||
canvasOpacity.value = 1;
|
||||
} else {
|
||||
await store.submitLesson(currentItem.value.wkSubjectId);
|
||||
sessionDone.value += 1;
|
||||
store.lessonQueue.shift();
|
||||
loadNext();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleMistake() {
|
||||
if (subPhase.value === 'practice') practiceCount.value = 0;
|
||||
}
|
||||
onMounted(async () => {
|
||||
await store.fetchLessonQueue();
|
||||
sessionTotal.value = store.lessonQueue.length;
|
||||
loadNext();
|
||||
});
|
||||
</script>
|
||||
@@ -1,11 +1,8 @@
|
||||
<template lang="pug">
|
||||
v-container.fill-height.justify-center.pa-4
|
||||
v-fade-transition(mode="out-in")
|
||||
v-card.pa-6.rounded-xl.elevation-10.border-subtle.d-flex.flex-column.align-center(
|
||||
v-card.pa-6.rounded-xl.elevation-10.d-flex.flex-column.align-center.review-card(
|
||||
v-if="currentItem"
|
||||
color="#1e1e24"
|
||||
width="100%"
|
||||
max-width="420"
|
||||
)
|
||||
.d-flex.align-center.w-100.mb-6
|
||||
v-btn.mr-2(
|
||||
@@ -30,24 +27,25 @@
|
||||
.text-h3.font-weight-bold.text-white.text-shadow
|
||||
| {{ currentItem.meaning }}
|
||||
|
||||
.canvas-wrapper.mb-2
|
||||
.review-canvas-area
|
||||
KanjiCanvas(
|
||||
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="#00cec9"
|
||||
color="primary"
|
||||
icon="mdi-arrow-right"
|
||||
size="large"
|
||||
elevation="8"
|
||||
)
|
||||
|
||||
.mb-4
|
||||
.mb-4.d-flex.gap-2
|
||||
v-btn.text-caption.font-weight-bold.opacity-80(
|
||||
variant="text"
|
||||
color="amber-lighten-1"
|
||||
@@ -55,7 +53,16 @@
|
||||
size="small"
|
||||
:disabled="showNext || statusCode === 'hint'"
|
||||
@click="triggerHint"
|
||||
) Show Hint
|
||||
) {{ $t('review.showHint') }}
|
||||
|
||||
v-btn.text-caption.font-weight-bold.opacity-80(
|
||||
variant="text"
|
||||
color="purple-lighten-2"
|
||||
prepend-icon="mdi-school-outline"
|
||||
size="small"
|
||||
:disabled="showNext"
|
||||
@click="redoLesson"
|
||||
) {{ $t('review.redoLesson') }}
|
||||
|
||||
v-sheet.d-flex.align-center.justify-center(
|
||||
width="100%"
|
||||
@@ -68,29 +75,25 @@
|
||||
:class="getStatusClass(statusCode)"
|
||||
) {{ $t('review.' + statusCode) }}
|
||||
|
||||
v-progress-linear.mt-4(
|
||||
v-progress-linear.mt-4.progress-bar(
|
||||
v-model="progressPercent"
|
||||
color="#00cec9"
|
||||
color="primary"
|
||||
height="4"
|
||||
rounded
|
||||
style="opacity: 0.3;"
|
||||
)
|
||||
|
||||
v-card.pa-8.rounded-xl.elevation-10.border-subtle.text-center(
|
||||
v-card.pa-8.rounded-xl.elevation-10.text-center.review-card(
|
||||
v-else-if="sessionTotal > 0 && store.queue.length === 0"
|
||||
color="#1e1e24"
|
||||
width="100%"
|
||||
max-width="400"
|
||||
)
|
||||
.mb-6
|
||||
v-avatar(color="rgba(0, 206, 201, 0.1)" size="80")
|
||||
v-icon(size="40" color="#00cec9") mdi-trophy
|
||||
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') }}
|
||||
|
||||
v-row.mb-6
|
||||
v-col.border-r.border-grey-darken-3(cols="6")
|
||||
v-col.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")
|
||||
@@ -100,7 +103,7 @@
|
||||
v-btn.text-black.font-weight-bold.glow-btn(
|
||||
to="/dashboard"
|
||||
block
|
||||
color="#00cec9"
|
||||
color="primary"
|
||||
height="50"
|
||||
rounded="lg"
|
||||
) {{ $t('review.back') }}
|
||||
@@ -111,19 +114,23 @@
|
||||
.text-grey.mb-6 {{ $t('review.noReviews') }}
|
||||
v-btn.font-weight-bold(
|
||||
to="/dashboard"
|
||||
color="#00cec9"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
) {{ $t('review.viewCollection') }}
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue';
|
||||
/* eslint-disable no-unused-vars */
|
||||
import {
|
||||
ref, onMounted, computed, watch,
|
||||
} from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import { useRoute } from 'vue-router';
|
||||
import KanjiCanvas from '@/components/kanji/KanjiCanvas.vue';
|
||||
|
||||
const store = useAppStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const currentItem = ref(null);
|
||||
const statusCode = ref('draw');
|
||||
@@ -136,103 +143,113 @@ const sessionDone = ref(0);
|
||||
const sessionCorrect = ref(0);
|
||||
|
||||
const accuracy = computed(() => {
|
||||
if (sessionDone.value === 0) return 100;
|
||||
return Math.round((sessionCorrect.value / sessionDone.value) * 100);
|
||||
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;
|
||||
if (sessionTotal.value === 0) return 0;
|
||||
return (sessionDone.value / sessionTotal.value) * 100;
|
||||
});
|
||||
|
||||
function loadNext() {
|
||||
if (store.queue.length === 0) {
|
||||
currentItem.value = null;
|
||||
return;
|
||||
}
|
||||
const idx = Math.floor(Math.random() * store.queue.length);
|
||||
currentItem.value = store.queue[idx];
|
||||
|
||||
statusCode.value = 'draw';
|
||||
showNext.value = false;
|
||||
isFailure.value = false;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const mode = route.query.mode || 'shuffled';
|
||||
await store.fetchQueue(mode);
|
||||
const mode = route.query.mode || 'shuffled';
|
||||
await store.fetchQueue(mode);
|
||||
|
||||
if (sessionTotal.value === 0 && store.queue.length > 0) {
|
||||
sessionTotal.value = store.queue.length;
|
||||
}
|
||||
loadNext();
|
||||
});
|
||||
|
||||
watch(() => store.batchSize, async () => {
|
||||
resetSession();
|
||||
if (sessionTotal.value === 0 && store.queue.length > 0) {
|
||||
sessionTotal.value = store.queue.length;
|
||||
}
|
||||
loadNext();
|
||||
});
|
||||
|
||||
async function resetSession() {
|
||||
sessionDone.value = 0;
|
||||
sessionCorrect.value = 0;
|
||||
sessionTotal.value = 0;
|
||||
currentItem.value = null;
|
||||
const mode = route.query.mode || 'shuffled';
|
||||
await store.fetchQueue(mode);
|
||||
if (store.queue.length > 0) sessionTotal.value = store.queue.length;
|
||||
loadNext();
|
||||
sessionDone.value = 0;
|
||||
sessionCorrect.value = 0;
|
||||
sessionTotal.value = 0;
|
||||
currentItem.value = null;
|
||||
const mode = route.query.mode || 'shuffled';
|
||||
await store.fetchQueue(mode);
|
||||
if (store.queue.length > 0) sessionTotal.value = store.queue.length;
|
||||
loadNext();
|
||||
}
|
||||
|
||||
function loadNext() {
|
||||
if (store.queue.length === 0) {
|
||||
currentItem.value = null;
|
||||
return;
|
||||
}
|
||||
const idx = Math.floor(Math.random() * store.queue.length);
|
||||
currentItem.value = store.queue[idx];
|
||||
|
||||
statusCode.value = "draw";
|
||||
showNext.value = false;
|
||||
isFailure.value = false;
|
||||
}
|
||||
watch(() => store.batchSize, async () => {
|
||||
resetSession();
|
||||
});
|
||||
|
||||
function triggerHint() {
|
||||
if (!kanjiCanvasRef.value) return;
|
||||
if (!kanjiCanvasRef.value) return;
|
||||
|
||||
isFailure.value = true;
|
||||
statusCode.value = "hint";
|
||||
isFailure.value = true;
|
||||
statusCode.value = 'hint';
|
||||
|
||||
kanjiCanvasRef.value.drawGuide(true);
|
||||
kanjiCanvasRef.value.drawGuide(true);
|
||||
}
|
||||
|
||||
function handleMistake(isHint) {
|
||||
if (isHint) {
|
||||
isFailure.value = true;
|
||||
statusCode.value = "hint";
|
||||
} else {
|
||||
statusCode.value = "tryAgain";
|
||||
}
|
||||
if (isHint) {
|
||||
isFailure.value = true;
|
||||
statusCode.value = 'hint';
|
||||
} else {
|
||||
statusCode.value = 'tryAgain';
|
||||
}
|
||||
}
|
||||
|
||||
function handleComplete() {
|
||||
statusCode.value = "correct";
|
||||
showNext.value = true;
|
||||
statusCode.value = 'correct';
|
||||
showNext.value = true;
|
||||
}
|
||||
|
||||
async function next() {
|
||||
if (!currentItem.value) return;
|
||||
if (!currentItem.value) return;
|
||||
|
||||
await store.submitReview(currentItem.value.wkSubjectId, !isFailure.value);
|
||||
await store.submitReview(currentItem.value.wkSubjectId, !isFailure.value);
|
||||
|
||||
sessionDone.value++;
|
||||
if (!isFailure.value) sessionCorrect.value++;
|
||||
const index = store.queue.findIndex(i => i._id === currentItem.value._id);
|
||||
if (index !== -1) {
|
||||
store.queue.splice(index, 1);
|
||||
}
|
||||
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);
|
||||
if (index !== -1) {
|
||||
store.queue.splice(index, 1);
|
||||
}
|
||||
|
||||
loadNext();
|
||||
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' };
|
||||
return colors[lvl] || 'grey';
|
||||
const colors = {
|
||||
1: '#ff7675', 2: '#fdcb6e', 3: '#55efc4', 4: '#0984e3', 5: '#a29bfe', 6: '#6c5ce7',
|
||||
};
|
||||
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';
|
||||
}
|
||||
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" src="@/styles/pages/_review.scss" scoped></style>
|
||||
|
||||
Reference in New Issue
Block a user