v1
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
1
.vscode/settings.json
vendored
Normal file
1
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
4
client/.browserslistrc
Normal file
4
client/.browserslistrc
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
> 1%
|
||||||
|
last 2 versions
|
||||||
|
not dead
|
||||||
|
not ie 11
|
||||||
6
client/.editorconfig
Normal file
6
client/.editorconfig
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
|
||||||
|
charset = utf-8
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
3
client/.eslintignore
Normal file
3
client/.eslintignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
public/
|
||||||
85
client/.eslintrc-auto-import.json
Normal file
85
client/.eslintrc-auto-import.json
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
{
|
||||||
|
"globals": {
|
||||||
|
"Component": true,
|
||||||
|
"ComponentPublicInstance": true,
|
||||||
|
"ComputedRef": true,
|
||||||
|
"DirectiveBinding": true,
|
||||||
|
"EffectScope": true,
|
||||||
|
"ExtractDefaultPropTypes": true,
|
||||||
|
"ExtractPropTypes": true,
|
||||||
|
"ExtractPublicPropTypes": true,
|
||||||
|
"InjectionKey": true,
|
||||||
|
"MaybeRef": true,
|
||||||
|
"MaybeRefOrGetter": true,
|
||||||
|
"PropType": true,
|
||||||
|
"Ref": true,
|
||||||
|
"ShallowRef": true,
|
||||||
|
"Slot": true,
|
||||||
|
"Slots": true,
|
||||||
|
"VNode": true,
|
||||||
|
"WritableComputedRef": true,
|
||||||
|
"computed": true,
|
||||||
|
"createApp": true,
|
||||||
|
"customRef": true,
|
||||||
|
"defineAsyncComponent": true,
|
||||||
|
"defineComponent": true,
|
||||||
|
"defineStore": true,
|
||||||
|
"effectScope": true,
|
||||||
|
"getCurrentInstance": true,
|
||||||
|
"getCurrentScope": true,
|
||||||
|
"getCurrentWatcher": true,
|
||||||
|
"h": true,
|
||||||
|
"inject": true,
|
||||||
|
"isProxy": true,
|
||||||
|
"isReactive": true,
|
||||||
|
"isReadonly": true,
|
||||||
|
"isRef": true,
|
||||||
|
"isShallow": true,
|
||||||
|
"markRaw": true,
|
||||||
|
"nextTick": true,
|
||||||
|
"onActivated": true,
|
||||||
|
"onBeforeMount": true,
|
||||||
|
"onBeforeRouteLeave": true,
|
||||||
|
"onBeforeRouteUpdate": true,
|
||||||
|
"onBeforeUnmount": true,
|
||||||
|
"onBeforeUpdate": true,
|
||||||
|
"onDeactivated": true,
|
||||||
|
"onErrorCaptured": true,
|
||||||
|
"onMounted": true,
|
||||||
|
"onRenderTracked": true,
|
||||||
|
"onRenderTriggered": true,
|
||||||
|
"onScopeDispose": true,
|
||||||
|
"onServerPrefetch": true,
|
||||||
|
"onUnmounted": true,
|
||||||
|
"onUpdated": true,
|
||||||
|
"onWatcherCleanup": true,
|
||||||
|
"provide": true,
|
||||||
|
"reactive": true,
|
||||||
|
"readonly": true,
|
||||||
|
"ref": true,
|
||||||
|
"resolveComponent": true,
|
||||||
|
"shallowReactive": true,
|
||||||
|
"shallowReadonly": true,
|
||||||
|
"shallowRef": true,
|
||||||
|
"storeToRefs": true,
|
||||||
|
"toRaw": true,
|
||||||
|
"toRef": true,
|
||||||
|
"toRefs": true,
|
||||||
|
"toValue": true,
|
||||||
|
"triggerRef": true,
|
||||||
|
"unref": true,
|
||||||
|
"useAttrs": true,
|
||||||
|
"useCssModule": true,
|
||||||
|
"useCssVars": true,
|
||||||
|
"useId": true,
|
||||||
|
"useModel": true,
|
||||||
|
"useRoute": true,
|
||||||
|
"useRouter": true,
|
||||||
|
"useSlots": true,
|
||||||
|
"useTemplateRef": true,
|
||||||
|
"watch": true,
|
||||||
|
"watchEffect": true,
|
||||||
|
"watchPostEffect": true,
|
||||||
|
"watchSyncEffect": true
|
||||||
|
}
|
||||||
|
}
|
||||||
22
client/.gitignore
vendored
Normal file
22
client/.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
/dist
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
15
client/Dockerfile
Normal file
15
client/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM node:24-alpine
|
||||||
|
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY pnpm-lock.yaml package*.json ./
|
||||||
|
|
||||||
|
RUN pnpm i
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
CMD [ "pnpm", "run", "dev", "--", "--host", "0.0.0.0" ]
|
||||||
81
client/README.md
Normal file
81
client/README.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Vuetify (Default)
|
||||||
|
|
||||||
|
This is the official scaffolding tool for Vuetify, designed to give you a head start in building your new Vuetify application. It sets up a base template with all the necessary configurations and standard directory structure, enabling you to begin development without the hassle of setting up the project from scratch.
|
||||||
|
|
||||||
|
## ❗️ Important Links
|
||||||
|
|
||||||
|
- 📄 [Docs](https://vuetifyjs.com/)
|
||||||
|
- 🚨 [Issues](https://issues.vuetifyjs.com/)
|
||||||
|
- 🏬 [Store](https://store.vuetifyjs.com/)
|
||||||
|
- 🎮 [Playground](https://play.vuetifyjs.com/)
|
||||||
|
- 💬 [Discord](https://community.vuetifyjs.com)
|
||||||
|
|
||||||
|
## 💿 Install
|
||||||
|
|
||||||
|
Set up your project using your preferred package manager. Use the corresponding command to install the dependencies:
|
||||||
|
|
||||||
|
| Package Manager | Command |
|
||||||
|
|---------------------------------------------------------------|----------------|
|
||||||
|
| [yarn](https://yarnpkg.com/getting-started) | `yarn install` |
|
||||||
|
| [npm](https://docs.npmjs.com/cli/v7/commands/npm-install) | `npm install` |
|
||||||
|
| [pnpm](https://pnpm.io/installation) | `pnpm install` |
|
||||||
|
| [bun](https://bun.sh/#getting-started) | `bun install` |
|
||||||
|
|
||||||
|
After completing the installation, your environment is ready for Vuetify development.
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- 🖼️ **Optimized Front-End Stack**: Leverage the latest Vue 3 and Vuetify 3 for a modern, reactive UI development experience. [Vue 3](https://v3.vuejs.org/) | [Vuetify 3](https://vuetifyjs.com/en/)
|
||||||
|
- 🗃️ **State Management**: Integrated with [Pinia](https://pinia.vuejs.org/), the intuitive, modular state management solution for Vue.
|
||||||
|
- 🚦 **Routing and Layouts**: Utilizes Vue Router for SPA navigation and vite-plugin-vue-layouts-next for organizing Vue file layouts. [Vue Router](https://router.vuejs.org/) | [vite-plugin-vue-layouts-next](https://github.com/loicduong/vite-plugin-vue-layouts-next)
|
||||||
|
- 💻 **Enhanced Development Experience**: Benefit from TypeScript's static type checking and the ESLint plugin suite for Vue, ensuring code quality and consistency. [TypeScript](https://www.typescriptlang.org/) | [ESLint Plugin Vue](https://eslint.vuejs.org/)
|
||||||
|
- ⚡ **Next-Gen Tooling**: Powered by Vite, experience fast cold starts and instant HMR (Hot Module Replacement). [Vite](https://vitejs.dev/)
|
||||||
|
- 🧩 **Automated Component Importing**: Streamline your workflow with unplugin-vue-components, automatically importing components as you use them. [unplugin-vue-components](https://github.com/antfu/unplugin-vue-components)
|
||||||
|
- 🛠️ **Strongly-Typed Vue**: Use vue-tsc for type-checking your Vue components, and enjoy a robust development experience. [vue-tsc](https://github.com/johnsoncodehk/volar/tree/master/packages/vue-tsc)
|
||||||
|
|
||||||
|
These features are curated to provide a seamless development experience from setup to deployment, ensuring that your Vuetify application is both powerful and maintainable.
|
||||||
|
|
||||||
|
## 💡 Usage
|
||||||
|
|
||||||
|
This section covers how to start the development server and build your project for production.
|
||||||
|
|
||||||
|
### Starting the Development Server
|
||||||
|
|
||||||
|
To start the development server with hot-reload, run the following command. The server will be accessible at [http://localhost:3000](http://localhost:3000):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
(Repeat for npm, pnpm, and bun with respective commands.)
|
||||||
|
|
||||||
|
> Add NODE_OPTIONS='--no-warnings' to suppress the JSON import warnings that happen as part of the Vuetify import mapping. If you are on Node [v21.3.0](https://nodejs.org/en/blog/release/v21.3.0) or higher, you can change this to NODE_OPTIONS='--disable-warning=5401'. If you don't mind the warning, you can remove this from your package.json dev script.
|
||||||
|
|
||||||
|
### Building for Production
|
||||||
|
|
||||||
|
To build your project for production, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn build
|
||||||
|
```
|
||||||
|
|
||||||
|
(Repeat for npm, pnpm, and bun with respective commands.)
|
||||||
|
|
||||||
|
Once the build process is completed, your application will be ready for deployment in a production environment.
|
||||||
|
|
||||||
|
## 💪 Support Vuetify Development
|
||||||
|
|
||||||
|
This project is built with [Vuetify](https://vuetifyjs.com/en/), a UI Library with a comprehensive collection of Vue components. Vuetify is an MIT licensed Open Source project that has been made possible due to the generous contributions by our [sponsors and backers](https://vuetifyjs.com/introduction/sponsors-and-backers/). If you are interested in supporting this project, please consider:
|
||||||
|
|
||||||
|
- [Requesting Enterprise Support](https://support.vuetifyjs.com/)
|
||||||
|
- [Sponsoring John on Github](https://github.com/users/johnleider/sponsorship)
|
||||||
|
- [Sponsoring Kael on Github](https://github.com/users/kaelwd/sponsorship)
|
||||||
|
- [Supporting the team on Open Collective](https://opencollective.com/vuetify)
|
||||||
|
- [Becoming a sponsor on Patreon](https://www.patreon.com/vuetify)
|
||||||
|
- [Becoming a subscriber on Tidelift](https://tidelift.com/subscription/npm/vuetify)
|
||||||
|
- [Making a one-time donation with Paypal](https://paypal.me/vuetify)
|
||||||
|
|
||||||
|
## 📑 License
|
||||||
|
[MIT](http://opensource.org/licenses/MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2016-present Vuetify, LLC
|
||||||
3
client/env.d.ts
vendored
Normal file
3
client/env.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="unplugin-vue-router/client" />
|
||||||
|
/// <reference types="vite-plugin-vue-layouts-next/client" />
|
||||||
13
client/eslint.config.js
Normal file
13
client/eslint.config.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import vuetify from 'eslint-config-vuetify'
|
||||||
|
|
||||||
|
export default [
|
||||||
|
vuetify(),
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
'node_modules/**',
|
||||||
|
'dist/**',
|
||||||
|
'public/**',
|
||||||
|
'*.min.js',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
19
client/index.html
Normal file
19
client/index.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Japanese SRS Trainer</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script src="/kanji-canvas.min.js"></script>
|
||||||
|
<script src="/ref-patterns.js"></script>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
0
client/index.ts
Normal file
0
client/index.ts
Normal file
44
client/package.json
Normal file
44
client/package.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "client",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host",
|
||||||
|
"build": "run-p type-check \"build-only {@}\" --",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"build-only": "vite build",
|
||||||
|
"type-check": "vue-tsc --build --force",
|
||||||
|
"lint": "eslint . --fix"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/roboto": "5.2.7",
|
||||||
|
"@mdi/font": "7.4.47",
|
||||||
|
"pinia": "^3.0.3",
|
||||||
|
"pug": "^3.0.3",
|
||||||
|
"vue": "^3.5.21",
|
||||||
|
"vue-pug": "^1.0.2",
|
||||||
|
"vue-router": "^4.5.1",
|
||||||
|
"vuetify": "^3.10.1",
|
||||||
|
"wanakana": "^5.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/node22": "^22.0.0",
|
||||||
|
"@types/node": "^22.9.0",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"eslint": "^9.35.0",
|
||||||
|
"eslint-config-vuetify": "^4.2.0",
|
||||||
|
"npm-run-all2": "^8.0.4",
|
||||||
|
"sass-embedded": "^1.92.1",
|
||||||
|
"typescript": "~5.9.2",
|
||||||
|
"unplugin-auto-import": "^20.1.0",
|
||||||
|
"unplugin-fonts": "^1.4.0",
|
||||||
|
"unplugin-vue-components": "^29.0.0",
|
||||||
|
"unplugin-vue-router": "^0.15.0",
|
||||||
|
"vite": "^7.1.5",
|
||||||
|
"vite-plugin-vue-layouts-next": "^1.0.0",
|
||||||
|
"vite-plugin-vuetify": "^2.1.2",
|
||||||
|
"vue-tsc": "^3.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
4182
client/pnpm-lock.yaml
generated
Normal file
4182
client/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
client/public/favicon.ico
Normal file
BIN
client/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
1
client/public/kanji-canvas.min.js
vendored
Normal file
1
client/public/kanji-canvas.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2228
client/public/ref-patterns.js
Normal file
2228
client/public/ref-patterns.js
Normal file
File diff suppressed because one or more lines are too long
5
client/src/App.vue
Normal file
5
client/src/App.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<v-app>
|
||||||
|
<router-view />
|
||||||
|
</v-app>
|
||||||
|
</template>
|
||||||
BIN
client/src/assets/logo.png
Normal file
BIN
client/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
6
client/src/assets/logo.svg
Normal file
6
client/src/assets/logo.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M261.126 140.65L164.624 307.732L256.001 466L377.028 256.5L498.001 47H315.192L261.126 140.65Z" fill="#1697F6"/>
|
||||||
|
<path d="M135.027 256.5L141.365 267.518L231.64 111.178L268.731 47H256H14L135.027 256.5Z" fill="#AEDDFF"/>
|
||||||
|
<path d="M315.191 47C360.935 197.446 256 466 256 466L164.624 307.732L315.191 47Z" fill="#1867C0"/>
|
||||||
|
<path d="M268.731 47C76.0026 47 141.366 267.518 141.366 267.518L268.731 47Z" fill="#7BC6FF"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 526 B |
151
client/src/auto-imports.d.ts
vendored
Normal file
151
client/src/auto-imports.d.ts
vendored
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* prettier-ignore */
|
||||||
|
// @ts-nocheck
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
// Generated by unplugin-auto-import
|
||||||
|
// biome-ignore lint: disable
|
||||||
|
export {}
|
||||||
|
declare global {
|
||||||
|
const EffectScope: typeof import('vue')['EffectScope']
|
||||||
|
const computed: typeof import('vue')['computed']
|
||||||
|
const createApp: typeof import('vue')['createApp']
|
||||||
|
const customRef: typeof import('vue')['customRef']
|
||||||
|
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||||
|
const defineComponent: typeof import('vue')['defineComponent']
|
||||||
|
const defineStore: typeof import('pinia')['defineStore']
|
||||||
|
const effectScope: typeof import('vue')['effectScope']
|
||||||
|
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||||
|
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||||
|
const getCurrentWatcher: typeof import('vue')['getCurrentWatcher']
|
||||||
|
const h: typeof import('vue')['h']
|
||||||
|
const inject: typeof import('vue')['inject']
|
||||||
|
const isProxy: typeof import('vue')['isProxy']
|
||||||
|
const isReactive: typeof import('vue')['isReactive']
|
||||||
|
const isReadonly: typeof import('vue')['isReadonly']
|
||||||
|
const isRef: typeof import('vue')['isRef']
|
||||||
|
const isShallow: typeof import('vue')['isShallow']
|
||||||
|
const markRaw: typeof import('vue')['markRaw']
|
||||||
|
const nextTick: typeof import('vue')['nextTick']
|
||||||
|
const onActivated: typeof import('vue')['onActivated']
|
||||||
|
const onBeforeMount: typeof import('vue')['onBeforeMount']
|
||||||
|
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
|
||||||
|
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
|
||||||
|
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
|
||||||
|
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
|
||||||
|
const onDeactivated: typeof import('vue')['onDeactivated']
|
||||||
|
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
||||||
|
const onMounted: typeof import('vue')['onMounted']
|
||||||
|
const onRenderTracked: typeof import('vue')['onRenderTracked']
|
||||||
|
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
|
||||||
|
const onScopeDispose: typeof import('vue')['onScopeDispose']
|
||||||
|
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
||||||
|
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||||
|
const onUpdated: typeof import('vue')['onUpdated']
|
||||||
|
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
|
||||||
|
const provide: typeof import('vue')['provide']
|
||||||
|
const reactive: typeof import('vue')['reactive']
|
||||||
|
const readonly: typeof import('vue')['readonly']
|
||||||
|
const ref: typeof import('vue')['ref']
|
||||||
|
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||||
|
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||||
|
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||||
|
const shallowRef: typeof import('vue')['shallowRef']
|
||||||
|
const storeToRefs: typeof import('pinia')['storeToRefs']
|
||||||
|
const toRaw: typeof import('vue')['toRaw']
|
||||||
|
const toRef: typeof import('vue')['toRef']
|
||||||
|
const toRefs: typeof import('vue')['toRefs']
|
||||||
|
const toValue: typeof import('vue')['toValue']
|
||||||
|
const triggerRef: typeof import('vue')['triggerRef']
|
||||||
|
const unref: typeof import('vue')['unref']
|
||||||
|
const useAttrs: typeof import('vue')['useAttrs']
|
||||||
|
const useCssModule: typeof import('vue')['useCssModule']
|
||||||
|
const useCssVars: typeof import('vue')['useCssVars']
|
||||||
|
const useId: typeof import('vue')['useId']
|
||||||
|
const useModel: typeof import('vue')['useModel']
|
||||||
|
const useRoute: typeof import('vue-router')['useRoute']
|
||||||
|
const useRouter: typeof import('vue-router')['useRouter']
|
||||||
|
const useSlots: typeof import('vue')['useSlots']
|
||||||
|
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
||||||
|
const watch: typeof import('vue')['watch']
|
||||||
|
const watchEffect: typeof import('vue')['watchEffect']
|
||||||
|
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||||
|
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
|
||||||
|
}
|
||||||
|
// for type re-export
|
||||||
|
declare global {
|
||||||
|
// @ts-ignore
|
||||||
|
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||||
|
import('vue')
|
||||||
|
}
|
||||||
|
|
||||||
|
// for vue template auto import
|
||||||
|
import { UnwrapRef } from 'vue'
|
||||||
|
declare module 'vue' {
|
||||||
|
interface GlobalComponents {}
|
||||||
|
interface ComponentCustomProperties {
|
||||||
|
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
|
||||||
|
readonly computed: UnwrapRef<typeof import('vue')['computed']>
|
||||||
|
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
|
||||||
|
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
|
||||||
|
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
|
||||||
|
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
|
||||||
|
readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
|
||||||
|
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
|
||||||
|
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
|
||||||
|
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
||||||
|
readonly getCurrentWatcher: UnwrapRef<typeof import('vue')['getCurrentWatcher']>
|
||||||
|
readonly h: UnwrapRef<typeof import('vue')['h']>
|
||||||
|
readonly inject: UnwrapRef<typeof import('vue')['inject']>
|
||||||
|
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
|
||||||
|
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
|
||||||
|
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
|
||||||
|
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
|
||||||
|
readonly isShallow: UnwrapRef<typeof import('vue')['isShallow']>
|
||||||
|
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
|
||||||
|
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
|
||||||
|
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
|
||||||
|
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
|
||||||
|
readonly onBeforeRouteLeave: UnwrapRef<typeof import('vue-router')['onBeforeRouteLeave']>
|
||||||
|
readonly onBeforeRouteUpdate: UnwrapRef<typeof import('vue-router')['onBeforeRouteUpdate']>
|
||||||
|
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
|
||||||
|
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
|
||||||
|
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
|
||||||
|
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
|
||||||
|
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
|
||||||
|
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
|
||||||
|
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
|
||||||
|
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
|
||||||
|
readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>
|
||||||
|
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
|
||||||
|
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
|
||||||
|
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
|
||||||
|
readonly provide: UnwrapRef<typeof import('vue')['provide']>
|
||||||
|
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
|
||||||
|
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
|
||||||
|
readonly ref: UnwrapRef<typeof import('vue')['ref']>
|
||||||
|
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
|
||||||
|
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
|
||||||
|
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
|
||||||
|
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
|
||||||
|
readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']>
|
||||||
|
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
|
||||||
|
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
|
||||||
|
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
|
||||||
|
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
|
||||||
|
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
|
||||||
|
readonly unref: UnwrapRef<typeof import('vue')['unref']>
|
||||||
|
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
|
||||||
|
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
|
||||||
|
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
|
||||||
|
readonly useId: UnwrapRef<typeof import('vue')['useId']>
|
||||||
|
readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
|
||||||
|
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
|
||||||
|
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
|
||||||
|
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
|
||||||
|
readonly useTemplateRef: UnwrapRef<typeof import('vue')['useTemplateRef']>
|
||||||
|
readonly watch: UnwrapRef<typeof import('vue')['watch']>
|
||||||
|
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
|
||||||
|
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
|
||||||
|
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
|
||||||
|
}
|
||||||
|
}
|
||||||
14
client/src/components.d.ts
vendored
Normal file
14
client/src/components.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
// @ts-nocheck
|
||||||
|
// Generated by unplugin-vue-components
|
||||||
|
// Read more: https://github.com/vuejs/core/pull/3399
|
||||||
|
// biome-ignore lint: disable
|
||||||
|
export {}
|
||||||
|
|
||||||
|
/* prettier-ignore */
|
||||||
|
declare module 'vue' {
|
||||||
|
export interface GlobalComponents {
|
||||||
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
}
|
||||||
|
}
|
||||||
143
client/src/composables/subject.ts
Normal file
143
client/src/composables/subject.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
type ReviewQueueItem = {
|
||||||
|
type: 'kanji' | 'vocab',
|
||||||
|
subject: any,
|
||||||
|
mode: 'meaning' | 'writing',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Reviews {
|
||||||
|
private static instance: Reviews
|
||||||
|
|
||||||
|
public data: {
|
||||||
|
kanji: any[],
|
||||||
|
vocab: any[],
|
||||||
|
}
|
||||||
|
|
||||||
|
private queue: { type: 'kanji' | 'vocab', subject: any, mode: 'meaning' | 'writing' }[] = []
|
||||||
|
|
||||||
|
private options: {
|
||||||
|
type: 'kanji' | 'vocab' | 'both',
|
||||||
|
mode: 'meaning' | 'writing' | 'both',
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructor(options: { type?: 'kanji' | 'vocab' | 'both', mode?: 'meaning' | 'writing' | 'both' }) {
|
||||||
|
this.data = {
|
||||||
|
kanji: [],
|
||||||
|
vocab: [],
|
||||||
|
}
|
||||||
|
this.options = {
|
||||||
|
type: options.type ?? 'both',
|
||||||
|
mode: options.mode ?? 'both',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getInstance(options: { type?: 'kanji' | 'vocab' | 'both', mode?: 'meaning' | 'writing' | 'both' } = {}): Promise<Reviews> {
|
||||||
|
if (!Reviews.instance) {
|
||||||
|
const instance = new Reviews(options)
|
||||||
|
await instance.loadSubjects()
|
||||||
|
instance.buildQueue()
|
||||||
|
Reviews.instance = instance
|
||||||
|
}
|
||||||
|
return Reviews.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setOptions(newOptions: { type?: 'kanji' | 'vocab' | 'both', mode?: 'meaning' | 'writing' | 'both' }) {
|
||||||
|
let needsReload = false
|
||||||
|
|
||||||
|
if (newOptions.type && newOptions.type !== this.options.type) {
|
||||||
|
this.options.type = newOptions.type
|
||||||
|
needsReload = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newOptions.mode && newOptions.mode !== this.options.mode) {
|
||||||
|
this.options.mode = newOptions.mode
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsReload) {
|
||||||
|
await this.loadSubjects()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.buildQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadSubjects() {
|
||||||
|
const typesToLoad: ('kanji' | 'vocab')[] =
|
||||||
|
this.options.type === 'both'
|
||||||
|
? ['kanji', 'vocab']
|
||||||
|
: [this.options.type]
|
||||||
|
|
||||||
|
for (const type of typesToLoad) {
|
||||||
|
await this.getSubjects(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildQueue() {
|
||||||
|
this.queue = []
|
||||||
|
|
||||||
|
const types: ('kanji' | 'vocab')[] =
|
||||||
|
this.options.type === 'both'
|
||||||
|
? ['kanji', 'vocab']
|
||||||
|
: [this.options.type]
|
||||||
|
|
||||||
|
for (const type of types) {
|
||||||
|
for (const subject of this.data[type]) {
|
||||||
|
switch (this.options.mode) {
|
||||||
|
case 'meaning':
|
||||||
|
this.queue.push({ type, subject, mode: 'meaning' })
|
||||||
|
break
|
||||||
|
case 'writing':
|
||||||
|
this.queue.push({ type, subject, mode: 'writing' })
|
||||||
|
break
|
||||||
|
case 'both':
|
||||||
|
this.queue.push({ type, subject, mode: 'meaning' })
|
||||||
|
this.queue.push({ type, subject, mode: 'writing' })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.shuffleQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
private shuffleQueue() {
|
||||||
|
for (let i = this.queue.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1))
|
||||||
|
const tmp = this.queue[i]!
|
||||||
|
this.queue[i] = this.queue[j]!
|
||||||
|
this.queue[j] = tmp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSubjects(type: 'kanji' | 'vocab') {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/subject/${type}`)
|
||||||
|
if (!res.ok) throw new Error(res.statusText)
|
||||||
|
this.data[type] = await res.json()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public nextSubject(): ReviewQueueItem | null {
|
||||||
|
return (this.queue.shift() as ReviewQueueItem | undefined) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
public getStats() {
|
||||||
|
const stats: {
|
||||||
|
mode: 'meaning' | 'writing' | 'both',
|
||||||
|
type: 'kanji' | 'vocab' | 'both',
|
||||||
|
kanjiCount: number,
|
||||||
|
vocabCount: number,
|
||||||
|
total: number,
|
||||||
|
queueRemaining: number,
|
||||||
|
} = {
|
||||||
|
mode: this.options.mode,
|
||||||
|
type: this.options.type,
|
||||||
|
kanjiCount: this.data.kanji.length,
|
||||||
|
vocabCount: this.data.vocab.length,
|
||||||
|
queueRemaining: this.queue.length,
|
||||||
|
total: this.queue.length,
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
}
|
||||||
7
client/src/global.d.ts
vendored
Normal file
7
client/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { }
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
KanjiCanvas: any
|
||||||
|
}
|
||||||
|
}
|
||||||
5
client/src/layouts/default.vue
Normal file
5
client/src/layouts/default.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<v-main>
|
||||||
|
<router-view />
|
||||||
|
</v-main>
|
||||||
|
</template>
|
||||||
23
client/src/main.ts
Normal file
23
client/src/main.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* main.ts
|
||||||
|
*
|
||||||
|
* Bootstraps Vuetify and other plugins then mounts the App`
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Composables
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
|
||||||
|
// Plugins
|
||||||
|
import { registerPlugins } from '@/plugins'
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
import 'unfonts.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
registerPlugins(app)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
297
client/src/pages/all.vue
Normal file
297
client/src/pages/all.vue
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
<template lang="pug">
|
||||||
|
v-container.fill-height.d-flex.justify-center.align-center
|
||||||
|
v-card.pa-12.rounded-lg.elevation-12
|
||||||
|
v-card-title.text-center.text-h2.font-weight-bold.mb-4
|
||||||
|
span.text-primary Full Trainer
|
||||||
|
|
||||||
|
v-sheet.d-flex.justify-center.align-center.rounded-lg.py-8.px-4.elevation-12(
|
||||||
|
:style="{backgroundColor: 'var(--color-all)', fontSize: '5rem', fontWeight: 'bold', whiteSpace: 'nowrap'}"
|
||||||
|
)
|
||||||
|
span {{ character }}
|
||||||
|
|
||||||
|
v-card-text.text-center.my-4.rounded-lg(
|
||||||
|
:style="{ backgroundColor: 'hsl(320, 100%, 50%, 0.1)', fontSize: '1.1rem', fontWeight: '500', color: 'var(--color-all)'}"
|
||||||
|
)
|
||||||
|
| {{ inputMode === 'kanji' ? 'Kanji' : inputMode === 'writing' ? 'Writing' : inputMode }}
|
||||||
|
|
||||||
|
transition(name="alert-grow")
|
||||||
|
v-alert.mb-4(
|
||||||
|
v-if="resultMessage"
|
||||||
|
:type="isCorrect ? 'success' : 'error'"
|
||||||
|
border="top"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
) {{ resultMessage }}
|
||||||
|
|
||||||
|
v-row.align-center
|
||||||
|
v-col(cols="9")
|
||||||
|
input#answer-input.customInput(
|
||||||
|
ref="answerInput"
|
||||||
|
placeholder="Type your answer"
|
||||||
|
:disabled="isDisabled"
|
||||||
|
)
|
||||||
|
v-col(cols="3")
|
||||||
|
v-btn(
|
||||||
|
color="primary"
|
||||||
|
block
|
||||||
|
@click="submitAnswer"
|
||||||
|
) Enter
|
||||||
|
|
||||||
|
v-row.justify-space-between
|
||||||
|
v-col(cols="4")
|
||||||
|
v-btn(
|
||||||
|
color="error"
|
||||||
|
variant="flat"
|
||||||
|
block
|
||||||
|
@click="quitSession"
|
||||||
|
) Quit
|
||||||
|
v-col(cols="4")
|
||||||
|
v-btn(
|
||||||
|
color="secondary"
|
||||||
|
variant="flat"
|
||||||
|
block
|
||||||
|
@click="createReview"
|
||||||
|
) Skip
|
||||||
|
v-col(cols="4")
|
||||||
|
v-btn(
|
||||||
|
color="success"
|
||||||
|
variant="flat"
|
||||||
|
block
|
||||||
|
@click="submitAnswer"
|
||||||
|
) Resolve
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, nextTick, onBeforeUnmount, computed } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import * as wanakana from 'wanakana'
|
||||||
|
import { Reviews } from '../composables/subject.ts'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const answerInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const character = ref<string>('')
|
||||||
|
const isCorrect = ref<boolean>(false)
|
||||||
|
const isDisabled = ref<boolean>(false)
|
||||||
|
const isWanakanaBound = ref<boolean>(false)
|
||||||
|
const inputMode = ref<'meaning' | 'writing' | 'kanji'>('meaning')
|
||||||
|
const resultMessage = ref<string>('')
|
||||||
|
const reviews = ref<Reviews | null>(null)
|
||||||
|
const subject = ref<any>(null)
|
||||||
|
|
||||||
|
const direction = computed(() => route.query.direction)
|
||||||
|
const options = computed(() => JSON.parse(route.query.options as string))
|
||||||
|
|
||||||
|
function createReview() {
|
||||||
|
if (!reviews.value) return
|
||||||
|
|
||||||
|
const next = reviews.value.nextSubject()
|
||||||
|
if (!next) {
|
||||||
|
// TODO: Add celebration or summary screen
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
subject.value = next.subject
|
||||||
|
inputMode.value = next.mode
|
||||||
|
|
||||||
|
if (direction.value === 'jp->en') {
|
||||||
|
character.value = subject.value.characters
|
||||||
|
} else {
|
||||||
|
character.value = subject.value.meanings
|
||||||
|
.filter((m: any) => m.primary)
|
||||||
|
.map((m: any) => m.meaning)
|
||||||
|
.join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTickWrapper()
|
||||||
|
isDisabled.value = false
|
||||||
|
resultMessage.value = ''
|
||||||
|
isCorrect.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitAnswer() {
|
||||||
|
if (isDisabled.value) {
|
||||||
|
createReview()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!answerInput.value || !subject.value) return
|
||||||
|
|
||||||
|
const userAnswer = answerInput.value.value.trim()
|
||||||
|
|
||||||
|
if (direction.value === 'jp->en')
|
||||||
|
isCorrect.value = inputMode.value === 'writing'
|
||||||
|
? subject.value.readings.some((r: any) => r.accepted_answer && r.reading.trim() === userAnswer)
|
||||||
|
: subject.value.meanings.some((m: any) => m.accepted_answer && m.meaning.trim().toLowerCase() === userAnswer.toLowerCase())
|
||||||
|
else
|
||||||
|
isCorrect.value = inputMode.value === 'kanji'
|
||||||
|
? subject.value.characters.trim() === userAnswer
|
||||||
|
: (subject.value.readings?.length === 0
|
||||||
|
? subject.value.readings?.some((r: any) => r.accepted_answer && r.reading.trim() === userAnswer)
|
||||||
|
: subject.value.characters.trim() === userAnswer)
|
||||||
|
|
||||||
|
|
||||||
|
const getAnswerText = () => {
|
||||||
|
if (direction.value === 'jp->en')
|
||||||
|
return inputMode.value === 'writing'
|
||||||
|
? subject.value.readings.filter((r: any) => r.accepted_answer).map((r: any) => r.reading).join(', ')
|
||||||
|
: subject.value.meanings.filter((m: any) => m.accepted_answer).map((m: any) => m.meaning).join(', ')
|
||||||
|
else
|
||||||
|
return inputMode.value === 'kanji'
|
||||||
|
? subject.value.characters
|
||||||
|
: subject.value.readings?.length === 0
|
||||||
|
? subject.value.characters
|
||||||
|
: subject.value.readings.filter((r: any) => r.accepted_answer).map((r: any) => r.reading).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
resultMessage.value = isCorrect.value ? 'Correct: ' : 'Wrong: '
|
||||||
|
resultMessage.value += getAnswerText()
|
||||||
|
|
||||||
|
answerInput.value.value = ''
|
||||||
|
isDisabled.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function quitSession() {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGlobalKey(event: KeyboardEvent) {
|
||||||
|
switch (event.key) {
|
||||||
|
case 'Enter':
|
||||||
|
!isDisabled.value ? submitAnswer() : createReview()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindWanakana(input: HTMLInputElement | null) {
|
||||||
|
if (!input || isWanakanaBound.value) return
|
||||||
|
wanakana.bind(input, { IMEMode: true })
|
||||||
|
isWanakanaBound.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function unbindWanakana(input: HTMLInputElement | null) {
|
||||||
|
if (!input || !isWanakanaBound.value) return
|
||||||
|
wanakana.unbind(input)
|
||||||
|
isWanakanaBound.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextTickWrapper() {
|
||||||
|
nextTick(() => {
|
||||||
|
if (!answerInput.value) return
|
||||||
|
inputMode.value === 'writing' || inputMode.value === 'kanji'
|
||||||
|
? bindWanakana(answerInput.value)
|
||||||
|
: unbindWanakana(answerInput.value)
|
||||||
|
answerInput.value.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const mode = options.value.meaning && options.value.writing
|
||||||
|
? 'both'
|
||||||
|
: options.value.meaning
|
||||||
|
? 'meaning'
|
||||||
|
: 'writing'
|
||||||
|
|
||||||
|
reviews.value = await Reviews.getInstance()
|
||||||
|
await reviews.value.setOptions({ type: 'both', mode })
|
||||||
|
createReview()
|
||||||
|
window.addEventListener('keydown', handleGlobalKey)
|
||||||
|
nextTickWrapper()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => window.removeEventListener('keydown', handleGlobalKey))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
$dark-background: #121212;
|
||||||
|
$dark-surface: #1e1e1e;
|
||||||
|
$dark-text: #ffffff;
|
||||||
|
$dark-hint: #ffffff99;
|
||||||
|
$error-color: #cf6679;
|
||||||
|
|
||||||
|
$dark-border: #ffffff14;
|
||||||
|
|
||||||
|
$accent-color: hsl(320, 100%, 50%);
|
||||||
|
|
||||||
|
@mixin vuetify-dark-input-base {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 16px;
|
||||||
|
min-height: 56px;
|
||||||
|
|
||||||
|
background-color: $dark-surface;
|
||||||
|
|
||||||
|
border: 1px solid $dark-border;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
color: $dark-text;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
transition: background-color 0.2s, border-color 0.2s, box-shadow 0.2s;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $dark-hint;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-grow-enter-active,
|
||||||
|
.alert-grow-leave-active {
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-grow-enter-from,
|
||||||
|
.alert-grow-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
padding-top: 0 !important;
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-grow-enter-to,
|
||||||
|
.alert-grow-leave-from {
|
||||||
|
opacity: 1;
|
||||||
|
max-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customInput {
|
||||||
|
@include vuetify-dark-input-base;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: lighten($dark-surface, 3%);
|
||||||
|
border-color: lighten($dark-border, 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: $accent-color;
|
||||||
|
box-shadow: 0 0 0 1px $accent-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background-color: $dark-surface;
|
||||||
|
border-color: $dark-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:read-only {
|
||||||
|
opacity: 0.8;
|
||||||
|
background-color: $dark-surface;
|
||||||
|
cursor: default;
|
||||||
|
border-color: $dark-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-error {
|
||||||
|
border-color: $error-color !important;
|
||||||
|
box-shadow: 0 0 0 1px $error-color !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
222
client/src/pages/index.vue
Normal file
222
client/src/pages/index.vue
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
<template lang="pug">
|
||||||
|
v-container.fill-height.d-flex.justify-center.align-center
|
||||||
|
v-card.pa-8.pb-6(
|
||||||
|
elevation="12"
|
||||||
|
rounded="xl"
|
||||||
|
max-width="650px"
|
||||||
|
width="100%"
|
||||||
|
)
|
||||||
|
v-card-title.text-center.text-h4.font-weight-bold.mb-4
|
||||||
|
span.text-primary SRS Trainer
|
||||||
|
|
||||||
|
v-card-text
|
||||||
|
v-row
|
||||||
|
v-col(cols="12" sm="3")
|
||||||
|
v-btn.py-6(
|
||||||
|
color="var(--color-all)"
|
||||||
|
variant="flat"
|
||||||
|
block
|
||||||
|
@click="_startTraining('all')"
|
||||||
|
)
|
||||||
|
span.font-weight-bold All
|
||||||
|
|
||||||
|
v-col(cols="12" sm="3")
|
||||||
|
v-btn.py-6(
|
||||||
|
color="var(--color-kanji)"
|
||||||
|
variant="flat"
|
||||||
|
block
|
||||||
|
@click="_startTraining('kanji')"
|
||||||
|
)
|
||||||
|
span.font-weight-bold Kanji
|
||||||
|
|
||||||
|
v-col(cols="12" sm="3")
|
||||||
|
v-btn.py-6(
|
||||||
|
color="var(--color-vocab)"
|
||||||
|
variant="flat"
|
||||||
|
block
|
||||||
|
@click="_startTraining('vocab')"
|
||||||
|
)
|
||||||
|
span.font-weight-bold Vocabulary
|
||||||
|
v-col(cols="12" sm="3")
|
||||||
|
v-btn.py-6(
|
||||||
|
color="var(--color-writing)"
|
||||||
|
variant="flat"
|
||||||
|
block
|
||||||
|
@click="_startTraining('writing')"
|
||||||
|
)
|
||||||
|
span.font-weight-bold Writing
|
||||||
|
|
||||||
|
v-row.d-flex.align-center.mb-6
|
||||||
|
v-col(cols="12" sm="6")
|
||||||
|
.text-subtitle-1.text-secondary.mb-2 Choose Direction
|
||||||
|
v-radio-group(v-model="direction" row hide-details)
|
||||||
|
v-radio(label="Japanese → English" value="jp->en" color="primary")
|
||||||
|
v-radio(label="English → Japanese" value="en->jp" color="primary")
|
||||||
|
v-radio(label="Both at random" value="both" color="primary")
|
||||||
|
|
||||||
|
v-col(cols="12" sm="6")
|
||||||
|
v-row.g-3
|
||||||
|
v-col(cols="6")
|
||||||
|
v-card.elevation-3.rounded-lg.text-center.pa-4.d-flex.flex-column.align-center.justify-center(
|
||||||
|
color="var(--color-kanji)"
|
||||||
|
variant="outlined"
|
||||||
|
height="120px"
|
||||||
|
)
|
||||||
|
v-icon.size-36.mb-2 mdi-book-open-variant
|
||||||
|
.text-subtitle-2 Kanji
|
||||||
|
.text-h6.font-weight-bold {{stats.kanjiCount}}
|
||||||
|
v-col(cols="6")
|
||||||
|
v-card.elevation-3.rounded-lg.text-center.pa-4.d-flex.flex-column.align-center.justify-center(
|
||||||
|
color="var(--color-vocab)"
|
||||||
|
variant="outlined"
|
||||||
|
height="120px"
|
||||||
|
)
|
||||||
|
v-icon.size-36.mb-2 mdi-translate
|
||||||
|
.text-subtitle-3 Vocabulary
|
||||||
|
.text-h6.font-weight-bold {{stats.vocabCount}}
|
||||||
|
|
||||||
|
v-row
|
||||||
|
v-col(cols="12")
|
||||||
|
.text-subtitle-1.text-secondary.mb-2 Training Options
|
||||||
|
v-row(row wrap)
|
||||||
|
v-col(cols="12" sm="4")
|
||||||
|
v-tooltip(text="Asked for writing. JP->EN only (本: ほん)")
|
||||||
|
template(#activator="{ props }")
|
||||||
|
v-checkbox(
|
||||||
|
v-bind="props"
|
||||||
|
v-model="options.writing"
|
||||||
|
label="Writing"
|
||||||
|
color="primary"
|
||||||
|
hide-details
|
||||||
|
)
|
||||||
|
v-col(cols="12" sm="4")
|
||||||
|
v-tooltip(text="Asked for english meaning. JP->EN only (本: Book)")
|
||||||
|
template(#activator="{ props }")
|
||||||
|
v-checkbox(
|
||||||
|
v-bind="props"
|
||||||
|
v-model="options.meaning"
|
||||||
|
label="Meaning"
|
||||||
|
color="primary"
|
||||||
|
hide-details
|
||||||
|
)
|
||||||
|
v-col(cols="12" sm="4")
|
||||||
|
v-tooltip(text="If Kanji should be written instead of kana. EN->JP only (Book: 本)")
|
||||||
|
template(#activator="{ props }")
|
||||||
|
v-checkbox(
|
||||||
|
v-bind="props"
|
||||||
|
v-model="options.kanji"
|
||||||
|
label="Write Kanji"
|
||||||
|
color="primary"
|
||||||
|
hide-details
|
||||||
|
)
|
||||||
|
|
||||||
|
v-row.d-flex.ga-4.align-center
|
||||||
|
v-col.flex-grow-1.py-0
|
||||||
|
v-text-field(
|
||||||
|
v-model="apiKey"
|
||||||
|
variant="outlined"
|
||||||
|
label="API Key"
|
||||||
|
type="password"
|
||||||
|
@keyup.enter="saveApiKey"
|
||||||
|
@click:clear="deleteApiKey"
|
||||||
|
hide-details
|
||||||
|
clearable
|
||||||
|
)
|
||||||
|
v-col.flex-shrink-0.py-0(cols="auto")
|
||||||
|
v-btn(
|
||||||
|
color="primary"
|
||||||
|
class="align-self-stretch"
|
||||||
|
variant="flat"
|
||||||
|
@click="syncWanikani"
|
||||||
|
) Sync
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import { Reviews } from '../composables/subject.ts'
|
||||||
|
|
||||||
|
const reviews = ref<Reviews | null>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const apiKey = ref<string>('')
|
||||||
|
const direction = ref<'en->jp' | 'jp->en' | 'both'>('jp->en')
|
||||||
|
const stats = ref<{ kanjiCount: number, vocabCount: number }>({
|
||||||
|
kanjiCount: 0,
|
||||||
|
vocabCount: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const options = ref({
|
||||||
|
meaning: true,
|
||||||
|
writing: true,
|
||||||
|
kanji: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
function _startTraining(type: 'kanji' | 'vocab' | 'all' | 'writing') {
|
||||||
|
router.push({
|
||||||
|
path: '/' + type,
|
||||||
|
query: {
|
||||||
|
direction: direction.value,
|
||||||
|
options: JSON.stringify(options.value),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getApiKey(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/key')
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch API key')
|
||||||
|
const data: { apiKey?: string } = await res.json()
|
||||||
|
if (data.apiKey) apiKey.value = data.apiKey
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching API key:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveApiKey(): Promise<void> {
|
||||||
|
if (!apiKey.value.trim()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/key', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ apiKey: apiKey.value }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Failed to update API key')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving API key:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteApiKey() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/key', {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Failed to delete API key')
|
||||||
|
apiKey.value = ''
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting API key:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncWanikani() {
|
||||||
|
try {
|
||||||
|
await fetch('/api/v1/wanikani/sync')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error syncing Wanikani data:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats() {
|
||||||
|
if (!reviews.value) return
|
||||||
|
stats.value = reviews.value.getStats()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
reviews.value = await Reviews.getInstance()
|
||||||
|
getApiKey()
|
||||||
|
updateStats()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
297
client/src/pages/kanji.vue
Normal file
297
client/src/pages/kanji.vue
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
<template lang="pug">
|
||||||
|
v-container.fill-height.d-flex.justify-center.align-center
|
||||||
|
v-card.pa-12.rounded-lg.elevation-12
|
||||||
|
v-card-title.text-center.text-h2.font-weight-bold.mb-4
|
||||||
|
span.text-primary Kanji Trainer
|
||||||
|
|
||||||
|
v-sheet.d-flex.justify-center.align-center.rounded-lg.py-8.px-4.elevation-12(
|
||||||
|
:style="{backgroundColor: 'var(--color-kanji)', fontSize: '5rem', fontWeight: 'bold', whiteSpace: 'nowrap'}"
|
||||||
|
)
|
||||||
|
span {{ character }}
|
||||||
|
|
||||||
|
v-card-text.text-center.my-4.rounded-lg(
|
||||||
|
:style="{ backgroundColor: 'hsl(320, 100%, 50%, 0.1)', fontSize: '1.1rem', fontWeight: '500', color: 'var(--color-kanji)'}"
|
||||||
|
)
|
||||||
|
| {{ inputMode === 'kanji' ? 'Kanji' : inputMode === 'writing' ? 'Writing' : inputMode }}
|
||||||
|
|
||||||
|
transition(name="alert-grow")
|
||||||
|
v-alert.mb-4(
|
||||||
|
v-if="resultMessage"
|
||||||
|
:type="isCorrect ? 'success' : 'error'"
|
||||||
|
border="top"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
) {{ resultMessage }}
|
||||||
|
|
||||||
|
v-row.align-center
|
||||||
|
v-col(cols="9")
|
||||||
|
input#answer-input.customInput(
|
||||||
|
ref="answerInput"
|
||||||
|
placeholder="Type your answer"
|
||||||
|
:disabled="isDisabled"
|
||||||
|
)
|
||||||
|
v-col(cols="3")
|
||||||
|
v-btn(
|
||||||
|
color="primary"
|
||||||
|
block
|
||||||
|
@click="submitAnswer"
|
||||||
|
) Enter
|
||||||
|
|
||||||
|
v-row.justify-space-between
|
||||||
|
v-col(cols="4")
|
||||||
|
v-btn(
|
||||||
|
color="error"
|
||||||
|
variant="flat"
|
||||||
|
block
|
||||||
|
@click="quitSession"
|
||||||
|
) Quit
|
||||||
|
v-col(cols="4")
|
||||||
|
v-btn(
|
||||||
|
color="secondary"
|
||||||
|
variant="flat"
|
||||||
|
block
|
||||||
|
@click="createReview"
|
||||||
|
) Skip
|
||||||
|
v-col(cols="4")
|
||||||
|
v-btn(
|
||||||
|
color="success"
|
||||||
|
variant="flat"
|
||||||
|
block
|
||||||
|
@click="submitAnswer"
|
||||||
|
) Resolve
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, nextTick, onBeforeUnmount, computed } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import * as wanakana from 'wanakana'
|
||||||
|
import { Reviews } from '../composables/subject.ts'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const answerInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const character = ref<string>('')
|
||||||
|
const isCorrect = ref<boolean>(false)
|
||||||
|
const isDisabled = ref<boolean>(false)
|
||||||
|
const isWanakanaBound = ref<boolean>(false)
|
||||||
|
const inputMode = ref<'meaning' | 'writing' | 'kanji'>('meaning')
|
||||||
|
const resultMessage = ref<string>('')
|
||||||
|
const reviews = ref<Reviews | null>(null)
|
||||||
|
const subject = ref<any>(null)
|
||||||
|
|
||||||
|
const direction = computed(() => route.query.direction)
|
||||||
|
const options = computed(() => JSON.parse(route.query.options as string))
|
||||||
|
|
||||||
|
function createReview() {
|
||||||
|
if (!reviews.value) return
|
||||||
|
|
||||||
|
const next = reviews.value.nextSubject()
|
||||||
|
if (!next) {
|
||||||
|
// TODO: Add celebration or summary screen
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
subject.value = next.subject
|
||||||
|
inputMode.value = next.mode
|
||||||
|
|
||||||
|
if (direction.value === 'jp->en') {
|
||||||
|
character.value = subject.value.characters
|
||||||
|
} else {
|
||||||
|
character.value = subject.value.meanings
|
||||||
|
.filter((m: any) => m.primary)
|
||||||
|
.map((m: any) => m.meaning)
|
||||||
|
.join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTickWrapper()
|
||||||
|
isDisabled.value = false
|
||||||
|
resultMessage.value = ''
|
||||||
|
isCorrect.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitAnswer() {
|
||||||
|
if (isDisabled.value) {
|
||||||
|
createReview()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!answerInput.value || !subject.value) return
|
||||||
|
|
||||||
|
const userAnswer = answerInput.value.value.trim()
|
||||||
|
|
||||||
|
if (direction.value === 'jp->en')
|
||||||
|
isCorrect.value = inputMode.value === 'writing'
|
||||||
|
? subject.value.readings.some((r: any) => r.accepted_answer && r.reading.trim() === userAnswer)
|
||||||
|
: subject.value.meanings.some((m: any) => m.accepted_answer && m.meaning.trim().toLowerCase() === userAnswer.toLowerCase())
|
||||||
|
else
|
||||||
|
isCorrect.value = inputMode.value === 'kanji'
|
||||||
|
? subject.value.characters.trim() === userAnswer
|
||||||
|
: (subject.value.readings?.length === 0
|
||||||
|
? subject.value.readings?.some((r: any) => r.accepted_answer && r.reading.trim() === userAnswer)
|
||||||
|
: subject.value.characters.trim() === userAnswer)
|
||||||
|
|
||||||
|
|
||||||
|
const getAnswerText = () => {
|
||||||
|
if (direction.value === 'jp->en')
|
||||||
|
return inputMode.value === 'writing'
|
||||||
|
? subject.value.readings.filter((r: any) => r.accepted_answer).map((r: any) => r.reading).join(', ')
|
||||||
|
: subject.value.meanings.filter((m: any) => m.accepted_answer).map((m: any) => m.meaning).join(', ')
|
||||||
|
else
|
||||||
|
return inputMode.value === 'kanji'
|
||||||
|
? subject.value.characters
|
||||||
|
: subject.value.readings?.length === 0
|
||||||
|
? subject.value.characters
|
||||||
|
: subject.value.readings.filter((r: any) => r.accepted_answer).map((r: any) => r.reading).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
resultMessage.value = isCorrect.value ? 'Correct: ' : 'Wrong: '
|
||||||
|
resultMessage.value += getAnswerText()
|
||||||
|
|
||||||
|
answerInput.value.value = ''
|
||||||
|
isDisabled.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function quitSession() {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGlobalKey(event: KeyboardEvent) {
|
||||||
|
switch (event.key) {
|
||||||
|
case 'Enter':
|
||||||
|
!isDisabled.value ? submitAnswer() : createReview()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindWanakana(input: HTMLInputElement | null) {
|
||||||
|
if (!input || isWanakanaBound.value) return
|
||||||
|
wanakana.bind(input, { IMEMode: true })
|
||||||
|
isWanakanaBound.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function unbindWanakana(input: HTMLInputElement | null) {
|
||||||
|
if (!input || !isWanakanaBound.value) return
|
||||||
|
wanakana.unbind(input)
|
||||||
|
isWanakanaBound.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextTickWrapper() {
|
||||||
|
nextTick(() => {
|
||||||
|
if (!answerInput.value) return
|
||||||
|
inputMode.value === 'writing' || inputMode.value === 'kanji'
|
||||||
|
? bindWanakana(answerInput.value)
|
||||||
|
: unbindWanakana(answerInput.value)
|
||||||
|
answerInput.value.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const mode = options.value.meaning && options.value.writing
|
||||||
|
? 'both'
|
||||||
|
: options.value.meaning
|
||||||
|
? 'meaning'
|
||||||
|
: 'writing'
|
||||||
|
|
||||||
|
reviews.value = await Reviews.getInstance()
|
||||||
|
await reviews.value.setOptions({ type: 'kanji', mode })
|
||||||
|
createReview()
|
||||||
|
window.addEventListener('keydown', handleGlobalKey)
|
||||||
|
nextTickWrapper()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => window.removeEventListener('keydown', handleGlobalKey))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
$dark-background: #121212;
|
||||||
|
$dark-surface: #1e1e1e;
|
||||||
|
$dark-text: #ffffff;
|
||||||
|
$dark-hint: #ffffff99;
|
||||||
|
$error-color: #cf6679;
|
||||||
|
|
||||||
|
$dark-border: #ffffff14;
|
||||||
|
|
||||||
|
$accent-color: hsl(320, 100%, 50%);
|
||||||
|
|
||||||
|
@mixin vuetify-dark-input-base {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 16px;
|
||||||
|
min-height: 56px;
|
||||||
|
|
||||||
|
background-color: $dark-surface;
|
||||||
|
|
||||||
|
border: 1px solid $dark-border;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
color: $dark-text;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
transition: background-color 0.2s, border-color 0.2s, box-shadow 0.2s;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $dark-hint;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-grow-enter-active,
|
||||||
|
.alert-grow-leave-active {
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-grow-enter-from,
|
||||||
|
.alert-grow-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
padding-top: 0 !important;
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-grow-enter-to,
|
||||||
|
.alert-grow-leave-from {
|
||||||
|
opacity: 1;
|
||||||
|
max-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customInput {
|
||||||
|
@include vuetify-dark-input-base;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: lighten($dark-surface, 3%);
|
||||||
|
border-color: lighten($dark-border, 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: $accent-color;
|
||||||
|
box-shadow: 0 0 0 1px $accent-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background-color: $dark-surface;
|
||||||
|
border-color: $dark-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:read-only {
|
||||||
|
opacity: 0.8;
|
||||||
|
background-color: $dark-surface;
|
||||||
|
cursor: default;
|
||||||
|
border-color: $dark-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-error {
|
||||||
|
border-color: $error-color !important;
|
||||||
|
box-shadow: 0 0 0 1px $error-color !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
297
client/src/pages/vocab.vue
Normal file
297
client/src/pages/vocab.vue
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
<template lang="pug">
|
||||||
|
v-container.fill-height.d-flex.justify-center.align-center
|
||||||
|
v-card.pa-12.rounded-lg.elevation-12
|
||||||
|
v-card-title.text-center.text-h2.font-weight-bold.mb-4
|
||||||
|
span.text-primary Vocabulary Trainer
|
||||||
|
|
||||||
|
v-sheet.d-flex.justify-center.align-center.rounded-lg.py-8.px-4.elevation-12(
|
||||||
|
:style="{backgroundColor: 'var(--color-vocab)', fontSize: '5rem', fontWeight: 'bold', whiteSpace: 'nowrap'}"
|
||||||
|
)
|
||||||
|
span {{ character }}
|
||||||
|
|
||||||
|
v-card-text.text-center.my-4.rounded-lg(
|
||||||
|
:style="{ backgroundColor: 'hsl(320, 100%, 50%, 0.1)', fontSize: '1.1rem', fontWeight: '500', color: 'var(--color-vocab)'}"
|
||||||
|
)
|
||||||
|
| {{ inputMode === 'kanji' ? 'Kanji' : inputMode === 'writing' ? 'Writing' : inputMode }}
|
||||||
|
|
||||||
|
transition(name="alert-grow")
|
||||||
|
v-alert.mb-4(
|
||||||
|
v-if="resultMessage"
|
||||||
|
:type="isCorrect ? 'success' : 'error'"
|
||||||
|
border="top"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
) {{ resultMessage }}
|
||||||
|
|
||||||
|
v-row.align-center
|
||||||
|
v-col(cols="9")
|
||||||
|
input#answer-input.customInput(
|
||||||
|
ref="answerInput"
|
||||||
|
placeholder="Type your answer"
|
||||||
|
:disabled="isDisabled"
|
||||||
|
)
|
||||||
|
v-col(cols="3")
|
||||||
|
v-btn(
|
||||||
|
color="primary"
|
||||||
|
block
|
||||||
|
@click="submitAnswer"
|
||||||
|
) Enter
|
||||||
|
|
||||||
|
v-row.justify-space-between
|
||||||
|
v-col(cols="4")
|
||||||
|
v-btn(
|
||||||
|
color="error"
|
||||||
|
variant="flat"
|
||||||
|
block
|
||||||
|
@click="quitSession"
|
||||||
|
) Quit
|
||||||
|
v-col(cols="4")
|
||||||
|
v-btn(
|
||||||
|
color="secondary"
|
||||||
|
variant="flat"
|
||||||
|
block
|
||||||
|
@click="createReview"
|
||||||
|
) Skip
|
||||||
|
v-col(cols="4")
|
||||||
|
v-btn(
|
||||||
|
color="success"
|
||||||
|
variant="flat"
|
||||||
|
block
|
||||||
|
@click="submitAnswer"
|
||||||
|
) Resolve
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, nextTick, onBeforeUnmount, computed } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import * as wanakana from 'wanakana'
|
||||||
|
import { Reviews } from '../composables/subject.ts'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const answerInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const character = ref<string>('')
|
||||||
|
const isCorrect = ref<boolean>(false)
|
||||||
|
const isDisabled = ref<boolean>(false)
|
||||||
|
const isWanakanaBound = ref<boolean>(false)
|
||||||
|
const inputMode = ref<'meaning' | 'writing' | 'kanji'>('meaning')
|
||||||
|
const resultMessage = ref<string>('')
|
||||||
|
const reviews = ref<Reviews | null>(null)
|
||||||
|
const subject = ref<any>(null)
|
||||||
|
|
||||||
|
const direction = computed(() => route.query.direction)
|
||||||
|
const options = computed(() => JSON.parse(route.query.options as string))
|
||||||
|
|
||||||
|
function createReview() {
|
||||||
|
if (!reviews.value) return
|
||||||
|
|
||||||
|
const next = reviews.value.nextSubject()
|
||||||
|
if (!next) {
|
||||||
|
// TODO: Add celebration or summary screen
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
subject.value = next.subject
|
||||||
|
inputMode.value = next.mode
|
||||||
|
|
||||||
|
if (direction.value === 'jp->en') {
|
||||||
|
character.value = subject.value.characters
|
||||||
|
} else {
|
||||||
|
character.value = subject.value.meanings
|
||||||
|
.filter((m: any) => m.primary)
|
||||||
|
.map((m: any) => m.meaning)
|
||||||
|
.join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTickWrapper()
|
||||||
|
isDisabled.value = false
|
||||||
|
resultMessage.value = ''
|
||||||
|
isCorrect.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitAnswer() {
|
||||||
|
if (isDisabled.value) {
|
||||||
|
createReview()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!answerInput.value || !subject.value) return
|
||||||
|
|
||||||
|
const userAnswer = answerInput.value.value.trim()
|
||||||
|
|
||||||
|
if (direction.value === 'jp->en')
|
||||||
|
isCorrect.value = inputMode.value === 'writing'
|
||||||
|
? subject.value.readings.some((r: any) => r.accepted_answer && r.reading.trim() === userAnswer)
|
||||||
|
: subject.value.meanings.some((m: any) => m.accepted_answer && m.meaning.trim().toLowerCase() === userAnswer.toLowerCase())
|
||||||
|
else
|
||||||
|
isCorrect.value = inputMode.value === 'kanji'
|
||||||
|
? subject.value.characters.trim() === userAnswer
|
||||||
|
: (subject.value.readings?.length === 0
|
||||||
|
? subject.value.readings?.some((r: any) => r.accepted_answer && r.reading.trim() === userAnswer)
|
||||||
|
: subject.value.characters.trim() === userAnswer)
|
||||||
|
|
||||||
|
|
||||||
|
const getAnswerText = () => {
|
||||||
|
if (direction.value === 'jp->en')
|
||||||
|
return inputMode.value === 'writing'
|
||||||
|
? subject.value.readings.filter((r: any) => r.accepted_answer).map((r: any) => r.reading).join(', ')
|
||||||
|
: subject.value.meanings.filter((m: any) => m.accepted_answer).map((m: any) => m.meaning).join(', ')
|
||||||
|
else
|
||||||
|
return inputMode.value === 'kanji'
|
||||||
|
? subject.value.characters
|
||||||
|
: subject.value.readings?.length === 0
|
||||||
|
? subject.value.characters
|
||||||
|
: subject.value.readings.filter((r: any) => r.accepted_answer).map((r: any) => r.reading).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
resultMessage.value = isCorrect.value ? 'Correct: ' : 'Wrong: '
|
||||||
|
resultMessage.value += getAnswerText()
|
||||||
|
|
||||||
|
answerInput.value.value = ''
|
||||||
|
isDisabled.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function quitSession() {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGlobalKey(event: KeyboardEvent) {
|
||||||
|
switch (event.key) {
|
||||||
|
case 'Enter':
|
||||||
|
!isDisabled.value ? submitAnswer() : createReview()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindWanakana(input: HTMLInputElement | null) {
|
||||||
|
if (!input || isWanakanaBound.value) return
|
||||||
|
wanakana.bind(input, { IMEMode: true })
|
||||||
|
isWanakanaBound.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function unbindWanakana(input: HTMLInputElement | null) {
|
||||||
|
if (!input || !isWanakanaBound.value) return
|
||||||
|
wanakana.unbind(input)
|
||||||
|
isWanakanaBound.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextTickWrapper() {
|
||||||
|
nextTick(() => {
|
||||||
|
if (!answerInput.value) return
|
||||||
|
inputMode.value === 'writing' || inputMode.value === 'kanji'
|
||||||
|
? bindWanakana(answerInput.value)
|
||||||
|
: unbindWanakana(answerInput.value)
|
||||||
|
answerInput.value.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const mode = options.value.meaning && options.value.writing
|
||||||
|
? 'both'
|
||||||
|
: options.value.meaning
|
||||||
|
? 'meaning'
|
||||||
|
: 'writing'
|
||||||
|
|
||||||
|
reviews.value = await Reviews.getInstance()
|
||||||
|
await reviews.value.setOptions({ type: 'vocab', mode })
|
||||||
|
createReview()
|
||||||
|
window.addEventListener('keydown', handleGlobalKey)
|
||||||
|
nextTickWrapper()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => window.removeEventListener('keydown', handleGlobalKey))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
$dark-background: #121212;
|
||||||
|
$dark-surface: #1e1e1e;
|
||||||
|
$dark-text: #ffffff;
|
||||||
|
$dark-hint: #ffffff99;
|
||||||
|
$error-color: #cf6679;
|
||||||
|
|
||||||
|
$dark-border: #ffffff14;
|
||||||
|
|
||||||
|
$accent-color: hsl(320, 100%, 50%);
|
||||||
|
|
||||||
|
@mixin vuetify-dark-input-base {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 16px;
|
||||||
|
min-height: 56px;
|
||||||
|
|
||||||
|
background-color: $dark-surface;
|
||||||
|
|
||||||
|
border: 1px solid $dark-border;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
color: $dark-text;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
transition: background-color 0.2s, border-color 0.2s, box-shadow 0.2s;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $dark-hint;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-grow-enter-active,
|
||||||
|
.alert-grow-leave-active {
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-grow-enter-from,
|
||||||
|
.alert-grow-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
padding-top: 0 !important;
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-grow-enter-to,
|
||||||
|
.alert-grow-leave-from {
|
||||||
|
opacity: 1;
|
||||||
|
max-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customInput {
|
||||||
|
@include vuetify-dark-input-base;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: lighten($dark-surface, 3%);
|
||||||
|
border-color: lighten($dark-border, 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: $accent-color;
|
||||||
|
box-shadow: 0 0 0 1px $accent-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background-color: $dark-surface;
|
||||||
|
border-color: $dark-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:read-only {
|
||||||
|
opacity: 0.8;
|
||||||
|
background-color: $dark-surface;
|
||||||
|
cursor: default;
|
||||||
|
border-color: $dark-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-error {
|
||||||
|
border-color: $error-color !important;
|
||||||
|
box-shadow: 0 0 0 1px $error-color !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
158
client/src/pages/writing.vue
Normal file
158
client/src/pages/writing.vue
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<template lang="pug">
|
||||||
|
v-container.fill-height.d-flex.justify-center.align-center
|
||||||
|
v-card.pa-12.rounded-lg.elevation-12
|
||||||
|
v-card-title.text-center.text-h2.font-weight-bold.mb-4
|
||||||
|
span.text-primary Handwriting Trainer
|
||||||
|
|
||||||
|
v-row.justify-center.mb-6
|
||||||
|
v-col(cols="12" class="text-center")
|
||||||
|
.text-h4.text-white.font-weight-bold
|
||||||
|
| Draw the kanji for: {{ subject?.meanings?.find(m => m.primary)?.meaning || subject?.readings?.[0]?.reading || '?' }}
|
||||||
|
|
||||||
|
v-row.justify-center
|
||||||
|
v-sheet.elevation-8.justify-center(
|
||||||
|
rounded="xl"
|
||||||
|
width="280px"
|
||||||
|
height="280px"
|
||||||
|
)
|
||||||
|
canvas#kanjiCanvas(
|
||||||
|
width="280"
|
||||||
|
height="280"
|
||||||
|
:style="{borderRadius: '12px', backgroundColor: '#313131'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
v-row.justify-center.my-6
|
||||||
|
v-col(cols="12" class="text-center")
|
||||||
|
.text-h5.text-white Select the kanji you drew:
|
||||||
|
v-btn-group
|
||||||
|
v-btn(
|
||||||
|
v-for="kanji in candidates"
|
||||||
|
:key="kanji"
|
||||||
|
color="var(--color-writing)"
|
||||||
|
class="mx-1 my-1"
|
||||||
|
@click="selectCandidate(kanji)"
|
||||||
|
) {{ kanji || '-' }}
|
||||||
|
|
||||||
|
transition(name="alert-grow")
|
||||||
|
v-alert.mb-4(
|
||||||
|
v-if="resultMessage"
|
||||||
|
:type="isCorrect ? 'success' : 'error'"
|
||||||
|
border="top"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
) {{ resultMessage }}
|
||||||
|
|
||||||
|
v-row.justify-center
|
||||||
|
v-col(cols="auto" class="mx-1")
|
||||||
|
v-btn(color="error" variant="flat" block @click="quitSession") Quit
|
||||||
|
v-col(cols="auto" class="mx-1")
|
||||||
|
v-btn(color="secondary" variant="flat" block :disabled="isDisabled" @click="skipItem") Skip
|
||||||
|
v-col(cols="auto" class="mx-1")
|
||||||
|
v-btn(color="primary" variant="flat" block :disabled="isDisabled" @click="recognizeKanji") Recognize
|
||||||
|
v-col(cols="auto" class="mx-1")
|
||||||
|
v-btn(color="success" variant="flat" block :disabled="isDisabled" @click="clearCanvas") Clear
|
||||||
|
v-col(cols="auto" class="mx-1")
|
||||||
|
v-btn(color="info" variant="flat" block :disabled="!isDisabled" @click="nextItem") Next
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Reviews } from '../composables/subject.ts'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const canvasId = 'kanjiCanvas'
|
||||||
|
const reviews = ref<Reviews | null>(null)
|
||||||
|
const subject = ref<any>(null)
|
||||||
|
const candidates = ref<string[]>([])
|
||||||
|
const recognizedKanji = ref('')
|
||||||
|
const isCorrect = ref(false)
|
||||||
|
const resultMessage = ref('')
|
||||||
|
const isDisabled = ref(false)
|
||||||
|
|
||||||
|
async function createReview() {
|
||||||
|
if (!reviews.value) return
|
||||||
|
const next = reviews.value.nextSubject()
|
||||||
|
if (!next) return
|
||||||
|
subject.value = next.subject
|
||||||
|
candidates.value = []
|
||||||
|
recognizedKanji.value = ''
|
||||||
|
isDisabled.value = false
|
||||||
|
resultMessage.value = ''
|
||||||
|
clearCanvas()
|
||||||
|
}
|
||||||
|
|
||||||
|
function initCanvas() {
|
||||||
|
if (window.KanjiCanvas) {
|
||||||
|
window.KanjiCanvas.init(canvasId, { strokeColor: 'white' })
|
||||||
|
} else console.error('KanjiCanvas library is not loaded.')
|
||||||
|
}
|
||||||
|
|
||||||
|
function recognizeKanji() {
|
||||||
|
if (!window.KanjiCanvas || !subject.value) return
|
||||||
|
const result = window.KanjiCanvas.recognize(canvasId).trim().split(/\s+/)
|
||||||
|
candidates.value = result.slice(0, 5)
|
||||||
|
recognizedKanji.value = candidates.value.join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCandidate(kanji: string) {
|
||||||
|
if (!subject.value) return
|
||||||
|
|
||||||
|
isCorrect.value = kanji === subject.value.characters
|
||||||
|
resultMessage.value = isCorrect.value ? 'Correct!' : `Wrong! Answer: ${subject.value.characters}`
|
||||||
|
isDisabled.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCanvas() {
|
||||||
|
if (window.KanjiCanvas) {
|
||||||
|
window.KanjiCanvas.erase(canvasId)
|
||||||
|
recognizedKanji.value = ''
|
||||||
|
candidates.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function quitSession() { router.push('/') }
|
||||||
|
function skipItem() { createReview() }
|
||||||
|
|
||||||
|
function nextItem() {
|
||||||
|
createReview()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
reviews.value = await Reviews.getInstance()
|
||||||
|
await reviews.value.setOptions({ type: 'kanji', mode: 'writing' })
|
||||||
|
createReview()
|
||||||
|
initCanvas()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (window.KanjiCanvas) window.KanjiCanvas.erase(canvasId)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.text-white {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-grow-enter-active,
|
||||||
|
.alert-grow-leave-active {
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-grow-enter-from,
|
||||||
|
.alert-grow-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
padding-top: 0 !important;
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-grow-enter-to,
|
||||||
|
.alert-grow-leave-from {
|
||||||
|
opacity: 1;
|
||||||
|
max-height: 100px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
20
client/src/plugins/index.ts
Normal file
20
client/src/plugins/index.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* plugins/index.ts
|
||||||
|
*
|
||||||
|
* Automatically included in `./src/main.ts`
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Plugins
|
||||||
|
import vuetify from './vuetify'
|
||||||
|
import pinia from '../stores'
|
||||||
|
import router from '../router'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import type { App } from 'vue'
|
||||||
|
|
||||||
|
export function registerPlugins (app: App) {
|
||||||
|
app
|
||||||
|
.use(vuetify)
|
||||||
|
.use(router)
|
||||||
|
.use(pinia)
|
||||||
|
}
|
||||||
19
client/src/plugins/vuetify.ts
Normal file
19
client/src/plugins/vuetify.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* plugins/vuetify.ts
|
||||||
|
*
|
||||||
|
* Framework documentation: https://vuetifyjs.com`
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
import '@mdi/font/css/materialdesignicons.css'
|
||||||
|
import 'vuetify/styles'
|
||||||
|
|
||||||
|
// Composables
|
||||||
|
import { createVuetify } from 'vuetify'
|
||||||
|
|
||||||
|
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
|
||||||
|
export default createVuetify({
|
||||||
|
theme: {
|
||||||
|
defaultTheme: 'system',
|
||||||
|
},
|
||||||
|
})
|
||||||
36
client/src/router/index.ts
Normal file
36
client/src/router/index.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* router/index.ts
|
||||||
|
*
|
||||||
|
* Automatic routes for `./src/pages/*.vue`
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Composables
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { setupLayouts } from 'virtual:generated-layouts'
|
||||||
|
import { routes } from 'vue-router/auto-routes'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes: setupLayouts(routes),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Workaround for https://github.com/vitejs/vite/issues/11804
|
||||||
|
router.onError((err, to) => {
|
||||||
|
if (err?.message?.includes?.('Failed to fetch dynamically imported module')) {
|
||||||
|
if (localStorage.getItem('vuetify:dynamic-reload')) {
|
||||||
|
console.error('Dynamic import error, reloading page did not fix it', err)
|
||||||
|
} else {
|
||||||
|
console.log('Reloading page to fix dynamic import error')
|
||||||
|
localStorage.setItem('vuetify:dynamic-reload', 'true')
|
||||||
|
location.assign(to.fullPath)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.isReady().then(() => {
|
||||||
|
localStorage.removeItem('vuetify:dynamic-reload')
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
8
client/src/stores/app.ts
Normal file
8
client/src/stores/app.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// Utilities
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
export const useAppStore = defineStore('app', {
|
||||||
|
state: () => ({
|
||||||
|
//
|
||||||
|
}),
|
||||||
|
})
|
||||||
4
client/src/stores/index.ts
Normal file
4
client/src/stores/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Utilities
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
|
export default createPinia()
|
||||||
16
client/src/styles/settings.scss
Normal file
16
client/src/styles/settings.scss
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* src/styles/settings.scss
|
||||||
|
*
|
||||||
|
* Configures SASS variables and Vuetify overwrites
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use 'vuetify/settings' with (
|
||||||
|
$color-pack: false
|
||||||
|
);
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-all: hsl(360, 100%, 50%);
|
||||||
|
--color-kanji: hsl(320, 100%, 50%);
|
||||||
|
--color-vocab: hsl(280, 100%, 50%);
|
||||||
|
--color-writing: hsl(220, 100%, 50%);
|
||||||
|
}
|
||||||
71
client/src/typed-router.d.ts
vendored
Normal file
71
client/src/typed-router.d.ts
vendored
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* prettier-ignore */
|
||||||
|
// @ts-nocheck
|
||||||
|
// Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️
|
||||||
|
// It's recommended to commit this file.
|
||||||
|
// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
|
||||||
|
|
||||||
|
declare module 'vue-router/auto-routes' {
|
||||||
|
import type {
|
||||||
|
RouteRecordInfo,
|
||||||
|
ParamValue,
|
||||||
|
ParamValueOneOrMore,
|
||||||
|
ParamValueZeroOrMore,
|
||||||
|
ParamValueZeroOrOne,
|
||||||
|
} from 'vue-router'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route name map generated by unplugin-vue-router
|
||||||
|
*/
|
||||||
|
export interface RouteNamedMap {
|
||||||
|
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
|
||||||
|
'/all': RouteRecordInfo<'/all', '/all', Record<never, never>, Record<never, never>>,
|
||||||
|
'/kanji': RouteRecordInfo<'/kanji', '/kanji', Record<never, never>, Record<never, never>>,
|
||||||
|
'/vocab': RouteRecordInfo<'/vocab', '/vocab', Record<never, never>, Record<never, never>>,
|
||||||
|
'/writing': RouteRecordInfo<'/writing', '/writing', Record<never, never>, Record<never, never>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route file to route info map by unplugin-vue-router.
|
||||||
|
* Used by the volar plugin to automatically type useRoute()
|
||||||
|
*
|
||||||
|
* Each key is a file path relative to the project root with 2 properties:
|
||||||
|
* - routes: union of route names of the possible routes when in this page (passed to useRoute<...>())
|
||||||
|
* - views: names of nested views (can be passed to <RouterView name="...">)
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export interface _RouteFileInfoMap {
|
||||||
|
'src/pages/index.vue': {
|
||||||
|
routes: '/'
|
||||||
|
views: never
|
||||||
|
}
|
||||||
|
'src/pages/all.vue': {
|
||||||
|
routes: '/all'
|
||||||
|
views: never
|
||||||
|
}
|
||||||
|
'src/pages/kanji.vue': {
|
||||||
|
routes: '/kanji'
|
||||||
|
views: never
|
||||||
|
}
|
||||||
|
'src/pages/vocab.vue': {
|
||||||
|
routes: '/vocab'
|
||||||
|
views: never
|
||||||
|
}
|
||||||
|
'src/pages/writing.vue': {
|
||||||
|
routes: '/writing'
|
||||||
|
views: never
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a union of possible route names in a certain route component file.
|
||||||
|
* Used by the volar plugin to automatically type useRoute()
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export type _RouteNamesForFilePath<FilePath extends string> =
|
||||||
|
_RouteFileInfoMap extends Record<FilePath, infer Info>
|
||||||
|
? Info['routes']
|
||||||
|
: keyof RouteNamedMap
|
||||||
|
}
|
||||||
21
client/tsconfig.app.json
Normal file
21
client/tsconfig.app.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": [
|
||||||
|
"env.d.ts",
|
||||||
|
"src/**/*",
|
||||||
|
"src/**/*.vue"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"src/**/__tests__/*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
client/tsconfig.json
Normal file
11
client/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
19
client/tsconfig.node.json
Normal file
19
client/tsconfig.node.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/node22/tsconfig.json",
|
||||||
|
"include": [
|
||||||
|
"vite.config.*",
|
||||||
|
"vitest.config.*",
|
||||||
|
"cypress.config.*",
|
||||||
|
"nightwatch.conf.*",
|
||||||
|
"playwright.config.*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
95
client/vite.config.mts
Normal file
95
client/vite.config.mts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
import Vue from '@vitejs/plugin-vue'
|
||||||
|
// Plugins
|
||||||
|
import AutoImport from 'unplugin-auto-import/vite'
|
||||||
|
import Fonts from 'unplugin-fonts/vite'
|
||||||
|
import Components from 'unplugin-vue-components/vite'
|
||||||
|
import { VueRouterAutoImports } from 'unplugin-vue-router'
|
||||||
|
import VueRouter from 'unplugin-vue-router/vite'
|
||||||
|
// Utilities
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
import Layouts from 'vite-plugin-vue-layouts-next'
|
||||||
|
import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
VueRouter({
|
||||||
|
dts: 'src/typed-router.d.ts',
|
||||||
|
}),
|
||||||
|
// https://github.com/posva/vite-plugin-vue-layouts
|
||||||
|
Layouts(),
|
||||||
|
AutoImport({
|
||||||
|
imports: [
|
||||||
|
'vue',
|
||||||
|
VueRouterAutoImports,
|
||||||
|
{
|
||||||
|
pinia: ['defineStore', 'storeToRefs'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dts: 'src/auto-imports.d.ts',
|
||||||
|
eslintrc: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
vueTemplate: true,
|
||||||
|
}),
|
||||||
|
Components({
|
||||||
|
dts: 'src/components.d.ts',
|
||||||
|
}),
|
||||||
|
Vue({
|
||||||
|
template: { transformAssetUrls },
|
||||||
|
}),
|
||||||
|
// https://github.com/vuetifyjs/vuetify-loader/tree/master/packages/vite-plugin#readme
|
||||||
|
Vuetify({
|
||||||
|
autoImport: true,
|
||||||
|
styles: {
|
||||||
|
configFile: 'src/styles/settings.scss',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Fonts({
|
||||||
|
fontsource: {
|
||||||
|
families: [
|
||||||
|
{
|
||||||
|
name: 'Roboto',
|
||||||
|
weights: [100, 300, 400, 500, 700, 900],
|
||||||
|
styles: ['normal', 'italic'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: [
|
||||||
|
'vuetify',
|
||||||
|
'vue-router',
|
||||||
|
'unplugin-vue-router/runtime',
|
||||||
|
'unplugin-vue-router/data-loaders',
|
||||||
|
'unplugin-vue-router/data-loaders/basic',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
define: { 'process.env': {} },
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('src', import.meta.url)),
|
||||||
|
},
|
||||||
|
extensions: [
|
||||||
|
'.js',
|
||||||
|
'.json',
|
||||||
|
'.jsx',
|
||||||
|
'.mjs',
|
||||||
|
'.ts',
|
||||||
|
'.tsx',
|
||||||
|
'.vue',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://srs-server:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
56
docker-compose.yml
Normal file
56
docker-compose.yml
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
services:
|
||||||
|
server:
|
||||||
|
build:
|
||||||
|
context: ./server
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: srs-server
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- ./server:/app
|
||||||
|
- /app/node_modules
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=dev
|
||||||
|
- PORT=3000
|
||||||
|
command: pnpm run dev
|
||||||
|
networks:
|
||||||
|
- srs-app-net
|
||||||
|
|
||||||
|
client:
|
||||||
|
build:
|
||||||
|
context: ./client
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: srs-client
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "5173:5173"
|
||||||
|
volumes:
|
||||||
|
- ./client:/app
|
||||||
|
- /app/node_modules
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=dev
|
||||||
|
- VITE_APP_URL=http://srs-server:3000
|
||||||
|
command: pnpm run dev
|
||||||
|
depends_on:
|
||||||
|
- server
|
||||||
|
networks:
|
||||||
|
- srs-app-net
|
||||||
|
|
||||||
|
mongo:
|
||||||
|
image: mongo:8
|
||||||
|
container_name: srs-mongo
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "27017:27017"
|
||||||
|
volumes:
|
||||||
|
- mongo_data:/data/db
|
||||||
|
networks:
|
||||||
|
- srs-app-net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mongo_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
srs-app-net:
|
||||||
|
driver: bridge
|
||||||
5
package.json
Normal file
5
package.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"wanakana": "^5.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
pnpm-lock.yaml
generated
Normal file
23
pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
importers:
|
||||||
|
|
||||||
|
.:
|
||||||
|
dependencies:
|
||||||
|
wanakana:
|
||||||
|
specifier: ^5.3.1
|
||||||
|
version: 5.3.1
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
wanakana@5.3.1:
|
||||||
|
resolution: {integrity: sha512-OSDqupzTlzl2LGyqTdhcXcl6ezMiFhcUwLBP8YKaBIbMYW1wAwDvupw2T9G9oVaKT9RmaSpyTXjxddFPUcFFIw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
snapshots:
|
||||||
|
|
||||||
|
wanakana@5.3.1: {}
|
||||||
0
server/.env
Normal file
0
server/.env
Normal file
15
server/Dockerfile
Normal file
15
server/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM node:24-alpine
|
||||||
|
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY pnpm-lock.yaml package*.json ./
|
||||||
|
RUN pnpm i
|
||||||
|
|
||||||
|
RUN pnpm add -D nodemon
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD [ "pnpm", "run", "dev" ]
|
||||||
29
server/package.json
Normal file
29
server/package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "srs-server",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/index.ts",
|
||||||
|
"start": "node --loader ts-node/esm src/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cors": "^11.1.0",
|
||||||
|
"@fastify/static": "^8.2.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"fastify": "^5.6.1",
|
||||||
|
"mongodb": "^6.20.0",
|
||||||
|
"mongoose": "^8.19.1",
|
||||||
|
"node": "^24.10.0",
|
||||||
|
"node-fetch": "^3.3.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/express": "^5.0.3",
|
||||||
|
"@types/node": "^24.7.2",
|
||||||
|
"eslint": "^9.37.0",
|
||||||
|
"nodemon": "^3.1.10",
|
||||||
|
"pnpm": "^10.18.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
2471
server/pnpm-lock.yaml
generated
Normal file
2471
server/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
57
server/src/api/v1/key/index.ts
Normal file
57
server/src/api/v1/key/index.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import express, { type Router, type Request, type Response } from 'express'
|
||||||
|
import { ApiKeyModel } from '../../../models/apikey.model.ts'
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
|
||||||
|
router.get('/', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const doc = await ApiKeyModel.findOne({})
|
||||||
|
|
||||||
|
const apiKey = doc?.apiKey || ''
|
||||||
|
|
||||||
|
res.json({ apiKey })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching API key:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to fetch API key' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { apiKey } = req.body as { apiKey?: string }
|
||||||
|
if (!apiKey || !apiKey.trim()) {
|
||||||
|
return res.status(400).json({ error: 'Invalid API key' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await ApiKeyModel.updateOne(
|
||||||
|
{},
|
||||||
|
{ $set: { apiKey: apiKey } },
|
||||||
|
{ upsert: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving API key:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to save API key' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.delete('/', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const result = await ApiKeyModel.deleteOne({})
|
||||||
|
|
||||||
|
if (result.deletedCount === 0) {
|
||||||
|
console.log('No API key found to delete.')
|
||||||
|
return res.status(204).end()
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('API key document deleted.')
|
||||||
|
res.json({ success: true, message: 'API key deleted' })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting API key:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to delete API key' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router as Router
|
||||||
18
server/src/api/v1/subject/kanji.ts
Normal file
18
server/src/api/v1/subject/kanji.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import express, { type Router, type Request, type Response } from 'express'
|
||||||
|
import { KanjiModel } from '../../../models/kanji.model.ts'
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
|
||||||
|
router.get('/', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const doc = await KanjiModel.find()
|
||||||
|
console.log(doc)
|
||||||
|
res.json(doc)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching Kanji Subjects', error)
|
||||||
|
res.status(500).json({ error: 'Failed to fetch Kanji Subjects' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router as Router
|
||||||
18
server/src/api/v1/subject/vocab.ts
Normal file
18
server/src/api/v1/subject/vocab.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import express, { type Router, type Request, type Response } from 'express'
|
||||||
|
import { VocabularyModel } from '../../../models/vocabulary.model.ts'
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
|
||||||
|
router.get('/', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const doc = await VocabularyModel.find()
|
||||||
|
|
||||||
|
res.json(doc)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching API key:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to fetch API key' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router as Router
|
||||||
31
server/src/api/v1/wanikani/sync.ts
Normal file
31
server/src/api/v1/wanikani/sync.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import express, { Router } from 'express'
|
||||||
|
|
||||||
|
import { ApiKeyModel } from '../../../models/apikey.model.ts'
|
||||||
|
|
||||||
|
import { syncWanikaniData } from '../../../services/wanikaniService.ts'
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
interface ApiKeyDocument {
|
||||||
|
apiKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/sync', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const apiKeyDoc = await ApiKeyModel.findOne() as ApiKeyDocument | null
|
||||||
|
|
||||||
|
const apiKey = apiKeyDoc?.apiKey
|
||||||
|
|
||||||
|
console.log(apiKey, apiKeyDoc)
|
||||||
|
if (!apiKey || apiKey.trim() === '') {
|
||||||
|
return res.status(401).json({ error: 'API Key not configured. Please sync your key first.' })
|
||||||
|
}
|
||||||
|
await syncWanikaniData(apiKey)
|
||||||
|
res.json({ success: true })
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
res.status(500).json({ error: 'Failed to sync WaniKani data' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router as Router
|
||||||
8
server/src/db/connect.ts
Normal file
8
server/src/db/connect.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import mongoose from 'mongoose'
|
||||||
|
|
||||||
|
export async function connectMongo() {
|
||||||
|
const url = process.env.MONGO_URL || 'mongodb://mongo:27017/srs'
|
||||||
|
if (mongoose.connection.readyState === 1) return
|
||||||
|
await mongoose.connect(url)
|
||||||
|
console.log('✅ Connected to MongoDB at', url)
|
||||||
|
}
|
||||||
21
server/src/index.ts
Normal file
21
server/src/index.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import { connectMongo } from './db/connect.ts'
|
||||||
|
import cors from 'cors'
|
||||||
|
import keyRouter from './api/v1/key/index.ts'
|
||||||
|
import syncRouter from './api/v1/wanikani/sync.ts'
|
||||||
|
import kanjiRouter from './api/v1/subject/kanji.ts'
|
||||||
|
import vocabRouter from './api/v1/subject/vocab.ts'
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
app.use(cors())
|
||||||
|
app.use(express.json())
|
||||||
|
|
||||||
|
app.use('/api/v1/key', keyRouter)
|
||||||
|
app.use('/api/v1/wanikani', syncRouter)
|
||||||
|
app.use('/api/v1/subject/kanji', kanjiRouter)
|
||||||
|
app.use('/api/v1/subject/vocab', vocabRouter)
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000
|
||||||
|
connectMongo().then(() => {
|
||||||
|
app.listen(PORT, () => console.log(`Server running on port ${PORT}`))
|
||||||
|
})
|
||||||
7
server/src/models/apikey.model.ts
Normal file
7
server/src/models/apikey.model.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import mongoose from 'mongoose'
|
||||||
|
|
||||||
|
const ApiKeySchema = new mongoose.Schema({
|
||||||
|
apiKey: { type: String, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ApiKeyModel = mongoose.model('ApiKey', ApiKeySchema)
|
||||||
8
server/src/models/assignments.model.ts
Normal file
8
server/src/models/assignments.model.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import mongoose from 'mongoose'
|
||||||
|
|
||||||
|
const AssignmentSchema = new mongoose.Schema({
|
||||||
|
subject_type: String,
|
||||||
|
subject_ids: [Number],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const AssignmentModel = mongoose.model('Assignment', AssignmentSchema)
|
||||||
14
server/src/models/kanji.model.ts
Normal file
14
server/src/models/kanji.model.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import mongoose from 'mongoose'
|
||||||
|
import type { KanjiItem } from '../types/wanikani.ts'
|
||||||
|
|
||||||
|
const KanjiSchema = new mongoose.Schema<KanjiItem>({
|
||||||
|
characters: String,
|
||||||
|
meanings: Array,
|
||||||
|
readings: Array,
|
||||||
|
auxiliary_meanings: Array,
|
||||||
|
level: Number,
|
||||||
|
slug: String,
|
||||||
|
srs_score: Number,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const KanjiModel = mongoose.model('Kanji', KanjiSchema)
|
||||||
15
server/src/models/vocabulary.model.ts
Normal file
15
server/src/models/vocabulary.model.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import mongoose from 'mongoose'
|
||||||
|
import type { VocabularyItem } from '../types/wanikani.ts'
|
||||||
|
|
||||||
|
const VocabSchema = new mongoose.Schema<VocabularyItem>({
|
||||||
|
characters: String,
|
||||||
|
meanings: Array,
|
||||||
|
readings: Array,
|
||||||
|
auxiliary_meanings: Array,
|
||||||
|
pronunciation_audios: Array,
|
||||||
|
level: Number,
|
||||||
|
slug: String,
|
||||||
|
srs_score: Number,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const VocabularyModel = mongoose.model('Vocabulary', VocabSchema)
|
||||||
141
server/src/services/wanikaniService.ts
Normal file
141
server/src/services/wanikaniService.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import fetch from 'node-fetch'
|
||||||
|
import { ApiKeyModel } from '../models/apikey.model.ts'
|
||||||
|
import { AssignmentModel } from '../models/assignments.model.ts'
|
||||||
|
import { KanjiModel } from '../models/kanji.model.ts'
|
||||||
|
import { VocabularyModel } from '../models/vocabulary.model.ts'
|
||||||
|
|
||||||
|
import type { KanjiItem, VocabularyItem } from '../types/wanikani.ts'
|
||||||
|
|
||||||
|
const WANIKANI_API_BASE = 'https://api.wanikani.com/v2'
|
||||||
|
|
||||||
|
export interface WaniKaniSubject {
|
||||||
|
id: number
|
||||||
|
object: string
|
||||||
|
data: {
|
||||||
|
characters: string
|
||||||
|
meanings: { meaning: string; primary: boolean; accepted_answers: boolean }[]
|
||||||
|
readings: { type: string; primary: boolean; accepted_answer: boolean; reading: string }[]
|
||||||
|
auxiliary_meanings: { meaning: string; type: string }[]
|
||||||
|
pronunciation_audios?: {
|
||||||
|
url: string
|
||||||
|
content_type: string
|
||||||
|
metadata: { gender: string; pronunciation: string }[]
|
||||||
|
}[]
|
||||||
|
level: number
|
||||||
|
slug: string
|
||||||
|
unlocked_at?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchAllPages = async (
|
||||||
|
url: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
): Promise<WaniKaniSubject[]> => {
|
||||||
|
let results: WaniKaniSubject[] = []
|
||||||
|
let nextUrl: string | null = url
|
||||||
|
|
||||||
|
while (nextUrl) {
|
||||||
|
const res = await fetch(nextUrl, { headers })
|
||||||
|
if (!res.ok) throw new Error(`Failed fetching ${url}: ${res.statusText}`)
|
||||||
|
const json = (await res.json()) as { data: WaniKaniSubject[]; pages: { next_url: string | null } }
|
||||||
|
results = results.concat(json.data)
|
||||||
|
nextUrl = json.pages.next_url
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchSubjects = async (
|
||||||
|
ids: number[],
|
||||||
|
headers: Record<string, string>,
|
||||||
|
): Promise<WaniKaniSubject[]> => {
|
||||||
|
const chunkSize = 1000
|
||||||
|
let results: WaniKaniSubject[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < ids.length; i += chunkSize) {
|
||||||
|
const chunk = ids.slice(i, i + chunkSize)
|
||||||
|
const res = await fetch(`${WANIKANI_API_BASE}/subjects?ids=${chunk.join(',')}`, { headers })
|
||||||
|
if (!res.ok) throw new Error(`Failed fetching subjects: ${res.statusText}`)
|
||||||
|
const json = (await res.json()) as { data: WaniKaniSubject[] }
|
||||||
|
results = results.concat(json.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapKanji = (item: WaniKaniSubject): KanjiItem => ({
|
||||||
|
characters: item.data.characters,
|
||||||
|
meanings: item.data.meanings,
|
||||||
|
readings: item.data.readings,
|
||||||
|
auxiliary_meanings: item.data.auxiliary_meanings,
|
||||||
|
level: item.data.level,
|
||||||
|
slug: item.data.slug,
|
||||||
|
srs_score: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapVocab = (item: WaniKaniSubject): VocabularyItem => ({
|
||||||
|
characters: item.data.characters,
|
||||||
|
meanings: item.data.meanings,
|
||||||
|
readings: item.data.readings ?? [],
|
||||||
|
auxiliary_meanings: item.data.auxiliary_meanings,
|
||||||
|
pronunciation_audios: item.data.pronunciation_audios ?? [],
|
||||||
|
level: item.data.level,
|
||||||
|
slug: item.data.slug,
|
||||||
|
srs_score: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const syncWanikaniData = async (apiKey: string): Promise<void> => {
|
||||||
|
const headers = { Authorization: `Bearer ${apiKey}` }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ApiKeyModel.updateOne(
|
||||||
|
{},
|
||||||
|
{ $set: { value: apiKey, lastUsed: new Date() } },
|
||||||
|
{ upsert: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const assignments = await fetchAllPages(`${WANIKANI_API_BASE}/assignments`, headers)
|
||||||
|
|
||||||
|
const unlockedKanjiSubjectIds: number[] = []
|
||||||
|
const unlockedVocabSubjectIds: number[] = []
|
||||||
|
|
||||||
|
for (const a of assignments) {
|
||||||
|
const assignmentData = a.data as any
|
||||||
|
|
||||||
|
if (!assignmentData.unlocked_at) continue
|
||||||
|
|
||||||
|
if (assignmentData.subject_type === 'kanji') {
|
||||||
|
unlockedKanjiSubjectIds.push(assignmentData.subject_id)
|
||||||
|
} else if (['vocabulary', 'kana_vocabulary'].includes(assignmentData.subject_type)) {
|
||||||
|
unlockedVocabSubjectIds.push(assignmentData.subject_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await AssignmentModel.updateOne(
|
||||||
|
{ subject_type: 'kanji' },
|
||||||
|
{ $set: { subject_ids: unlockedKanjiSubjectIds } },
|
||||||
|
{ upsert: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
await AssignmentModel.updateOne(
|
||||||
|
{ subject_type: 'vocabulary' },
|
||||||
|
{ $set: { subject_ids: unlockedVocabSubjectIds } },
|
||||||
|
{ upsert: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const existingKanjiSlugs = new Set((await KanjiModel.find({}, { slug: 1 })).map(k => k.slug))
|
||||||
|
const kanjiSubjects = await fetchSubjects(unlockedKanjiSubjectIds, headers)
|
||||||
|
const newKanji = kanjiSubjects.filter(s => !existingKanjiSlugs.has(s.data.slug))
|
||||||
|
if (newKanji.length > 0) await KanjiModel.insertMany(newKanji.map(mapKanji))
|
||||||
|
|
||||||
|
const existingVocabSlugs = new Set((await VocabularyModel.find({}, { slug: 1 })).map(v => v.slug))
|
||||||
|
const vocabSubjects = await fetchSubjects(unlockedVocabSubjectIds, headers)
|
||||||
|
const newVocab = vocabSubjects.filter(s => !existingVocabSlugs.has(s.data.slug))
|
||||||
|
if (newVocab.length > 0) await VocabularyModel.insertMany(newVocab.map(mapVocab))
|
||||||
|
|
||||||
|
console.log('✅ Sync complete')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Error syncing WaniKani data:', err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
57
server/src/types/wanikani.ts
Normal file
57
server/src/types/wanikani.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
export interface KanjiItem {
|
||||||
|
characters: string
|
||||||
|
meanings: {
|
||||||
|
meaning: string,
|
||||||
|
primary: boolean,
|
||||||
|
accepted_answers: boolean
|
||||||
|
}[]
|
||||||
|
readings: {
|
||||||
|
type: string
|
||||||
|
primary: boolean
|
||||||
|
accepted_answer: boolean
|
||||||
|
reading: string
|
||||||
|
}[]
|
||||||
|
auxiliary_meanings: {
|
||||||
|
meaning: string
|
||||||
|
type: string
|
||||||
|
}[]
|
||||||
|
level: number
|
||||||
|
slug: string
|
||||||
|
srs_score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VocabularyItem {
|
||||||
|
characters: string
|
||||||
|
meanings: {
|
||||||
|
meaning: string
|
||||||
|
primary: boolean
|
||||||
|
accepted_answers: boolean
|
||||||
|
}[]
|
||||||
|
readings: {
|
||||||
|
type: string
|
||||||
|
primary: boolean
|
||||||
|
accepted_answer: boolean
|
||||||
|
reading: string
|
||||||
|
}[]
|
||||||
|
auxiliary_meanings: {
|
||||||
|
meaning: string
|
||||||
|
type: string
|
||||||
|
}[]
|
||||||
|
pronunciation_audios: {
|
||||||
|
url: string
|
||||||
|
content_type: string
|
||||||
|
metadata: {
|
||||||
|
gender: string
|
||||||
|
pronunciation: string
|
||||||
|
}[]
|
||||||
|
}[]
|
||||||
|
level: number
|
||||||
|
slug: string
|
||||||
|
srs_score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Assignment {
|
||||||
|
unlocked_at?: Date
|
||||||
|
subject_ids: number[]
|
||||||
|
subject_type: string
|
||||||
|
}
|
||||||
46
server/tsconfig.json
Normal file
46
server/tsconfig.json
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
// Visit https://aka.ms/tsconfig to read more about this file
|
||||||
|
"compilerOptions": {
|
||||||
|
// File Layout
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
// Environment Settings
|
||||||
|
// See also https://aka.ms/tsconfig/module
|
||||||
|
"module": "nodenext",
|
||||||
|
"target": "esnext",
|
||||||
|
"types": [],
|
||||||
|
// For nodejs:
|
||||||
|
// "lib": ["esnext"],
|
||||||
|
// "types": ["node"],
|
||||||
|
// and npm install -D @types/node
|
||||||
|
// Other Outputs
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
// Stricter Typechecking Options
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
// Style Options
|
||||||
|
// "noImplicitReturns": true,
|
||||||
|
// "noImplicitOverride": true,
|
||||||
|
// "noUnusedLocals": true,
|
||||||
|
// "noUnusedParameters": true,
|
||||||
|
// "noFallthroughCasesInSwitch": true,
|
||||||
|
// "noPropertyAccessFromIndexSignature": true,
|
||||||
|
// Recommended Options
|
||||||
|
"strict": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user