This commit is contained in:
Rene Kievits
2025-10-19 22:52:32 +02:00
commit a728add8af
64 changed files with 11693 additions and 0 deletions

4
client/.browserslistrc Normal file
View File

@@ -0,0 +1,4 @@
> 1%
last 2 versions
not dead
not ie 11

6
client/.editorconfig Normal file
View 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
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
public/

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

44
client/package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

5
client/src/App.vue Normal file
View File

@@ -0,0 +1,5 @@
<template>
<v-app>
<router-view />
</v-app>
</template>

BIN
client/src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View 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
View 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
View 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']
}
}

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

@@ -0,0 +1,7 @@
export { }
declare global {
interface Window {
KanjiCanvas: any
}
}

View File

@@ -0,0 +1,5 @@
<template>
<v-main>
<router-view />
</v-main>
</template>

23
client/src/main.ts Normal file
View 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
View 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
View 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
View 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
View 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>

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

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

View 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',
},
})

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

@@ -0,0 +1,8 @@
// Utilities
import { defineStore } from 'pinia'
export const useAppStore = defineStore('app', {
state: () => ({
//
}),
})

View File

@@ -0,0 +1,4 @@
// Utilities
import { createPinia } from 'pinia'
export default createPinia()

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

@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

19
client/tsconfig.node.json Normal file
View 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
View 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,
},
},
},
})