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