init
2
client/.env.android
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_URL=http://10.0.2.2:3000
|
||||
CAP_ENV=dev
|
||||
2
client/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
.env
|
||||
17
client/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM node:24-alpine AS dev-stage
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
EXPOSE 5173
|
||||
CMD ["npm", "run", "dev", "--", "--host"]
|
||||
|
||||
FROM dev-stage AS build-stage
|
||||
ARG VITE_API_URL
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:stable-alpine AS production-stage
|
||||
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
30
client/capacitor.config.js
Normal file
@@ -0,0 +1,30 @@
|
||||
/// <reference types="@capacitor/cli" />
|
||||
require('dotenv').config();
|
||||
|
||||
const isDev = process.env.CAP_ENV === 'dev';
|
||||
|
||||
const config = {
|
||||
appId: "com.zenkanji.app",
|
||||
appName: "Zen Kanji",
|
||||
webDir: "dist",
|
||||
server: {
|
||||
androidScheme: isDev ? "http" : "https",
|
||||
cleartext: isDev
|
||||
},
|
||||
plugins: {
|
||||
Keyboard: {
|
||||
resize: "body",
|
||||
style: "dark",
|
||||
resizeOnFullScreen: true
|
||||
},
|
||||
SplashScreen: {
|
||||
launchShowDuration: 2000,
|
||||
backgroundColor: "#1e1e24",
|
||||
showSpinner: false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
console.log(`⚡️ Capacitor Config loaded for: ${isDev ? 'DEVELOPMENT (http)' : 'PRODUCTION (https)'}`);
|
||||
|
||||
module.exports = config;
|
||||
69
client/eslint.config.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import pluginVue from 'eslint-plugin-vue';
|
||||
import pluginVuePug from 'eslint-plugin-vue-pug';
|
||||
import pluginJsonc from 'eslint-plugin-jsonc';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/android/**',
|
||||
'**/coverage/**',
|
||||
'**/*.min.js'
|
||||
]
|
||||
},
|
||||
|
||||
js.configs.recommended,
|
||||
{
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
...globals.es2021
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': 'warn',
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
|
||||
}
|
||||
},
|
||||
|
||||
...pluginVue.configs['flat/recommended'],
|
||||
{
|
||||
files: ['*.vue', '**/*.vue'],
|
||||
plugins: {
|
||||
'vue-pug': pluginVuePug
|
||||
},
|
||||
languageOptions: {
|
||||
parser: pluginVue.parser,
|
||||
parserOptions: {
|
||||
parser: js.configs.recommended.parser,
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/require-default-prop': 'off',
|
||||
'vue/html-indent': ['error', 2],
|
||||
'no-unused-vars': 'off',
|
||||
'vue/component-name-in-template-casing': ['error', 'PascalCase', {
|
||||
registeredComponentsOnly: true,
|
||||
ignores: []
|
||||
}]
|
||||
}
|
||||
},
|
||||
|
||||
...pluginJsonc.configs['flat/recommended-with-jsonc'],
|
||||
{
|
||||
files: ['*.json', '**/*.json'],
|
||||
rules: {
|
||||
'jsonc/sort-keys': 'off',
|
||||
}
|
||||
}
|
||||
];
|
||||
16
client/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Zen Kanji</title>
|
||||
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
6658
client/package-lock.json
generated
Normal file
47
client/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "zen-kanji-client",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"lint:style": "stylelint \"**/*.{css,scss,vue}\"",
|
||||
"lint:style:fix": "stylelint \"**/*.{css,scss,vue}\" --fix",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:android": "vite build --mode android",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^8.0.0",
|
||||
"@capacitor/core": "^8.0.0",
|
||||
"@mdi/font": "^7.3.67",
|
||||
"capacitor": "^0.5.6",
|
||||
"i18n": "^0.15.3",
|
||||
"pinia": "^3.0.4",
|
||||
"pug": "^3.0.3",
|
||||
"vue": "^3.3.4",
|
||||
"vue-i18n": "^11.2.2",
|
||||
"vue-pug": "^1.0.2",
|
||||
"vue-pug-plugin": "^2.0.4",
|
||||
"vue-router": "^4.2.5",
|
||||
"vuetify": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^7.4.4",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-jsonc": "^2.21.0",
|
||||
"eslint-plugin-vue": "^9.33.0",
|
||||
"eslint-plugin-vue-pug": "^0.6.2",
|
||||
"globals": "^16.5.0",
|
||||
"sass": "^1.97.0",
|
||||
"stylelint": "^16.26.1",
|
||||
"stylelint-config-recommended-vue": "^1.6.1",
|
||||
"stylelint-config-standard-scss": "^16.0.0",
|
||||
"vite": "^7.3.0",
|
||||
"vue-eslint-parser": "^9.4.3"
|
||||
}
|
||||
}
|
||||
348
client/src/App.vue
Normal file
@@ -0,0 +1,348 @@
|
||||
<template lang="pug">
|
||||
v-app.zen-app
|
||||
v-navigation-drawer(
|
||||
v-model="drawer"
|
||||
temporary
|
||||
location="right"
|
||||
color="#1e1e24"
|
||||
class="border-none"
|
||||
width="280"
|
||||
)
|
||||
.d-flex.flex-column.h-100.pa-4
|
||||
.d-flex.align-center.mb-6.px-2
|
||||
img.mr-3(:src="logo" height="32" width="32" alt="Logo")
|
||||
span.text-h6.font-weight-bold {{ $t('nav.menu') }}
|
||||
|
||||
v-list.pa-0(nav bg-color="transparent")
|
||||
v-list-item.mb-2(
|
||||
to="/"
|
||||
rounded="lg"
|
||||
:active="$route.path === '/'"
|
||||
)
|
||||
template(v-slot:prepend)
|
||||
v-icon(color="#00cec9" icon="mdi-view-dashboard")
|
||||
v-list-item-title.font-weight-bold {{ $t('nav.dashboard') }}
|
||||
|
||||
v-list-item.mb-2(
|
||||
to="/collection"
|
||||
rounded="lg"
|
||||
:active="$route.path === '/collection'"
|
||||
)
|
||||
template(v-slot:prepend)
|
||||
v-icon(color="#00cec9" icon="mdi-bookshelf")
|
||||
v-list-item-title.font-weight-bold {{ $t('nav.collection') }}
|
||||
|
||||
v-divider.my-4.border-subtle
|
||||
|
||||
.d-flex.flex-column.gap-2
|
||||
v-btn.justify-start.px-4(
|
||||
variant="text"
|
||||
block
|
||||
color="grey-lighten-1"
|
||||
@click="showSettings = true; drawer = false"
|
||||
)
|
||||
v-icon(start icon="mdi-cog")
|
||||
| {{ $t('nav.settings') }}
|
||||
|
||||
v-btn.justify-start.px-4(
|
||||
variant="text"
|
||||
block
|
||||
color="grey-lighten-1"
|
||||
:loading="syncing"
|
||||
@click="manualSync"
|
||||
)
|
||||
v-icon(start icon="mdi-sync")
|
||||
| {{ $t('nav.sync') }}
|
||||
|
||||
v-btn.justify-start.px-4.text-red-lighten-2(
|
||||
variant="text"
|
||||
block
|
||||
@click="handleLogout"
|
||||
)
|
||||
v-icon(start icon="mdi-logout")
|
||||
| {{ $t('nav.logout') }}
|
||||
|
||||
v-app-bar.px-2.app-bar-blur(
|
||||
flat
|
||||
color="rgba(30, 30, 36, 0.8)"
|
||||
border="b"
|
||||
)
|
||||
v-app-bar-title.font-weight-bold.text-h6(style="min-width: fit-content;")
|
||||
.d-flex.align-center.cursor-pointer.logo-hover(@click="$router.push('/')")
|
||||
img(:src="logo" height="32" width="32" alt="Zen Kanji Logo")
|
||||
span.ml-3.tracking-tight Zen Kanji
|
||||
|
||||
v-spacer
|
||||
|
||||
template(v-if="store.token")
|
||||
.d-none.d-md-flex.align-center
|
||||
v-btn.mx-1(
|
||||
to="/"
|
||||
:active="false"
|
||||
variant="text"
|
||||
:color="$route.path === '/' ? '#00cec9' : 'grey'"
|
||||
) {{ $t('nav.dashboard') }}
|
||||
|
||||
v-btn.mx-1(
|
||||
to="/collection"
|
||||
:active="false"
|
||||
variant="text"
|
||||
:color="$route.path === '/collection' ? '#00cec9' : 'grey'"
|
||||
) {{ $t('nav.collection') }}
|
||||
|
||||
v-divider.mx-2.my-auto(vertical length="20" color="grey-darken-2")
|
||||
|
||||
v-tooltip(:text="$t('nav.settings')" location="bottom")
|
||||
template(v-slot:activator="{ props }")
|
||||
v-btn(
|
||||
v-bind="props"
|
||||
icon="mdi-cog"
|
||||
variant="text"
|
||||
color="grey-lighten-1"
|
||||
@click="showSettings = true"
|
||||
)
|
||||
|
||||
v-tooltip(:text="$t('nav.sync')" location="bottom")
|
||||
template(v-slot:activator="{ props }")
|
||||
v-btn(
|
||||
v-bind="props"
|
||||
icon="mdi-sync"
|
||||
variant="text"
|
||||
:loading="syncing"
|
||||
color="grey-lighten-1"
|
||||
@click="manualSync"
|
||||
)
|
||||
|
||||
v-tooltip(:text="$t('nav.logout')" location="bottom")
|
||||
template(v-slot:activator="{ props }")
|
||||
v-btn(
|
||||
v-bind="props"
|
||||
icon="mdi-logout"
|
||||
variant="text"
|
||||
color="grey-darken-1"
|
||||
@click="handleLogout"
|
||||
)
|
||||
|
||||
.d-flex.d-md-none
|
||||
v-btn(
|
||||
icon="mdi-menu"
|
||||
variant="text"
|
||||
color="grey-lighten-1"
|
||||
@click="drawer = !drawer"
|
||||
)
|
||||
|
||||
v-main
|
||||
.fill-height.d-flex.align-center.justify-center.px-4(v-if="!store.token")
|
||||
v-card.pa-6.rounded-lg.elevation-10.text-center.border-subtle(
|
||||
color="#1e1e24"
|
||||
width="100%"
|
||||
max-width="400"
|
||||
)
|
||||
img.mb-4(:src="logo" height="64" width="64")
|
||||
|
||||
h1.text-h5.font-weight-bold.mb-2 {{ $t('hero.welcome') }}
|
||||
p.text-grey-lighten-1.text-body-2.mb-6 {{ $t('login.instruction') }}
|
||||
|
||||
v-text-field.mb-2(
|
||||
v-model="inputKey"
|
||||
:placeholder="$t('login.placeholder')"
|
||||
variant="solo-filled"
|
||||
bg-color="#2f3542"
|
||||
color="white"
|
||||
hide-details
|
||||
density="comfortable"
|
||||
@keyup.enter="handleLogin"
|
||||
)
|
||||
|
||||
v-alert.mb-4.text-left.text-caption(
|
||||
v-if="errorMsg"
|
||||
type="error"
|
||||
density="compact"
|
||||
variant="tonal"
|
||||
) {{ errorMsg }}
|
||||
|
||||
v-btn.text-black.font-weight-bold.mt-4(
|
||||
block
|
||||
color="#00cec9"
|
||||
height="44"
|
||||
:loading="loggingIn"
|
||||
@click="handleLogin"
|
||||
) {{ $t('login.button') }}
|
||||
|
||||
router-view(v-else)
|
||||
|
||||
v-dialog(v-model="showSettings" max-width="340")
|
||||
v-card.rounded-xl.pa-4.border-subtle(color="#1e1e24")
|
||||
v-card-title.text-center.font-weight-bold {{ $t('settings.title') }}
|
||||
|
||||
v-card-text.pt-4
|
||||
.text-caption.text-grey.mb-2 {{ $t('settings.batchSize') }}
|
||||
v-slider(
|
||||
v-model="tempBatchSize"
|
||||
:min="5"
|
||||
:max="100"
|
||||
:step="5"
|
||||
thumb-label
|
||||
color="#00cec9"
|
||||
track-color="grey-darken-3"
|
||||
)
|
||||
.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
|
||||
v-slider(
|
||||
v-model="tempDrawingAccuracy"
|
||||
:min="5"
|
||||
:max="20"
|
||||
:step="1"
|
||||
thumb-label
|
||||
color="#00cec9"
|
||||
track-color="grey-darken-3"
|
||||
)
|
||||
.d-flex.justify-space-between.text-caption.text-grey-lighten-1.mb-6.px-1
|
||||
span Strict (5)
|
||||
span.font-weight-bold.text-body-1(color="#00cec9") {{ tempDrawingAccuracy }}
|
||||
span Loose (20)
|
||||
|
||||
.text-caption.text-grey.mb-2 {{ $t('settings.language') }}
|
||||
v-btn-toggle.d-flex.w-100.border-subtle(
|
||||
v-model="$i18n.locale"
|
||||
mandatory
|
||||
rounded="lg"
|
||||
color="#00cec9"
|
||||
)
|
||||
v-btn.flex-grow-1(value="en") EN
|
||||
v-btn.flex-grow-1(value="de") DE
|
||||
v-btn.flex-grow-1(value="ja") JA
|
||||
|
||||
v-card-actions.mt-2
|
||||
v-btn.text-white(
|
||||
block
|
||||
color="#2f3542"
|
||||
@click="saveSettings"
|
||||
) {{ $t('settings.save') }}
|
||||
|
||||
v-dialog(v-model="showLogoutDialog" max-width="320")
|
||||
v-card.rounded-xl.pa-4.border-subtle(color="#1e1e24")
|
||||
.d-flex.flex-column.align-center.text-center.pt-2
|
||||
v-avatar.mb-3(color="red-darken-4" size="48")
|
||||
v-icon(icon="mdi-logout" size="24" color="red-lighten-1")
|
||||
|
||||
h3.text-h6.font-weight-bold.mb-2 {{ $t('nav.logout') }}?
|
||||
|
||||
p.text-body-2.text-grey-lighten-1.px-2.mb-4
|
||||
| {{ $t('alerts.logoutConfirm') }}
|
||||
|
||||
v-card-actions.d-flex.gap-2.pa-0.mt-2
|
||||
v-btn.flex-grow-1.text-capitalize(
|
||||
variant="tonal"
|
||||
color="grey"
|
||||
height="40"
|
||||
@click="showLogoutDialog = false"
|
||||
) {{ $t('common.cancel') }}
|
||||
|
||||
v-btn.flex-grow-1.text-capitalize(
|
||||
color="red-accent-2"
|
||||
variant="flat"
|
||||
height="40"
|
||||
@click="confirmLogout"
|
||||
) {{ $t('nav.logout') }}
|
||||
|
||||
v-snackbar(v-model="snackbar.show" :color="snackbar.color" timeout="3000")
|
||||
| {{ snackbar.text }}
|
||||
template(v-slot:actions)
|
||||
v-btn(variant="text" @click="snackbar.show = false") {{ $t('common.close') }}
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import logo from '@/assets/icon.svg';
|
||||
|
||||
const drawer = ref(false);
|
||||
const { locale, t } = useI18n();
|
||||
const store = useAppStore();
|
||||
|
||||
const inputKey = ref('');
|
||||
const loggingIn = ref(false);
|
||||
const syncing = ref(false);
|
||||
const errorMsg = ref('');
|
||||
const showSettings = ref(false);
|
||||
const showLogoutDialog = ref(false);
|
||||
const snackbar = ref({ show: false, text: '', color: 'success' });
|
||||
|
||||
const tempBatchSize = ref(store.batchSize);
|
||||
const tempDrawingAccuracy = ref(store.drawingAccuracy);
|
||||
|
||||
onMounted(() => {
|
||||
if (store.token) {
|
||||
store.fetchStats();
|
||||
}
|
||||
});
|
||||
|
||||
watch(showSettings, (isOpen) => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
store.saveSettings({
|
||||
batchSize: tempBatchSize.value,
|
||||
drawingAccuracy: tempDrawingAccuracy.value
|
||||
});
|
||||
|
||||
localStorage.setItem('zen_locale', locale.value);
|
||||
|
||||
showSettings.value = false;
|
||||
store.fetchQueue();
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
showLogoutDialog.value = true;
|
||||
drawer.value = false;
|
||||
}
|
||||
function confirmLogout() {
|
||||
store.logout();
|
||||
inputKey.value = '';
|
||||
showLogoutDialog.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/pages/_app.scss"></style>
|
||||
BIN
client/src/assets/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
client/src/assets/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
client/src/assets/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
client/src/assets/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 771 B |
BIN
client/src/assets/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
client/src/assets/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
18
client/src/assets/icon.svg
Normal file
|
After Width: | Height: | Size: 122 KiB |
38
client/src/components/dashboard/WidgetAccuracy.vue
Normal file
@@ -0,0 +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') }}
|
||||
|
||||
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
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
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);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>
|
||||
73
client/src/components/dashboard/WidgetDistribution.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template lang="pug">
|
||||
v-card.pa-5.rounded-xl.border-subtle.d-flex.flex-column.flex-grow-1(color="#1e1e24")
|
||||
.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
|
||||
| {{ $t('stats.srsDistribution') }}
|
||||
v-chip.font-weight-bold(
|
||||
size="x-small"
|
||||
color="white"
|
||||
variant="tonal"
|
||||
) {{ totalItems }}
|
||||
|
||||
.srs-chart-container.d-flex.justify-space-between.align-end.px-2.gap-2.flex-grow-1
|
||||
.d-flex.flex-column.align-center.flex-grow-1.srs-column(
|
||||
v-for="lvl in 10"
|
||||
:key="lvl"
|
||||
)
|
||||
.text-caption.font-weight-bold.mb-2.transition-text(
|
||||
:class="getCount(lvl) > 0 ? 'text-white' : 'text-grey-darken-3'"
|
||||
) {{ 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'\
|
||||
}")
|
||||
|
||||
.text-caption.text-grey-darken-1.font-weight-bold.mt-3(
|
||||
style="font-size: 10px !important;"
|
||||
) {{ toRoman(lvl) }}
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
distribution: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
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 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;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>
|
||||
41
client/src/components/dashboard/WidgetForecast.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<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') }}
|
||||
|
||||
.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(
|
||||
size="small"
|
||||
color="#2f3542"
|
||||
style="color: #00cec9 !important;"
|
||||
) {{ count }}
|
||||
|
||||
.fill-height.d-flex.align-center.justify-center.text-grey.text-center.pa-4(v-else)
|
||||
| {{ $t('stats.noIncoming') }}
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
forecast: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const hasUpcoming = computed(() => {
|
||||
return 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>
|
||||
32
client/src/components/dashboard/WidgetGhosts.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<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') }}
|
||||
|
||||
.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 }}%
|
||||
|
||||
.text-center.py-2.text-caption.text-grey(v-else)
|
||||
| {{ $t('stats.noGhosts') }}
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
ghosts: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>
|
||||
45
client/src/components/dashboard/WidgetGuruMastery.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<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') }}
|
||||
.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
|
||||
)
|
||||
|
||||
.text-caption.text-medium-emphasis.mt-2.text-right
|
||||
| {{ masteredCount }} / {{ totalItems }} {{ $t('stats.items') }}
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
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);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>
|
||||
89
client/src/components/dashboard/WidgetHeatmap.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<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') }}
|
||||
|
||||
.legend-container
|
||||
span.text-caption.text-medium-emphasis.mr-1 {{ $t('stats.less') }}
|
||||
.legend-box.level-0
|
||||
.legend-box.level-1
|
||||
.legend-box.level-2
|
||||
.legend-box.level-3
|
||||
.legend-box.level-4
|
||||
span.text-caption.text-medium-emphasis.ml-1 {{ $t('stats.more') }}
|
||||
|
||||
.heatmap-container
|
||||
.heatmap-year
|
||||
.heatmap-week(v-for="(week, wIdx) in weeks" :key="wIdx")
|
||||
.heatmap-day-wrapper(v-for="(day, dIdx) in week" :key="dIdx")
|
||||
v-tooltip(location="top" open-delay="100")
|
||||
template(v-slot:activator="{ props }")
|
||||
.heatmap-cell(
|
||||
v-bind="props"
|
||||
:class="[\
|
||||
isToday(day.date) ? 'today-cell' : '',\
|
||||
getHeatmapClass(day.count)\
|
||||
]"
|
||||
)
|
||||
.text-center
|
||||
.font-weight-bold {{ formatDate(day.date) }}
|
||||
div {{ $t('stats.reviewsCount', { count: day.count }) }}
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const { locale } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
heatmapData: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const weeks = computed(() => {
|
||||
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);
|
||||
|
||||
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];
|
||||
|
||||
currentWeek.push({
|
||||
date: dateStr,
|
||||
count: data[dateStr] || 0
|
||||
});
|
||||
|
||||
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';
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => new Date(dateStr).toLocaleDateString(locale.value, { month: 'short', day: 'numeric' });
|
||||
const isToday = (dateStr) => dateStr === new Date().toISOString().split('T')[0];
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>
|
||||
59
client/src/components/dashboard/WidgetStreak.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<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') }}
|
||||
|
||||
.text-center
|
||||
v-tooltip(
|
||||
location="start"
|
||||
:text="streak?.shield?.ready ? $t('stats.shieldActive') : $t('stats.shieldCooldown', { n: streak?.shield?.cooldown })"
|
||||
)
|
||||
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) }}
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
streak: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({
|
||||
current: 0,
|
||||
history: [],
|
||||
shield: { ready: false, cooldown: 0 }
|
||||
})
|
||||
}
|
||||
});
|
||||
const { locale } = useI18n();
|
||||
|
||||
const getDayLabel = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
return new Date(dateStr).toLocaleDateString(locale.value, { weekday: 'short' });
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>
|
||||
76
client/src/components/dashboard/WidgetWelcome.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template lang="pug">
|
||||
.grid-area-full.text-center.mb-4
|
||||
.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(
|
||||
@click="$emit('start', 'shuffled')"
|
||||
height="80"
|
||||
width="280"
|
||||
rounded="xl"
|
||||
color="#00cec9"
|
||||
:disabled="queueLength === 0"
|
||||
)
|
||||
v-icon(size="32" start) mdi-brush
|
||||
| {{ queueLength > 0 ? $t('hero.start') : $t('hero.noReviews') }}
|
||||
|
||||
v-chip.ml-3.font-weight-bold(
|
||||
v-if="queueLength > 0"
|
||||
color="#1e1e24"
|
||||
variant="flat"
|
||||
size="default"
|
||||
style="color: white !important;"
|
||||
) {{ queueLength }}
|
||||
|
||||
v-fade-transition
|
||||
v-btn.mt-4.text-caption.font-weight-bold(
|
||||
v-if="hasLowerLevels"
|
||||
@click="$emit('start', 'priority')"
|
||||
variant="plain"
|
||||
color="grey-lighten-1"
|
||||
prepend-icon="mdi-sort-ascending"
|
||||
:ripple="false"
|
||||
) {{ $t('hero.prioritize', { count: lowerLevelCount }) }}
|
||||
|
||||
.text-caption.text-grey.mt-2(v-if="queueLength === 0")
|
||||
| {{ $t('hero.nextIn') }} {{ nextReviewTime }}
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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: () => ({})
|
||||
}
|
||||
});
|
||||
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);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/components/_buttons.scss" scoped></style>
|
||||
247
client/src/components/kanji/KanjiCanvas.vue
Normal file
@@ -0,0 +1,247 @@
|
||||
<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(
|
||||
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"
|
||||
)
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
|
||||
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 wrapper = ref(null);
|
||||
const bgCanvas = ref(null);
|
||||
const snapCanvas = ref(null);
|
||||
const drawCanvas = ref(null);
|
||||
let ctxBg, ctxSnap, ctxDraw;
|
||||
|
||||
const kanjiPaths = ref([]);
|
||||
const currentStrokeIndex = ref(0);
|
||||
const failureCount = ref(0);
|
||||
const loading = ref(false);
|
||||
let isDrawing = false;
|
||||
let userPath = [];
|
||||
|
||||
onMounted(() => {
|
||||
initContexts();
|
||||
if (props.char) loadKanji(props.char);
|
||||
});
|
||||
|
||||
watch(() => props.char, (newChar) => {
|
||||
if (newChar) loadKanji(newChar);
|
||||
});
|
||||
|
||||
function initContexts() {
|
||||
ctxBg = bgCanvas.value.getContext('2d');
|
||||
ctxSnap = snapCanvas.value.getContext('2d');
|
||||
ctxDraw = drawCanvas.value.getContext('2d');
|
||||
|
||||
[ctxBg, ctxSnap, ctxDraw].forEach(ctx => {
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.scale(SCALE, SCALE);
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
});
|
||||
}
|
||||
|
||||
async function loadKanji(char) {
|
||||
reset();
|
||||
loading.value = true;
|
||||
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");
|
||||
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 });
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/components/_kanji.scss" scoped></style>
|
||||
138
client/src/components/kanji/KanjiSvgViewer.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<template lang="pug">
|
||||
.svg-container(:class="{ loading: loading }")
|
||||
svg.kanji-svg(
|
||||
v-if="!loading"
|
||||
viewBox="0 0 109 109"
|
||||
width="100%"
|
||||
height="100%"
|
||||
)
|
||||
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'\
|
||||
}"
|
||||
)
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
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') }}
|
||||
|
||||
button.play-btn(
|
||||
v-if="!loading && !isPlaying"
|
||||
@click="playAnimation"
|
||||
)
|
||||
svg(viewBox="0 0 24 24" fill="currentColor")
|
||||
path(d="M8 5v14l11-7z")
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
char: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
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 = [];
|
||||
|
||||
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");
|
||||
|
||||
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 };
|
||||
|
||||
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]) };
|
||||
}
|
||||
|
||||
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("SVG Load Failed", e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function playAnimation() {
|
||||
if (isPlaying.value) return;
|
||||
isPlaying.value = true;
|
||||
currentStrokeIdx.value = -1;
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
isPlaying.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/components/_kanji.scss" scoped></style>
|
||||
49
client/src/main.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import i18n from '@/plugins/i18n'
|
||||
|
||||
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 App from './App.vue'
|
||||
import Dashboard from './views/Dashboard.vue'
|
||||
import Collection from './views/Collection.vue'
|
||||
import Review from './views/Review.vue'
|
||||
|
||||
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 }
|
||||
]
|
||||
})
|
||||
|
||||
const vuetify = createVuetify({
|
||||
components,
|
||||
directives,
|
||||
theme: {
|
||||
defaultTheme: 'dark',
|
||||
themes: {
|
||||
dark: { colors: { primary: '#00cec9', secondary: '#ffeaa7' } }
|
||||
}
|
||||
},
|
||||
icons: { defaultSet: 'mdi', aliases, sets: { mdi } }
|
||||
})
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(vuetify)
|
||||
app.use(i18n)
|
||||
app.mount('#app')
|
||||
306
client/src/plugins/i18n.js
Normal file
@@ -0,0 +1,306 @@
|
||||
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",
|
||||
|
||||
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",
|
||||
|
||||
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",
|
||||
|
||||
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",
|
||||
|
||||
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} レビュー",
|
||||
|
||||
consistency: "学習の一貫性",
|
||||
less: "少",
|
||||
more: "多",
|
||||
|
||||
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: "閉じる"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const savedLocale = localStorage.getItem('zen_locale') || 'en'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: savedLocale,
|
||||
fallbackLocale: 'en',
|
||||
messages
|
||||
})
|
||||
|
||||
export default i18n
|
||||
136
client/src/stores/appStore.js
Normal file
@@ -0,0 +1,136 @@
|
||||
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
|
||||
}),
|
||||
|
||||
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');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
localStorage.setItem('zen_token', data.token);
|
||||
|
||||
await this.fetchStats();
|
||||
return data;
|
||||
},
|
||||
|
||||
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();
|
||||
}
|
||||
},
|
||||
|
||||
clearData() {
|
||||
this.token = '';
|
||||
this.user = null;
|
||||
this.queue = [];
|
||||
this.stats = {};
|
||||
localStorage.removeItem('zen_token');
|
||||
},
|
||||
|
||||
getHeaders() {
|
||||
return {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
},
|
||||
|
||||
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 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 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 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();
|
||||
},
|
||||
|
||||
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();
|
||||
},
|
||||
|
||||
async saveSettings(settings) {
|
||||
if (settings.batchSize) this.batchSize = settings.batchSize;
|
||||
if (settings.drawingAccuracy) this.drawingAccuracy = settings.drawingAccuracy;
|
||||
|
||||
await fetch(`${BASE_URL}/api/settings`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
68
client/src/styles/abstracts/_mixins.scss
Normal file
@@ -0,0 +1,68 @@
|
||||
@use 'sass:color';
|
||||
@use 'variables' as *;
|
||||
|
||||
@mixin flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@mixin flex-column($gap: 0) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $gap;
|
||||
}
|
||||
|
||||
@mixin grid-responsive($min-width: 960px) {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@media (min-width: $min-width) {
|
||||
// stylelint-disable-next-line no-invalid-position-declaration
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin card-base {
|
||||
background: $color-surface-light;
|
||||
border-radius: $radius-md;
|
||||
border: 1px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
@mixin hover-lift {
|
||||
transition:
|
||||
transform $duration-fast $ease-default,
|
||||
background $duration-fast $ease-default,
|
||||
box-shadow $duration-fast $ease-default;
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
background: color.adjust($color-surface-light, $lightness: 5%);
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #333 transparent;
|
||||
}
|
||||
|
||||
@mixin animate-fade-up($duration: 0.4s) {
|
||||
animation: fade-slide-up $duration ease-out backwards;
|
||||
|
||||
@keyframes fade-slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
5
client/src/styles/abstracts/_variables.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
@forward 'variables/colors';
|
||||
@forward 'variables/typography';
|
||||
@forward 'variables/spacing';
|
||||
@forward 'variables/layout';
|
||||
@forward 'variables/effects';
|
||||
18
client/src/styles/abstracts/variables/_colors.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
$color-primary: hsl(178deg 100% 40%);
|
||||
$color-secondary: hsl(46deg 100% 82%);
|
||||
$color-bg-dark: hsl(0deg 0% 7%);
|
||||
$color-surface: hsl(240deg 9% 13%);
|
||||
$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-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-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%);
|
||||
30
client/src/styles/abstracts/variables/_effects.scss
Normal file
@@ -0,0 +1,30 @@
|
||||
@use 'sass:color';
|
||||
@use 'colors' as *;
|
||||
|
||||
$opacity-disabled: 0.5;
|
||||
$opacity-inactive: 0.3;
|
||||
$opacity-hover: 0.8;
|
||||
$opacity-active: 1;
|
||||
$blur-sm: blur(4px);
|
||||
$blur-md: blur(10px);
|
||||
$blur-lg: blur(20px);
|
||||
$bg-glass-subtle: hsl(0deg 0% 100% / 5%);
|
||||
$bg-glass-light: hsl(0deg 0% 100% / 6%);
|
||||
$bg-glass-strong: hsl(0deg 0% 100% / 10%);
|
||||
$bg-glass-dark: hsl(0deg 0% 0% / 20%);
|
||||
$shadow-sm: 0 2px 4px hsl(0deg 0% 0% / 20%);
|
||||
$shadow-md: 0 4px 15px hsl(0deg 0% 0% / 30%);
|
||||
$shadow-lg: 0 20px 50px hsl(0deg 0% 0% / 50%);
|
||||
$shadow-inset: inset 0 0 20px hsl(0deg 0% 0% / 30%);
|
||||
$text-shadow: 0 2px 10px hsl(0deg 0% 0% / 50%);
|
||||
$shadow-glow-xs: 0 0 4px hsl(0deg 0% 100% / 50%);
|
||||
$shadow-glow-base: 0 0 20px color.change($color-primary, $alpha: 0.4);
|
||||
$shadow-glow-hover: 0 0 40px color.change($color-primary, $alpha: 0.6);
|
||||
$shadow-glow-active: 0 0 20px color.change($color-primary, $alpha: 0.4);
|
||||
$dist-slide-sm: 10px;
|
||||
$duration-fast: 0.2s;
|
||||
$duration-normal: 0.3s;
|
||||
$duration-slow: 0.5s;
|
||||
$duration-chart: 1s;
|
||||
$ease-default: ease;
|
||||
$ease-out-back: cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
46
client/src/styles/abstracts/variables/_layout.scss
Normal file
@@ -0,0 +1,46 @@
|
||||
@use 'colors' as *;
|
||||
|
||||
$radius-sm: 4px;
|
||||
$radius-md: 8px;
|
||||
$radius-lg: 12px;
|
||||
$radius-xl: 24px;
|
||||
$radius-pill: 999px;
|
||||
$radius-circle: 50%;
|
||||
$border-width-sm: 1px;
|
||||
$border-width-md: 2px;
|
||||
$border-subtle: $border-width-sm solid $color-border;
|
||||
$border-focus: $border-width-md solid $color-primary;
|
||||
$border-transparent: $border-width-md solid transparent;
|
||||
$z-back: -1;
|
||||
$z-normal: 1;
|
||||
$z-sticky: 10;
|
||||
$z-dropdown: 50;
|
||||
$z-modal: 100;
|
||||
$z-tooltip: 200;
|
||||
$z-above: 2;
|
||||
$size-canvas: 300px;
|
||||
$size-kanji-preview: 200px;
|
||||
$size-icon-btn: 32px;
|
||||
$size-icon-small: 18px;
|
||||
$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;
|
||||
$size-chart-height: 160px;
|
||||
$size-ghost-min-height: 80px;
|
||||
$max-width-heatmap: 950px;
|
||||
$max-width-desktop: 1400px;
|
||||
$padding-page-y: 40px;
|
||||
$padding-page-x: 24px;
|
||||
$breakpoint-md: 960px;
|
||||
$offset-fab: 20px;
|
||||
$size-heatmap-cell-height: 10px;
|
||||
10
client/src/styles/abstracts/variables/_spacing.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
$spacing-2xs: 2px;
|
||||
$spacing-xs: 4px;
|
||||
$spacing-sm: 8px;
|
||||
$spacing-md: 16px;
|
||||
$spacing-lg: 24px;
|
||||
$spacing-xl: 32px;
|
||||
$spacing-xxl: 48px;
|
||||
$size-loading-spinner: 64px;
|
||||
$gap-heatmap: 3px;
|
||||
$size-card-min: 60px;
|
||||
25
client/src/styles/abstracts/variables/_typography.scss
Normal file
@@ -0,0 +1,25 @@
|
||||
$font-family-sans:
|
||||
'Inter',
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
$font-family-mono: 'Fira Code', monospace;
|
||||
$font-xs: 0.75rem;
|
||||
$font-sm: 0.875rem;
|
||||
$font-md: 1rem;
|
||||
$font-lg: 1.25rem;
|
||||
$font-xl: 1.5rem;
|
||||
$font-2xl: 2rem;
|
||||
$font-3xl: 3rem;
|
||||
$weight-regular: 400;
|
||||
$weight-medium: 500;
|
||||
$weight-bold: 700;
|
||||
$weight-black: 900;
|
||||
$tracking-tighter: -0.05em;
|
||||
$tracking-tight: -0.025em;
|
||||
$tracking-normal: 0em;
|
||||
$tracking-wide: 0.025em;
|
||||
$tracking-wider: 0.05em;
|
||||
$leading-tight: 1.2;
|
||||
$leading-normal: 1.5;
|
||||
$leading-loose: 1.8;
|
||||
23
client/src/styles/base/_typography.scss
Normal file
@@ -0,0 +1,23 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
|
||||
body {
|
||||
font-family: $font-family-sans;
|
||||
line-height: $leading-normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-weight: $weight-bold;
|
||||
line-height: $leading-tight;
|
||||
letter-spacing: $tracking-tight;
|
||||
}
|
||||
|
||||
code,
|
||||
pre {
|
||||
font-family: $font-family-mono;
|
||||
}
|
||||
26
client/src/styles/components/_buttons.scss
Normal file
@@ -0,0 +1,26 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
|
||||
.glow-btn {
|
||||
box-shadow: $shadow-glow-base;
|
||||
transition:
|
||||
transform $duration-fast $ease-default,
|
||||
box-shadow $duration-fast $ease-default;
|
||||
will-change: transform, box-shadow;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: $shadow-glow-hover;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: $shadow-glow-active;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
opacity: $opacity-disabled;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
147
client/src/styles/components/_kanji.scss
Normal file
@@ -0,0 +1,147 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
@use '../abstracts/mixins' as *;
|
||||
|
||||
.canvas-container {
|
||||
position: relative;
|
||||
|
||||
@include flex-center;
|
||||
|
||||
margin-bottom: $spacing-xl;
|
||||
}
|
||||
|
||||
.canvas-wrapper {
|
||||
width: $size-canvas;
|
||||
height: $size-canvas;
|
||||
border-radius: $radius-lg;
|
||||
background: rgba($color-surface, 0.95);
|
||||
border: $border-width-md solid $color-surface-light;
|
||||
position: relative;
|
||||
cursor: crosshair;
|
||||
box-shadow: $shadow-inset;
|
||||
|
||||
canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
touch-action: none;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
position: absolute;
|
||||
color: $color-text-grey;
|
||||
z-index: $z-sticky;
|
||||
font-size: $font-sm;
|
||||
font-weight: $weight-medium;
|
||||
letter-spacing: $tracking-wide;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.stroke-path {
|
||||
fill: none;
|
||||
stroke: $color-stroke-inactive;
|
||||
stroke-width: $stroke-width-main;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
transition: stroke $duration-normal;
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&.animating {
|
||||
stroke: $color-primary;
|
||||
stroke-dasharray: var(--len);
|
||||
stroke-dashoffset: var(--len);
|
||||
animation: draw-stroke var(--duration) linear forwards;
|
||||
}
|
||||
|
||||
&.drawn {
|
||||
stroke: $color-text-white;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes draw-stroke {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: $size-icon-small;
|
||||
height: $size-icon-small;
|
||||
}
|
||||
}
|
||||
174
client/src/styles/components/_widgets.scss
Normal file
@@ -0,0 +1,174 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
@use '../abstracts/mixins' as *;
|
||||
@use 'sass:color';
|
||||
|
||||
.widget-card {
|
||||
@include card-base;
|
||||
|
||||
border: $border-subtle;
|
||||
}
|
||||
|
||||
.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%);
|
||||
}
|
||||
|
||||
.heatmap-container {
|
||||
width: 100%;
|
||||
|
||||
@include flex-center;
|
||||
}
|
||||
|
||||
.heatmap-year {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(53, 1fr);
|
||||
gap: $gap-heatmap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heatmap-week {
|
||||
display: grid;
|
||||
grid-template-rows: repeat(7, max-content);
|
||||
gap: $gap-heatmap;
|
||||
}
|
||||
|
||||
.heatmap-day-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heatmap-cell {
|
||||
width: 100%;
|
||||
height: $size-heatmap-cell-height;
|
||||
border-radius: $radius-xs;
|
||||
transition:
|
||||
opacity $duration-fast,
|
||||
background-color $duration-fast;
|
||||
|
||||
&:hover {
|
||||
opacity: $opacity-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.today-cell {
|
||||
border: $border-width-sm solid $color-text-white;
|
||||
box-shadow: $shadow-glow-xs;
|
||||
z-index: $z-above;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.legend-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.legend-box {
|
||||
width: $size-legend-box;
|
||||
height: $size-legend-box;
|
||||
border-radius: $radius-xs;
|
||||
}
|
||||
|
||||
.ghost-card {
|
||||
background: $bg-glass-subtle;
|
||||
border-radius: $radius-md;
|
||||
padding: $spacing-sm;
|
||||
text-align: center;
|
||||
border: $border-width-sm solid $bg-glass-subtle;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: $size-ghost-min-height;
|
||||
}
|
||||
|
||||
.srs-chart-container {
|
||||
height: $size-chart-height;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.srs-column {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.srs-track {
|
||||
width: $size-srs-track;
|
||||
flex-grow: 1;
|
||||
background: $bg-glass-light;
|
||||
border-radius: $radius-md;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
margin: $spacing-xs 0;
|
||||
}
|
||||
|
||||
.srs-fill {
|
||||
width: 100%;
|
||||
border-radius: $radius-sm;
|
||||
transition: height 1s $ease-out-back;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.transition-text {
|
||||
transition: color $duration-normal $ease-default;
|
||||
}
|
||||
|
||||
.streak-dot {
|
||||
width: $size-streak-dot;
|
||||
height: $size-streak-dot;
|
||||
border-radius: $radius-circle;
|
||||
background: $bg-glass-strong;
|
||||
|
||||
@include flex-center;
|
||||
|
||||
transition: all $duration-normal;
|
||||
|
||||
&.active {
|
||||
background: $color-primary;
|
||||
box-shadow: $shadow-glow-active;
|
||||
}
|
||||
}
|
||||
|
||||
.forecast-list {
|
||||
overflow-y: auto;
|
||||
|
||||
@include scrollbar;
|
||||
}
|
||||
|
||||
.gap-1 {
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.border-subtle {
|
||||
border: $border-subtle;
|
||||
}
|
||||
|
||||
.border-b-subtle {
|
||||
border-bottom: $border-subtle;
|
||||
}
|
||||
|
||||
.border-t-subtle {
|
||||
border-top: $border-subtle;
|
||||
}
|
||||
10
client/src/styles/main.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
@use 'abstracts/variables';
|
||||
@use 'abstracts/mixins';
|
||||
@use 'base/typography';
|
||||
@use 'components/buttons';
|
||||
@use 'components/widgets';
|
||||
@use 'components/kanji';
|
||||
@use 'pages/app';
|
||||
@use 'pages/dashboard';
|
||||
@use 'pages/review';
|
||||
@use 'pages/collection';
|
||||
61
client/src/styles/pages/_app.scss
Normal file
@@ -0,0 +1,61 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
|
||||
:root {
|
||||
--v-theme-background: #{$color-bg-dark};
|
||||
}
|
||||
|
||||
body,
|
||||
html,
|
||||
.zen-app,
|
||||
.v-application {
|
||||
background-color: $color-bg-dark !important;
|
||||
font-family: $font-family-sans;
|
||||
color: $color-text-white;
|
||||
}
|
||||
|
||||
.v-navigation-drawer {
|
||||
background-color: $color-surface !important;
|
||||
|
||||
// stylelint-disable-next-line selector-class-pattern
|
||||
.v-list-item--active {
|
||||
background-color: rgba($color-primary, 0.15);
|
||||
color: $color-primary !important;
|
||||
|
||||
.v-icon {
|
||||
color: $color-primary !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.v-card {
|
||||
box-shadow: $shadow-lg !important;
|
||||
}
|
||||
|
||||
.app-bar-blur {
|
||||
border-bottom-color: $color-border !important;
|
||||
backdrop-filter: $blur-md;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tracking-wide {
|
||||
letter-spacing: $tracking-wider;
|
||||
}
|
||||
|
||||
.tracking-tight {
|
||||
letter-spacing: $tracking-tight;
|
||||
}
|
||||
|
||||
.border-subtle {
|
||||
border: $border-subtle;
|
||||
}
|
||||
|
||||
.logo-hover {
|
||||
transition: opacity $duration-fast $ease-default;
|
||||
|
||||
&:hover {
|
||||
opacity: $opacity-hover;
|
||||
}
|
||||
}
|
||||
128
client/src/styles/pages/_collection.scss
Normal file
@@ -0,0 +1,128 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
@use '../abstracts/mixins' as *;
|
||||
|
||||
.sticky-search {
|
||||
position: sticky;
|
||||
top: $spacing-sm;
|
||||
z-index: $z-sticky;
|
||||
box-shadow: $shadow-md;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
|
||||
.fade-slide-up {
|
||||
@include animate-fade-up;
|
||||
}
|
||||
|
||||
.kanji-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax($size-card-min, 1fr));
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.kanji-card {
|
||||
@include card-base;
|
||||
@include hover-lift;
|
||||
|
||||
aspect-ratio: 1;
|
||||
|
||||
@include flex-column;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.k-char {
|
||||
font-size: $font-2xl;
|
||||
font-weight: $weight-medium;
|
||||
line-height: $leading-tight;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
&.zen-master {
|
||||
border: 1px solid rgb(255 215 0 / 50%);
|
||||
background: linear-gradient(135deg, rgb(30 30 36 / 100%) 0%, rgb(45 45 55 / 100%) 100%);
|
||||
box-shadow: 0 0 15px rgb(255 215 0 / 15%);
|
||||
animation: zen-pulse 4s infinite ease-in-out;
|
||||
|
||||
.k-char {
|
||||
text-shadow: 0 0 15px rgb(255 215 0 / 60%);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.k-dots {
|
||||
display: flex;
|
||||
gap: $spacing-2xs;
|
||||
margin-top: $spacing-xs;
|
||||
|
||||
.dot {
|
||||
width: $spacing-xs;
|
||||
height: $spacing-xs;
|
||||
border-radius: $radius-circle;
|
||||
background: $color-dot-base;
|
||||
}
|
||||
}
|
||||
|
||||
.k-bars {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
width: 60%;
|
||||
height: 4px;
|
||||
margin-top: $spacing-sm;
|
||||
|
||||
.bar {
|
||||
flex: 1;
|
||||
background: rgb(255 255 255 / 10%);
|
||||
border-radius: 2px;
|
||||
transition: background-color 0.3s ease;
|
||||
|
||||
&.filled {
|
||||
box-shadow: 0 0 4px rgb(0 0 0 / 30%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes zen-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 10px rgb(255 215 0 / 10%);
|
||||
border-color: rgb(255 215 0 / 30%);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgb(255 215 0 / 30%);
|
||||
border-color: rgb(255 215 0 / 80%);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 10px rgb(255 215 0 / 10%);
|
||||
border-color: rgb(255 215 0 / 30%);
|
||||
}
|
||||
}
|
||||
|
||||
.readings-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $spacing-sm;
|
||||
text-align: left;
|
||||
|
||||
.reading-group {
|
||||
background: $bg-glass-dark;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border-radius: $radius-md;
|
||||
|
||||
.reading-label {
|
||||
font-size: $font-xs;
|
||||
color: $color-text-grey;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: $tracking-wider;
|
||||
margin-bottom: $spacing-2xs;
|
||||
}
|
||||
|
||||
.reading-value {
|
||||
color: $color-text-white;
|
||||
font-size: $font-sm;
|
||||
}
|
||||
}
|
||||
}
|
||||
34
client/src/styles/pages/_dashboard.scss
Normal file
@@ -0,0 +1,34 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
@use '../abstracts/mixins' as *;
|
||||
|
||||
.dashboard-layout {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-auto-rows: min-content;
|
||||
gap: $spacing-lg;
|
||||
width: 100%;
|
||||
max-width: $max-width-desktop;
|
||||
margin: 0 auto;
|
||||
padding: $padding-page-y $padding-page-x;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.fade-in {
|
||||
@include animate-fade-up($duration-slow);
|
||||
}
|
||||
|
||||
.grid-area-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.stats-split-layout {
|
||||
grid-column: 1 / -1;
|
||||
gap: $spacing-lg;
|
||||
|
||||
@include grid-responsive($breakpoint-md);
|
||||
|
||||
.stats-left,
|
||||
.stats-right {
|
||||
@include flex-column($spacing-lg);
|
||||
}
|
||||
}
|
||||
}
|
||||
47
client/src/styles/pages/_review.scss
Normal file
@@ -0,0 +1,47 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
|
||||
.text-shadow {
|
||||
text-shadow: $text-shadow;
|
||||
}
|
||||
|
||||
.canvas-wrapper {
|
||||
position: relative;
|
||||
width: $size-canvas;
|
||||
height: $size-canvas;
|
||||
border-radius: $radius-lg;
|
||||
background: $bg-glass-dark;
|
||||
box-shadow: $shadow-inset;
|
||||
|
||||
.next-fab {
|
||||
position: absolute;
|
||||
bottom: -#{$offset-fab};
|
||||
right: -#{$offset-fab};
|
||||
z-index: $z-sticky;
|
||||
}
|
||||
}
|
||||
|
||||
.scale-enter-active,
|
||||
.scale-leave-active {
|
||||
transition: all $duration-normal $ease-out-back;
|
||||
}
|
||||
|
||||
.scale-enter-from,
|
||||
.scale-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
.fade-slide-enter-active,
|
||||
.fade-slide-leave-active {
|
||||
transition: all $duration-normal $ease-default;
|
||||
}
|
||||
|
||||
.fade-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY($dist-slide-sm);
|
||||
}
|
||||
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-#{$dist-slide-sm});
|
||||
}
|
||||
181
client/src/views/Collection.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<template lang="pug">
|
||||
v-container
|
||||
v-text-field.mb-6.sticky-search(
|
||||
v-model="searchQuery"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
:label="$t('collection.searchLabel')"
|
||||
:placeholder="$t('collection.placeholder')"
|
||||
variant="solo-filled"
|
||||
density="comfortable"
|
||||
bg-color="#2f3542"
|
||||
color="white"
|
||||
hide-details
|
||||
clearable
|
||||
)
|
||||
|
||||
.text-center.mt-10(v-if="loading") {{ $t('collection.loading') }}
|
||||
|
||||
.text-center.mt-10.text-grey-lighten-1(
|
||||
v-else-if="!loading && Object.keys(groupedItems).length === 0"
|
||||
)
|
||||
v-icon.mb-4(size="64" color="grey-darken-2") mdi-text-search-variant
|
||||
.text-h6 {{ $t('collection.noMatches') }}
|
||||
.text-body-2 {{ $t('collection.tryDifferent') }}
|
||||
|
||||
.mb-6.fade-slide-up(
|
||||
v-else
|
||||
v-for="(group, level) in groupedItems"
|
||||
:key="level"
|
||||
)
|
||||
.text-subtitle-2.text-grey.mb-2.font-weight-bold.ml-1
|
||||
| {{ $t('collection.levelHeader') }} {{ level }}
|
||||
|
||||
.kanji-grid
|
||||
.kanji-card(
|
||||
v-for="item in group"
|
||||
:key="item._id"
|
||||
:class="{ 'zen-master': item.srsLevel >= 10 }"
|
||||
@click="openDetail(item)"
|
||||
)
|
||||
.k-char(:style="{ color: item.srsLevel >= 10 ? '#ffd700' : getSRSColor(item.srsLevel) }")
|
||||
| {{ item.char }}
|
||||
|
||||
.k-dots(v-if="item.srsLevel <= 6")
|
||||
.dot(
|
||||
v-for="n in 5"
|
||||
:key="n"
|
||||
:class="{ filled: n < item.srsLevel }"
|
||||
:style="{ background: n < item.srsLevel ? getSRSColor(item.srsLevel) : '' }"
|
||||
)
|
||||
|
||||
.k-bars(v-else-if="item.srsLevel < 10")
|
||||
.bar(
|
||||
:class="{ filled: item.srsLevel >= 7 }"
|
||||
:style="{ background: item.srsLevel >= 7 ? getSRSColor(item.srsLevel) : '' }"
|
||||
)
|
||||
.bar(
|
||||
:class="{ filled: item.srsLevel >= 8 }"
|
||||
:style="{ background: item.srsLevel >= 8 ? getSRSColor(item.srsLevel) : '' }"
|
||||
)
|
||||
.bar(
|
||||
:class="{ filled: item.srsLevel >= 9 }"
|
||||
:style="{ background: item.srsLevel >= 9 ? getSRSColor(item.srsLevel) : '' }"
|
||||
)
|
||||
|
||||
v-dialog(
|
||||
v-model="showModal"
|
||||
max-width="400"
|
||||
transition="dialog-bottom-transition"
|
||||
)
|
||||
v-card.pa-4.pt-6.text-center.rounded-xl.border-subtle(color="#1e1e24")
|
||||
.d-flex.justify-space-between.align-center.px-2.mb-2
|
||||
.text-caption.text-grey {{ $t('review.level') }} {{ selectedItem?.level }}
|
||||
v-chip.font-weight-bold(
|
||||
size="x-small"
|
||||
:color="getSRSColor(selectedItem?.srsLevel)"
|
||||
variant="flat"
|
||||
) {{ getNextReviewText(selectedItem?.nextReview, selectedItem?.srsLevel) }}
|
||||
|
||||
.text-h2.font-weight-bold.mb-1 {{ selectedItem?.char }}
|
||||
.text-subtitle-1.text-grey-lighten-1.text-capitalize.mb-4 {{ selectedItem?.meaning }}
|
||||
|
||||
KanjiSvgViewer(
|
||||
v-if="showModal && selectedItem"
|
||||
:char="selectedItem.char"
|
||||
)
|
||||
|
||||
.readings-container.mb-4
|
||||
.reading-group(v-if="hasReading(selectedItem?.onyomi)")
|
||||
.reading-label {{ $t('collection.onyomi') }}
|
||||
.reading-value {{ selectedItem?.onyomi.join(', ') }}
|
||||
|
||||
.reading-group(v-if="hasReading(selectedItem?.kunyomi)")
|
||||
.reading-label {{ $t('collection.kunyomi') }}
|
||||
.reading-value {{ selectedItem?.kunyomi.join(', ') }}
|
||||
|
||||
.reading-group(v-if="hasReading(selectedItem?.nanori)")
|
||||
.reading-label {{ $t('collection.nanori') }}
|
||||
.reading-value {{ selectedItem?.nanori.join(', ') }}
|
||||
|
||||
v-btn.text-white(
|
||||
block
|
||||
color="#2f3542"
|
||||
@click="showModal = false"
|
||||
) {{ $t('collection.close') }}
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import KanjiSvgViewer from '@/components/kanji/KanjiSvgViewer.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useAppStore();
|
||||
|
||||
const loading = ref(true);
|
||||
const showModal = ref(false);
|
||||
const selectedItem = ref(null);
|
||||
const searchQuery = ref('');
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchCollection();
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
const filteredCollection = computed(() => {
|
||||
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;
|
||||
});
|
||||
});
|
||||
|
||||
const groupedItems = computed(() => {
|
||||
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'
|
||||
};
|
||||
|
||||
const getSRSColor = (lvl) => SRS_COLORS[lvl] || '#444';
|
||||
|
||||
const openDetail = (item) => {
|
||||
selectedItem.value = item;
|
||||
showModal.value = true;
|
||||
};
|
||||
|
||||
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');
|
||||
|
||||
const diffMs = date - now;
|
||||
const diffHrs = Math.floor(diffMs / 3600000);
|
||||
|
||||
if (diffHrs < 24) return t('stats.inHours', { n: diffHrs }, diffHrs);
|
||||
|
||||
const diffDays = Math.floor(diffHrs / 24);
|
||||
return `Next: ${diffDays}d`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/pages/_collection.scss" scoped></style>
|
||||
81
client/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template lang="pug">
|
||||
.dashboard-layout.fade-in
|
||||
WidgetWelcome(
|
||||
:queue-length="stats.queueLength"
|
||||
:has-lower-levels="stats.hasLowerLevels"
|
||||
:lower-level-count="stats.lowerLevelCount"
|
||||
:forecast="stats.forecast"
|
||||
@start="handleStart"
|
||||
)
|
||||
|
||||
.grid-area-full(v-if="!loading")
|
||||
WidgetHeatmap(:heatmap-data="stats.heatmap")
|
||||
|
||||
.stats-split-layout(v-if="!loading")
|
||||
.stats-left.d-flex.flex-column.gap-4
|
||||
WidgetGhosts(:ghosts="stats.ghosts")
|
||||
WidgetDistribution(:distribution="stats.distribution")
|
||||
WidgetGuruMastery(:distribution="stats.distribution")
|
||||
|
||||
.stats-right
|
||||
WidgetAccuracy(:accuracy="stats.accuracy")
|
||||
WidgetStreak(:streak="stats.streak")
|
||||
WidgetForecast(:forecast="stats.forecast")
|
||||
|
||||
.grid-area-full.d-flex.justify-center.mt-12(v-if="loading")
|
||||
v-progress-circular(
|
||||
indeterminate
|
||||
color="#00cec9"
|
||||
size="64"
|
||||
)
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import WidgetWelcome from '@/components/dashboard/WidgetWelcome.vue';
|
||||
import WidgetHeatmap from '@/components/dashboard/WidgetHeatmap.vue';
|
||||
import WidgetGhosts from '@/components/dashboard/WidgetGhosts.vue';
|
||||
import WidgetDistribution from '@/components/dashboard/WidgetDistribution.vue';
|
||||
import WidgetAccuracy from '@/components/dashboard/WidgetAccuracy.vue';
|
||||
import WidgetStreak from '@/components/dashboard/WidgetStreak.vue';
|
||||
import WidgetForecast from '@/components/dashboard/WidgetForecast.vue';
|
||||
import WidgetGuruMastery from '@/components/dashboard/WidgetGuruMastery.vue';
|
||||
|
||||
const store = useAppStore();
|
||||
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 } }
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
function handleStart(mode) {
|
||||
router.push({ path: '/review', query: { mode: mode } });
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/pages/_dashboard.scss" scoped></style>
|
||||
238
client/src/views/Review.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<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-if="currentItem"
|
||||
color="#1e1e24"
|
||||
width="100%"
|
||||
max-width="420"
|
||||
)
|
||||
.d-flex.align-center.w-100.mb-6
|
||||
v-btn.mr-2(
|
||||
icon="mdi-arrow-left"
|
||||
variant="text"
|
||||
color="grey"
|
||||
to="/"
|
||||
density="comfortable"
|
||||
)
|
||||
.text-caption.text-grey.font-weight-bold
|
||||
| {{ sessionDone }} / {{ sessionTotal }}
|
||||
v-spacer
|
||||
v-chip.font-weight-bold(
|
||||
size="small"
|
||||
:color="getSRSColor(currentItem.srsLevel)"
|
||||
variant="flat"
|
||||
) {{ $t('review.level') }} {{ currentItem.srsLevel }}
|
||||
|
||||
.text-center.mb-6
|
||||
.text-caption.text-grey.text-uppercase.tracking-wide.mb-1
|
||||
| {{ $t('review.meaning') }}
|
||||
.text-h3.font-weight-bold.text-white.text-shadow
|
||||
| {{ currentItem.meaning }}
|
||||
|
||||
.canvas-wrapper.mb-2
|
||||
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"
|
||||
icon="mdi-arrow-right"
|
||||
size="large"
|
||||
elevation="8"
|
||||
)
|
||||
|
||||
.mb-4
|
||||
v-btn.text-caption.font-weight-bold.opacity-80(
|
||||
variant="text"
|
||||
color="amber-lighten-1"
|
||||
prepend-icon="mdi-lightbulb-outline"
|
||||
size="small"
|
||||
:disabled="showNext || statusCode === 'hint'"
|
||||
@click="triggerHint"
|
||||
) Show Hint
|
||||
|
||||
v-sheet.d-flex.align-center.justify-center(
|
||||
width="100%"
|
||||
height="48"
|
||||
color="transparent"
|
||||
)
|
||||
transition(name="fade-slide" mode="out-in")
|
||||
.status-text.text-h6.font-weight-bold(
|
||||
:key="statusCode"
|
||||
:class="getStatusClass(statusCode)"
|
||||
) {{ $t('review.' + statusCode) }}
|
||||
|
||||
v-progress-linear.mt-4(
|
||||
v-model="progressPercent"
|
||||
color="#00cec9"
|
||||
height="4"
|
||||
rounded
|
||||
style="opacity: 0.3;"
|
||||
)
|
||||
|
||||
v-card.pa-8.rounded-xl.elevation-10.border-subtle.text-center(
|
||||
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
|
||||
|
||||
.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")
|
||||
.text-h3.font-weight-bold.text-white {{ accuracy }}%
|
||||
.text-caption.text-grey.text-uppercase.mt-1 {{ $t('stats.accuracy') }}
|
||||
v-col(cols="6")
|
||||
.text-h3.font-weight-bold.text-white {{ sessionCorrect }}
|
||||
.text-caption.text-grey.text-uppercase.mt-1 {{ $t('stats.correct') }}
|
||||
|
||||
v-btn.text-black.font-weight-bold.glow-btn(
|
||||
to="/dashboard"
|
||||
block
|
||||
color="#00cec9"
|
||||
height="50"
|
||||
rounded="lg"
|
||||
) {{ $t('review.back') }}
|
||||
|
||||
.text-center(v-else)
|
||||
v-icon.mb-4(size="64" color="grey-darken-2") mdi-check-circle-outline
|
||||
.text-h4.mb-2.text-white {{ $t('review.caughtUp') }}
|
||||
.text-grey.mb-6 {{ $t('review.noReviews') }}
|
||||
v-btn.font-weight-bold(
|
||||
to="/dashboard"
|
||||
color="#00cec9"
|
||||
variant="tonal"
|
||||
) {{ $t('review.viewCollection') }}
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import { useRoute } from 'vue-router';
|
||||
import KanjiCanvas from '@/components/kanji/KanjiCanvas.vue';
|
||||
|
||||
const store = useAppStore();
|
||||
const route = useRoute();
|
||||
|
||||
const currentItem = ref(null);
|
||||
const statusCode = ref('draw');
|
||||
const showNext = ref(false);
|
||||
const isFailure = ref(false);
|
||||
const kanjiCanvasRef = ref(null);
|
||||
|
||||
const sessionTotal = ref(0);
|
||||
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);
|
||||
});
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
if (sessionTotal.value === 0) return 0;
|
||||
return (sessionDone.value / sessionTotal.value) * 100;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function triggerHint() {
|
||||
if (!kanjiCanvasRef.value) return;
|
||||
|
||||
isFailure.value = true;
|
||||
statusCode.value = "hint";
|
||||
|
||||
kanjiCanvasRef.value.drawGuide(true);
|
||||
}
|
||||
|
||||
function handleMistake(isHint) {
|
||||
if (isHint) {
|
||||
isFailure.value = true;
|
||||
statusCode.value = "hint";
|
||||
} else {
|
||||
statusCode.value = "tryAgain";
|
||||
}
|
||||
}
|
||||
|
||||
function handleComplete() {
|
||||
statusCode.value = "correct";
|
||||
showNext.value = true;
|
||||
}
|
||||
|
||||
async function next() {
|
||||
if (!currentItem.value) return;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
loadNext();
|
||||
}
|
||||
|
||||
const getSRSColor = (lvl) => {
|
||||
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';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/pages/_review.scss" scoped></style>
|
||||
17
client/stylelint.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/** @type {import('stylelint').Config} */
|
||||
export default {
|
||||
extends: [
|
||||
'stylelint-config-standard-scss',
|
||||
'stylelint-config-recommended-vue'
|
||||
],
|
||||
ignoreFiles: [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/android/**'
|
||||
],
|
||||
rules: {
|
||||
'at-rule-no-unknown': null,
|
||||
'scss/at-rule-no-unknown': true,
|
||||
'no-empty-source': null,
|
||||
}
|
||||
};
|
||||
19
client/vite.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
additionalData: `@use "@/styles/abstracts/_variables.scss" as *; @use "@/styles/abstracts/_mixins.scss" as *;`
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||