init
34
.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
zen_kanji_js/.env
|
||||||
|
zen_kanji_js/client/.env
|
||||||
|
zen_kanji_js/client/.env.android
|
||||||
|
|
||||||
|
dist/
|
||||||
|
dist-ssr/
|
||||||
|
*.local
|
||||||
|
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea/
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
zen_kanji_js/client/android/
|
||||||
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 *;`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
51
docker-compose.yml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
services:
|
||||||
|
mongo:
|
||||||
|
image: mongo:6
|
||||||
|
container_name: zen_mongo
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "27017:27017"
|
||||||
|
volumes:
|
||||||
|
- mongo-data:/data/db
|
||||||
|
networks:
|
||||||
|
- zen-network
|
||||||
|
|
||||||
|
server:
|
||||||
|
build: ./server
|
||||||
|
container_name: zen_server
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
depends_on:
|
||||||
|
- mongo
|
||||||
|
networks:
|
||||||
|
- zen-network
|
||||||
|
volumes:
|
||||||
|
- ./server:/app
|
||||||
|
- /app/node_modules
|
||||||
|
|
||||||
|
client:
|
||||||
|
build:
|
||||||
|
context: ./client
|
||||||
|
target: dev-stage
|
||||||
|
container_name: zen_client
|
||||||
|
ports:
|
||||||
|
- "5173:5173"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
depends_on:
|
||||||
|
- server
|
||||||
|
networks:
|
||||||
|
- zen-network
|
||||||
|
volumes:
|
||||||
|
- ./client:/app
|
||||||
|
- /app/node_modules
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mongo-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
zen-network:
|
||||||
|
driver: bridge
|
||||||
13
server/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM node:24-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "start"]
|
||||||
2851
server/package-lock.json
generated
Normal file
25
server/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "zen-kanji-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "node --watch server.js",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:cov": "vitest run --coverage"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cors": "^11.2.0",
|
||||||
|
"@fastify/jwt": "^10.0.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"fastify": "^5.6.2",
|
||||||
|
"fastify-cors": "^6.0.3",
|
||||||
|
"mongoose": "^9.0.1",
|
||||||
|
"node-fetch": "^3.3.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitest/coverage-v8": "^4.0.16",
|
||||||
|
"vitest": "^4.0.16"
|
||||||
|
}
|
||||||
|
}
|
||||||
73
server/server.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import Fastify from 'fastify';
|
||||||
|
import cors from '@fastify/cors';
|
||||||
|
import jwt from '@fastify/jwt';
|
||||||
|
import { PORT, JWT_SECRET } from './src/config/constants.js';
|
||||||
|
import { connectDB } from './src/config/db.js';
|
||||||
|
import routes from './src/routes/v1.js';
|
||||||
|
import { User } from './src/models/User.js';
|
||||||
|
|
||||||
|
const fastify = Fastify({ logger: true });
|
||||||
|
|
||||||
|
await connectDB();
|
||||||
|
|
||||||
|
const allowedOrigins = [
|
||||||
|
'http://localhost:5173',
|
||||||
|
'http://localhost',
|
||||||
|
'capacitor://localhost',
|
||||||
|
'https://10.0.2.2:5173',
|
||||||
|
'https://zenkanji.crylia.de'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (process.env.CORS_ORIGINS) {
|
||||||
|
const prodOrigins = process.env.CORS_ORIGINS.split(',');
|
||||||
|
allowedOrigins.push(...prodOrigins);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fastify.register(cors, {
|
||||||
|
origin: allowedOrigins,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||||
|
credentials: true
|
||||||
|
});
|
||||||
|
|
||||||
|
await fastify.register(jwt, {
|
||||||
|
secret: JWT_SECRET
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.decorate('authenticate', async function (req, reply) {
|
||||||
|
try {
|
||||||
|
const payload = await req.jwtVerify();
|
||||||
|
|
||||||
|
const user = await User.findById(payload.userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
reply.code(401).send({ message: 'User not found', code: 'INVALID_USER' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.version !== user.tokenVersion) {
|
||||||
|
reply.code(401).send({ message: 'Session invalid', code: 'INVALID_SESSION' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.version !== user.tokenVersion) {
|
||||||
|
throw new Error('Session invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = user;
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
reply.code(401).send(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await fastify.register(routes);
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
try {
|
||||||
|
await fastify.listen({ port: PORT, host: '0.0.0.0' });
|
||||||
|
console.log(`Server running at http://localhost:${PORT}`);
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
start();
|
||||||
4
server/src/config/constants.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const PORT = process.env.PORT || 3000;
|
||||||
|
export const MONGO_URI = process.env.MONGO_URI || 'mongodb://mongo:27017/zenkanji';
|
||||||
|
export const SRS_TIMINGS_HOURS = [0, 0, 4, 8, 23, 47];
|
||||||
|
export const JWT_SECRET = process.env.JWT_SECRET;
|
||||||
12
server/src/config/db.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { MONGO_URI } from './constants.js';
|
||||||
|
|
||||||
|
export const connectDB = async () => {
|
||||||
|
try {
|
||||||
|
await mongoose.connect(MONGO_URI);
|
||||||
|
console.log('✅ Connected to MongoDB');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ MongoDB Connection Error:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
28
server/src/controllers/auth.controller.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import * as AuthService from '../services/auth.service.js';
|
||||||
|
|
||||||
|
export const login = async (req, reply) => {
|
||||||
|
const { apiKey } = req.body;
|
||||||
|
if (!apiKey) return reply.code(400).send({ error: 'API Key required' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await AuthService.loginUser(apiKey);
|
||||||
|
|
||||||
|
const token = await reply.jwtSign(
|
||||||
|
{ userId: user._id, version: user.tokenVersion },
|
||||||
|
{ expiresIn: '365d' }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
token,
|
||||||
|
user: { settings: user.settings, stats: user.stats }
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(401).send({ error: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logout = async (req, reply) => {
|
||||||
|
await AuthService.logoutUser(req.user._id);
|
||||||
|
return { success: true };
|
||||||
|
};
|
||||||
33
server/src/controllers/collection.controller.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { User } from '../models/User.js';
|
||||||
|
import * as ReviewService from '../services/review.service.js';
|
||||||
|
import * as StatsService from '../services/stats.service.js';
|
||||||
|
import { StudyItem } from '../models/StudyItem.js';
|
||||||
|
|
||||||
|
export const getCollection = async (req, reply) => {
|
||||||
|
const items = await StudyItem.find({ userId: req.user._id });
|
||||||
|
return reply.send(items);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getQueue = async (req, reply) => {
|
||||||
|
const { limit, sort } = req.query;
|
||||||
|
const queue = await ReviewService.getQueue(req.user, parseInt(limit) || 20, sort);
|
||||||
|
return reply.send(queue);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStats = async (req, reply) => {
|
||||||
|
const stats = await StatsService.getUserStats(req.user);
|
||||||
|
return reply.send(stats);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateSettings = async (req, reply) => {
|
||||||
|
const { batchSize, drawingAccuracy } = req.body;
|
||||||
|
const user = req.user;
|
||||||
|
|
||||||
|
if (!user.settings) user.settings = {};
|
||||||
|
|
||||||
|
if (batchSize) user.settings.batchSize = batchSize;
|
||||||
|
if (drawingAccuracy) user.settings.drawingAccuracy = drawingAccuracy;
|
||||||
|
|
||||||
|
await user.save();
|
||||||
|
return reply.send({ success: true, settings: user.settings });
|
||||||
|
};
|
||||||
11
server/src/controllers/review.controller.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import * as ReviewService from '../services/review.service.js';
|
||||||
|
|
||||||
|
export const submitReview = async (req, reply) => {
|
||||||
|
const { subjectId, success } = req.body;
|
||||||
|
try {
|
||||||
|
const result = await ReviewService.processReview(req.user, subjectId, success);
|
||||||
|
return reply.send(result);
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(404).send({ error: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
10
server/src/controllers/sync.controller.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import * as SyncService from '../services/sync.service.js';
|
||||||
|
|
||||||
|
export const sync = async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const result = await SyncService.syncWithWaniKani(req.user);
|
||||||
|
return reply.send(result);
|
||||||
|
} catch (error) {
|
||||||
|
return reply.code(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
10
server/src/models/ReviewLog.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
|
||||||
|
const reviewLogSchema = new mongoose.Schema({
|
||||||
|
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
|
||||||
|
date: { type: String, required: true },
|
||||||
|
count: { type: Number, default: 0 }
|
||||||
|
});
|
||||||
|
|
||||||
|
reviewLogSchema.index({ userId: 1, date: 1 }, { unique: true });
|
||||||
|
export const ReviewLog = mongoose.model('ReviewLog', reviewLogSchema);
|
||||||
21
server/src/models/StudyItem.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
|
||||||
|
const studyItemSchema = new mongoose.Schema({
|
||||||
|
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
|
||||||
|
wkSubjectId: { type: Number, required: true },
|
||||||
|
char: { type: String, required: true },
|
||||||
|
meaning: { type: String, required: true },
|
||||||
|
level: { type: Number, required: true },
|
||||||
|
srsLevel: { type: Number, default: 0 },
|
||||||
|
nextReview: { type: Date, default: Date.now },
|
||||||
|
onyomi: { type: [String], default: [] },
|
||||||
|
kunyomi: { type: [String], default: [] },
|
||||||
|
nanori: { type: [String], default: [] },
|
||||||
|
stats: {
|
||||||
|
correct: { type: Number, default: 0 },
|
||||||
|
total: { type: Number, default: 0 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
studyItemSchema.index({ userId: 1, wkSubjectId: 1 }, { unique: true });
|
||||||
|
export const StudyItem = mongoose.model('StudyItem', studyItemSchema);
|
||||||
21
server/src/models/User.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
|
||||||
|
const userSchema = new mongoose.Schema({
|
||||||
|
wkApiKey: { type: String, required: true, unique: true },
|
||||||
|
lastSync: { type: Date, default: Date.now },
|
||||||
|
settings: {
|
||||||
|
batchSize: { type: Number, default: 20 },
|
||||||
|
drawingAccuracy: { type: Number, default: 10 }
|
||||||
|
},
|
||||||
|
tokenVersion: { type: Number, default: 0 },
|
||||||
|
stats: {
|
||||||
|
totalReviews: { type: Number, default: 0 },
|
||||||
|
correctReviews: { type: Number, default: 0 },
|
||||||
|
currentStreak: { type: Number, default: 0 },
|
||||||
|
maxStreak: { type: Number, default: 0 },
|
||||||
|
lastStudyDate: { type: String, default: null },
|
||||||
|
lastFreezeDate: { type: Date, default: null }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const User = mongoose.model('User', userSchema);
|
||||||
22
server/src/routes/v1.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { login, logout } from '../controllers/auth.controller.js';
|
||||||
|
import { sync } from '../controllers/sync.controller.js';
|
||||||
|
import { submitReview } from '../controllers/review.controller.js';
|
||||||
|
import { getStats, getQueue, getCollection, updateSettings } from '../controllers/collection.controller.js';
|
||||||
|
|
||||||
|
async function routes(fastify, options) {
|
||||||
|
fastify.post('/api/auth/login', login);
|
||||||
|
|
||||||
|
fastify.register(async (privateParams) => {
|
||||||
|
privateParams.addHook('onRequest', fastify.authenticate);
|
||||||
|
|
||||||
|
privateParams.post('/api/auth/logout', logout);
|
||||||
|
privateParams.post('/api/sync', sync);
|
||||||
|
privateParams.post('/api/review', submitReview);
|
||||||
|
privateParams.get('/api/stats', getStats);
|
||||||
|
privateParams.get('/api/queue', getQueue);
|
||||||
|
privateParams.get('/api/collection', getCollection);
|
||||||
|
privateParams.post('/api/settings', updateSettings);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default routes;
|
||||||
29
server/src/services/auth.service.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { User } from '../models/User.js';
|
||||||
|
|
||||||
|
export const loginUser = async (apiKey) => {
|
||||||
|
const response = await fetch('https://api.wanikani.com/v2/user', {
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error('Invalid API Key');
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = await User.findOne({ wkApiKey: apiKey });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
user = await User.create({
|
||||||
|
wkApiKey: apiKey,
|
||||||
|
tokenVersion: 0,
|
||||||
|
stats: { totalReviews: 0, correctReviews: 0, currentStreak: 0, maxStreak: 0 },
|
||||||
|
settings: { batchSize: 20 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logoutUser = async (userId) => {
|
||||||
|
await User.findByIdAndUpdate(userId, { $inc: { tokenVersion: 1 } });
|
||||||
|
return true;
|
||||||
|
};
|
||||||
85
server/src/services/review.service.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { ReviewLog } from '../models/ReviewLog.js';
|
||||||
|
import { StudyItem } from '../models/StudyItem.js';
|
||||||
|
import { getDaysDiff, getSRSDate } from '../utils/dateUtils.js';
|
||||||
|
|
||||||
|
export const processReview = async (user, subjectId, success) => {
|
||||||
|
if (!user.stats) user.stats = { totalReviews: 0, correctReviews: 0, currentStreak: 0, maxStreak: 0 };
|
||||||
|
user.stats.totalReviews += 1;
|
||||||
|
if (success) user.stats.correctReviews += 1;
|
||||||
|
|
||||||
|
const todayStr = new Date().toISOString().split('T')[0];
|
||||||
|
const lastStudyStr = user.stats.lastStudyDate;
|
||||||
|
|
||||||
|
if (lastStudyStr !== todayStr) {
|
||||||
|
if (!lastStudyStr) {
|
||||||
|
user.stats.currentStreak = 1;
|
||||||
|
} else {
|
||||||
|
const diff = getDaysDiff(lastStudyStr, todayStr);
|
||||||
|
if (diff === 1) {
|
||||||
|
user.stats.currentStreak += 1;
|
||||||
|
} else if (diff > 1) {
|
||||||
|
const lastFreeze = user.stats.lastFreezeDate ? new Date(user.stats.lastFreezeDate) : null;
|
||||||
|
let daysSinceFreeze = 999;
|
||||||
|
if (lastFreeze) daysSinceFreeze = getDaysDiff(lastFreeze.toISOString().split('T')[0], todayStr);
|
||||||
|
|
||||||
|
const canUseShield = daysSinceFreeze >= 7;
|
||||||
|
|
||||||
|
if (canUseShield && diff === 2) {
|
||||||
|
console.log(`User ${user._id} saved by Zen Shield!`);
|
||||||
|
user.stats.lastFreezeDate = new Date();
|
||||||
|
user.stats.currentStreak += 1;
|
||||||
|
} else {
|
||||||
|
user.stats.currentStreak = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user.stats.lastStudyDate = todayStr;
|
||||||
|
if (user.stats.currentStreak > user.stats.maxStreak) user.stats.maxStreak = user.stats.currentStreak;
|
||||||
|
}
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
await ReviewLog.findOneAndUpdate(
|
||||||
|
{ userId: user._id, date: todayStr },
|
||||||
|
{ $inc: { count: 1 } },
|
||||||
|
{ upsert: true, new: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const item = await StudyItem.findOne({ userId: user._id, wkSubjectId: subjectId });
|
||||||
|
if (!item) throw new Error('Item not found');
|
||||||
|
|
||||||
|
if (!item.stats) item.stats = { correct: 0, total: 0 };
|
||||||
|
item.stats.total += 1;
|
||||||
|
if (success) item.stats.correct += 1;
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
const nextLevel = Math.min(item.srsLevel + 1, 10);
|
||||||
|
item.srsLevel = nextLevel;
|
||||||
|
item.nextReview = getSRSDate(nextLevel);
|
||||||
|
} else {
|
||||||
|
item.srsLevel = Math.max(1, item.srsLevel - 1);
|
||||||
|
item.nextReview = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
await item.save();
|
||||||
|
return { nextReview: item.nextReview, srsLevel: item.srsLevel };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getQueue = async (user, limit = 100, sortMode) => {
|
||||||
|
const query = {
|
||||||
|
userId: user._id,
|
||||||
|
srsLevel: { $lt: 10, $gt: 0 },
|
||||||
|
nextReview: { $lte: new Date() }
|
||||||
|
};
|
||||||
|
|
||||||
|
let dueItems;
|
||||||
|
if (sortMode === 'priority') {
|
||||||
|
dueItems = await StudyItem.find(query).sort({ srsLevel: 1, level: 1 }).limit(limit);
|
||||||
|
} else {
|
||||||
|
dueItems = await StudyItem.find(query).limit(limit);
|
||||||
|
for (let i = dueItems.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[dueItems[i], dueItems[j]] = [dueItems[j], dueItems[i]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dueItems;
|
||||||
|
};
|
||||||
112
server/src/services/stats.service.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { StudyItem } from '../models/StudyItem.js';
|
||||||
|
import { ReviewLog } from '../models/ReviewLog.js';
|
||||||
|
import { getDaysDiff } from '../utils/dateUtils.js';
|
||||||
|
|
||||||
|
export const getUserStats = async (user) => {
|
||||||
|
const userId = user._id;
|
||||||
|
|
||||||
|
const srsCounts = await StudyItem.aggregate([
|
||||||
|
{ $match: { userId: userId } },
|
||||||
|
{ $group: { _id: "$srsLevel", count: { $sum: 1 } } }
|
||||||
|
]);
|
||||||
|
const dist = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 };
|
||||||
|
srsCounts.forEach(g => { if (dist[g._id] !== undefined) dist[g._id] = g.count; });
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const next24h = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||||
|
const upcoming = await StudyItem.find({
|
||||||
|
userId: userId,
|
||||||
|
srsLevel: { $lt: 6, $gt: 0 },
|
||||||
|
nextReview: { $lte: next24h }
|
||||||
|
}).select('nextReview');
|
||||||
|
|
||||||
|
const forecast = new Array(24).fill(0);
|
||||||
|
upcoming.forEach(item => {
|
||||||
|
const diff = Math.floor((new Date(item.nextReview) - now) / 3600000);
|
||||||
|
if (diff < 0) forecast[0]++;
|
||||||
|
else if (diff < 24) forecast[diff]++;
|
||||||
|
});
|
||||||
|
|
||||||
|
const queueItems = await StudyItem.find({
|
||||||
|
userId: userId,
|
||||||
|
srsLevel: { $lt: 6, $gt: 0 },
|
||||||
|
nextReview: { $lte: now }
|
||||||
|
}).select('srsLevel');
|
||||||
|
const queueCount = queueItems.length;
|
||||||
|
|
||||||
|
let hasLowerLevels = false;
|
||||||
|
let lowerLevelCount = 0;
|
||||||
|
if (queueCount > 0) {
|
||||||
|
const levels = queueItems.map(i => i.srsLevel);
|
||||||
|
const minSrs = Math.min(...levels);
|
||||||
|
const maxSrs = Math.max(...levels);
|
||||||
|
if (minSrs < maxSrs) {
|
||||||
|
hasLowerLevels = true;
|
||||||
|
lowerLevelCount = queueItems.filter(i => i.srsLevel === minSrs).length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const oneYearAgo = new Date();
|
||||||
|
oneYearAgo.setDate(oneYearAgo.getDate() - 365);
|
||||||
|
const dateStr = oneYearAgo.toISOString().split('T')[0];
|
||||||
|
const logs = await ReviewLog.find({ userId: userId, date: { $gte: dateStr } });
|
||||||
|
const heatmap = {};
|
||||||
|
logs.forEach(l => { heatmap[l.date] = l.count; });
|
||||||
|
|
||||||
|
const todayStr = new Date().toISOString().split('T')[0];
|
||||||
|
const lastStudyStr = user.stats.lastStudyDate || null;
|
||||||
|
let displayStreak = user.stats.currentStreak || 0;
|
||||||
|
|
||||||
|
if (lastStudyStr) {
|
||||||
|
const diff = getDaysDiff(lastStudyStr, todayStr);
|
||||||
|
if (diff > 1) displayStreak = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastFreeze = user.stats.lastFreezeDate ? new Date(user.stats.lastFreezeDate) : null;
|
||||||
|
let daysSinceFreeze = 999;
|
||||||
|
if (lastFreeze) {
|
||||||
|
daysSinceFreeze = getDaysDiff(lastFreeze.toISOString().split('T')[0], todayStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shieldReady = daysSinceFreeze >= 7;
|
||||||
|
const shieldCooldown = shieldReady ? 0 : (7 - daysSinceFreeze);
|
||||||
|
|
||||||
|
const history7Days = [];
|
||||||
|
for (let i = 6; i >= 0; i--) {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - i);
|
||||||
|
const dStr = d.toISOString().split('T')[0];
|
||||||
|
const count = heatmap[dStr] || 0;
|
||||||
|
history7Days.push({ date: dStr, active: count > 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const allGhosts = await StudyItem.find({ userId: userId, 'stats.total': { $gte: 2 } })
|
||||||
|
.select('char meaning stats srsLevel');
|
||||||
|
|
||||||
|
const sortedGhosts = allGhosts.map(item => ({
|
||||||
|
...item.toObject(),
|
||||||
|
accuracy: Math.round((item.stats.correct / item.stats.total) * 100)
|
||||||
|
})).filter(i => i.accuracy < 85)
|
||||||
|
.sort((a, b) => a.accuracy - b.accuracy)
|
||||||
|
.slice(0, 4);
|
||||||
|
|
||||||
|
return {
|
||||||
|
distribution: dist,
|
||||||
|
forecast: forecast,
|
||||||
|
queueLength: queueCount,
|
||||||
|
hasLowerLevels,
|
||||||
|
lowerLevelCount,
|
||||||
|
heatmap: heatmap,
|
||||||
|
ghosts: sortedGhosts,
|
||||||
|
streak: {
|
||||||
|
current: displayStreak,
|
||||||
|
best: user.stats.maxStreak || 0,
|
||||||
|
shield: { ready: shieldReady, cooldown: Math.max(0, shieldCooldown) },
|
||||||
|
history: history7Days
|
||||||
|
},
|
||||||
|
accuracy: {
|
||||||
|
total: user.stats.totalReviews || 0,
|
||||||
|
correct: user.stats.correctReviews || 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
78
server/src/services/sync.service.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { User } from '../models/User.js';
|
||||||
|
import { StudyItem } from '../models/StudyItem.js';
|
||||||
|
|
||||||
|
export const syncWithWaniKani = async (user) => {
|
||||||
|
const apiKey = user.wkApiKey;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('User has no WaniKani API Key');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Starting sync for user: ${user._id}`);
|
||||||
|
|
||||||
|
let allSubjectIds = [];
|
||||||
|
let nextUrl = 'https://api.wanikani.com/v2/assignments?subject_types=kanji&started=true';
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (nextUrl) {
|
||||||
|
const res = await fetch(nextUrl, {
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`WaniKani API Error: ${res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
allSubjectIds = allSubjectIds.concat(json.data.map(d => d.data.subject_id));
|
||||||
|
nextUrl = json.pages.next_url;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching assignments:", err);
|
||||||
|
throw new Error("Failed to connect to WaniKani. Check your internet connection.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allSubjectIds.length === 0) {
|
||||||
|
return { count: 0, message: "No unlocked kanji found.", settings: user.settings };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingItems = await StudyItem.find({ userId: user._id }).select('wkSubjectId');
|
||||||
|
const existingIds = new Set(existingItems.map(i => i.wkSubjectId));
|
||||||
|
const newIds = allSubjectIds.filter(id => !existingIds.has(id));
|
||||||
|
|
||||||
|
console.log(`Found ${newIds.length} new items to download.`);
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 100;
|
||||||
|
for (let i = 0; i < newIds.length; i += CHUNK_SIZE) {
|
||||||
|
const chunk = newIds.slice(i, i + CHUNK_SIZE);
|
||||||
|
|
||||||
|
const subRes = await fetch(`https://api.wanikani.com/v2/subjects?ids=${chunk.join(',')}`, {
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}` }
|
||||||
|
});
|
||||||
|
const subJson = await subRes.json();
|
||||||
|
|
||||||
|
const operations = subJson.data.map(d => {
|
||||||
|
const readings = d.data.readings || [];
|
||||||
|
return {
|
||||||
|
userId: user._id,
|
||||||
|
wkSubjectId: d.id,
|
||||||
|
char: d.data.characters,
|
||||||
|
meaning: d.data.meanings.find(m => m.primary)?.meaning || 'Unknown',
|
||||||
|
level: d.data.level,
|
||||||
|
srsLevel: 1,
|
||||||
|
nextReview: Date.now(),
|
||||||
|
onyomi: readings.filter(r => r.type === 'onyomi').map(r => r.reading),
|
||||||
|
kunyomi: readings.filter(r => r.type === 'kunyomi').map(r => r.reading),
|
||||||
|
nanori: readings.filter(r => r.type === 'nanori').map(r => r.reading),
|
||||||
|
stats: { correct: 0, total: 0 }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (operations.length > 0) {
|
||||||
|
await StudyItem.insertMany(operations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalCount = await StudyItem.countDocuments({ userId: user._id });
|
||||||
|
return { success: true, count: finalCount, settings: user.settings };
|
||||||
|
};
|
||||||
32
server/src/utils/dateUtils.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export const getDaysDiff = (date1Str, date2Str) => {
|
||||||
|
const d1 = new Date(date1Str);
|
||||||
|
const d2 = new Date(date2Str);
|
||||||
|
const diff = d2 - d1;
|
||||||
|
return Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSRSDate = (level) => {
|
||||||
|
const now = new Date();
|
||||||
|
let hoursToAdd = 0;
|
||||||
|
|
||||||
|
switch (level) {
|
||||||
|
case 1: hoursToAdd = 4; break;
|
||||||
|
case 2: hoursToAdd = 8; break;
|
||||||
|
case 3: hoursToAdd = 24; break;
|
||||||
|
case 4: hoursToAdd = 2 * 24; break;
|
||||||
|
case 5: hoursToAdd = 7 * 24; break;
|
||||||
|
case 6: hoursToAdd = 14 * 24; break;
|
||||||
|
|
||||||
|
case 7: hoursToAdd = 7 * 24; break;
|
||||||
|
case 8: hoursToAdd = 30 * 24; break;
|
||||||
|
case 9: hoursToAdd = 90 * 24; break;
|
||||||
|
|
||||||
|
case 10: break;
|
||||||
|
default: hoursToAdd = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hoursToAdd === 0) return null;
|
||||||
|
|
||||||
|
now.setUTCHours(now.getUTCHours() + hoursToAdd);
|
||||||
|
return now;
|
||||||
|
};
|
||||||
52
server/tests/services/auth.service.test.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { loginUser, logoutUser } from '../../src/services/auth.service.js';
|
||||||
|
import { User } from '../../src/models/User.js';
|
||||||
|
|
||||||
|
vi.mock('../../src/models/User.js');
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
|
||||||
|
describe('Auth Service', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loginUser', () => {
|
||||||
|
it('should throw error for invalid API Key', async () => {
|
||||||
|
fetch.mockResolvedValue({ status: 401 });
|
||||||
|
await expect(loginUser('bad_key')).rejects.toThrow('Invalid API Key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return existing user if found', async () => {
|
||||||
|
fetch.mockResolvedValue({ status: 200 });
|
||||||
|
const mockUser = { wkApiKey: 'valid_key', _id: '123' };
|
||||||
|
User.findOne.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
const result = await loginUser('valid_key');
|
||||||
|
expect(result).toEqual(mockUser);
|
||||||
|
expect(User.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create new user if not found', async () => {
|
||||||
|
fetch.mockResolvedValue({ status: 200 });
|
||||||
|
User.findOne.mockResolvedValue(null);
|
||||||
|
const newUser = { wkApiKey: 'valid_key', _id: 'new_id' };
|
||||||
|
User.create.mockResolvedValue(newUser);
|
||||||
|
|
||||||
|
const result = await loginUser('valid_key');
|
||||||
|
expect(result).toEqual(newUser);
|
||||||
|
expect(User.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
wkApiKey: 'valid_key',
|
||||||
|
tokenVersion: 0
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('logoutUser', () => {
|
||||||
|
it('should increment token version', async () => {
|
||||||
|
User.findByIdAndUpdate.mockResolvedValue(true);
|
||||||
|
const result = await logoutUser('userId');
|
||||||
|
expect(User.findByIdAndUpdate).toHaveBeenCalledWith('userId', { $inc: { tokenVersion: 1 } });
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
142
server/tests/services/controllers.test.js
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import * as AuthController from '../../src/controllers/auth.controller.js';
|
||||||
|
import * as ReviewController from '../../src/controllers/review.controller.js';
|
||||||
|
import * as SyncController from '../../src/controllers/sync.controller.js';
|
||||||
|
import * as CollectionController from '../../src/controllers/collection.controller.js';
|
||||||
|
|
||||||
|
import * as AuthService from '../../src/services/auth.service.js';
|
||||||
|
import * as ReviewService from '../../src/services/review.service.js';
|
||||||
|
import * as SyncService from '../../src/services/sync.service.js';
|
||||||
|
import * as StatsService from '../../src/services/stats.service.js';
|
||||||
|
import { StudyItem } from '../../src/models/StudyItem.js';
|
||||||
|
|
||||||
|
vi.mock('../../src/services/auth.service.js');
|
||||||
|
vi.mock('../../src/services/review.service.js');
|
||||||
|
vi.mock('../../src/services/sync.service.js');
|
||||||
|
vi.mock('../../src/services/stats.service.js');
|
||||||
|
vi.mock('../../src/models/StudyItem.js');
|
||||||
|
|
||||||
|
const mockReq = (body = {}, user = {}, query = {}) => ({ body, user, query });
|
||||||
|
const mockReply = () => {
|
||||||
|
const res = {};
|
||||||
|
res.code = vi.fn().mockReturnValue(res);
|
||||||
|
res.send = vi.fn().mockReturnValue(res);
|
||||||
|
res.jwtSign = vi.fn().mockResolvedValue('token');
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Controllers', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Auth Controller', () => {
|
||||||
|
it('login should fail without apiKey', async () => {
|
||||||
|
const reply = mockReply();
|
||||||
|
await AuthController.login(mockReq({}), reply);
|
||||||
|
expect(reply.code).toHaveBeenCalledWith(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('login should succeed', async () => {
|
||||||
|
const reply = mockReply();
|
||||||
|
AuthService.loginUser.mockResolvedValue({ _id: 1, tokenVersion: 1 });
|
||||||
|
await AuthController.login(mockReq({ apiKey: 'key' }), reply);
|
||||||
|
expect(reply.jwtSign).toHaveBeenCalled();
|
||||||
|
expect(reply.code).not.toHaveBeenCalledWith(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('login should catch errors', async () => {
|
||||||
|
const reply = mockReply();
|
||||||
|
AuthService.loginUser.mockRejectedValue(new Error('fail'));
|
||||||
|
await AuthController.login(mockReq({ apiKey: 'k' }), reply);
|
||||||
|
expect(reply.code).toHaveBeenCalledWith(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logout should succeed', async () => {
|
||||||
|
const reply = mockReply();
|
||||||
|
await AuthController.logout(mockReq({}, { _id: 1 }), reply);
|
||||||
|
expect(AuthService.logoutUser).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Review Controller', () => {
|
||||||
|
it('submitReview should succeed', async () => {
|
||||||
|
const reply = mockReply();
|
||||||
|
ReviewService.processReview.mockResolvedValue({});
|
||||||
|
await ReviewController.submitReview(mockReq({}), reply);
|
||||||
|
expect(reply.send).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submitReview should handle error', async () => {
|
||||||
|
const reply = mockReply();
|
||||||
|
ReviewService.processReview.mockRejectedValue(new Error('err'));
|
||||||
|
await ReviewController.submitReview(mockReq({}), reply);
|
||||||
|
expect(reply.code).toHaveBeenCalledWith(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Sync Controller', () => {
|
||||||
|
it('sync should succeed', async () => {
|
||||||
|
const reply = mockReply();
|
||||||
|
SyncService.syncWithWaniKani.mockResolvedValue({});
|
||||||
|
await SyncController.sync(mockReq({}, {}), reply);
|
||||||
|
expect(reply.send).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sync should handle error', async () => {
|
||||||
|
const reply = mockReply();
|
||||||
|
SyncService.syncWithWaniKani.mockRejectedValue(new Error('err'));
|
||||||
|
await SyncController.sync(mockReq({}, {}), reply);
|
||||||
|
expect(reply.code).toHaveBeenCalledWith(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Collection Controller', () => {
|
||||||
|
it('getCollection should return items', async () => {
|
||||||
|
const reply = mockReply();
|
||||||
|
StudyItem.find.mockResolvedValue([]);
|
||||||
|
await CollectionController.getCollection(mockReq({}, { _id: 1 }), reply);
|
||||||
|
expect(reply.send).toHaveBeenCalledWith([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getQueue should call service with default limit', async () => {
|
||||||
|
const reply = mockReply();
|
||||||
|
ReviewService.getQueue.mockResolvedValue([]);
|
||||||
|
await CollectionController.getQueue(mockReq({}, {}, {}), reply);
|
||||||
|
expect(ReviewService.getQueue).toHaveBeenCalledWith(expect.anything(), 20, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getQueue should call service with provided limit', async () => {
|
||||||
|
const reply = mockReply();
|
||||||
|
ReviewService.getQueue.mockResolvedValue([]);
|
||||||
|
await CollectionController.getQueue(mockReq({}, {}, { limit: '50' }), reply);
|
||||||
|
expect(ReviewService.getQueue).toHaveBeenCalledWith(expect.anything(), 50, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getStats should call service', async () => {
|
||||||
|
const reply = mockReply();
|
||||||
|
StatsService.getUserStats.mockResolvedValue({});
|
||||||
|
await CollectionController.getStats(mockReq({}, {}), reply);
|
||||||
|
expect(StatsService.getUserStats).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateSettings should initialize settings if missing', async () => {
|
||||||
|
const reply = mockReply();
|
||||||
|
const save = vi.fn();
|
||||||
|
const user = { save };
|
||||||
|
await CollectionController.updateSettings(mockReq({ batchSize: 50 }, user), reply);
|
||||||
|
expect(user.settings).toBeDefined();
|
||||||
|
expect(user.settings.batchSize).toBe(50);
|
||||||
|
expect(save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateSettings should update drawingAccuracy', async () => {
|
||||||
|
const reply = mockReply();
|
||||||
|
const save = vi.fn();
|
||||||
|
const user = { settings: {}, save };
|
||||||
|
await CollectionController.updateSettings(mockReq({ drawingAccuracy: 5 }, user), reply);
|
||||||
|
expect(user.settings.drawingAccuracy).toBe(5);
|
||||||
|
expect(save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
144
server/tests/services/review.service.test.js
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import * as ReviewService from '../../src/services/review.service.js';
|
||||||
|
import { User } from '../../src/models/User.js';
|
||||||
|
import { ReviewLog } from '../../src/models/ReviewLog.js';
|
||||||
|
import { StudyItem } from '../../src/models/StudyItem.js';
|
||||||
|
|
||||||
|
vi.mock('../../src/models/ReviewLog.js');
|
||||||
|
vi.mock('../../src/models/StudyItem.js');
|
||||||
|
|
||||||
|
describe('Review Service', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2023-01-10T00:00:00Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('processReview', () => {
|
||||||
|
const mockUser = (stats = {}) => ({
|
||||||
|
_id: 'u1',
|
||||||
|
stats: { ...stats },
|
||||||
|
save: vi.fn()
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockItem = (srs = 1) => ({
|
||||||
|
userId: 'u1',
|
||||||
|
wkSubjectId: 100,
|
||||||
|
srsLevel: srs,
|
||||||
|
stats: { correct: 0, total: 0 },
|
||||||
|
save: vi.fn(),
|
||||||
|
nextReview: null
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if item not found', async () => {
|
||||||
|
const user = mockUser();
|
||||||
|
StudyItem.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(ReviewService.processReview(user, 999, true))
|
||||||
|
.rejects.toThrow('Item not found');
|
||||||
|
|
||||||
|
expect(StudyItem.findOne).toHaveBeenCalledWith({
|
||||||
|
userId: user._id,
|
||||||
|
wkSubjectId: 999
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle standard success flow', async () => {
|
||||||
|
const user = mockUser();
|
||||||
|
delete user.stats;
|
||||||
|
StudyItem.findOne.mockResolvedValue(mockItem());
|
||||||
|
const res = await ReviewService.processReview(user, 100, true);
|
||||||
|
expect(user.save).toHaveBeenCalled();
|
||||||
|
expect(res.srsLevel).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle failure flow', async () => {
|
||||||
|
const user = mockUser();
|
||||||
|
const item = mockItem(2);
|
||||||
|
StudyItem.findOne.mockResolvedValue(item);
|
||||||
|
await ReviewService.processReview(user, 100, false);
|
||||||
|
expect(item.srsLevel).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should increment streak (diff = 1)', async () => {
|
||||||
|
const user = mockUser({ lastStudyDate: '2023-01-09', currentStreak: 5 });
|
||||||
|
StudyItem.findOne.mockResolvedValue(mockItem());
|
||||||
|
await ReviewService.processReview(user, 100, true);
|
||||||
|
expect(user.stats.currentStreak).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain streak (diff = 0)', async () => {
|
||||||
|
const user = mockUser({ lastStudyDate: '2023-01-10', currentStreak: 5 });
|
||||||
|
StudyItem.findOne.mockResolvedValue(mockItem());
|
||||||
|
await ReviewService.processReview(user, 100, true);
|
||||||
|
expect(user.stats.currentStreak).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset streak (diff > 1, no shield)', async () => {
|
||||||
|
const user = mockUser({ lastStudyDate: '2023-01-08', currentStreak: 5, lastFreezeDate: '2023-01-09' });
|
||||||
|
StudyItem.findOne.mockResolvedValue(mockItem());
|
||||||
|
await ReviewService.processReview(user, 100, true);
|
||||||
|
expect(user.stats.currentStreak).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use shield (diff = 2, shield ready)', async () => {
|
||||||
|
const user = mockUser({ lastStudyDate: '2023-01-08', currentStreak: 5, lastFreezeDate: '2022-01-01' });
|
||||||
|
StudyItem.findOne.mockResolvedValue(mockItem());
|
||||||
|
await ReviewService.processReview(user, 100, true);
|
||||||
|
expect(user.stats.currentStreak).toBe(6);
|
||||||
|
expect(user.stats.lastFreezeDate).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use shield (diff = 2) when lastFreezeDate is undefined', async () => {
|
||||||
|
const user = mockUser({ lastStudyDate: '2023-01-08', currentStreak: 5 });
|
||||||
|
user.stats.lastFreezeDate = null;
|
||||||
|
|
||||||
|
StudyItem.findOne.mockResolvedValue(mockItem());
|
||||||
|
|
||||||
|
await ReviewService.processReview(user, 100, true);
|
||||||
|
|
||||||
|
expect(user.stats.currentStreak).toBe(6);
|
||||||
|
expect(user.stats.lastFreezeDate).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize item stats if missing', async () => {
|
||||||
|
const user = mockUser();
|
||||||
|
const item = mockItem();
|
||||||
|
delete item.stats;
|
||||||
|
StudyItem.findOne.mockResolvedValue(item);
|
||||||
|
|
||||||
|
await ReviewService.processReview(user, 100, true);
|
||||||
|
|
||||||
|
expect(item.stats).toEqual(expect.objectContaining({ correct: 1, total: 1 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not break on time travel (diff < 0)', async () => {
|
||||||
|
const user = mockUser({ lastStudyDate: '2023-01-11', currentStreak: 5 });
|
||||||
|
StudyItem.findOne.mockResolvedValue(mockItem());
|
||||||
|
|
||||||
|
await ReviewService.processReview(user, 100, true);
|
||||||
|
|
||||||
|
expect(user.stats.lastStudyDate).toBe('2023-01-10');
|
||||||
|
expect(user.stats.currentStreak).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getQueue', () => {
|
||||||
|
it('should sort by priority', async () => {
|
||||||
|
const mockFind = { sort: vi.fn().mockReturnThis(), limit: vi.fn().mockResolvedValue([]) };
|
||||||
|
StudyItem.find.mockReturnValue(mockFind);
|
||||||
|
await ReviewService.getQueue({ _id: 'u1' }, 10, 'priority');
|
||||||
|
expect(mockFind.sort).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('should shuffle (default)', async () => {
|
||||||
|
const mockFind = { limit: vi.fn().mockResolvedValue([1, 2]) };
|
||||||
|
StudyItem.find.mockReturnValue(mockFind);
|
||||||
|
await ReviewService.getQueue({ _id: 'u1' }, 10, 'random');
|
||||||
|
expect(mockFind.limit).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
147
server/tests/services/stats.service.test.js
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { getUserStats } from '../../src/services/stats.service.js';
|
||||||
|
import { StudyItem } from '../../src/models/StudyItem.js';
|
||||||
|
import { ReviewLog } from '../../src/models/ReviewLog.js';
|
||||||
|
|
||||||
|
vi.mock('../../src/models/StudyItem.js');
|
||||||
|
vi.mock('../../src/models/ReviewLog.js');
|
||||||
|
|
||||||
|
describe('Stats Service', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2023-01-10T12:00:00Z'));
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockUser = {
|
||||||
|
_id: 'u1',
|
||||||
|
stats: {
|
||||||
|
totalReviews: 10,
|
||||||
|
correctReviews: 8,
|
||||||
|
currentStreak: 5,
|
||||||
|
lastStudyDate: '2023-01-10',
|
||||||
|
lastFreezeDate: '2022-01-01'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should calculate stats correctly including ghosts, forecast, and unknown SRS levels', async () => {
|
||||||
|
StudyItem.aggregate.mockResolvedValue([
|
||||||
|
{ _id: 1, count: 5 },
|
||||||
|
{ _id: 8, count: 2 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
StudyItem.find.mockImplementation(() => ({
|
||||||
|
select: vi.fn().mockImplementation((fields) => {
|
||||||
|
if (fields.includes('nextReview')) {
|
||||||
|
const now = new Date();
|
||||||
|
return Promise.resolve([
|
||||||
|
{ nextReview: new Date(now.getTime() - 1000) },
|
||||||
|
{ nextReview: new Date(now.getTime() + 3600000) },
|
||||||
|
{ nextReview: new Date(now.getTime() + 24 * 3600000) }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (fields.includes('stats')) {
|
||||||
|
const mkGhost = (acc) => ({
|
||||||
|
char: 'A', meaning: 'B', stats: { correct: acc, total: 100 }, srsLevel: 1,
|
||||||
|
toObject: () => ({ char: 'A', meaning: 'B', stats: { correct: acc, total: 100 }, srsLevel: 1 })
|
||||||
|
});
|
||||||
|
return Promise.resolve([mkGhost(60), mkGhost(50), mkGhost(90)]);
|
||||||
|
}
|
||||||
|
if (fields.includes('srsLevel')) {
|
||||||
|
return Promise.resolve([{ srsLevel: 1 }, { srsLevel: 4 }]);
|
||||||
|
}
|
||||||
|
return Promise.resolve([]);
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
ReviewLog.find.mockResolvedValue([
|
||||||
|
{ date: '2023-01-09', count: 5 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stats = await getUserStats(mockUser);
|
||||||
|
|
||||||
|
expect(stats.forecast[0]).toBe(1);
|
||||||
|
expect(stats.forecast[1]).toBe(1);
|
||||||
|
|
||||||
|
expect(stats.ghosts.length).toBe(2);
|
||||||
|
expect(stats.ghosts[0].accuracy).toBe(50);
|
||||||
|
expect(stats.ghosts[1].accuracy).toBe(60);
|
||||||
|
|
||||||
|
expect(stats.distribution[1]).toBe(5);
|
||||||
|
expect(stats.distribution[8]).toBeUndefined();
|
||||||
|
|
||||||
|
expect(stats.heatmap['2023-01-09']).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle queue with same levels (hasLowerLevels = false)', async () => {
|
||||||
|
StudyItem.aggregate.mockResolvedValue([]);
|
||||||
|
StudyItem.find.mockImplementation(() => ({
|
||||||
|
select: vi.fn().mockImplementation((fields) => {
|
||||||
|
if (fields.includes('stats')) {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.includes('srsLevel')) {
|
||||||
|
return Promise.resolve([{ srsLevel: 2 }, { srsLevel: 2 }]);
|
||||||
|
}
|
||||||
|
return Promise.resolve([]);
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
ReviewLog.find.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const stats = await getUserStats(mockUser);
|
||||||
|
expect(stats.hasLowerLevels).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate shield cooldown', async () => {
|
||||||
|
StudyItem.aggregate.mockResolvedValue([]);
|
||||||
|
StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) });
|
||||||
|
ReviewLog.find.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const frozenUser = {
|
||||||
|
...mockUser,
|
||||||
|
stats: { ...mockUser.stats, lastFreezeDate: '2023-01-07' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = await getUserStats(frozenUser);
|
||||||
|
expect(stats.streak.shield.ready).toBe(false);
|
||||||
|
expect(stats.streak.shield.cooldown).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle streak logic when lastStudyDate is missing or old', async () => {
|
||||||
|
const userNoDate = { ...mockUser, stats: { ...mockUser.stats, lastStudyDate: null, currentStreak: 5 } };
|
||||||
|
StudyItem.aggregate.mockResolvedValue([]);
|
||||||
|
StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) });
|
||||||
|
ReviewLog.find.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const res1 = await getUserStats(userNoDate);
|
||||||
|
expect(res1.streak.current).toBe(5);
|
||||||
|
|
||||||
|
const userMissed = { ...mockUser, stats: { ...mockUser.stats, lastStudyDate: '2023-01-08' } };
|
||||||
|
const res2 = await getUserStats(userMissed);
|
||||||
|
expect(res2.streak.current).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing user stats fields (null checks)', async () => {
|
||||||
|
const emptyUser = {
|
||||||
|
_id: 'u2',
|
||||||
|
stats: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
StudyItem.aggregate.mockResolvedValue([]);
|
||||||
|
StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) });
|
||||||
|
ReviewLog.find.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const stats = await getUserStats(emptyUser);
|
||||||
|
|
||||||
|
expect(stats.streak.current).toBe(0);
|
||||||
|
expect(stats.streak.best).toBe(0);
|
||||||
|
expect(stats.accuracy.total).toBe(0);
|
||||||
|
expect(stats.accuracy.correct).toBe(0);
|
||||||
|
|
||||||
|
expect(stats.streak.shield.ready).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
212
server/tests/services/sync.service.test.js
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { syncWithWaniKani } from '../../src/services/sync.service.js';
|
||||||
|
import { StudyItem } from '../../src/models/StudyItem.js';
|
||||||
|
|
||||||
|
vi.mock('../../src/models/StudyItem.js');
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
|
||||||
|
describe('Sync Service', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockUser = { _id: 'u1', wkApiKey: 'key', settings: {} };
|
||||||
|
|
||||||
|
it('should throw if no API key', async () => {
|
||||||
|
await expect(syncWithWaniKani({}))
|
||||||
|
.rejects.toThrow('User has no WaniKani API Key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle API fetch error', async () => {
|
||||||
|
fetch.mockResolvedValue({ ok: false, statusText: 'Unauthorized' });
|
||||||
|
await expect(syncWithWaniKani(mockUser))
|
||||||
|
.rejects.toThrow('Failed to connect to WaniKani');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 if no unlocked kanji', async () => {
|
||||||
|
fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ data: [], pages: { next_url: null } })
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await syncWithWaniKani(mockUser);
|
||||||
|
expect(res.count).toBe(0);
|
||||||
|
expect(res.message).toContain('No unlocked kanji');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sync new items with full reading data', async () => {
|
||||||
|
fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: [{ data: { subject_id: 101 } }],
|
||||||
|
pages: { next_url: null }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) });
|
||||||
|
|
||||||
|
fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: [{
|
||||||
|
id: 101,
|
||||||
|
data: {
|
||||||
|
characters: 'A',
|
||||||
|
meanings: [{ primary: true, meaning: 'A_Meaning' }],
|
||||||
|
level: 5,
|
||||||
|
readings: [
|
||||||
|
{ type: 'onyomi', reading: 'on' },
|
||||||
|
{ type: 'kunyomi', reading: 'kun' },
|
||||||
|
{ type: 'nanori', reading: 'nan' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
StudyItem.insertMany.mockResolvedValue(true);
|
||||||
|
StudyItem.countDocuments.mockResolvedValue(1);
|
||||||
|
|
||||||
|
await syncWithWaniKani(mockUser);
|
||||||
|
|
||||||
|
expect(StudyItem.insertMany).toHaveBeenCalledWith(expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
wkSubjectId: 101,
|
||||||
|
onyomi: ['on'],
|
||||||
|
kunyomi: ['kun'],
|
||||||
|
nanori: ['nan']
|
||||||
|
})
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter out existing items', async () => {
|
||||||
|
fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: [
|
||||||
|
{ data: { subject_id: 101 } },
|
||||||
|
{ data: { subject_id: 102 } }
|
||||||
|
],
|
||||||
|
pages: { next_url: null }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
StudyItem.find.mockReturnValue({
|
||||||
|
select: vi.fn().mockResolvedValue([{ wkSubjectId: 102 }])
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: [{
|
||||||
|
id: 101,
|
||||||
|
data: {
|
||||||
|
characters: 'New',
|
||||||
|
meanings: [],
|
||||||
|
level: 1,
|
||||||
|
readings: []
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
StudyItem.insertMany.mockResolvedValue(true);
|
||||||
|
StudyItem.countDocuments.mockResolvedValue(2);
|
||||||
|
|
||||||
|
await syncWithWaniKani(mockUser);
|
||||||
|
|
||||||
|
expect(StudyItem.insertMany).toHaveBeenCalledTimes(1);
|
||||||
|
expect(StudyItem.insertMany).toHaveBeenCalledWith(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ wkSubjectId: 101 })
|
||||||
|
]));
|
||||||
|
expect(StudyItem.insertMany).not.toHaveBeenCalledWith(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ wkSubjectId: 102 })
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle assignment pagination', async () => {
|
||||||
|
fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: [{ data: { subject_id: 1 } }],
|
||||||
|
pages: { next_url: 'http://next-page' }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: [{ data: { subject_id: 2 } }],
|
||||||
|
pages: { next_url: null }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) });
|
||||||
|
|
||||||
|
fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ data: [] })
|
||||||
|
});
|
||||||
|
|
||||||
|
StudyItem.countDocuments.mockResolvedValue(2);
|
||||||
|
|
||||||
|
await syncWithWaniKani(mockUser);
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip insert if operations are empty (e.g. subject data missing)', async () => {
|
||||||
|
fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: [{ data: { subject_id: 101 } }],
|
||||||
|
pages: { next_url: null }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) });
|
||||||
|
|
||||||
|
fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ data: [] })
|
||||||
|
});
|
||||||
|
|
||||||
|
StudyItem.countDocuments.mockResolvedValue(0);
|
||||||
|
|
||||||
|
await syncWithWaniKani(mockUser);
|
||||||
|
|
||||||
|
expect(StudyItem.insertMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sync items in chunks', async () => {
|
||||||
|
const manyIds = Array.from({ length: 150 }, (_, i) => i + 1);
|
||||||
|
const subjectData = manyIds.map(id => ({ data: { subject_id: id } }));
|
||||||
|
|
||||||
|
fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: subjectData,
|
||||||
|
pages: { next_url: null }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) });
|
||||||
|
|
||||||
|
fetch
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ data: [{ id: 1, data: { characters: 'C', meanings: [], level: 1 } }] })
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ data: [{ id: 101, data: { characters: 'D', meanings: [], level: 1 } }] })
|
||||||
|
});
|
||||||
|
|
||||||
|
StudyItem.insertMany.mockResolvedValue(true);
|
||||||
|
StudyItem.countDocuments.mockResolvedValue(150);
|
||||||
|
|
||||||
|
await syncWithWaniKani(mockUser);
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(3);
|
||||||
|
expect(StudyItem.insertMany).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
54
server/tests/utils.test.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { getDaysDiff, getSRSDate } from '../src/utils/dateUtils.js';
|
||||||
|
|
||||||
|
describe('Date Utils', () => {
|
||||||
|
describe('getDaysDiff', () => {
|
||||||
|
it('should calculate difference between two dates correctly', () => {
|
||||||
|
const d1 = '2023-01-01';
|
||||||
|
const d2 = '2023-01-03';
|
||||||
|
expect(getDaysDiff(d1, d2)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 for same day', () => {
|
||||||
|
expect(getDaysDiff('2023-01-01', '2023-01-01')).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSRSDate', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2023-01-01T12:00:00Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add correct hours for levels 1-6', () => {
|
||||||
|
const base = new Date('2023-01-01T12:00:00Z');
|
||||||
|
|
||||||
|
expect(getSRSDate(1).toISOString()).toBe(new Date(base.getTime() + 4 * 3600000).toISOString());
|
||||||
|
expect(getSRSDate(2).toISOString()).toBe(new Date(base.getTime() + 8 * 3600000).toISOString());
|
||||||
|
expect(getSRSDate(3).toISOString()).toBe(new Date(base.getTime() + 24 * 3600000).toISOString());
|
||||||
|
expect(getSRSDate(4).toISOString()).toBe(new Date(base.getTime() + 48 * 3600000).toISOString());
|
||||||
|
expect(getSRSDate(5).toISOString()).toBe(new Date(base.getTime() + 7 * 24 * 3600000).toISOString());
|
||||||
|
expect(getSRSDate(6).toISOString()).toBe(new Date(base.getTime() + 14 * 24 * 3600000).toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add correct hours for levels 7-9', () => {
|
||||||
|
const base = new Date('2023-01-01T12:00:00Z');
|
||||||
|
expect(getSRSDate(7).toISOString()).toBe(new Date(base.getTime() + 7 * 24 * 3600000).toISOString());
|
||||||
|
expect(getSRSDate(8).toISOString()).toBe(new Date(base.getTime() + 30 * 24 * 3600000).toISOString());
|
||||||
|
expect(getSRSDate(9).toISOString()).toBe(new Date(base.getTime() + 90 * 24 * 3600000).toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for level 10 (burned)', () => {
|
||||||
|
expect(getSRSDate(10)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default to 4 hours for unknown levels', () => {
|
||||||
|
const base = new Date('2023-01-01T12:00:00Z');
|
||||||
|
expect(getSRSDate(99).toISOString()).toBe(new Date(base.getTime() + 4 * 3600000).toISOString());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||