add new lesson mode and started code refraction

This commit is contained in:
Rene Kievits
2025-12-20 04:31:15 +01:00
parent 6438660b03
commit 4428a2b7be
101 changed files with 12255 additions and 8172 deletions

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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' });

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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');

View File

@@ -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;

View File

@@ -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),
});
},
},
});

View File

@@ -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
);

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -8,3 +8,4 @@
@use 'pages/dashboard';
@use 'pages/review';
@use 'pages/collection';
@use 'pages/lesson';

View File

@@ -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;
}
}

View 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;
}

View File

@@ -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;
}

View 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 };
});
}
}

View File

@@ -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>

View File

@@ -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
View 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>

View File

@@ -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>