Compare commits
15 Commits
36fcece01d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fade850767 | |||
|
|
96458b9e94 | ||
| 695f883649 | |||
| 5e70a6ec70 | |||
|
|
150667f781 | ||
|
|
882328c28e | ||
| fc341e57af | |||
| 4fbcee761d | |||
|
|
1980e14e88 | ||
|
|
673d29b05f | ||
| 0edd27fb7d | |||
| 9a7ebd5cf0 | |||
| 12e3737e39 | |||
| ba58aa6b1f | |||
| 4908e6679e |
40
.gitea/workflows/deploy.yaml
Normal file
40
.gitea/workflows/deploy.yaml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
name: Build and Push Docker Images
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to local registry
|
||||||
|
if: ${{ secrets.REGISTRY_USER != '' }}
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login 192.168.0.26:5008 -u ${{ secrets.REGISTRY_USER }} --password-stdin || true
|
||||||
|
|
||||||
|
- name: Build and Push Client
|
||||||
|
run: |
|
||||||
|
echo "🚀 Building Client..."
|
||||||
|
docker buildx build --load -t crylia/japanese-srs-trainer-wanikani-client:latest -f client/Dockerfile client
|
||||||
|
docker tag crylia/japanese-srs-trainer-wanikani-client:latest 192.168.0.26:5008/japanese-srs-trainer-wanikani-client:latest
|
||||||
|
docker push 192.168.0.26:5008/japanese-srs-trainer-wanikani-client:latest
|
||||||
|
|
||||||
|
- name: Build and Push Server
|
||||||
|
run: |
|
||||||
|
echo "🚀 Building Server..."
|
||||||
|
docker buildx build \
|
||||||
|
--build-arg ACCESS_TOKEN_SECRET=${{ secrets.ACCESS_TOKEN_SECRET }} \
|
||||||
|
--build-arg REFRESH_TOKEN_SECRET=${{ secrets.REFRESH_TOKEN_SECRET }} \
|
||||||
|
--load -t crylia/japanese-srs-trainer-wanikani-server:latest -f server/Dockerfile server
|
||||||
|
docker tag crylia/japanese-srs-trainer-wanikani-server:latest 192.168.0.26:5008/japanese-srs-trainer-wanikani-server:latest
|
||||||
|
docker push 192.168.0.26:5008/japanese-srs-trainer-wanikani-server:latest
|
||||||
41
.gitignore
vendored
41
.gitignore
vendored
@@ -1 +1,40 @@
|
|||||||
node_modules
|
# Node modules
|
||||||
|
node_modules/
|
||||||
|
**/node_modules/
|
||||||
|
|
||||||
|
# Vite / Vue build output
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# VSCode
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# JetBrains
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# System
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Lint / Typegen
|
||||||
|
*.d.ts
|
||||||
|
*.eslintcache
|
||||||
|
|
||||||
|
|
||||||
|
# Ignore temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
|||||||
27
client/.dockerignore
Normal file
27
client/.dockerignore
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Node modules
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Local dev / editor
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.vite
|
||||||
|
|
||||||
|
# TypeScript build info
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Environment files (if you want them injected via Docker ENV)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
@@ -2,7 +2,14 @@
|
|||||||
"hash": "92874bb6",
|
"hash": "92874bb6",
|
||||||
"configHash": "ae1864eb",
|
"configHash": "ae1864eb",
|
||||||
"lockfileHash": "a03ea363",
|
"lockfileHash": "a03ea363",
|
||||||
"browserHash": "85be85d4",
|
"browserHash": "03c64258",
|
||||||
"optimized": {},
|
"optimized": {
|
||||||
|
"vue": {
|
||||||
|
"src": "../../node_modules/.pnpm/vue@3.5.22_typescript@5.9.3/node_modules/vue/dist/vue.runtime.esm-bundler.js",
|
||||||
|
"file": "vue.js",
|
||||||
|
"fileHash": "6b167e91",
|
||||||
|
"needsInterop": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"chunks": {}
|
"chunks": {}
|
||||||
}
|
}
|
||||||
12704
client/.vite/deps/vue.js
Normal file
12704
client/.vite/deps/vue.js
Normal file
File diff suppressed because it is too large
Load Diff
7
client/.vite/deps/vue.js.map
Normal file
7
client/.vite/deps/vue.js.map
Normal file
File diff suppressed because one or more lines are too long
@@ -14,6 +14,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/roboto": "5.2.7",
|
"@fontsource/roboto": "5.2.7",
|
||||||
"@mdi/font": "7.4.47",
|
"@mdi/font": "7.4.47",
|
||||||
|
"axios": "^1.12.2",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"pug": "^3.0.3",
|
"pug": "^3.0.3",
|
||||||
"vue": "^3.5.21",
|
"vue": "^3.5.21",
|
||||||
|
|||||||
86
client/pnpm-lock.yaml
generated
86
client/pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
|||||||
'@mdi/font':
|
'@mdi/font':
|
||||||
specifier: 7.4.47
|
specifier: 7.4.47
|
||||||
version: 7.4.47
|
version: 7.4.47
|
||||||
|
axios:
|
||||||
|
specifier: ^1.12.2
|
||||||
|
version: 1.12.2
|
||||||
pinia:
|
pinia:
|
||||||
specifier: ^3.0.3
|
specifier: ^3.0.3
|
||||||
version: 3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3))
|
version: 3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3))
|
||||||
@@ -800,6 +803,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==}
|
resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==}
|
||||||
engines: {node: '>=20.19.0'}
|
engines: {node: '>=20.19.0'}
|
||||||
|
|
||||||
|
asynckit@0.4.0:
|
||||||
|
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||||
|
|
||||||
|
axios@1.12.2:
|
||||||
|
resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==}
|
||||||
|
|
||||||
babel-walk@3.0.0-canary-5:
|
babel-walk@3.0.0-canary-5:
|
||||||
resolution: {integrity: sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==}
|
resolution: {integrity: sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
@@ -897,6 +906,10 @@ packages:
|
|||||||
colorjs.io@0.5.2:
|
colorjs.io@0.5.2:
|
||||||
resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==}
|
resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==}
|
||||||
|
|
||||||
|
combined-stream@1.0.8:
|
||||||
|
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||||
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
comment-parser@1.4.1:
|
comment-parser@1.4.1:
|
||||||
resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==}
|
resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -948,6 +961,10 @@ packages:
|
|||||||
deep-is@0.1.4:
|
deep-is@0.1.4:
|
||||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||||
|
|
||||||
|
delayed-stream@1.0.0:
|
||||||
|
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||||
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
detect-libc@1.0.3:
|
detect-libc@1.0.3:
|
||||||
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
|
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
|
||||||
engines: {node: '>=0.10'}
|
engines: {node: '>=0.10'}
|
||||||
@@ -987,6 +1004,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
es-set-tostringtag@2.1.0:
|
||||||
|
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
esbuild@0.25.10:
|
esbuild@0.25.10:
|
||||||
resolution: {integrity: sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==}
|
resolution: {integrity: sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1218,6 +1239,19 @@ packages:
|
|||||||
flatted@3.3.3:
|
flatted@3.3.3:
|
||||||
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
||||||
|
|
||||||
|
follow-redirects@1.15.11:
|
||||||
|
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
|
||||||
|
engines: {node: '>=4.0'}
|
||||||
|
peerDependencies:
|
||||||
|
debug: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
debug:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
form-data@4.0.4:
|
||||||
|
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@@ -1440,6 +1474,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||||
engines: {node: '>=8.6'}
|
engines: {node: '>=8.6'}
|
||||||
|
|
||||||
|
mime-db@1.52.0:
|
||||||
|
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
mime-types@2.1.35:
|
||||||
|
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
minimatch@3.1.2:
|
minimatch@3.1.2:
|
||||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
||||||
|
|
||||||
@@ -1597,6 +1639,9 @@ packages:
|
|||||||
promise@7.3.1:
|
promise@7.3.1:
|
||||||
resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==}
|
resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==}
|
||||||
|
|
||||||
|
proxy-from-env@1.1.0:
|
||||||
|
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||||
|
|
||||||
pug-attrs@3.0.0:
|
pug-attrs@3.0.0:
|
||||||
resolution: {integrity: sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==}
|
resolution: {integrity: sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==}
|
||||||
|
|
||||||
@@ -2793,6 +2838,16 @@ snapshots:
|
|||||||
'@babel/parser': 7.28.4
|
'@babel/parser': 7.28.4
|
||||||
ast-kit: 2.1.3
|
ast-kit: 2.1.3
|
||||||
|
|
||||||
|
asynckit@0.4.0: {}
|
||||||
|
|
||||||
|
axios@1.12.2:
|
||||||
|
dependencies:
|
||||||
|
follow-redirects: 1.15.11
|
||||||
|
form-data: 4.0.4
|
||||||
|
proxy-from-env: 1.1.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- debug
|
||||||
|
|
||||||
babel-walk@3.0.0-canary-5:
|
babel-walk@3.0.0-canary-5:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/types': 7.28.4
|
'@babel/types': 7.28.4
|
||||||
@@ -2891,6 +2946,10 @@ snapshots:
|
|||||||
|
|
||||||
colorjs.io@0.5.2: {}
|
colorjs.io@0.5.2: {}
|
||||||
|
|
||||||
|
combined-stream@1.0.8:
|
||||||
|
dependencies:
|
||||||
|
delayed-stream: 1.0.0
|
||||||
|
|
||||||
comment-parser@1.4.1: {}
|
comment-parser@1.4.1: {}
|
||||||
|
|
||||||
concat-map@0.0.1: {}
|
concat-map@0.0.1: {}
|
||||||
@@ -2930,6 +2989,8 @@ snapshots:
|
|||||||
|
|
||||||
deep-is@0.1.4: {}
|
deep-is@0.1.4: {}
|
||||||
|
|
||||||
|
delayed-stream@1.0.0: {}
|
||||||
|
|
||||||
detect-libc@1.0.3:
|
detect-libc@1.0.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -2957,6 +3018,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
|
|
||||||
|
es-set-tostringtag@2.1.0:
|
||||||
|
dependencies:
|
||||||
|
es-errors: 1.3.0
|
||||||
|
get-intrinsic: 1.3.0
|
||||||
|
has-tostringtag: 1.0.2
|
||||||
|
hasown: 2.0.2
|
||||||
|
|
||||||
esbuild@0.25.10:
|
esbuild@0.25.10:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@esbuild/aix-ppc64': 0.25.10
|
'@esbuild/aix-ppc64': 0.25.10
|
||||||
@@ -3274,6 +3342,16 @@ snapshots:
|
|||||||
|
|
||||||
flatted@3.3.3: {}
|
flatted@3.3.3: {}
|
||||||
|
|
||||||
|
follow-redirects@1.15.11: {}
|
||||||
|
|
||||||
|
form-data@4.0.4:
|
||||||
|
dependencies:
|
||||||
|
asynckit: 0.4.0
|
||||||
|
combined-stream: 1.0.8
|
||||||
|
es-set-tostringtag: 2.1.0
|
||||||
|
hasown: 2.0.2
|
||||||
|
mime-types: 2.1.35
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -3465,6 +3543,12 @@ snapshots:
|
|||||||
braces: 3.0.3
|
braces: 3.0.3
|
||||||
picomatch: 2.3.1
|
picomatch: 2.3.1
|
||||||
|
|
||||||
|
mime-db@1.52.0: {}
|
||||||
|
|
||||||
|
mime-types@2.1.35:
|
||||||
|
dependencies:
|
||||||
|
mime-db: 1.52.0
|
||||||
|
|
||||||
minimatch@3.1.2:
|
minimatch@3.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 1.1.12
|
brace-expansion: 1.1.12
|
||||||
@@ -3613,6 +3697,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
asap: 2.0.6
|
asap: 2.0.6
|
||||||
|
|
||||||
|
proxy-from-env@1.1.0: {}
|
||||||
|
|
||||||
pug-attrs@3.0.0:
|
pug-attrs@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
constantinople: 4.0.1
|
constantinople: 4.0.1
|
||||||
|
|||||||
145
client/src/composables/srs.scoring.ts
Normal file
145
client/src/composables/srs.scoring.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
type SubjectType = 'kanji' | 'vocab' | 'both'
|
||||||
|
type LearningDirection = 'jp->en' | 'en->jp'
|
||||||
|
type WritingMode = 'kana' | 'kanji'
|
||||||
|
|
||||||
|
interface SubjectOptions {
|
||||||
|
subject: SubjectType
|
||||||
|
direction?: LearningDirection
|
||||||
|
writingMode?: WritingMode
|
||||||
|
mode?: 'meaning' | 'reading'
|
||||||
|
writingPractice: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewItem {
|
||||||
|
type: 'kanji' | 'vocab'
|
||||||
|
subject: string
|
||||||
|
answers: string[]
|
||||||
|
pronunciation_audios?: string[],
|
||||||
|
mode: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentReviewItem: any = {}
|
||||||
|
let currentSubjectOptions: SubjectOptions
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles logic for when a subject answer is correct.
|
||||||
|
*
|
||||||
|
* @param id - The unique identifier of the subject item.
|
||||||
|
* @param subjectOptions - Configuration describing what kind of subject
|
||||||
|
* and learning mode is being used (kanji, vocab, etc.).
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
const correct = async () => (
|
||||||
|
await axios.post('/api/v1/review/correct', {
|
||||||
|
itemId: currentReviewItem.id,
|
||||||
|
currentSubjectOptions,
|
||||||
|
}, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles logic for when a subject answer is incorrect
|
||||||
|
*
|
||||||
|
* @param id - The unique identifier of the subject item.
|
||||||
|
* @param subjectOptions - Configuration describing what kind of subject
|
||||||
|
* and learning mode is being used (kanji, vocab, etc.).
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
const incorrect = async () => {
|
||||||
|
await axios.post('/api/v1/review/incorrect', {
|
||||||
|
itemId: currentReviewItem.id,
|
||||||
|
currentSubjectOptions,
|
||||||
|
}, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the next subject from the server
|
||||||
|
*
|
||||||
|
* @param subjectOptions - Configuration describing what kind of subject
|
||||||
|
* and learning mode is being used (kanji, vocab, etc.).
|
||||||
|
*
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
const nextSubject = async (subjectOptions: SubjectOptions): Promise<ReviewItem | null> => {
|
||||||
|
const res = await axios.post('/api/v1/review/', { subjectOptions }, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const item = res.data
|
||||||
|
if (!item || item.message === 'No reviews due') return null
|
||||||
|
|
||||||
|
const mode: 'writing_practice' | 'jp_en_meaning' | 'jp_en_reading' | 'en_jp_writing' = item._selectedMode
|
||||||
|
|
||||||
|
currentReviewItem = item
|
||||||
|
currentSubjectOptions = { ...subjectOptions, mode }
|
||||||
|
|
||||||
|
let subjectText = ''
|
||||||
|
let answers: string[] = []
|
||||||
|
let pronunciation_audios: string[] | undefined = item.pronunciation_audios
|
||||||
|
|
||||||
|
if (mode === 'writing_practice') {
|
||||||
|
subjectText = item.meanings?.filter(m => m.primary).map(m => m.meaning).join(', ') || ''
|
||||||
|
answers = [item.characters]
|
||||||
|
} else if (mode === 'jp_en_meaning') {
|
||||||
|
subjectText = item.characters
|
||||||
|
answers = item.meanings?.filter(m => m.accepted_answer).map(m => m.meaning) || []
|
||||||
|
} else if (mode === 'jp_en_reading') {
|
||||||
|
subjectText = item.characters
|
||||||
|
answers = item.readings?.filter(r => r.accepted_answer).map(r => r.reading) || []
|
||||||
|
if (answers.length === 0)
|
||||||
|
answers = [item.characters]
|
||||||
|
} else if (mode === 'en_jp_writing') {
|
||||||
|
subjectText = item.meanings?.filter(m => m.primary).map(m => m.meaning).join(', ') || ''
|
||||||
|
if (subjectOptions.writingMode === 'kanji') {
|
||||||
|
answers = [item.characters]
|
||||||
|
} else if (subjectOptions.writingMode === 'kana') {
|
||||||
|
answers = item.readings?.filter(r => r.accepted_answer).map(r => r.reading) || []
|
||||||
|
} else if (answers.length === 0)
|
||||||
|
answers = [item.characters]
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: item.type,
|
||||||
|
subject: subjectText,
|
||||||
|
answers,
|
||||||
|
pronunciation_audios,
|
||||||
|
mode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatistics = async () => {
|
||||||
|
const res = await axios.get('/api/v1/review/stats', {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSubjectStatistics = async (id: string) => {
|
||||||
|
const res = await axios.get(`/api/v1/review/stats/${id}`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
correct,
|
||||||
|
incorrect,
|
||||||
|
nextSubject,
|
||||||
|
getStatistics,
|
||||||
|
getSubjectStatistics,
|
||||||
|
}
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +1,85 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
v-container.fill-height.d-flex.justify-center.align-center
|
v-container.fill-height.d-flex.justify-center.align-center
|
||||||
v-card.pa-12.rounded-lg.elevation-12
|
v-card.pa-12.rounded-lg.elevation-12
|
||||||
v-card-title.text-center.text-h2.font-weight-bold.mb-4
|
template(v-if="!allDone")
|
||||||
span.text-primary Full Trainer
|
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(
|
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'}"
|
:style="{ backgroundColor: subjectColor, fontSize: '5rem', fontWeight: 'bold', whiteSpace: 'nowrap' }"
|
||||||
)
|
)
|
||||||
span {{ character }}
|
span {{ character }}
|
||||||
|
|
||||||
v-card-text.text-center.my-4.rounded-lg(
|
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)'}"
|
:style="{ backgroundColor: subjectColor + '20', fontSize: '1.1rem', fontWeight: '500', color: subjectColor }"
|
||||||
)
|
)
|
||||||
| {{ inputMode === 'kanji' ? 'Kanji' : inputMode === 'writing' ? 'Writing' : inputMode }}
|
| {{ inputMode === 'kanji' ? 'Kanji' : inputMode === 'writing' ? 'Writing' : inputMode }}
|
||||||
|
|
||||||
transition(name="alert-grow")
|
transition(name="alert-grow")
|
||||||
v-alert.mb-4(
|
v-alert.mb-4(
|
||||||
v-if="resultMessage"
|
v-if="resultMessage"
|
||||||
:type="isCorrect ? 'success' : 'error'"
|
:type="isCorrect ? 'success' : 'error'"
|
||||||
border="top"
|
border="top"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
density="compact"
|
density="compact"
|
||||||
) {{ resultMessage }}
|
) {{ resultMessage }}
|
||||||
|
|
||||||
v-row.align-center
|
v-row.align-center
|
||||||
v-col(cols="9")
|
v-col(cols="9")
|
||||||
input#answer-input.customInput(
|
input#answer-input.customInput(
|
||||||
ref="answerInput"
|
ref="answerInput"
|
||||||
placeholder="Type your answer"
|
placeholder="Type your answer"
|
||||||
:disabled="isDisabled"
|
:disabled="isDisabled"
|
||||||
)
|
)
|
||||||
v-col(cols="3")
|
v-col(cols="3")
|
||||||
v-btn(
|
v-btn(
|
||||||
color="primary"
|
color="primary"
|
||||||
block
|
block
|
||||||
@click="submitAnswer"
|
@click="submitAnswer"
|
||||||
) Enter
|
) Enter
|
||||||
|
|
||||||
v-row.justify-space-between
|
v-row.justify-space-between
|
||||||
v-col(cols="4")
|
v-col(cols="4")
|
||||||
v-btn(
|
v-btn(
|
||||||
color="error"
|
color="error"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
block
|
block
|
||||||
@click="quitSession"
|
@click="quitSession"
|
||||||
) Quit
|
) Quit
|
||||||
v-col(cols="4")
|
v-col(cols="4")
|
||||||
v-btn(
|
v-btn(
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
block
|
block
|
||||||
@click="createReview"
|
@click="createReview"
|
||||||
) Skip
|
) Skip
|
||||||
v-col(cols="4")
|
v-col(cols="4")
|
||||||
v-btn(
|
v-btn(
|
||||||
color="success"
|
color="success"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
block
|
block
|
||||||
@click="submitAnswer"
|
@click="submitAnswer"
|
||||||
) Resolve
|
) Resolve
|
||||||
|
template(v-else)
|
||||||
|
v-card-title.text-center.text-h2.font-weight-bold.mb-4
|
||||||
|
span.text-primary 🎉 Congratulations!
|
||||||
|
v-card-text.text-center
|
||||||
|
| You have completed all reviews for today!
|
||||||
|
v-row.justify-center.mt-6
|
||||||
|
v-col(cols="6")
|
||||||
|
v-btn(
|
||||||
|
color="primary"
|
||||||
|
block
|
||||||
|
@click="quitSession"
|
||||||
|
) Back to Home
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, nextTick, onBeforeUnmount, computed } from 'vue'
|
import { ref, onMounted, nextTick, onBeforeUnmount, computed } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import * as wanakana from 'wanakana'
|
import * as wanakana from 'wanakana'
|
||||||
import { Reviews } from '../composables/subject.ts'
|
|
||||||
|
import { nextSubject, correct, incorrect } from '../composables/srs.scoring.ts'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -77,76 +91,76 @@ const isDisabled = ref<boolean>(false)
|
|||||||
const isWanakanaBound = ref<boolean>(false)
|
const isWanakanaBound = ref<boolean>(false)
|
||||||
const inputMode = ref<'meaning' | 'writing' | 'kanji'>('meaning')
|
const inputMode = ref<'meaning' | 'writing' | 'kanji'>('meaning')
|
||||||
const resultMessage = ref<string>('')
|
const resultMessage = ref<string>('')
|
||||||
const reviews = ref<Reviews | null>(null)
|
|
||||||
const subject = ref<any>(null)
|
const subject = ref<any>(null)
|
||||||
|
const allDone = ref(false)
|
||||||
|
|
||||||
|
const subjectColor = computed(() => {
|
||||||
|
if (!subject.value) return 'var(--color-all)'
|
||||||
|
switch (subject.value.type) {
|
||||||
|
case 'kanji':
|
||||||
|
return 'var(--color-kanji)'
|
||||||
|
case 'vocab':
|
||||||
|
return 'var(--color-vocab)'
|
||||||
|
default:
|
||||||
|
return 'var(--color-all)'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const direction = computed(() => route.query.direction)
|
|
||||||
const options = computed(() => JSON.parse(route.query.options as string))
|
const options = computed(() => JSON.parse(route.query.options as string))
|
||||||
|
|
||||||
function createReview() {
|
const optionsCalculated = ref({
|
||||||
if (!reviews.value) return
|
subject: options.value.type === 'writing' ? 'kanji' : options.value.type === 'all' ? ['kanji', 'vocab'] : options.value.type,
|
||||||
|
mode: options.value.writing && options.value.meaning ? ['reading', 'meaning'] : options.value.writing ? 'reading' : options.value.meaning ? 'meaning' : 'reading',
|
||||||
|
writingPractice: options.value.type === 'writing',
|
||||||
|
direction: options.value.direction,
|
||||||
|
writingMode: options.value.kanji ? 'kanji' : 'kana',
|
||||||
|
})
|
||||||
|
|
||||||
const next = reviews.value.nextSubject()
|
async function createReview() {
|
||||||
|
const next = await nextSubject(optionsCalculated.value)
|
||||||
if (!next) {
|
if (!next) {
|
||||||
// TODO: Add celebration or summary screen
|
allDone.value = true
|
||||||
|
character.value = ''
|
||||||
|
resultMessage.value = '✅ All done!'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
subject.value = next.subject
|
allDone.value = false
|
||||||
inputMode.value = next.mode
|
subject.value = next
|
||||||
|
|
||||||
if (direction.value === 'jp->en') {
|
if (optionsCalculated.value.direction === 'en->jp' && optionsCalculated.value.writingMode === 'kanji')
|
||||||
character.value = subject.value.characters
|
inputMode.value = 'kanji'
|
||||||
} else {
|
else if (subject.value.mode === 'jp_en_reading' || optionsCalculated.value.direction === 'en->jp')
|
||||||
character.value = subject.value.meanings
|
inputMode.value = 'writing'
|
||||||
.filter((m: any) => m.primary)
|
else
|
||||||
.map((m: any) => m.meaning)
|
inputMode.value = 'meaning'
|
||||||
.join(', ')
|
|
||||||
}
|
|
||||||
|
|
||||||
nextTickWrapper()
|
character.value = subject.value.subject
|
||||||
isDisabled.value = false
|
isDisabled.value = false
|
||||||
resultMessage.value = ''
|
resultMessage.value = ''
|
||||||
isCorrect.value = false
|
isCorrect.value = false
|
||||||
|
nextTickWrapper()
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitAnswer() {
|
async function submitAnswer() {
|
||||||
|
if (!answerInput.value || !subject) return
|
||||||
|
|
||||||
if (isDisabled.value) {
|
if (isDisabled.value) {
|
||||||
createReview()
|
createReview()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!answerInput.value || !subject.value) return
|
|
||||||
|
|
||||||
const userAnswer = answerInput.value.value.trim()
|
const userAnswer = answerInput.value.value.trim()
|
||||||
|
isCorrect.value = subject.value.answers.some(a => a.trim().toLowerCase() === userAnswer)
|
||||||
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 = isCorrect.value ? 'Correct: ' : 'Wrong: '
|
||||||
resultMessage.value += getAnswerText()
|
resultMessage.value += subject.value.answers.join(', ')
|
||||||
|
|
||||||
|
if (isCorrect.value) {
|
||||||
|
await correct()
|
||||||
|
} else {
|
||||||
|
await incorrect()
|
||||||
|
}
|
||||||
|
|
||||||
answerInput.value.value = ''
|
answerInput.value.value = ''
|
||||||
isDisabled.value = true
|
isDisabled.value = true
|
||||||
@@ -187,14 +201,6 @@ function nextTickWrapper() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
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()
|
createReview()
|
||||||
window.addEventListener('keydown', handleGlobalKey)
|
window.addEventListener('keydown', handleGlobalKey)
|
||||||
nextTickWrapper()
|
nextTickWrapper()
|
||||||
@@ -294,4 +300,9 @@ $accent-color: hsl(320, 100%, 50%);
|
|||||||
box-shadow: 0 0 0 1px $error-color !important;
|
box-shadow: 0 0 0 1px $error-color !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
v-sheet,
|
||||||
|
v-card-text {
|
||||||
|
transition: background-color 0.4s ease, color 0.4s ease;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
v-container.fill-height.d-flex.justify-center.align-center
|
v-container.fill-height.d-flex.justify-center.align-center.position-relative
|
||||||
|
v-btn.position-absolute.top-0.right-0.ma-4(
|
||||||
|
color="error"
|
||||||
|
variant="outlined"
|
||||||
|
@click="logoutHandler"
|
||||||
|
) Logout
|
||||||
v-card.pa-8.pb-6(
|
v-card.pa-8.pb-6(
|
||||||
elevation="12"
|
elevation="12"
|
||||||
rounded="xl"
|
rounded="xl"
|
||||||
@@ -12,19 +17,21 @@ v-container.fill-height.d-flex.justify-center.align-center
|
|||||||
v-card-text
|
v-card-text
|
||||||
v-row
|
v-row
|
||||||
v-col(cols="12" sm="3")
|
v-col(cols="12" sm="3")
|
||||||
v-btn.py-6(
|
v-btn.py-6(
|
||||||
color="var(--color-all)"
|
color="var(--color-all)"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
block
|
block
|
||||||
@click="_startTraining('all')"
|
:disabled="!hasSelectedOptions"
|
||||||
)
|
@click="_startTraining('all')"
|
||||||
span.font-weight-bold All
|
)
|
||||||
|
span.font-weight-bold All
|
||||||
|
|
||||||
v-col(cols="12" sm="3")
|
v-col(cols="12" sm="3")
|
||||||
v-btn.py-6(
|
v-btn.py-6(
|
||||||
color="var(--color-kanji)"
|
color="var(--color-kanji)"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
block
|
block
|
||||||
|
:disabled="!hasSelectedOptions"
|
||||||
@click="_startTraining('kanji')"
|
@click="_startTraining('kanji')"
|
||||||
)
|
)
|
||||||
span.font-weight-bold Kanji
|
span.font-weight-bold Kanji
|
||||||
@@ -34,14 +41,17 @@ v-container.fill-height.d-flex.justify-center.align-center
|
|||||||
color="var(--color-vocab)"
|
color="var(--color-vocab)"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
block
|
block
|
||||||
|
:disabled="!hasSelectedOptions"
|
||||||
@click="_startTraining('vocab')"
|
@click="_startTraining('vocab')"
|
||||||
)
|
)
|
||||||
span.font-weight-bold Vocabulary
|
span.font-weight-bold Vocabulary
|
||||||
|
|
||||||
v-col(cols="12" sm="3")
|
v-col(cols="12" sm="3")
|
||||||
v-btn.py-6(
|
v-btn.py-6(
|
||||||
color="var(--color-writing)"
|
color="var(--color-writing)"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
block
|
block
|
||||||
|
:disabled="!hasSelectedOptions"
|
||||||
@click="_startTraining('writing')"
|
@click="_startTraining('writing')"
|
||||||
)
|
)
|
||||||
span.font-weight-bold Writing
|
span.font-weight-bold Writing
|
||||||
@@ -60,7 +70,7 @@ v-container.fill-height.d-flex.justify-center.align-center
|
|||||||
v-card.elevation-3.rounded-lg.text-center.pa-4.d-flex.flex-column.align-center.justify-center(
|
v-card.elevation-3.rounded-lg.text-center.pa-4.d-flex.flex-column.align-center.justify-center(
|
||||||
color="var(--color-kanji)"
|
color="var(--color-kanji)"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
height="120px"
|
height="100px"
|
||||||
)
|
)
|
||||||
v-icon.size-36.mb-2 mdi-book-open-variant
|
v-icon.size-36.mb-2 mdi-book-open-variant
|
||||||
.text-subtitle-2 Kanji
|
.text-subtitle-2 Kanji
|
||||||
@@ -69,7 +79,7 @@ v-container.fill-height.d-flex.justify-center.align-center
|
|||||||
v-card.elevation-3.rounded-lg.text-center.pa-4.d-flex.flex-column.align-center.justify-center(
|
v-card.elevation-3.rounded-lg.text-center.pa-4.d-flex.flex-column.align-center.justify-center(
|
||||||
color="var(--color-vocab)"
|
color="var(--color-vocab)"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
height="120px"
|
height="100px"
|
||||||
)
|
)
|
||||||
v-icon.size-36.mb-2 mdi-translate
|
v-icon.size-36.mb-2 mdi-translate
|
||||||
.text-subtitle-3 Vocabulary
|
.text-subtitle-3 Vocabulary
|
||||||
@@ -134,12 +144,10 @@ v-container.fill-height.d-flex.justify-center.align-center
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { Reviews } from '../composables/subject.ts'
|
|
||||||
|
|
||||||
const reviews = ref<Reviews | null>()
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
const apiKey = ref<string>('')
|
const apiKey = ref<string>('')
|
||||||
const direction = ref<'en->jp' | 'jp->en' | 'both'>('jp->en')
|
const direction = ref<'en->jp' | 'jp->en' | 'both'>('jp->en')
|
||||||
const stats = ref<{ kanjiCount: number, vocabCount: number }>({
|
const stats = ref<{ kanjiCount: number, vocabCount: number }>({
|
||||||
@@ -148,17 +156,32 @@ const stats = ref<{ kanjiCount: number, vocabCount: number }>({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const options = ref({
|
const options = ref({
|
||||||
meaning: true,
|
|
||||||
writing: true,
|
writing: true,
|
||||||
|
meaning: false,
|
||||||
kanji: false,
|
kanji: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const hasSelectedOptions = computed(() => {
|
||||||
|
return options.value.writing || options.value.meaning
|
||||||
|
})
|
||||||
|
|
||||||
|
async function logoutHandler() {
|
||||||
|
await auth.logout()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
|
||||||
function _startTraining(type: 'kanji' | 'vocab' | 'all' | 'writing') {
|
function _startTraining(type: 'kanji' | 'vocab' | 'all' | 'writing') {
|
||||||
router.push({
|
router.push({
|
||||||
path: '/' + type,
|
path: type === 'writing' ? '/writing' : 'trainer',
|
||||||
query: {
|
query: {
|
||||||
direction: direction.value,
|
direction: direction.value,
|
||||||
options: JSON.stringify(options.value),
|
options: JSON.stringify({
|
||||||
|
direction: direction.value,
|
||||||
|
type: type,
|
||||||
|
writing: options.value.writing,
|
||||||
|
meaning: options.value.meaning,
|
||||||
|
kanji: options.value.kanji,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -204,19 +227,24 @@ async function deleteApiKey() {
|
|||||||
async function syncWanikani() {
|
async function syncWanikani() {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/v1/wanikani/sync')
|
await fetch('/api/v1/wanikani/sync')
|
||||||
|
await updateStats()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error syncing Wanikani data:', error)
|
console.error('Error syncing Wanikani data:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateStats() {
|
import { getStatistics } from '@/composables/srs.scoring'
|
||||||
if (!reviews.value) return
|
|
||||||
stats.value = reviews.value.getStats()
|
async function updateStats() {
|
||||||
|
const statsTemp = await getStatistics()
|
||||||
|
console.log(statsTemp)
|
||||||
|
if (!stats) return
|
||||||
|
stats.value.kanjiCount = statsTemp.totalKanji || 0
|
||||||
|
stats.value.vocabCount = statsTemp.totalVocab || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
reviews.value = await Reviews.getInstance()
|
|
||||||
getApiKey()
|
getApiKey()
|
||||||
updateStats()
|
await updateStats()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,297 +0,0 @@
|
|||||||
<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>
|
|
||||||
62
client/src/pages/login.vue
Normal file
62
client/src/pages/login.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<template lang="pug">
|
||||||
|
v-container.fill-height.d-flex.justify-center.align-center
|
||||||
|
v-card.elevation-12.rounded-lg.pa-8(style="max-width: 400px; width: 100%;")
|
||||||
|
v-card-title.text-h4.text-center.font-weight-bold.mb-6 Login
|
||||||
|
v-card-text
|
||||||
|
v-form
|
||||||
|
v-text-field(
|
||||||
|
label="Username"
|
||||||
|
prepend-inner-icon="mdi-account"
|
||||||
|
v-model="username"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
required
|
||||||
|
)
|
||||||
|
v-text-field(
|
||||||
|
label="Password"
|
||||||
|
prepend-inner-icon="mdi-lock"
|
||||||
|
type="password"
|
||||||
|
v-model="password"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
required
|
||||||
|
)
|
||||||
|
v-checkbox(
|
||||||
|
label="Remember me"
|
||||||
|
class="my-4"
|
||||||
|
v-model="rememberMe"
|
||||||
|
)
|
||||||
|
v-btn(
|
||||||
|
color="primary"
|
||||||
|
large
|
||||||
|
block
|
||||||
|
@click="loginHandler"
|
||||||
|
) Login
|
||||||
|
v-card-actions.justify-center
|
||||||
|
span.text-caption Don't have an account?
|
||||||
|
v-btn.text.small( color="primary") Sign Up
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const rememberMe = ref(false)
|
||||||
|
|
||||||
|
async function loginHandler() {
|
||||||
|
const success = await auth.login(username.value, password.value, rememberMe.value)
|
||||||
|
if (success) router.push('/')
|
||||||
|
else console.error(auth.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
auth.fetchUser()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
312
client/src/pages/trainer.vue
Normal file
312
client/src/pages/trainer.vue
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
<template lang="pug">
|
||||||
|
v-container.fill-height.d-flex.justify-center.align-center
|
||||||
|
v-card.pa-12.rounded-lg.elevation-12
|
||||||
|
template(v-if="!allDone")
|
||||||
|
v-card-title.text-center.text-h2.font-weight-bold.mb-4
|
||||||
|
span.text-primary {{ trainerTitle }}
|
||||||
|
|
||||||
|
v-sheet.d-flex.justify-center.align-center.rounded-lg.py-8.px-4.elevation-12(
|
||||||
|
:style="{ backgroundColor: subjectColor, fontSize: '5rem', fontWeight: 'bold', whiteSpace: 'nowrap' }"
|
||||||
|
)
|
||||||
|
span {{ character }}
|
||||||
|
|
||||||
|
v-card-text.text-center.my-4.rounded-lg(
|
||||||
|
:style="{ backgroundColor: subjectColor + '20', fontSize: '1.1rem', fontWeight: '500', color: subjectColor }"
|
||||||
|
)
|
||||||
|
| {{ inputMode.charAt(0).toUpperCase() + inputMode.slice(1) }}
|
||||||
|
|
||||||
|
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(v-else)
|
||||||
|
v-card-title.text-center.text-h2.font-weight-bold.mb-4
|
||||||
|
span.text-primary 🎉 Congratulations!
|
||||||
|
v-card-text.text-center
|
||||||
|
| You have completed all reviews for today!
|
||||||
|
v-row.justify-center.mt-6
|
||||||
|
v-col(cols="6")
|
||||||
|
v-btn(
|
||||||
|
color="primary"
|
||||||
|
block
|
||||||
|
@click="quitSession"
|
||||||
|
) Back to Home
|
||||||
|
</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 { nextSubject, correct, incorrect } from '../composables/srs.scoring.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 subject = ref<any>(null)
|
||||||
|
const allDone = ref(false)
|
||||||
|
|
||||||
|
const options = computed(() => JSON.parse(route.query.options as string))
|
||||||
|
|
||||||
|
const optionsCalculated = ref({
|
||||||
|
subject: options.value.type === 'writing' ? 'kanji' : options.value.type === 'all' ? ['kanji', 'vocab'] : options.value.type,
|
||||||
|
mode: options.value.writing && options.value.meaning ? ['reading', 'meaning'] : options.value.writing ? 'reading' : options.value.meaning ? 'meaning' : 'reading',
|
||||||
|
writingPractice: options.value.type === 'writing',
|
||||||
|
direction: options.value.direction,
|
||||||
|
writingMode: options.value.kanji ? 'kanji' : 'kana',
|
||||||
|
})
|
||||||
|
|
||||||
|
const trainerTitle = computed(() => {
|
||||||
|
if (!subject.value) return 'Trainer'
|
||||||
|
switch (subject.value.type) {
|
||||||
|
case 'kanji':
|
||||||
|
return 'Kanji Trainer'
|
||||||
|
case 'vocab':
|
||||||
|
return 'Vocabulary Trainer'
|
||||||
|
default:
|
||||||
|
return 'Full Trainer'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const subjectColor = computed(() => {
|
||||||
|
if (!subject.value) return 'var(--color-all)'
|
||||||
|
switch (subject.value.type) {
|
||||||
|
case 'kanji':
|
||||||
|
return 'var(--color-kanji)'
|
||||||
|
case 'vocab':
|
||||||
|
return 'var(--color-vocab)'
|
||||||
|
default:
|
||||||
|
return 'var(--color-all)'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function createReview() {
|
||||||
|
const next = await nextSubject(optionsCalculated.value)
|
||||||
|
if (!next) {
|
||||||
|
allDone.value = true
|
||||||
|
character.value = ''
|
||||||
|
resultMessage.value = '✅ All done!'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
allDone.value = false
|
||||||
|
subject.value = next
|
||||||
|
|
||||||
|
if (optionsCalculated.value.direction === 'en->jp' && optionsCalculated.value.writingMode === 'kanji')
|
||||||
|
inputMode.value = 'kanji'
|
||||||
|
else if (subject.value.mode === 'jp_en_reading' || subject.value.mode === 'en_jp_writing')
|
||||||
|
inputMode.value = 'writing'
|
||||||
|
else
|
||||||
|
inputMode.value = 'meaning'
|
||||||
|
|
||||||
|
character.value = subject.value.subject
|
||||||
|
isDisabled.value = false
|
||||||
|
resultMessage.value = ''
|
||||||
|
isCorrect.value = false
|
||||||
|
nextTickWrapper()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAnswer() {
|
||||||
|
if (!answerInput.value || !subject.value) return
|
||||||
|
|
||||||
|
if (isDisabled.value) {
|
||||||
|
createReview()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAnswer = answerInput.value.value.trim()
|
||||||
|
isCorrect.value = subject.value.answers.some(a => a.trim().toLowerCase() === userAnswer)
|
||||||
|
|
||||||
|
resultMessage.value = isCorrect.value ? 'Correct: ' : 'Wrong: '
|
||||||
|
resultMessage.value += subject.value.answers.join(', ')
|
||||||
|
|
||||||
|
if (isCorrect.value) {
|
||||||
|
await correct()
|
||||||
|
} else {
|
||||||
|
await incorrect()
|
||||||
|
}
|
||||||
|
|
||||||
|
answerInput.value.value = ''
|
||||||
|
isDisabled.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function quitSession() {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGlobalKey(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
!isDisabled.value ? submitAnswer() : createReview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
if (inputMode.value === 'writing' || inputMode.value === 'kanji') bindWanakana(answerInput.value)
|
||||||
|
else unbindWanakana(answerInput.value)
|
||||||
|
answerInput.value.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
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>
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,80 +1,106 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
v-container.fill-height.d-flex.justify-center.align-center
|
v-container.fill-height.d-flex.justify-center.align-center
|
||||||
v-card.pa-12.rounded-lg.elevation-12
|
v-card.pa-12.rounded-lg.elevation-12
|
||||||
v-card-title.text-center.text-h2.font-weight-bold.mb-4
|
template(v-if="!allDone")
|
||||||
span.text-primary Handwriting Trainer
|
v-card-title.text-center.text-h2.font-weight-bold.mb-4
|
||||||
|
span.text-primary Handwriting Trainer
|
||||||
|
|
||||||
v-row.justify-center.mb-6
|
v-row.justify-center.mb-6
|
||||||
v-col(cols="12" class="text-center")
|
v-col(cols="12" class="text-center")
|
||||||
.text-h4.text-white.font-weight-bold
|
.text-h4.text-white.font-weight-bold
|
||||||
| Draw the kanji for: {{ subject?.meanings?.find(m => m.primary)?.meaning || subject?.readings?.[0]?.reading || '?' }}
|
| Draw the kanji for: {{ subject?.subject || '?' }}
|
||||||
|
|
||||||
v-row.justify-center
|
v-row.justify-center
|
||||||
v-sheet.elevation-8.justify-center(
|
v-sheet.elevation-8.justify-center(
|
||||||
rounded="xl"
|
rounded="xl"
|
||||||
width="280px"
|
width="280px"
|
||||||
height="280px"
|
height="280px"
|
||||||
)
|
)
|
||||||
canvas#kanjiCanvas(
|
canvas#kanjiCanvas(
|
||||||
width="280"
|
width="280"
|
||||||
height="280"
|
height="280"
|
||||||
:style="{borderRadius: '12px', backgroundColor: '#313131'}"
|
:style="{borderRadius: '12px', backgroundColor: '#313131'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
v-row.justify-center.my-6
|
v-row.justify-center.my-6
|
||||||
v-col(cols="12" class="text-center")
|
v-col(cols="12" class="text-center")
|
||||||
.text-h5.text-white Select the kanji you drew:
|
.text-h5.text-white Select the kanji you drew:
|
||||||
v-btn-group
|
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(v-else)
|
||||||
|
v-card-title.text-center.text-h2.font-weight-bold.mb-4
|
||||||
|
span.text-primary 🎉 Congratulations!
|
||||||
|
v-card-text.text-center
|
||||||
|
| You have completed all handwriting reviews for today!
|
||||||
|
v-row.justify-center.mt-6
|
||||||
|
v-col(cols="6")
|
||||||
v-btn(
|
v-btn(
|
||||||
v-for="kanji in candidates"
|
color="primary"
|
||||||
:key="kanji"
|
block
|
||||||
color="var(--color-writing)"
|
@click="quitSession"
|
||||||
class="mx-1 my-1"
|
) Back to Home
|
||||||
@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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Reviews } from '../composables/subject.ts'
|
import { nextSubject, correct, incorrect } from '../composables/srs.scoring.ts'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const canvasId = 'kanjiCanvas'
|
const canvasId = 'kanjiCanvas'
|
||||||
const reviews = ref<Reviews | null>(null)
|
|
||||||
const subject = ref<any>(null)
|
const subject = ref<any>(null)
|
||||||
const candidates = ref<string[]>([])
|
const candidates = ref<string[]>([])
|
||||||
const recognizedKanji = ref('')
|
const recognizedKanji = ref('')
|
||||||
const isCorrect = ref(false)
|
const isCorrect = ref(false)
|
||||||
const resultMessage = ref('')
|
const resultMessage = ref('')
|
||||||
const isDisabled = ref(false)
|
const isDisabled = ref(false)
|
||||||
|
const allDone = ref(false)
|
||||||
|
|
||||||
async function createReview() {
|
async function createReview() {
|
||||||
if (!reviews.value) return
|
const options = {
|
||||||
const next = reviews.value.nextSubject()
|
subject: ['kanji'],
|
||||||
if (!next) return
|
mode: ['meaning'], // request the meaning since we draw the kanji
|
||||||
subject.value = next.subject
|
direction: 'en->jp',
|
||||||
|
writingPractice: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = await nextSubject(options)
|
||||||
|
if (!next) {
|
||||||
|
allDone.value = true
|
||||||
|
resultMessage.value = '✅ All handwriting reviews done!'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
subject.value = next
|
||||||
candidates.value = []
|
candidates.value = []
|
||||||
recognizedKanji.value = ''
|
recognizedKanji.value = ''
|
||||||
isDisabled.value = false
|
isDisabled.value = false
|
||||||
@@ -95,12 +121,14 @@ function recognizeKanji() {
|
|||||||
recognizedKanji.value = candidates.value.join(', ')
|
recognizedKanji.value = candidates.value.join(', ')
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectCandidate(kanji: string) {
|
async function selectCandidate(kanji: string) {
|
||||||
if (!subject.value) return
|
if (!subject.value) return
|
||||||
|
isCorrect.value = kanji === subject.value.answers[0]
|
||||||
isCorrect.value = kanji === subject.value.characters
|
resultMessage.value = isCorrect.value ? 'Correct!' : `Wrong! Answer: ${subject.value.answers[0]}`
|
||||||
resultMessage.value = isCorrect.value ? 'Correct!' : `Wrong! Answer: ${subject.value.characters}`
|
|
||||||
isDisabled.value = true
|
isDisabled.value = true
|
||||||
|
|
||||||
|
if (isCorrect.value) await correct()
|
||||||
|
else await incorrect()
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearCanvas() {
|
function clearCanvas() {
|
||||||
@@ -113,16 +141,11 @@ function clearCanvas() {
|
|||||||
|
|
||||||
function quitSession() { router.push('/') }
|
function quitSession() { router.push('/') }
|
||||||
function skipItem() { createReview() }
|
function skipItem() { createReview() }
|
||||||
|
function nextItem() { createReview() }
|
||||||
function nextItem() {
|
|
||||||
createReview()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
reviews.value = await Reviews.getInstance()
|
|
||||||
await reviews.value.setOptions({ type: 'kanji', mode: 'writing' })
|
|
||||||
createReview()
|
|
||||||
initCanvas()
|
initCanvas()
|
||||||
|
createReview()
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import { setupLayouts } from 'virtual:generated-layouts'
|
import { setupLayouts } from 'virtual:generated-layouts'
|
||||||
import { routes } from 'vue-router/auto-routes'
|
import { routes } from 'vue-router/auto-routes'
|
||||||
|
import { useAuthStore } from '../stores/auth.ts'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
@@ -33,4 +34,24 @@ router.isReady().then(() => {
|
|||||||
localStorage.removeItem('vuetify:dynamic-reload')
|
localStorage.removeItem('vuetify:dynamic-reload')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let hasFetchedUser = false
|
||||||
|
|
||||||
|
router.beforeEach(async (to) => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
if (to.path === '/login') return true
|
||||||
|
|
||||||
|
if (!hasFetchedUser && !auth.user && !auth.loading) {
|
||||||
|
hasFetchedUser = true
|
||||||
|
await auth.fetchUser()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!auth.isAuthenticated) {
|
||||||
|
return { path: '/login', query: { redirect: to.fullPath } }
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
168
client/src/stores/auth.ts
Normal file
168
client/src/stores/auth.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
email?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
let refreshInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the current logged-in user from the server.
|
||||||
|
* Tries refresh if access token expired.
|
||||||
|
*/
|
||||||
|
async function fetchUser() {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const res = await fetch('/api/v1/user/info', {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
user.value = data.user
|
||||||
|
error.value = null
|
||||||
|
startAutoRefresh()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
const refreshed = await refreshToken()
|
||||||
|
if (refreshed) return await fetchUser()
|
||||||
|
user.value = null
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to fetch user')
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.warn('fetchUser failed:', err)
|
||||||
|
error.value = err.message
|
||||||
|
user.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform login and set cookies.
|
||||||
|
*/
|
||||||
|
async function login(username: string, password: string, remember: boolean) {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
const res = await fetch('/api/v1/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ username, password, remember }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error('Invalid credentials')
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (data.ok && data.user) {
|
||||||
|
user.value = data.user
|
||||||
|
startAutoRefresh()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Login failed')
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the access token using refresh cookie.
|
||||||
|
*/
|
||||||
|
async function refreshToken() {
|
||||||
|
if (!document.cookie.includes('refresh_token')) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
console.info('[Auth] Token refreshed')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('[Auth] Token refresh failed with status', res.status)
|
||||||
|
return false
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Auth] Refresh error', err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatically refresh tokens before expiry.
|
||||||
|
*/
|
||||||
|
function startAutoRefresh() {
|
||||||
|
if (refreshInterval) clearInterval(refreshInterval)
|
||||||
|
|
||||||
|
refreshInterval = setInterval(async () => {
|
||||||
|
if (!user.value) return
|
||||||
|
const success = await refreshToken()
|
||||||
|
if (!success) {
|
||||||
|
console.warn('[Auth] Auto-refresh failed, trying fetchUser')
|
||||||
|
const ok = await fetchUser()
|
||||||
|
if (!ok) {
|
||||||
|
console.warn('[Auth] Session expired, logging out')
|
||||||
|
await logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 7.5 * 60 * 1000)
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', async () => {
|
||||||
|
if (document.visibilityState === 'visible' && user.value) {
|
||||||
|
const success = await refreshToken()
|
||||||
|
if (!success) await logout()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the refresh timer and logout from backend.
|
||||||
|
*/
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await fetch('/api/v1/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Logout error:', err)
|
||||||
|
} finally {
|
||||||
|
user.value = null
|
||||||
|
if (refreshInterval) clearInterval(refreshInterval)
|
||||||
|
refreshInterval = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthenticated = computed(() => !!user.value)
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
isAuthenticated,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
fetchUser,
|
||||||
|
refreshToken,
|
||||||
|
}
|
||||||
|
})
|
||||||
12
client/src/typed-router.d.ts
vendored
12
client/src/typed-router.d.ts
vendored
@@ -20,8 +20,8 @@ declare module 'vue-router/auto-routes' {
|
|||||||
export interface RouteNamedMap {
|
export interface RouteNamedMap {
|
||||||
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
|
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
|
||||||
'/all': RouteRecordInfo<'/all', '/all', 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>>,
|
'/login': RouteRecordInfo<'/login', '/login', Record<never, never>, Record<never, never>>,
|
||||||
'/vocab': RouteRecordInfo<'/vocab', '/vocab', Record<never, never>, Record<never, never>>,
|
'/trainer': RouteRecordInfo<'/trainer', '/trainer', Record<never, never>, Record<never, never>>,
|
||||||
'/writing': RouteRecordInfo<'/writing', '/writing', Record<never, never>, Record<never, never>>,
|
'/writing': RouteRecordInfo<'/writing', '/writing', Record<never, never>, Record<never, never>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,12 +44,12 @@ declare module 'vue-router/auto-routes' {
|
|||||||
routes: '/all'
|
routes: '/all'
|
||||||
views: never
|
views: never
|
||||||
}
|
}
|
||||||
'src/pages/kanji.vue': {
|
'src/pages/login.vue': {
|
||||||
routes: '/kanji'
|
routes: '/login'
|
||||||
views: never
|
views: never
|
||||||
}
|
}
|
||||||
'src/pages/vocab.vue': {
|
'src/pages/trainer.vue': {
|
||||||
routes: '/vocab'
|
routes: '/trainer'
|
||||||
views: never
|
views: never
|
||||||
}
|
}
|
||||||
'src/pages/writing.vue': {
|
'src/pages/writing.vue': {
|
||||||
|
|||||||
@@ -86,10 +86,13 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://srs-server:3000',
|
target: process.env.NODE_ENV === 'DEV'
|
||||||
|
? 'http://srs-server:3000'
|
||||||
|
: 'http://192.168.0.26:3000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
allowedHosts: ['srs.crylia.de', 'localhost']
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ services:
|
|||||||
- ./server:/app
|
- ./server:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=dev
|
- NODE_ENV=DEV
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
command: pnpm run dev
|
command: pnpm run dev
|
||||||
networks:
|
networks:
|
||||||
- srs-app-net
|
- srs-app-net
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
client:
|
client:
|
||||||
build:
|
build:
|
||||||
@@ -29,7 +31,7 @@ services:
|
|||||||
- ./client:/app
|
- ./client:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=dev
|
- NODE_ENV=DEV
|
||||||
- VITE_APP_URL=http://srs-server:3000
|
- VITE_APP_URL=http://srs-server:3000
|
||||||
command: pnpm run dev
|
command: pnpm run dev
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"dependencies": {
|
|
||||||
"wanakana": "^5.3.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
23
pnpm-lock.yaml
generated
23
pnpm-lock.yaml
generated
@@ -1,23 +0,0 @@
|
|||||||
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: {}
|
|
||||||
27
server/.dockerignore
Normal file
27
server/.dockerignore
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Node modules
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Local dev / editor
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.vite
|
||||||
|
|
||||||
|
# TypeScript build info
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Environment files (if you want them injected via Docker ENV)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
@@ -8,9 +8,17 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.1.0",
|
"@fastify/cors": "^11.1.0",
|
||||||
"@fastify/static": "^8.2.0",
|
"@fastify/static": "^8.2.0",
|
||||||
|
"@types/cookie-parser": "^1.4.9",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"express-rate-limit": "^8.1.0",
|
||||||
"fastify": "^5.6.1",
|
"fastify": "^5.6.1",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"ldapts": "^8.0.9",
|
||||||
"mongodb": "^6.20.0",
|
"mongodb": "^6.20.0",
|
||||||
"mongoose": "^8.19.1",
|
"mongoose": "^8.19.1",
|
||||||
"node": "^24.10.0",
|
"node": "^24.10.0",
|
||||||
|
|||||||
223
server/pnpm-lock.yaml
generated
223
server/pnpm-lock.yaml
generated
@@ -14,15 +14,39 @@ importers:
|
|||||||
'@fastify/static':
|
'@fastify/static':
|
||||||
specifier: ^8.2.0
|
specifier: ^8.2.0
|
||||||
version: 8.2.0
|
version: 8.2.0
|
||||||
|
'@types/cookie-parser':
|
||||||
|
specifier: ^1.4.9
|
||||||
|
version: 1.4.9(@types/express@5.0.3)
|
||||||
|
'@types/jsonwebtoken':
|
||||||
|
specifier: ^9.0.10
|
||||||
|
version: 9.0.10
|
||||||
|
cookie-parser:
|
||||||
|
specifier: ^1.4.7
|
||||||
|
version: 1.4.7
|
||||||
cors:
|
cors:
|
||||||
specifier: ^2.8.5
|
specifier: ^2.8.5
|
||||||
version: 2.8.5
|
version: 2.8.5
|
||||||
|
dotenv:
|
||||||
|
specifier: ^17.2.3
|
||||||
|
version: 17.2.3
|
||||||
express:
|
express:
|
||||||
specifier: ^5.1.0
|
specifier: ^5.1.0
|
||||||
version: 5.1.0
|
version: 5.1.0
|
||||||
|
express-rate-limit:
|
||||||
|
specifier: ^8.1.0
|
||||||
|
version: 8.1.0(express@5.1.0)
|
||||||
fastify:
|
fastify:
|
||||||
specifier: ^5.6.1
|
specifier: ^5.6.1
|
||||||
version: 5.6.1
|
version: 5.6.1
|
||||||
|
helmet:
|
||||||
|
specifier: ^8.1.0
|
||||||
|
version: 8.1.0
|
||||||
|
jsonwebtoken:
|
||||||
|
specifier: ^9.0.2
|
||||||
|
version: 9.0.2
|
||||||
|
ldapts:
|
||||||
|
specifier: ^8.0.9
|
||||||
|
version: 8.0.9
|
||||||
mongodb:
|
mongodb:
|
||||||
specifier: ^6.20.0
|
specifier: ^6.20.0
|
||||||
version: 6.20.0
|
version: 6.20.0
|
||||||
@@ -192,12 +216,20 @@ packages:
|
|||||||
'@tsconfig/node16@1.0.4':
|
'@tsconfig/node16@1.0.4':
|
||||||
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
|
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
|
||||||
|
|
||||||
|
'@types/asn1@0.2.4':
|
||||||
|
resolution: {integrity: sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA==}
|
||||||
|
|
||||||
'@types/body-parser@1.19.6':
|
'@types/body-parser@1.19.6':
|
||||||
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
|
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
|
||||||
|
|
||||||
'@types/connect@3.4.38':
|
'@types/connect@3.4.38':
|
||||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||||
|
|
||||||
|
'@types/cookie-parser@1.4.9':
|
||||||
|
resolution: {integrity: sha512-tGZiZ2Gtc4m3wIdLkZ8mkj1T6CEHb35+VApbL2T14Dew8HA7c+04dmKqsKRNC+8RJPm16JEK0tFSwdZqubfc4g==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/express': '*'
|
||||||
|
|
||||||
'@types/cors@2.8.19':
|
'@types/cors@2.8.19':
|
||||||
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
|
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
|
||||||
|
|
||||||
@@ -216,9 +248,15 @@ packages:
|
|||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
|
'@types/jsonwebtoken@9.0.10':
|
||||||
|
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
|
||||||
|
|
||||||
'@types/mime@1.3.5':
|
'@types/mime@1.3.5':
|
||||||
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
|
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
|
||||||
|
|
||||||
|
'@types/ms@2.1.0':
|
||||||
|
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||||
|
|
||||||
'@types/node@24.7.2':
|
'@types/node@24.7.2':
|
||||||
resolution: {integrity: sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==}
|
resolution: {integrity: sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==}
|
||||||
|
|
||||||
@@ -304,6 +342,9 @@ packages:
|
|||||||
argparse@2.0.1:
|
argparse@2.0.1:
|
||||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||||
|
|
||||||
|
asn1@0.2.6:
|
||||||
|
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
|
||||||
|
|
||||||
atomic-sleep@1.0.0:
|
atomic-sleep@1.0.0:
|
||||||
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||||
engines: {node: '>=8.0.0'}
|
engines: {node: '>=8.0.0'}
|
||||||
@@ -333,6 +374,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==}
|
resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==}
|
||||||
engines: {node: '>=16.20.1'}
|
engines: {node: '>=16.20.1'}
|
||||||
|
|
||||||
|
buffer-equal-constant-time@1.0.1:
|
||||||
|
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||||
|
|
||||||
bytes@3.1.2:
|
bytes@3.1.2:
|
||||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -379,6 +423,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
|
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
cookie-parser@1.4.7:
|
||||||
|
resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==}
|
||||||
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
cookie-signature@1.0.6:
|
||||||
|
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
|
||||||
|
|
||||||
cookie-signature@1.2.2:
|
cookie-signature@1.2.2:
|
||||||
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
|
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
|
||||||
engines: {node: '>=6.6.0'}
|
engines: {node: '>=6.6.0'}
|
||||||
@@ -406,6 +457,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
|
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
|
||||||
engines: {node: '>= 12'}
|
engines: {node: '>= 12'}
|
||||||
|
|
||||||
|
debug@4.4.1:
|
||||||
|
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
|
||||||
|
engines: {node: '>=6.0'}
|
||||||
|
peerDependencies:
|
||||||
|
supports-color: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
supports-color:
|
||||||
|
optional: true
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -430,6 +490,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
|
resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
|
||||||
engines: {node: '>=0.3.1'}
|
engines: {node: '>=0.3.1'}
|
||||||
|
|
||||||
|
dotenv@17.2.3:
|
||||||
|
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -437,6 +501,9 @@ packages:
|
|||||||
eastasianwidth@0.2.0:
|
eastasianwidth@0.2.0:
|
||||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||||
|
|
||||||
ee-first@1.1.1:
|
ee-first@1.1.1:
|
||||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||||
|
|
||||||
@@ -515,6 +582,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
express-rate-limit@8.1.0:
|
||||||
|
resolution: {integrity: sha512-4nLnATuKupnmwqiJc27b4dCFmB/T60ExgmtDD7waf4LdrbJ8CPZzZRHYErDYNhoz+ql8fUdYwM/opf90PoPAQA==}
|
||||||
|
engines: {node: '>= 16'}
|
||||||
|
peerDependencies:
|
||||||
|
express: '>= 4.11'
|
||||||
|
|
||||||
express@5.1.0:
|
express@5.1.0:
|
||||||
resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==}
|
resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
@@ -649,6 +722,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
helmet@8.1.0:
|
||||||
|
resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
http-errors@2.0.0:
|
http-errors@2.0.0:
|
||||||
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
|
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -679,6 +756,10 @@ packages:
|
|||||||
inherits@2.0.4:
|
inherits@2.0.4:
|
||||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||||
|
|
||||||
|
ip-address@10.0.1:
|
||||||
|
resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==}
|
||||||
|
engines: {node: '>= 12'}
|
||||||
|
|
||||||
ipaddr.js@1.9.1:
|
ipaddr.js@1.9.1:
|
||||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
@@ -736,6 +817,16 @@ packages:
|
|||||||
json-stable-stringify-without-jsonify@1.0.1:
|
json-stable-stringify-without-jsonify@1.0.1:
|
||||||
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
|
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
|
||||||
|
|
||||||
|
jsonwebtoken@9.0.2:
|
||||||
|
resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
|
||||||
|
engines: {node: '>=12', npm: '>=6'}
|
||||||
|
|
||||||
|
jwa@1.4.2:
|
||||||
|
resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==}
|
||||||
|
|
||||||
|
jws@3.2.2:
|
||||||
|
resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
|
||||||
|
|
||||||
kareem@2.6.3:
|
kareem@2.6.3:
|
||||||
resolution: {integrity: sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==}
|
resolution: {integrity: sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -743,6 +834,10 @@ packages:
|
|||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
|
ldapts@8.0.9:
|
||||||
|
resolution: {integrity: sha512-6UwfVFUX0Yp5XFY8ST0p9sytpmHGNm32GehI/dq4HuA3pL5kh0AceHBSfowv+cutIJFQnfBZmBo/6cnj87JDqA==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
levn@0.4.1:
|
levn@0.4.1:
|
||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -754,9 +849,30 @@ packages:
|
|||||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
lodash.includes@4.3.0:
|
||||||
|
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||||
|
|
||||||
|
lodash.isboolean@3.0.3:
|
||||||
|
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
|
||||||
|
|
||||||
|
lodash.isinteger@4.0.4:
|
||||||
|
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
|
||||||
|
|
||||||
|
lodash.isnumber@3.0.3:
|
||||||
|
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
|
||||||
|
|
||||||
|
lodash.isplainobject@4.0.6:
|
||||||
|
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||||
|
|
||||||
|
lodash.isstring@4.0.1:
|
||||||
|
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
|
||||||
|
|
||||||
lodash.merge@4.6.2:
|
lodash.merge@4.6.2:
|
||||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||||
|
|
||||||
|
lodash.once@4.1.1:
|
||||||
|
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||||
|
|
||||||
lru-cache@11.2.2:
|
lru-cache@11.2.2:
|
||||||
resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==}
|
resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
@@ -1111,6 +1227,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
|
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
strict-event-emitter-types@2.0.0:
|
||||||
|
resolution: {integrity: sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==}
|
||||||
|
|
||||||
string-width@4.2.3:
|
string-width@4.2.3:
|
||||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1202,6 +1321,10 @@ packages:
|
|||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||||
|
|
||||||
|
uuid@11.1.0:
|
||||||
|
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
v8-compile-cache-lib@3.0.1:
|
v8-compile-cache-lib@3.0.1:
|
||||||
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
|
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
|
||||||
|
|
||||||
@@ -1397,6 +1520,10 @@ snapshots:
|
|||||||
|
|
||||||
'@tsconfig/node16@1.0.4': {}
|
'@tsconfig/node16@1.0.4': {}
|
||||||
|
|
||||||
|
'@types/asn1@0.2.4':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 24.7.2
|
||||||
|
|
||||||
'@types/body-parser@1.19.6':
|
'@types/body-parser@1.19.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/connect': 3.4.38
|
'@types/connect': 3.4.38
|
||||||
@@ -1406,6 +1533,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.7.2
|
'@types/node': 24.7.2
|
||||||
|
|
||||||
|
'@types/cookie-parser@1.4.9(@types/express@5.0.3)':
|
||||||
|
dependencies:
|
||||||
|
'@types/express': 5.0.3
|
||||||
|
|
||||||
'@types/cors@2.8.19':
|
'@types/cors@2.8.19':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.7.2
|
'@types/node': 24.7.2
|
||||||
@@ -1429,8 +1560,15 @@ snapshots:
|
|||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
|
'@types/jsonwebtoken@9.0.10':
|
||||||
|
dependencies:
|
||||||
|
'@types/ms': 2.1.0
|
||||||
|
'@types/node': 24.7.2
|
||||||
|
|
||||||
'@types/mime@1.3.5': {}
|
'@types/mime@1.3.5': {}
|
||||||
|
|
||||||
|
'@types/ms@2.1.0': {}
|
||||||
|
|
||||||
'@types/node@24.7.2':
|
'@types/node@24.7.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.14.0
|
undici-types: 7.14.0
|
||||||
@@ -1514,6 +1652,10 @@ snapshots:
|
|||||||
|
|
||||||
argparse@2.0.1: {}
|
argparse@2.0.1: {}
|
||||||
|
|
||||||
|
asn1@0.2.6:
|
||||||
|
dependencies:
|
||||||
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
atomic-sleep@1.0.0: {}
|
atomic-sleep@1.0.0: {}
|
||||||
|
|
||||||
avvio@9.1.0:
|
avvio@9.1.0:
|
||||||
@@ -1550,6 +1692,8 @@ snapshots:
|
|||||||
|
|
||||||
bson@6.10.4: {}
|
bson@6.10.4: {}
|
||||||
|
|
||||||
|
buffer-equal-constant-time@1.0.1: {}
|
||||||
|
|
||||||
bytes@3.1.2: {}
|
bytes@3.1.2: {}
|
||||||
|
|
||||||
call-bind-apply-helpers@1.0.2:
|
call-bind-apply-helpers@1.0.2:
|
||||||
@@ -1599,6 +1743,13 @@ snapshots:
|
|||||||
|
|
||||||
content-type@1.0.5: {}
|
content-type@1.0.5: {}
|
||||||
|
|
||||||
|
cookie-parser@1.4.7:
|
||||||
|
dependencies:
|
||||||
|
cookie: 0.7.2
|
||||||
|
cookie-signature: 1.0.6
|
||||||
|
|
||||||
|
cookie-signature@1.0.6: {}
|
||||||
|
|
||||||
cookie-signature@1.2.2: {}
|
cookie-signature@1.2.2: {}
|
||||||
|
|
||||||
cookie@0.7.2: {}
|
cookie@0.7.2: {}
|
||||||
@@ -1620,6 +1771,10 @@ snapshots:
|
|||||||
|
|
||||||
data-uri-to-buffer@4.0.1: {}
|
data-uri-to-buffer@4.0.1: {}
|
||||||
|
|
||||||
|
debug@4.4.1:
|
||||||
|
dependencies:
|
||||||
|
ms: 2.1.3
|
||||||
|
|
||||||
debug@4.4.3(supports-color@5.5.0):
|
debug@4.4.3(supports-color@5.5.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
@@ -1634,6 +1789,8 @@ snapshots:
|
|||||||
|
|
||||||
diff@4.0.2: {}
|
diff@4.0.2: {}
|
||||||
|
|
||||||
|
dotenv@17.2.3: {}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind-apply-helpers: 1.0.2
|
call-bind-apply-helpers: 1.0.2
|
||||||
@@ -1642,6 +1799,10 @@ snapshots:
|
|||||||
|
|
||||||
eastasianwidth@0.2.0: {}
|
eastasianwidth@0.2.0: {}
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
ee-first@1.1.1: {}
|
ee-first@1.1.1: {}
|
||||||
|
|
||||||
emoji-regex@8.0.0: {}
|
emoji-regex@8.0.0: {}
|
||||||
@@ -1731,6 +1892,11 @@ snapshots:
|
|||||||
|
|
||||||
etag@1.8.1: {}
|
etag@1.8.1: {}
|
||||||
|
|
||||||
|
express-rate-limit@8.1.0(express@5.1.0):
|
||||||
|
dependencies:
|
||||||
|
express: 5.1.0
|
||||||
|
ip-address: 10.0.1
|
||||||
|
|
||||||
express@5.1.0:
|
express@5.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
accepts: 2.0.0
|
accepts: 2.0.0
|
||||||
@@ -1919,6 +2085,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
function-bind: 1.1.2
|
function-bind: 1.1.2
|
||||||
|
|
||||||
|
helmet@8.1.0: {}
|
||||||
|
|
||||||
http-errors@2.0.0:
|
http-errors@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
depd: 2.0.0
|
depd: 2.0.0
|
||||||
@@ -1948,6 +2116,8 @@ snapshots:
|
|||||||
|
|
||||||
inherits@2.0.4: {}
|
inherits@2.0.4: {}
|
||||||
|
|
||||||
|
ip-address@10.0.1: {}
|
||||||
|
|
||||||
ipaddr.js@1.9.1: {}
|
ipaddr.js@1.9.1: {}
|
||||||
|
|
||||||
ipaddr.js@2.2.0: {}
|
ipaddr.js@2.2.0: {}
|
||||||
@@ -1990,12 +2160,47 @@ snapshots:
|
|||||||
|
|
||||||
json-stable-stringify-without-jsonify@1.0.1: {}
|
json-stable-stringify-without-jsonify@1.0.1: {}
|
||||||
|
|
||||||
|
jsonwebtoken@9.0.2:
|
||||||
|
dependencies:
|
||||||
|
jws: 3.2.2
|
||||||
|
lodash.includes: 4.3.0
|
||||||
|
lodash.isboolean: 3.0.3
|
||||||
|
lodash.isinteger: 4.0.4
|
||||||
|
lodash.isnumber: 3.0.3
|
||||||
|
lodash.isplainobject: 4.0.6
|
||||||
|
lodash.isstring: 4.0.1
|
||||||
|
lodash.once: 4.1.1
|
||||||
|
ms: 2.1.3
|
||||||
|
semver: 7.7.3
|
||||||
|
|
||||||
|
jwa@1.4.2:
|
||||||
|
dependencies:
|
||||||
|
buffer-equal-constant-time: 1.0.1
|
||||||
|
ecdsa-sig-formatter: 1.0.11
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
|
jws@3.2.2:
|
||||||
|
dependencies:
|
||||||
|
jwa: 1.4.2
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
kareem@2.6.3: {}
|
kareem@2.6.3: {}
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
|
|
||||||
|
ldapts@8.0.9:
|
||||||
|
dependencies:
|
||||||
|
'@types/asn1': 0.2.4
|
||||||
|
asn1: 0.2.6
|
||||||
|
debug: 4.4.1
|
||||||
|
strict-event-emitter-types: 2.0.0
|
||||||
|
uuid: 11.1.0
|
||||||
|
whatwg-url: 14.2.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
levn@0.4.1:
|
levn@0.4.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
@@ -2011,8 +2216,22 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-locate: 5.0.0
|
p-locate: 5.0.0
|
||||||
|
|
||||||
|
lodash.includes@4.3.0: {}
|
||||||
|
|
||||||
|
lodash.isboolean@3.0.3: {}
|
||||||
|
|
||||||
|
lodash.isinteger@4.0.4: {}
|
||||||
|
|
||||||
|
lodash.isnumber@3.0.3: {}
|
||||||
|
|
||||||
|
lodash.isplainobject@4.0.6: {}
|
||||||
|
|
||||||
|
lodash.isstring@4.0.1: {}
|
||||||
|
|
||||||
lodash.merge@4.6.2: {}
|
lodash.merge@4.6.2: {}
|
||||||
|
|
||||||
|
lodash.once@4.1.1: {}
|
||||||
|
|
||||||
lru-cache@11.2.2: {}
|
lru-cache@11.2.2: {}
|
||||||
|
|
||||||
make-error@1.3.6: {}
|
make-error@1.3.6: {}
|
||||||
@@ -2345,6 +2564,8 @@ snapshots:
|
|||||||
|
|
||||||
statuses@2.0.1: {}
|
statuses@2.0.1: {}
|
||||||
|
|
||||||
|
strict-event-emitter-types@2.0.0: {}
|
||||||
|
|
||||||
string-width@4.2.3:
|
string-width@4.2.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
emoji-regex: 8.0.0
|
emoji-regex: 8.0.0
|
||||||
@@ -2433,6 +2654,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
punycode: 2.3.1
|
||||||
|
|
||||||
|
uuid@11.1.0: {}
|
||||||
|
|
||||||
v8-compile-cache-lib@3.0.1: {}
|
v8-compile-cache-lib@3.0.1: {}
|
||||||
|
|
||||||
vary@1.1.2: {}
|
vary@1.1.2: {}
|
||||||
|
|||||||
136
server/src/api/v1/auth/index.ts
Normal file
136
server/src/api/v1/auth/index.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import express, { type Router, type Request, type Response } from 'express'
|
||||||
|
import jwt from 'jsonwebtoken'
|
||||||
|
|
||||||
|
import { UserModel } from '../../../models/user.model.ts'
|
||||||
|
|
||||||
|
import ldapAuth from './ldap.ts'
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET!
|
||||||
|
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET!
|
||||||
|
|
||||||
|
function createAccessToken(user: any) {
|
||||||
|
return jwt.sign(
|
||||||
|
{ sub: user._id, role: user.role },
|
||||||
|
ACCESS_TOKEN_SECRET,
|
||||||
|
{ expiresIn: '7d' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRefreshToken(user: any) {
|
||||||
|
return jwt.sign(
|
||||||
|
{ sub: user._id },
|
||||||
|
REFRESH_TOKEN_SECRET,
|
||||||
|
{ expiresIn: '7d' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/login', async (req: Request, res: Response) => {
|
||||||
|
const { username, password, remember } = req.body
|
||||||
|
if (!username || !password) return res.status(400).json({ error: 'Missing credentials' })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ldapUser = await ldapAuth({ username, password })
|
||||||
|
|
||||||
|
if (!ldapUser.auth) return res.status(401).json({ error: 'Invalid credentials' })
|
||||||
|
|
||||||
|
let user = await UserModel.findOne({ username: ldapUser.user.cn })
|
||||||
|
if (!user) {
|
||||||
|
user = await UserModel.create({
|
||||||
|
username: ldapUser.user.cn,
|
||||||
|
email: ldapUser.user.dn,
|
||||||
|
refreshToken: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = createAccessToken(user)
|
||||||
|
const refreshToken = createRefreshToken(user)
|
||||||
|
|
||||||
|
user.refreshToken = refreshToken
|
||||||
|
await user.save()
|
||||||
|
|
||||||
|
res.cookie('access_token', accessToken, {
|
||||||
|
httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV !== 'dev', maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||||
|
})
|
||||||
|
const refreshMaxAge = remember
|
||||||
|
? 365 * 24 * 60 * 60 * 1000
|
||||||
|
: 7 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
res.cookie('refreshToken', refreshToken, {
|
||||||
|
httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV !== 'dev', maxAge: refreshMaxAge,
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
user: {
|
||||||
|
username: ldapUser.user.cn,
|
||||||
|
email: ldapUser.user.dn
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
res.status(401).json({ error: 'Invalid credentials' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/refresh', async (req: Request, res: Response) => {
|
||||||
|
const token = req.cookies.refreshToken
|
||||||
|
if (!token) return res.status(401).json({ error: 'No refresh token' })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(token, REFRESH_TOKEN_SECRET) as any
|
||||||
|
const user = await UserModel.findById(payload.id)
|
||||||
|
if (!user || user.refreshToken !== token)
|
||||||
|
return res.status(403).json({ error: 'Invalid refresh token' })
|
||||||
|
|
||||||
|
const newAccessToken = createAccessToken(user)
|
||||||
|
const newRefreshToken = createRefreshToken(user)
|
||||||
|
|
||||||
|
user.refreshToken = newRefreshToken
|
||||||
|
await user.save()
|
||||||
|
|
||||||
|
const existingRefreshCookie = req.cookies.refreshToken
|
||||||
|
const decodedOld = jwt.decode(existingRefreshCookie) as any
|
||||||
|
const remainingDays = (decodedOld.exp * 1000 - Date.now()) / (1000 * 60 * 60 * 24)
|
||||||
|
|
||||||
|
const refreshMaxAge = remainingDays > 7 ? 365 * 24 * 60 * 60 * 1000 : 7 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
res.cookie('access_token', newAccessToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: process.env.NODE_ENV !== 'dev',
|
||||||
|
maxAge: 15 * 60 * 1000,
|
||||||
|
})
|
||||||
|
res.cookie('refreshToken', newRefreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: process.env.NODE_ENV !== 'dev',
|
||||||
|
maxAge: refreshMaxAge,
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json({ ok: true })
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(401).json({ error: 'Invalid refresh token' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/logout', async (req: Request, res: Response) => {
|
||||||
|
const token = req.cookies.refreshToken
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(token, REFRESH_TOKEN_SECRET)
|
||||||
|
const user = await UserModel.findById(payload.sub)
|
||||||
|
if (user) {
|
||||||
|
user.refreshToken = ''
|
||||||
|
await user.save()
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
res.clearCookie('access_token')
|
||||||
|
res.clearCookie('refreshToken')
|
||||||
|
res.json({ loggedOut: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router as Router
|
||||||
31
server/src/api/v1/auth/ldap.ts
Normal file
31
server/src/api/v1/auth/ldap.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
|
||||||
|
import { Client } from 'ldapts'
|
||||||
|
|
||||||
|
const LDAP_URL = 'ldap://192.168.0.26:389';
|
||||||
|
const BASE_DN = 'DC=ldap,DC=goauthentik,DC=io';
|
||||||
|
|
||||||
|
async function ldapAuth(userOptions: any) {
|
||||||
|
const { username, password } = userOptions;
|
||||||
|
if (!username || !password) return { auth: false }
|
||||||
|
|
||||||
|
const client = new Client({ url: LDAP_URL });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userDN = `cn=${username},ou=users,${BASE_DN}`;
|
||||||
|
await client.bind(userDN, password);
|
||||||
|
|
||||||
|
const { searchEntries } = await client.search(BASE_DN, {
|
||||||
|
scope: 'sub',
|
||||||
|
filter: `(cn=${username})`,
|
||||||
|
attributes: ['cn', 'mail'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { auth: true, user: searchEntries[0] }
|
||||||
|
} catch (err) {
|
||||||
|
return { auth: false }
|
||||||
|
} finally {
|
||||||
|
await client.unbind().catch(() => { })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ldapAuth
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import express, { type Router, type Request, type Response } from 'express'
|
import express, { type Router, type Response } from 'express'
|
||||||
import { ApiKeyModel } from '../../../models/apikey.model.ts'
|
import { ApiKeyModel } from '../../../models/apikey.model.ts'
|
||||||
|
import { verifyAccessToken, type AuthRequest } from '../../../middleware/auth.ts'
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
|
router.use(verifyAccessToken)
|
||||||
|
|
||||||
router.get('/', async (_req: Request, res: Response) => {
|
router.get('/', verifyAccessToken, async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const doc = await ApiKeyModel.findOne({})
|
const userId = req.userId
|
||||||
|
const doc = await ApiKeyModel.findOne({ userId: userId })
|
||||||
|
|
||||||
const apiKey = doc?.apiKey || ''
|
const apiKey = doc?.apiKey || ''
|
||||||
|
|
||||||
@@ -17,17 +20,19 @@ router.get('/', async (_req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/', async (req: Request, res: Response) => {
|
router.post('/', verifyAccessToken, async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { apiKey } = req.body as { apiKey?: string }
|
const { apiKey } = req.body
|
||||||
if (!apiKey || !apiKey.trim()) {
|
if (!apiKey || !apiKey.trim()) {
|
||||||
return res.status(400).json({ error: 'Invalid API key' })
|
return res.status(400).json({ error: 'Invalid API key' })
|
||||||
}
|
}
|
||||||
|
console.log(req.body)
|
||||||
|
const userId = req.userId
|
||||||
|
|
||||||
await ApiKeyModel.updateOne(
|
await ApiKeyModel.updateOne(
|
||||||
{},
|
{},
|
||||||
{ $set: { apiKey: apiKey } },
|
{ $set: { userId: userId, apiKey: apiKey, lastUsed: new Date() } },
|
||||||
{ upsert: true }
|
{ upsert: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
res.json({ success: true })
|
res.json({ success: true })
|
||||||
@@ -37,9 +42,10 @@ router.post('/', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.delete('/', async (_req: Request, res: Response) => {
|
router.delete('/', verifyAccessToken, async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const result = await ApiKeyModel.deleteOne({})
|
const userId = req.userId
|
||||||
|
const result = await ApiKeyModel.deleteOne({ userId: userId })
|
||||||
|
|
||||||
if (result.deletedCount === 0) {
|
if (result.deletedCount === 0) {
|
||||||
console.log('No API key found to delete.')
|
console.log('No API key found to delete.')
|
||||||
|
|||||||
269
server/src/api/v1/subject/index.ts
Normal file
269
server/src/api/v1/subject/index.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import express, { type Router, type Response } from 'express'
|
||||||
|
import { ReviewItemModel, type ReviewItemType } from '../../../models/reviewitem.model.ts'
|
||||||
|
import { verifyAccessToken, type AuthRequest } from '../../../middleware/auth.ts'
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
router.use(verifyAccessToken)
|
||||||
|
|
||||||
|
router.post('/', verifyAccessToken, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId
|
||||||
|
if (!userId) return res.status(401).json({ error: 'Unauthorized' })
|
||||||
|
|
||||||
|
const { subjectOptions } = req.body
|
||||||
|
if (!subjectOptions) return res.status(400).json({ error: 'Missing subject options' })
|
||||||
|
|
||||||
|
let typeFilter: ReviewItemType[] = ['kanji', 'vocab']
|
||||||
|
if (subjectOptions.subject) {
|
||||||
|
typeFilter = Array.isArray(subjectOptions.subject)
|
||||||
|
? subjectOptions.subject
|
||||||
|
: [subjectOptions.subject]
|
||||||
|
}
|
||||||
|
|
||||||
|
const possibleModes: { key: string; type: string }[] = []
|
||||||
|
|
||||||
|
if (subjectOptions.writingPractice) {
|
||||||
|
possibleModes.push({ key: 'writing_practice', type: 'writing_practice' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const directions = Array.isArray(subjectOptions.direction)
|
||||||
|
? subjectOptions.direction
|
||||||
|
: subjectOptions.direction === 'both'
|
||||||
|
? ['jp->en', 'en->jp']
|
||||||
|
: [subjectOptions.direction]
|
||||||
|
|
||||||
|
for (const dir of directions) {
|
||||||
|
if (dir === 'jp->en') {
|
||||||
|
const modes = Array.isArray(subjectOptions.mode)
|
||||||
|
? subjectOptions.mode
|
||||||
|
: [subjectOptions.mode]
|
||||||
|
if (modes.includes('meaning')) {
|
||||||
|
possibleModes.push({ key: 'jp_en_meaning', type: 'jp_en_meaning' })
|
||||||
|
}
|
||||||
|
if (modes.includes('reading')) {
|
||||||
|
possibleModes.push({ key: 'jp_en_reading', type: 'jp_en_reading' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dir === 'en->jp') {
|
||||||
|
const modes = Array.isArray(subjectOptions.mode)
|
||||||
|
? subjectOptions.mode
|
||||||
|
: [subjectOptions.mode]
|
||||||
|
|
||||||
|
if (!subjectOptions.meaning && (modes.includes('reading'))) {
|
||||||
|
possibleModes.push({ key: 'en_jp_writing', type: 'en_jp_writing' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (possibleModes.length === 0) return res.status(200).json({ message: 'No reviews due' })
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const dueConditions = possibleModes.map(m => ({ [`srs.${m.key}.next_review`]: { $lte: now } }))
|
||||||
|
|
||||||
|
const dueItems = await ReviewItemModel.find({
|
||||||
|
userId,
|
||||||
|
type: { $in: typeFilter },
|
||||||
|
$or: dueConditions,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!dueItems || dueItems.length === 0) return res.status(200).json({ message: 'No reviews due' })
|
||||||
|
|
||||||
|
const nextItem = dueItems[Math.floor(Math.random() * dueItems.length)]
|
||||||
|
|
||||||
|
const dueModesForItem = possibleModes.filter(
|
||||||
|
m => nextItem.srs[m.key].next_review <= now
|
||||||
|
)
|
||||||
|
|
||||||
|
if (dueModesForItem.length === 0) {
|
||||||
|
return res.status(200).json({ message: 'No reviews due for this item' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedMode = dueModesForItem[Math.floor(Math.random() * dueModesForItem.length)].type
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
...nextItem.toObject(),
|
||||||
|
_selectedMode: selectedMode,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching next review item:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to fetch next review item' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/correct', verifyAccessToken, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId
|
||||||
|
if (!userId) return res.status(401).json({ error: 'Unauthorized' })
|
||||||
|
|
||||||
|
const { itemId } = req.body
|
||||||
|
if (!itemId) return res.status(400).json({ error: 'Missing itemId' })
|
||||||
|
|
||||||
|
const item = await ReviewItemModel.findOne({ id: itemId, userId })
|
||||||
|
if (!item) return res.status(404).json({ error: 'Item not found' })
|
||||||
|
|
||||||
|
let key: string
|
||||||
|
if (req.body.currentSubjectOptions.writingPractice) {
|
||||||
|
key = 'srs.writing_practice'
|
||||||
|
} else if (req.body.currentSubjectOptions.direction === 'jp->en') {
|
||||||
|
key = req.body.currentSubjectOptions.mode === 'jp_en_reading' ? 'srs.jp_en_reading' : 'srs.jp_en_meaning'
|
||||||
|
} else {
|
||||||
|
key = 'srs.en_jp_writing'
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
item.set(`${key}.streak`, (item.get(`${key}.streak`) ?? 0) + 1)
|
||||||
|
item.set(`${key}.ease_factor`, Math.min(3.0, (item.get(`${key}.ease_factor`) ?? 2.5) + 0.1))
|
||||||
|
item.set(`${key}.interval`, Math.round((item.get(`${key}.interval`) ?? 1) * (item.get(`${key}.ease_factor`) ?? 2.5)))
|
||||||
|
item.set(`${key}.last_review`, now)
|
||||||
|
item.set(`${key}.next_review`, new Date(now.getTime() + (item.get(`${key}.interval`) ?? 1) * 3600 * 1000))
|
||||||
|
|
||||||
|
await item.save()
|
||||||
|
|
||||||
|
res.status(200).json({ message: 'Item marked as correct' })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking item correct:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to update item' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/incorrect', verifyAccessToken, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId
|
||||||
|
if (!userId) return res.status(401).json({ error: 'Unauthorized' })
|
||||||
|
|
||||||
|
const { itemId } = req.body
|
||||||
|
if (!itemId) return res.status(400).json({ error: 'Missing itemId' })
|
||||||
|
|
||||||
|
const item = await ReviewItemModel.findOne({ id: itemId, userId })
|
||||||
|
if (!item) return res.status(404).json({ error: 'Item not found' })
|
||||||
|
|
||||||
|
let key: string
|
||||||
|
if (req.body.currentSubjectOptions.writingPractice) {
|
||||||
|
key = 'srs.writing_practice'
|
||||||
|
} else if (req.body.currentSubjectOptions.direction === 'jp->en') {
|
||||||
|
key = req.body.currentSubjectOptions.mode === 'reading' ? 'srs.jp_en_reading' : 'srs.jp_en_meaning'
|
||||||
|
} else {
|
||||||
|
key = 'srs.en_jp_writing'
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
item.set(`${key}.streak`, 0)
|
||||||
|
item.set(`${key}.ease_factor`, Math.max(1.3, (item.get(`${key}.ease_factor`) ?? 2.5) - 0.2))
|
||||||
|
item.set(`${key}.interval`, 1)
|
||||||
|
item.set(`${key}.last_review`, now)
|
||||||
|
item.set(`${key}.next_review`, new Date(now.getTime() + 60 * 60 * 1000)) // 1h cooldown for wrong answers
|
||||||
|
|
||||||
|
await item.save()
|
||||||
|
|
||||||
|
res.status(200).json({ message: 'Item marked as incorrect' })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking item incorrect:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to update item' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/stats', verifyAccessToken, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId
|
||||||
|
if (!userId) return res.status(401).json({ error: 'Unauthorized' })
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
// Total counts
|
||||||
|
const totalKanji = await ReviewItemModel.countDocuments({ userId, type: 'kanji' })
|
||||||
|
const totalVocab = await ReviewItemModel.countDocuments({ userId, type: 'vocab' })
|
||||||
|
|
||||||
|
// Any of the SRS fields that are due
|
||||||
|
const dueConditions = [
|
||||||
|
{ 'srs.jp_en_meaning.next_review': { $lte: now } },
|
||||||
|
{ 'srs.jp_en_reading.next_review': { $lte: now } },
|
||||||
|
{ 'srs.en_jp_writing.next_review': { $lte: now } },
|
||||||
|
{ 'srs.writing_practice.next_review': { $lte: now } },
|
||||||
|
]
|
||||||
|
|
||||||
|
const dueItems = await ReviewItemModel.countDocuments({
|
||||||
|
userId,
|
||||||
|
$or: dueConditions,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Waiting items = have next_review set to the future in any field
|
||||||
|
const waitingConditions = [
|
||||||
|
{ 'srs.jp_en_meaning.next_review': { $gt: now } },
|
||||||
|
{ 'srs.jp_en_reading.next_review': { $gt: now } },
|
||||||
|
{ 'srs.en_jp_writing.next_review': { $gt: now } },
|
||||||
|
{ 'srs.writing_practice.next_review': { $gt: now } },
|
||||||
|
]
|
||||||
|
|
||||||
|
const waitingItems = await ReviewItemModel.countDocuments({
|
||||||
|
userId,
|
||||||
|
$or: waitingConditions,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Average SRS score per type (optional — kept from original)
|
||||||
|
const kanjiAgg = await ReviewItemModel.aggregate([
|
||||||
|
{ $match: { userId, type: 'kanji' } },
|
||||||
|
{ $group: { _id: null, avgScore: { $avg: '$srs_score' } } },
|
||||||
|
])
|
||||||
|
|
||||||
|
const vocabAgg = await ReviewItemModel.aggregate([
|
||||||
|
{ $match: { userId, type: 'vocab' } },
|
||||||
|
{ $group: { _id: null, avgScore: { $avg: '$srs_score' } } },
|
||||||
|
])
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
totalKanji,
|
||||||
|
totalVocab,
|
||||||
|
dueItems,
|
||||||
|
waitingItems,
|
||||||
|
avgKanjiScore: kanjiAgg[0]?.avgScore ?? 0,
|
||||||
|
avgVocabScore: vocabAgg[0]?.avgScore ?? 0,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching stats:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to fetch stats' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/:itemId', verifyAccessToken, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId
|
||||||
|
if (!userId) return res.status(401).json({ error: 'Unauthorized' })
|
||||||
|
|
||||||
|
const { itemId } = req.params
|
||||||
|
if (!itemId) return res.status(400).json({ error: 'Missing itemId' })
|
||||||
|
|
||||||
|
const item = await ReviewItemModel.findOne({ _id: itemId, userId })
|
||||||
|
if (!item) return res.status(404).json({ error: 'Item not found' })
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const due = item.srs_next_review && item.srs_next_review <= now
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
itemId: item._id,
|
||||||
|
type: item.type,
|
||||||
|
characters: item.characters,
|
||||||
|
meanings: item.meanings,
|
||||||
|
readings: item.readings,
|
||||||
|
auxiliary_meanings: item.auxiliary_meanings,
|
||||||
|
pronunciation_audios: item.pronunciation_audios ?? [],
|
||||||
|
level: item.level,
|
||||||
|
slug: item.slug,
|
||||||
|
srs_score: item.srs_score ?? 0,
|
||||||
|
srs_streak: item.srs_streak ?? 0,
|
||||||
|
srs_ease_factor: item.srs_ease_factor ?? 2.5,
|
||||||
|
srs_last_review: item.srs_last_review,
|
||||||
|
srs_next_review: item.srs_next_review,
|
||||||
|
srs_interval: item.srs_interval ?? 1,
|
||||||
|
isDue: due,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching item stats:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to fetch item stats' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router as Router
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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
|
|
||||||
22
server/src/api/v1/user/index.ts
Normal file
22
server/src/api/v1/user/index.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import express, { type Router } from 'express'
|
||||||
|
|
||||||
|
import { UserModel } from '../../../models/user.model.ts'
|
||||||
|
import { verifyAccessToken, type AuthRequest } from '../../../middleware/auth.ts'
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
router.get('/info', verifyAccessToken, async (req: AuthRequest, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.userId) return res.status(401).json({ ok: false, message: 'Unauthorized' })
|
||||||
|
|
||||||
|
const user = await UserModel.findById(req.userId).select('-refreshToken -__v -createdAt -updatedAt')
|
||||||
|
if (!user) return res.status(404).json({ ok: false, message: 'User not found' })
|
||||||
|
|
||||||
|
return res.json({ ok: true, user })
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
return res.status(500).json({ ok: false, message: 'Server error' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router as Router
|
||||||
@@ -1,26 +1,22 @@
|
|||||||
import express, { Router } from 'express'
|
import express, { type Router, type Response } from 'express'
|
||||||
|
|
||||||
import { ApiKeyModel } from '../../../models/apikey.model.ts'
|
import { ApiKeyModel } from '../../../models/apikey.model.ts'
|
||||||
|
|
||||||
import { syncWanikaniData } from '../../../services/wanikaniService.ts'
|
import { syncWanikaniData } from '../../../services/wanikaniService.ts'
|
||||||
|
import { verifyAccessToken, type AuthRequest } from '../../../middleware/auth.ts'
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
interface ApiKeyDocument {
|
router.get('/sync', verifyAccessToken, async (req: AuthRequest, res: Response) => {
|
||||||
apiKey?: string;
|
if (!req.userId) return res.status(401).json({ error: 'Unauthorized' })
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/sync', async (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
const apiKeyDoc = await ApiKeyModel.findOne() as ApiKeyDocument | null
|
const apiKeyDoc = await ApiKeyModel.findOne({ userId: req.userId })
|
||||||
|
|
||||||
const apiKey = apiKeyDoc?.apiKey
|
const apiKey = apiKeyDoc?.apiKey
|
||||||
|
|
||||||
console.log(apiKey, apiKeyDoc)
|
|
||||||
if (!apiKey || apiKey.trim() === '') {
|
if (!apiKey || apiKey.trim() === '') {
|
||||||
return res.status(401).json({ error: 'API Key not configured. Please sync your key first.' })
|
return res.status(401).json({ error: 'API Key not configured. Please sync your key first.' })
|
||||||
}
|
}
|
||||||
await syncWanikaniData(apiKey)
|
await syncWanikaniData(apiKey, req.userId)
|
||||||
res.json({ success: true })
|
res.json({ success: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import mongoose from 'mongoose'
|
import mongoose from 'mongoose'
|
||||||
|
|
||||||
export async function connectMongo() {
|
export async function connectMongo() {
|
||||||
const url = process.env.MONGO_URL || 'mongodb://mongo:27017/srs'
|
const url = process.env.NODE_ENV === 'DEV' ? 'mongodb://mongo:27017/srs' : 'mongodb://192.168.0.26:27017/srs'
|
||||||
if (mongoose.connection.readyState === 1) return
|
if (mongoose.connection.readyState === 1) return
|
||||||
await mongoose.connect(url)
|
await mongoose.connect(url)
|
||||||
console.log('✅ Connected to MongoDB at', url)
|
console.log('✅ Connected to MongoDB at', url)
|
||||||
|
|||||||
@@ -1,19 +1,65 @@
|
|||||||
|
import dotenv from 'dotenv'
|
||||||
|
dotenv.config()
|
||||||
|
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { connectMongo } from './db/connect.ts'
|
|
||||||
import cors from 'cors'
|
import cors from 'cors'
|
||||||
|
import cookieParser from 'cookie-parser'
|
||||||
|
import helmet from 'helmet'
|
||||||
|
import rateLimit from 'express-rate-limit'
|
||||||
|
|
||||||
|
import { connectMongo } from './db/connect.ts'
|
||||||
|
|
||||||
import keyRouter from './api/v1/key/index.ts'
|
import keyRouter from './api/v1/key/index.ts'
|
||||||
import syncRouter from './api/v1/wanikani/sync.ts'
|
import syncRouter from './api/v1/wanikani/sync.ts'
|
||||||
import kanjiRouter from './api/v1/subject/kanji.ts'
|
import authRouter from './api/v1/auth/index.ts'
|
||||||
import vocabRouter from './api/v1/subject/vocab.ts'
|
import userRoutes from './api/v1/user/index.ts'
|
||||||
|
import subjectRoutes from './api/v1/subject/index.ts'
|
||||||
|
|
||||||
|
import { verifyAccessToken } from './middleware/auth.ts'
|
||||||
|
|
||||||
|
const allowedOrigins = [
|
||||||
|
'http://localhost:5173',
|
||||||
|
'https://srs.crylia.de',
|
||||||
|
]
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
app.use(cors())
|
app.use(cors({
|
||||||
|
origin: (origin, callback) => {
|
||||||
|
if (!origin) return callback(null, true)
|
||||||
|
if (allowedOrigins.includes(origin)) return callback(null, true)
|
||||||
|
callback(new Error('Not allowed by CORS'))
|
||||||
|
},
|
||||||
|
credentials: true,
|
||||||
|
}))
|
||||||
app.use(express.json())
|
app.use(express.json())
|
||||||
|
app.use(cookieParser())
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
scriptSrc: ["'self'"],
|
||||||
|
connectSrc: ["'self'", "https://srs.crylia.de"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
crossOriginEmbedderPolicy: true,
|
||||||
|
crossOriginResourcePolicy: { policy: "same-origin" },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
contentSecurityPolicy: false,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
app.use('/api/v1/key', keyRouter)
|
app.use('/api/v1/key', verifyAccessToken, keyRouter)
|
||||||
app.use('/api/v1/wanikani', syncRouter)
|
app.use('/api/v1/user', verifyAccessToken, userRoutes)
|
||||||
app.use('/api/v1/subject/kanji', kanjiRouter)
|
app.use('/api/v1/wanikani', verifyAccessToken, syncRouter)
|
||||||
app.use('/api/v1/subject/vocab', vocabRouter)
|
app.use('/api/v1/auth', authRouter)
|
||||||
|
app.use('/api/v1/review', verifyAccessToken, subjectRoutes)
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000
|
const PORT = process.env.PORT || 3000
|
||||||
connectMongo().then(() => {
|
connectMongo().then(() => {
|
||||||
|
|||||||
21
server/src/middleware/auth.ts
Normal file
21
server/src/middleware/auth.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { type Request, type Response, type NextFunction } from 'express'
|
||||||
|
import jwt from 'jsonwebtoken'
|
||||||
|
|
||||||
|
export interface AuthRequest extends Request {
|
||||||
|
userId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET!
|
||||||
|
|
||||||
|
export function verifyAccessToken(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
const token = req.cookies.access_token
|
||||||
|
if (!token) return res.status(401).json({ ok: false, message: 'No token provided' })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(token, ACCESS_TOKEN_SECRET!)
|
||||||
|
req.userId = (payload as any).sub
|
||||||
|
next()
|
||||||
|
} catch {
|
||||||
|
return res.status(401).json({ ok: false, message: 'Invalid token' })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import mongoose from 'mongoose'
|
import mongoose from 'mongoose'
|
||||||
|
|
||||||
const ApiKeySchema = new mongoose.Schema({
|
const ApiKeySchema = new mongoose.Schema({
|
||||||
|
userId: { type: String, required: true },
|
||||||
apiKey: { type: String, required: true },
|
apiKey: { type: String, required: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import mongoose from 'mongoose'
|
import mongoose from 'mongoose'
|
||||||
|
|
||||||
const AssignmentSchema = new mongoose.Schema({
|
const AssignmentSchema = new mongoose.Schema({
|
||||||
|
userId: { type: String, required: true },
|
||||||
subject_type: String,
|
subject_type: String,
|
||||||
subject_ids: [Number],
|
subject_ids: [Number],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
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)
|
|
||||||
51
server/src/models/reviewitem.model.ts
Normal file
51
server/src/models/reviewitem.model.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import mongoose from 'mongoose'
|
||||||
|
|
||||||
|
export type ReviewItemType = 'kanji' | 'vocab'
|
||||||
|
|
||||||
|
const ReviewItemSchema = new mongoose.Schema({
|
||||||
|
userId: { type: String, required: true },
|
||||||
|
type: { type: String, enum: ['kanji', 'vocab'], required: true },
|
||||||
|
id: { type: Number, required: true },
|
||||||
|
|
||||||
|
characters: String,
|
||||||
|
meanings: Array,
|
||||||
|
readings: { type: Array, default: [] },
|
||||||
|
auxiliary_meanings: Array,
|
||||||
|
level: Number,
|
||||||
|
slug: String,
|
||||||
|
|
||||||
|
pronunciation_audios: { type: Array, default: [] },
|
||||||
|
|
||||||
|
srs: {
|
||||||
|
jp_en_meaning: {
|
||||||
|
streak: { type: Number, default: 0 },
|
||||||
|
ease_factor: { type: Number, default: 2.5 },
|
||||||
|
interval: { type: Number, default: 1 },
|
||||||
|
next_review: { type: Date, default: () => new Date() },
|
||||||
|
last_review: { type: Date, default: null },
|
||||||
|
},
|
||||||
|
jp_en_reading: {
|
||||||
|
streak: { type: Number, default: 0 },
|
||||||
|
ease_factor: { type: Number, default: 2.5 },
|
||||||
|
interval: { type: Number, default: 1 },
|
||||||
|
next_review: { type: Date, default: () => new Date() },
|
||||||
|
last_review: { type: Date, default: null },
|
||||||
|
},
|
||||||
|
en_jp_writing: {
|
||||||
|
streak: { type: Number, default: 0 },
|
||||||
|
ease_factor: { type: Number, default: 2.5 },
|
||||||
|
interval: { type: Number, default: 1 },
|
||||||
|
next_review: { type: Date, default: () => new Date() },
|
||||||
|
last_review: { type: Date, default: null },
|
||||||
|
},
|
||||||
|
writing_practice: {
|
||||||
|
streak: { type: Number, default: 0 },
|
||||||
|
ease_factor: { type: Number, default: 2.5 },
|
||||||
|
interval: { type: Number, default: 1 },
|
||||||
|
next_review: { type: Date, default: () => new Date() },
|
||||||
|
last_review: { type: Date, default: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ReviewItemModel = mongoose.model('ReviewItem', ReviewItemSchema)
|
||||||
9
server/src/models/user.model.ts
Normal file
9
server/src/models/user.model.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import mongoose from "mongoose";
|
||||||
|
|
||||||
|
const UserSchema = new mongoose.Schema({
|
||||||
|
username: { type: String, unique: true, required: true },
|
||||||
|
email: { type: String, unique: true },
|
||||||
|
refreshToken: String,
|
||||||
|
}, { timestamps: true })
|
||||||
|
|
||||||
|
export const UserModel = mongoose.model('User', UserSchema)
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import fetch from 'node-fetch'
|
|
||||||
import { ApiKeyModel } from '../models/apikey.model.ts'
|
import { ApiKeyModel } from '../models/apikey.model.ts'
|
||||||
import { AssignmentModel } from '../models/assignments.model.ts'
|
import { AssignmentModel } from '../models/assignments.model.ts'
|
||||||
import { KanjiModel } from '../models/kanji.model.ts'
|
import { ReviewItemModel } from '../models/reviewitem.model.ts'
|
||||||
import { VocabularyModel } from '../models/vocabulary.model.ts'
|
|
||||||
|
|
||||||
import type { KanjiItem, VocabularyItem } from '../types/wanikani.ts'
|
import type { KanjiItem, VocabularyItem } from '../types/wanikani.ts'
|
||||||
|
|
||||||
@@ -24,6 +22,7 @@ export interface WaniKaniSubject {
|
|||||||
level: number
|
level: number
|
||||||
slug: string
|
slug: string
|
||||||
unlocked_at?: string
|
unlocked_at?: string
|
||||||
|
subject_type: 'kanji' | 'vocabulary' | 'kana_vocabulary'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,77 +62,105 @@ const fetchSubjects = async (
|
|||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapKanji = (item: WaniKaniSubject): KanjiItem => ({
|
const mapToReviewItem = (item: WaniKaniSubject, userId: string) => {
|
||||||
characters: item.data.characters,
|
let type: 'kanji' | 'vocab'
|
||||||
meanings: item.data.meanings,
|
const subjectType = item.data.subject_type || item.object
|
||||||
readings: item.data.readings,
|
if (subjectType === 'kanji') type = 'kanji'
|
||||||
auxiliary_meanings: item.data.auxiliary_meanings,
|
else if (['vocabulary', 'kana_vocabulary'].includes(subjectType)) type = 'vocab'
|
||||||
level: item.data.level,
|
else throw new Error(`Unknown WaniKani subject type: ${subjectType}`)
|
||||||
slug: item.data.slug,
|
|
||||||
srs_score: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const mapVocab = (item: WaniKaniSubject): VocabularyItem => ({
|
return {
|
||||||
characters: item.data.characters,
|
id: item.id,
|
||||||
meanings: item.data.meanings,
|
userId,
|
||||||
readings: item.data.readings ?? [],
|
type,
|
||||||
auxiliary_meanings: item.data.auxiliary_meanings,
|
characters: item.data.characters,
|
||||||
pronunciation_audios: item.data.pronunciation_audios ?? [],
|
meanings: item.data.meanings,
|
||||||
level: item.data.level,
|
readings: item.data.readings ?? [],
|
||||||
slug: item.data.slug,
|
auxiliary_meanings: item.data.auxiliary_meanings ?? [],
|
||||||
srs_score: 0,
|
pronunciation_audios: item.data.pronunciation_audios ?? [],
|
||||||
})
|
level: item.data.level,
|
||||||
|
slug: item.data.slug,
|
||||||
|
srs: {
|
||||||
|
jp_en_meaning: {
|
||||||
|
streak: 0,
|
||||||
|
ease_factor: 2.5,
|
||||||
|
interval: 1,
|
||||||
|
next_review: new Date(),
|
||||||
|
last_review: null,
|
||||||
|
},
|
||||||
|
jp_en_reading: {
|
||||||
|
streak: 0,
|
||||||
|
ease_factor: 2.5,
|
||||||
|
interval: 1,
|
||||||
|
next_review: new Date(),
|
||||||
|
last_review: null,
|
||||||
|
},
|
||||||
|
en_jp_writing: {
|
||||||
|
streak: 0,
|
||||||
|
ease_factor: 2.5,
|
||||||
|
interval: 1,
|
||||||
|
next_review: new Date(),
|
||||||
|
last_review: null,
|
||||||
|
},
|
||||||
|
writing_practice: {
|
||||||
|
streak: 0,
|
||||||
|
ease_factor: 2.5,
|
||||||
|
interval: 1,
|
||||||
|
next_review: new Date(),
|
||||||
|
last_review: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const syncWanikaniData = async (apiKey: string): Promise<void> => {
|
export const syncWanikaniData = async (apiKey: string, userId: string): Promise<void> => {
|
||||||
const headers = { Authorization: `Bearer ${apiKey}` }
|
const headers = { Authorization: `Bearer ${apiKey}` }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ApiKeyModel.updateOne(
|
await ApiKeyModel.updateOne(
|
||||||
{},
|
{},
|
||||||
{ $set: { value: apiKey, lastUsed: new Date() } },
|
{ $set: { userId: userId, apiKey: apiKey, lastUsed: new Date() } },
|
||||||
{ upsert: true },
|
{ upsert: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
const assignments = await fetchAllPages(`${WANIKANI_API_BASE}/assignments`, headers)
|
const assignments = await fetchAllPages(`${WANIKANI_API_BASE}/assignments`, headers)
|
||||||
|
|
||||||
const unlockedKanjiSubjectIds: number[] = []
|
const unlockedKanjiIds: number[] = []
|
||||||
const unlockedVocabSubjectIds: number[] = []
|
const unlockedVocabIds: number[] = []
|
||||||
|
|
||||||
for (const a of assignments) {
|
for (const a of assignments) {
|
||||||
const assignmentData = a.data as any
|
const assignmentData = a.data as any
|
||||||
|
|
||||||
if (!assignmentData.unlocked_at) continue
|
if (!assignmentData.unlocked_at) continue
|
||||||
|
|
||||||
if (assignmentData.subject_type === 'kanji') {
|
if (assignmentData.subject_type === 'kanji') unlockedKanjiIds.push(assignmentData.subject_id)
|
||||||
unlockedKanjiSubjectIds.push(assignmentData.subject_id)
|
else if (['vocabulary', 'kana_vocabulary'].includes(assignmentData.subject_type)) unlockedVocabIds.push(assignmentData.subject_id)
|
||||||
} else if (['vocabulary', 'kana_vocabulary'].includes(assignmentData.subject_type)) {
|
|
||||||
unlockedVocabSubjectIds.push(assignmentData.subject_id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await AssignmentModel.updateOne(
|
await AssignmentModel.updateOne(
|
||||||
{ subject_type: 'kanji' },
|
{ userId, subject_type: 'kanji' },
|
||||||
{ $set: { subject_ids: unlockedKanjiSubjectIds } },
|
{ $set: { subject_ids: unlockedKanjiIds } },
|
||||||
{ upsert: true },
|
{ upsert: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
await AssignmentModel.updateOne(
|
await AssignmentModel.updateOne(
|
||||||
{ subject_type: 'vocabulary' },
|
{ userId, subject_type: 'vocab' },
|
||||||
{ $set: { subject_ids: unlockedVocabSubjectIds } },
|
{ $set: { subject_ids: unlockedVocabIds } },
|
||||||
{ upsert: true },
|
{ upsert: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
const existingKanjiSlugs = new Set((await KanjiModel.find({}, { slug: 1 })).map(k => k.slug))
|
// Fetch and insert new review items
|
||||||
const kanjiSubjects = await fetchSubjects(unlockedKanjiSubjectIds, headers)
|
const kanjiSubjects = await fetchSubjects(unlockedKanjiIds, headers)
|
||||||
const newKanji = kanjiSubjects.filter(s => !existingKanjiSlugs.has(s.data.slug))
|
const vocabSubjects = await fetchSubjects(unlockedVocabIds, headers)
|
||||||
if (newKanji.length > 0) await KanjiModel.insertMany(newKanji.map(mapKanji))
|
|
||||||
|
|
||||||
const existingVocabSlugs = new Set((await VocabularyModel.find({}, { slug: 1 })).map(v => v.slug))
|
const allSlugs = new Set((await ReviewItemModel.find({ userId }, { slug: 1 })).map(r => r.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')
|
const newItems = [...kanjiSubjects, ...vocabSubjects]
|
||||||
|
.filter(s => !allSlugs.has(s.data.slug))
|
||||||
|
.map(s => mapToReviewItem(s, userId))
|
||||||
|
|
||||||
|
if (newItems.length > 0) await ReviewItemModel.insertMany(newItems)
|
||||||
|
|
||||||
|
console.log('✅ WaniKani sync complete')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('❌ Error syncing WaniKani data:', err)
|
console.error('❌ Error syncing WaniKani data:', err)
|
||||||
throw err
|
throw err
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export interface KanjiItem {
|
export interface KanjiItem {
|
||||||
|
userId: string
|
||||||
characters: string
|
characters: string
|
||||||
meanings: {
|
meanings: {
|
||||||
meaning: string,
|
meaning: string,
|
||||||
@@ -21,6 +22,7 @@ export interface KanjiItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface VocabularyItem {
|
export interface VocabularyItem {
|
||||||
|
userId: string
|
||||||
characters: string
|
characters: string
|
||||||
meanings: {
|
meanings: {
|
||||||
meaning: string
|
meaning: string
|
||||||
@@ -51,6 +53,7 @@ export interface VocabularyItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Assignment {
|
export interface Assignment {
|
||||||
|
userId: string
|
||||||
unlocked_at?: Date
|
unlocked_at?: Date
|
||||||
subject_ids: number[]
|
subject_ids: number[]
|
||||||
subject_type: string
|
subject_type: string
|
||||||
|
|||||||
Reference in New Issue
Block a user