Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a40bfa7838 | ||
|
|
26041cba1e | ||
|
|
76dc09901a | ||
|
|
2715e6f9b3 | ||
|
|
63aa9b572a | ||
|
|
1ec1633dd9 | ||
|
|
7755e77039 | ||
|
|
201d21a678 | ||
|
|
993265318b | ||
|
|
f40f4508fd |
No files matched your search
@@ -33,7 +33,17 @@
|
||||
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" diff .gitea/workflows/deploy.yml)",
|
||||
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" diff HEAD -- .gitea/workflows/deploy.yml)",
|
||||
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" log --oneline -3 -- .gitea/workflows/deploy.yml)",
|
||||
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" log --oneline origin/main..HEAD)"
|
||||
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" log --oneline origin/main..HEAD)",
|
||||
"Bash(grep -E \"\\\\.tsx$\")",
|
||||
"Bash(Get-ChildItem -Path \"c:\\\\Users\\\\Laurens Bartelds\\\\Documents\\\\Gitea\\\\brainwave\" -Force)",
|
||||
"Bash(Select-Object Name)",
|
||||
"Bash(git -C \"c:/Users/Laurens Bartelds/Documents/Gitea/brainwave\" stash list)",
|
||||
"Bash(git -C \"c:/Users/Laurens Bartelds/Documents/Gitea/brainwave\" stash pop)",
|
||||
"Bash(git -C \"c:/Users/Laurens Bartelds/Documents/Gitea/brainwave\" diff --stat HEAD)",
|
||||
"Bash(git -C \"c:/Users/Laurens Bartelds/Documents/Gitea/brainwave\" checkout -- .claude/settings.local.json)",
|
||||
"Bash(git -C \"c:/Users/Laurens Bartelds/Documents/Gitea/brainwave\" diff HEAD -- frontend/src/components/Overview.tsx)",
|
||||
"Bash(npm test *)",
|
||||
"Bash(git -C \"c:/Users/Laurens Bartelds/Documents/Gitea/brainwave\" diff HEAD -- backend/src/validators/adminSchemas.ts backend/src/validators/adminSchemas.test.ts)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Deploy ARM to OrangePi
|
||||
name: Deploy ARM to OrangePi
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
@@ -26,6 +26,12 @@ jobs:
|
||||
echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$GITHUB_ENV"
|
||||
echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Add OrangePi to known_hosts
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
ssh-keyscan -H "${{ secrets.ANSIBLE_HOST }}" >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Write Inventory
|
||||
run: |
|
||||
printf '[orangepi]\norangepi ansible_host=%s ansible_user=%s\n' \
|
||||
|
||||
+5
-1
@@ -7,4 +7,8 @@ backend/db/brainwave.db-wal
|
||||
node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
|
||||
brainwave.db-shm
|
||||
brainwave.db-wal
|
||||
node_modules/
|
||||
node_modules/
|
||||
brainwave.db
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
Generated
+282
-2
@@ -17,6 +17,7 @@
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nodemailer": "^7.0.3",
|
||||
"openai": "^4.95.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -955,12 +956,21 @@
|
||||
"version": "25.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-fetch": {
|
||||
"version": "2.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
|
||||
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"form-data": "^4.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "6.4.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.23.tgz",
|
||||
@@ -1119,6 +1129,18 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"event-target-shim": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.5"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
@@ -1132,6 +1154,18 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/agentkeepalive": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
|
||||
"integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"humanize-ms": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -1142,6 +1176,12 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -1315,6 +1355,18 @@
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||
@@ -1420,6 +1472,15 @@
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -1534,6 +1595,21 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||
@@ -1601,6 +1677,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
@@ -1723,6 +1808,62 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data-encoder": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
|
||||
"integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/form-data/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/formdata-node": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
|
||||
"integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-domexception": "1.0.0",
|
||||
"web-streams-polyfill": "4.0.0-beta.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.20"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -1851,6 +1992,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||
@@ -1892,6 +2048,15 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/humanize-ms": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
|
||||
"integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
@@ -2454,6 +2619,46 @@
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||
"deprecated": "Use your platform's native DOMException instead",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build": {
|
||||
"version": "4.8.4",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||
@@ -2527,6 +2732,51 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/openai": {
|
||||
"version": "4.104.0",
|
||||
"resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz",
|
||||
"integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/node-fetch": "^2.6.4",
|
||||
"abort-controller": "^3.0.0",
|
||||
"agentkeepalive": "^4.2.1",
|
||||
"form-data-encoder": "1.7.2",
|
||||
"formdata-node": "^4.3.2",
|
||||
"node-fetch": "^2.6.7"
|
||||
},
|
||||
"bin": {
|
||||
"openai": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ws": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/openai/node_modules/@types/node": {
|
||||
"version": "18.19.130",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
|
||||
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/openai/node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -3125,6 +3375,12 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -3197,7 +3453,6 @@
|
||||
"version": "7.19.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
@@ -3392,6 +3647,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "4.0.0-beta.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
|
||||
"integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nodemailer": "^7.0.3",
|
||||
"openai": "^4.95.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -53,6 +53,19 @@ export function runMigrations(db: Database.Database): void {
|
||||
CREATE INDEX IF NOT EXISTS idx_exam_results_user ON exam_results(user_id);
|
||||
`);
|
||||
|
||||
// ── Add share_token and is_public columns if missing ──
|
||||
const examCols = db.prepare(`PRAGMA table_info(saved_exams)`).all() as { name: string }[];
|
||||
if (!examCols.some((c) => c.name === 'share_token')) {
|
||||
db.exec(`ALTER TABLE saved_exams ADD COLUMN share_token TEXT`);
|
||||
}
|
||||
if (!examCols.some((c) => c.name === 'is_public')) {
|
||||
db.exec(`ALTER TABLE saved_exams ADD COLUMN is_public INTEGER DEFAULT 0`);
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_exams_share ON saved_exams(share_token);
|
||||
`);
|
||||
|
||||
// ── Add role column if missing (existing databases) ──
|
||||
const cols = db.prepare(`PRAGMA table_info(users)`).all() as {
|
||||
name: string;
|
||||
@@ -61,15 +74,53 @@ export function runMigrations(db: Database.Database): void {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`);
|
||||
}
|
||||
|
||||
// ── Add storage_quota_bytes if missing ──
|
||||
if (!cols.some((c) => c.name === "storage_quota_bytes")) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN storage_quota_bytes INTEGER NOT NULL DEFAULT 1073741824`);
|
||||
}
|
||||
// ── Add storage_quota_bytes if missing ──
|
||||
if (!cols.some((c) => c.name === "storage_quota_bytes")) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN storage_quota_bytes INTEGER NOT NULL DEFAULT 1073741824`);
|
||||
}
|
||||
|
||||
// ── Add plan if missing ──
|
||||
if (!cols.some((c) => c.name === "plan")) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN plan TEXT NOT NULL DEFAULT 'free'`);
|
||||
}
|
||||
// ── Add plan if missing ──
|
||||
if (!cols.some((c) => c.name === "plan")) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN plan TEXT NOT NULL DEFAULT 'free'`);
|
||||
}
|
||||
|
||||
// ── Add is_favorite to saved_exams if missing ──
|
||||
const examSavedCols = db.prepare(`PRAGMA table_info(saved_exams)`).all() as { name: string }[];
|
||||
if (!examSavedCols.some((c) => c.name === 'is_favorite')) {
|
||||
db.exec(`ALTER TABLE saved_exams ADD COLUMN is_favorite INTEGER DEFAULT 0`);
|
||||
}
|
||||
|
||||
// ── Add tags to saved_exams if missing ──
|
||||
if (!examSavedCols.some((c) => c.name === 'tags')) {
|
||||
db.exec(`ALTER TABLE saved_exams ADD COLUMN tags TEXT DEFAULT '[]'`);
|
||||
}
|
||||
|
||||
// ── Add collection_id to saved_exams if missing ──
|
||||
if (!examSavedCols.some((c) => c.name === 'collection_id')) {
|
||||
db.exec(`ALTER TABLE saved_exams ADD COLUMN collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL`);
|
||||
}
|
||||
|
||||
// ── Add share_mode to saved_exams if missing ──
|
||||
if (!examSavedCols.some((c) => c.name === 'share_mode')) {
|
||||
db.exec(`ALTER TABLE saved_exams ADD COLUMN share_mode TEXT DEFAULT 'clone'`);
|
||||
}
|
||||
|
||||
// ── Add cloned_from_share_token to saved_exams if missing ──
|
||||
if (!examSavedCols.some((c) => c.name === 'cloned_from_share_token')) {
|
||||
db.exec(`ALTER TABLE saved_exams ADD COLUMN cloned_from_share_token TEXT`);
|
||||
}
|
||||
|
||||
// ── Collections table ──
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS collections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
created_at INTEGER DEFAULT (unixepoch()),
|
||||
updated_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_collections_user ON collections(user_id);
|
||||
`);
|
||||
|
||||
// ── Promote first user to admin if no admins exist ──
|
||||
const adminCount = db
|
||||
|
||||
@@ -9,6 +9,10 @@ import examRoutes from './routes/exams.js'
|
||||
import resultRoutes from './routes/results.js'
|
||||
import adminRoutes from './routes/admin.js'
|
||||
import setupRoutes from './routes/setup.js'
|
||||
import gradeRoutes from './routes/grade.js'
|
||||
import shareRoutes from './routes/share.js'
|
||||
import collectionRoutes from './routes/collections.js'
|
||||
import aiRoutes from './routes/ai.js'
|
||||
import { errorHandler } from './middleware/error.js'
|
||||
import { config } from './config.js'
|
||||
import path from 'path'
|
||||
@@ -60,7 +64,11 @@ app.use('/api/setup', setupRoutes)
|
||||
app.use('/api/auth', authRoutes)
|
||||
app.use('/api/exams', examRoutes)
|
||||
app.use('/api/results', resultRoutes)
|
||||
app.use('/api/grade', gradeRoutes)
|
||||
app.use('/api/ai', aiRoutes)
|
||||
app.use('/api/admin', adminRoutes)
|
||||
app.use('/api/share', shareRoutes)
|
||||
app.use('/api/collections', collectionRoutes)
|
||||
|
||||
// Health check with subsystem status
|
||||
app.get('/api/health', (_req, res) => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { getDb } from '../db/database.js'
|
||||
|
||||
export interface DbCollection {
|
||||
id: number
|
||||
user_id: number
|
||||
name: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export interface CollectionWithCount extends DbCollection {
|
||||
exam_count: number
|
||||
}
|
||||
|
||||
export function createCollection(userId: number, name: string): { id: number } {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
INSERT INTO collections (user_id, name)
|
||||
VALUES (?, ?)
|
||||
`).run(userId, name)
|
||||
return { id: result.lastInsertRowid as number }
|
||||
}
|
||||
|
||||
export function getCollectionById(id: number, userId: number): DbCollection | undefined {
|
||||
const db = getDb()
|
||||
return db.prepare('SELECT * FROM collections WHERE id = ? AND user_id = ?').get(id, userId) as DbCollection | undefined
|
||||
}
|
||||
|
||||
export function listCollectionsByUser(userId: number): CollectionWithCount[] {
|
||||
const db = getDb()
|
||||
return db.prepare(`
|
||||
SELECT c.*, COUNT(se.id) AS exam_count
|
||||
FROM collections c
|
||||
LEFT JOIN saved_exams se ON se.collection_id = c.id
|
||||
WHERE c.user_id = ?
|
||||
GROUP BY c.id
|
||||
ORDER BY c.updated_at DESC
|
||||
`).all(userId) as CollectionWithCount[]
|
||||
}
|
||||
|
||||
export function updateCollection(id: number, userId: number, name: string): boolean {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
UPDATE collections
|
||||
SET name = ?, updated_at = unixepoch()
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(name, id, userId)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function deleteCollection(id: number, userId: number): boolean {
|
||||
const db = getDb()
|
||||
const result = db.prepare('DELETE FROM collections WHERE id = ? AND user_id = ?').run(id, userId)
|
||||
return result.changes > 0
|
||||
}
|
||||
@@ -8,6 +8,12 @@ export interface DbExam {
|
||||
exam_json: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
share_token: string | null
|
||||
is_public: number
|
||||
is_favorite: number
|
||||
tags: string | null
|
||||
share_mode: string | null
|
||||
cloned_from_share_token: string | null
|
||||
}
|
||||
|
||||
export interface ExamListItem {
|
||||
@@ -17,14 +23,18 @@ export interface ExamListItem {
|
||||
created_at: number
|
||||
updated_at: number
|
||||
question_count: number
|
||||
share_token: string | null
|
||||
is_favorite: number
|
||||
tags: string
|
||||
collection_id: number | null
|
||||
}
|
||||
|
||||
export function createExam(userId: number, title: string, subtitle: string | null, examJson: string): { id: number } {
|
||||
export function createExam(userId: number, title: string, subtitle: string | null, examJson: string, tagsJson: string = '[]'): { id: number } {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
INSERT INTO saved_exams (user_id, title, subtitle, exam_json)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(userId, title, subtitle, examJson)
|
||||
INSERT INTO saved_exams (user_id, title, subtitle, exam_json, tags)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(userId, title, subtitle, examJson, tagsJson)
|
||||
return { id: result.lastInsertRowid as number }
|
||||
}
|
||||
|
||||
@@ -36,16 +46,24 @@ export function getExamById(id: number, userId: number): DbExam | undefined {
|
||||
export function listExamsByUser(userId: number): ExamListItem[] {
|
||||
const db = getDb()
|
||||
return db.prepare(`
|
||||
SELECT id, title, subtitle, created_at, updated_at,
|
||||
SELECT id, title, subtitle, created_at, updated_at, share_token, is_favorite, tags, collection_id,
|
||||
json_array_length(json_extract(exam_json, '$.questions')) AS question_count
|
||||
FROM saved_exams
|
||||
WHERE user_id = ?
|
||||
ORDER BY updated_at DESC
|
||||
ORDER BY is_favorite DESC, updated_at DESC
|
||||
`).all(userId) as ExamListItem[]
|
||||
}
|
||||
|
||||
export function updateExam(id: number, userId: number, title: string, subtitle: string | null, examJson: string): boolean {
|
||||
export function updateExam(id: number, userId: number, title: string, subtitle: string | null, examJson: string, tagsJson: string | null = null): boolean {
|
||||
const db = getDb()
|
||||
if (tagsJson !== null) {
|
||||
const result = db.prepare(`
|
||||
UPDATE saved_exams
|
||||
SET title = ?, subtitle = ?, exam_json = ?, tags = ?, updated_at = unixepoch()
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(title, subtitle, examJson, tagsJson, id, userId)
|
||||
return result.changes > 0
|
||||
}
|
||||
const result = db.prepare(`
|
||||
UPDATE saved_exams
|
||||
SET title = ?, subtitle = ?, exam_json = ?, updated_at = unixepoch()
|
||||
@@ -65,3 +83,106 @@ export function countExams(): number {
|
||||
const row = db.prepare('SELECT COUNT(*) AS cnt FROM saved_exams').get() as { cnt: number }
|
||||
return row.cnt
|
||||
}
|
||||
|
||||
export function setShareToken(id: number, userId: number, token: string, mode: string = 'clone'): boolean {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
UPDATE saved_exams
|
||||
SET share_token = ?, is_public = 1, share_mode = ?, updated_at = unixepoch()
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(token, mode, id, userId)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function clearShareToken(id: number, userId: number): boolean {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
UPDATE saved_exams
|
||||
SET share_token = NULL, is_public = 0, share_mode = 'clone', updated_at = unixepoch()
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(id, userId)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function getExamByShareToken(token: string): DbExam | undefined {
|
||||
const db = getDb()
|
||||
return db.prepare('SELECT * FROM saved_exams WHERE share_token = ? AND is_public = 1').get(token) as DbExam | undefined
|
||||
}
|
||||
|
||||
export function setShareMode(id: number, userId: number, mode: string): boolean {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
UPDATE saved_exams
|
||||
SET share_mode = ?, updated_at = unixepoch()
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(mode, id, userId)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function cloneExam(userId: number, sourceExam: DbExam, shareToken: string): { id: number } {
|
||||
const db = getDb()
|
||||
const parsed = JSON.parse(sourceExam.exam_json)
|
||||
parsed.id = undefined
|
||||
const result = db.prepare(`
|
||||
INSERT INTO saved_exams (user_id, title, subtitle, exam_json, cloned_from_share_token)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(userId, sourceExam.title + ' (copy)', sourceExam.subtitle, JSON.stringify(parsed), shareToken)
|
||||
return { id: result.lastInsertRowid as number }
|
||||
}
|
||||
|
||||
export interface ClonedExamItem {
|
||||
id: number
|
||||
title: string
|
||||
subtitle: string | null
|
||||
created_at: number
|
||||
updated_at: number
|
||||
question_count: number
|
||||
cloned_from_share_token: string
|
||||
}
|
||||
|
||||
export function listClonedExams(userId: number): ClonedExamItem[] {
|
||||
const db = getDb()
|
||||
return db.prepare(`
|
||||
SELECT id, title, subtitle, created_at, updated_at, cloned_from_share_token,
|
||||
json_array_length(json_extract(exam_json, '$.questions')) AS question_count
|
||||
FROM saved_exams
|
||||
WHERE user_id = ? AND cloned_from_share_token IS NOT NULL
|
||||
ORDER BY updated_at DESC
|
||||
`).all(userId) as ClonedExamItem[]
|
||||
}
|
||||
|
||||
export function setFavorite(id: number, userId: number, isFavorite: boolean): boolean {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
UPDATE saved_exams
|
||||
SET is_favorite = ?, updated_at = unixepoch()
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(isFavorite ? 1 : 0, id, userId)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function getExamRawJson(id: number, userId: number): { title: string; exam_json: string } | undefined {
|
||||
const db = getDb()
|
||||
return db.prepare('SELECT title, exam_json FROM saved_exams WHERE id = ? AND user_id = ?').get(id, userId) as { title: string; exam_json: string } | undefined
|
||||
}
|
||||
|
||||
export function duplicateOwnExam(userId: number, sourceExam: DbExam): { id: number } {
|
||||
const db = getDb()
|
||||
const parsed = JSON.parse(sourceExam.exam_json)
|
||||
parsed.id = undefined
|
||||
const result = db.prepare(`
|
||||
INSERT INTO saved_exams (user_id, title, subtitle, exam_json, tags)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(userId, sourceExam.title + ' (copy)', sourceExam.subtitle, JSON.stringify(parsed), sourceExam.tags ?? '[]')
|
||||
return { id: result.lastInsertRowid as number }
|
||||
}
|
||||
|
||||
export function setExamCollection(id: number, userId: number, collectionId: number | null): boolean {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
UPDATE saved_exams
|
||||
SET collection_id = ?, updated_at = unixepoch()
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(collectionId, id, userId)
|
||||
return result.changes > 0
|
||||
}
|
||||
@@ -110,3 +110,62 @@ export function countResultsByUser(userId: number): number {
|
||||
const row = db.prepare('SELECT COUNT(*) AS cnt FROM exam_results WHERE user_id = ?').get(userId) as { cnt: number }
|
||||
return row.cnt
|
||||
}
|
||||
|
||||
export function getResultsWithExamJson(userId: number, limit = 100): Array<{
|
||||
title: string
|
||||
score_points: number
|
||||
total_points: number
|
||||
correct_count: number
|
||||
graded_count: number
|
||||
completed_at: number
|
||||
exam_json: string
|
||||
exam_id: number | null
|
||||
}> {
|
||||
const db = getDb()
|
||||
return db.prepare(`
|
||||
SELECT title, score_points, total_points, correct_count, graded_count, completed_at, exam_json, exam_id
|
||||
FROM exam_results
|
||||
WHERE user_id = ?
|
||||
ORDER BY completed_at DESC
|
||||
LIMIT ?
|
||||
`).all(userId, limit) as Array<{
|
||||
title: string
|
||||
score_points: number
|
||||
total_points: number
|
||||
correct_count: number
|
||||
graded_count: number
|
||||
completed_at: number
|
||||
exam_json: string
|
||||
exam_id: number | null
|
||||
}>
|
||||
}
|
||||
|
||||
export interface ScoreTrend {
|
||||
completed_at: number
|
||||
score_pct: number
|
||||
title: string
|
||||
exam_id: number | null
|
||||
}
|
||||
|
||||
export function getScoreTrends(userId: number, limit = 30): Array<{
|
||||
completed_at: number
|
||||
score_points: number
|
||||
total_points: number
|
||||
title: string
|
||||
exam_id: number | null
|
||||
}> {
|
||||
const db = getDb()
|
||||
return db.prepare(`
|
||||
SELECT completed_at, score_points, total_points, title, exam_id
|
||||
FROM exam_results
|
||||
WHERE user_id = ? AND total_points > 0
|
||||
ORDER BY completed_at ASC
|
||||
LIMIT ?
|
||||
`).all(userId, limit) as Array<{
|
||||
completed_at: number
|
||||
score_points: number
|
||||
total_points: number
|
||||
title: string
|
||||
exam_id: number | null
|
||||
}>
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { validateBody, validateQuery, validateParams } from '../middleware/validate.js'
|
||||
import { asyncHandler } from '../utils/asyncHandler.js'
|
||||
import { requireAuth, requireAdmin } from '../middleware/auth.js'
|
||||
@@ -7,6 +8,7 @@ import * as userRepo from '../repositories/userRepo.js'
|
||||
import * as resultRepo from '../repositories/resultRepo.js'
|
||||
import * as settingsRepo from '../repositories/settingsRepo.js'
|
||||
import { updateUserRoleSchema, updateSettingSchema, updateSettingsBatchSchema, listUsersQuerySchema, userIdParamSchema } from '../validators/adminSchemas.js'
|
||||
import { gradeQuestionWithAI } from '../services/aiGrading.js'
|
||||
import type { Request } from 'express'
|
||||
|
||||
const router = Router()
|
||||
@@ -140,4 +142,28 @@ router.put(
|
||||
})
|
||||
)
|
||||
|
||||
// POST /api/admin/test-ai
|
||||
router.post(
|
||||
'/test-ai',
|
||||
validateBody(z.object({
|
||||
provider: z.string().min(1),
|
||||
apiKey: z.string().min(1),
|
||||
model: z.string().min(1),
|
||||
baseURL: z.string().optional(),
|
||||
})),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const { provider, apiKey, model, baseURL } = req.body
|
||||
const result = await gradeQuestionWithAI(
|
||||
{
|
||||
type: 'short-answer',
|
||||
question: { prompt: 'What is 2+2?', answer: '4' },
|
||||
answer: '4',
|
||||
language: 'en',
|
||||
},
|
||||
{ provider, apiKey, model, baseURL },
|
||||
)
|
||||
res.json(result)
|
||||
}),
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { validateBody } from '../middleware/validate.js'
|
||||
import { asyncHandler } from '../utils/asyncHandler.js'
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
import { generateQuestions, getHint, tutorChat } from '../services/aiAssist.js'
|
||||
import { getResultById } from '../repositories/resultRepo.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(requireAuth)
|
||||
|
||||
const generateSchema = z.object({
|
||||
topic: z.string().min(1).max(2000),
|
||||
type: z.enum([
|
||||
'multiple-choice',
|
||||
'multiple-select',
|
||||
'short-answer',
|
||||
'translation',
|
||||
'math-input',
|
||||
'essay',
|
||||
'match',
|
||||
'order',
|
||||
]),
|
||||
count: z.number().int().min(1).max(10),
|
||||
language: z.string().min(1).max(20),
|
||||
context: z.string().max(8000).optional(),
|
||||
})
|
||||
|
||||
router.post(
|
||||
'/generate-question',
|
||||
validateBody(generateSchema),
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = req.body as z.infer<typeof generateSchema>
|
||||
const questions = await generateQuestions(body)
|
||||
res.json({ questions })
|
||||
}),
|
||||
)
|
||||
|
||||
const hintSchema = z.object({
|
||||
question: z.record(z.unknown()),
|
||||
language: z.string().min(1).max(20),
|
||||
})
|
||||
|
||||
router.post(
|
||||
'/hint',
|
||||
validateBody(hintSchema),
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = req.body as z.infer<typeof hintSchema>
|
||||
const result = await getHint(body)
|
||||
res.json(result)
|
||||
}),
|
||||
)
|
||||
|
||||
const tutorSchema = z.object({
|
||||
resultId: z.number().int().positive(),
|
||||
messages: z
|
||||
.array(
|
||||
z.object({
|
||||
role: z.enum(['user', 'assistant']),
|
||||
content: z.string().min(1).max(4000),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(40),
|
||||
language: z.string().min(1).max(20),
|
||||
})
|
||||
|
||||
interface TokenBucket {
|
||||
tokens: number
|
||||
lastRefill: number
|
||||
}
|
||||
const TUTOR_RATE_LIMIT = 30
|
||||
const TUTOR_REFILL_MS = 60 * 60 * 1000
|
||||
const tutorBuckets = new Map<number, TokenBucket>()
|
||||
|
||||
function consumeTutorToken(userId: number): boolean {
|
||||
const now = Date.now()
|
||||
const bucket = tutorBuckets.get(userId) ?? { tokens: TUTOR_RATE_LIMIT, lastRefill: now }
|
||||
const elapsed = now - bucket.lastRefill
|
||||
if (elapsed >= TUTOR_REFILL_MS) {
|
||||
bucket.tokens = TUTOR_RATE_LIMIT
|
||||
bucket.lastRefill = now
|
||||
}
|
||||
if (bucket.tokens <= 0) {
|
||||
tutorBuckets.set(userId, bucket)
|
||||
return false
|
||||
}
|
||||
bucket.tokens -= 1
|
||||
tutorBuckets.set(userId, bucket)
|
||||
return true
|
||||
}
|
||||
|
||||
router.post(
|
||||
'/tutor-chat',
|
||||
validateBody(tutorSchema),
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = req.body as z.infer<typeof tutorSchema>
|
||||
const userId = req.user!.userId
|
||||
|
||||
if (!consumeTutorToken(userId)) {
|
||||
res.status(429).json({ error: 'Tutor chat rate limit reached. Please try again later.' })
|
||||
return
|
||||
}
|
||||
|
||||
const result = getResultById(body.resultId, userId)
|
||||
if (!result) {
|
||||
res.status(404).json({ error: 'Result not found' })
|
||||
return
|
||||
}
|
||||
|
||||
let exam: Record<string, unknown> = {}
|
||||
let state: Record<string, unknown> = {}
|
||||
try {
|
||||
exam = JSON.parse(result.exam_json)
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Stored exam data is corrupted' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
state = JSON.parse(result.state_json)
|
||||
} catch {
|
||||
state = {}
|
||||
}
|
||||
|
||||
const reply = await tutorChat({
|
||||
exam,
|
||||
state,
|
||||
scoreSummary: {
|
||||
title: result.title,
|
||||
scorePoints: result.score_points,
|
||||
totalPoints: result.total_points,
|
||||
correctCount: result.correct_count,
|
||||
gradedCount: result.graded_count,
|
||||
},
|
||||
messages: body.messages,
|
||||
language: body.language,
|
||||
})
|
||||
|
||||
res.json(reply)
|
||||
}),
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Router } from 'express'
|
||||
import { validateBody, validateParams } from '../middleware/validate.js'
|
||||
import { asyncHandler } from '../utils/asyncHandler.js'
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
import { AppError } from '../middleware/error.js'
|
||||
import * as collectionRepo from '../repositories/collectionRepo.js'
|
||||
import * as examRepo from '../repositories/examRepo.js'
|
||||
import {
|
||||
createCollectionSchema,
|
||||
updateCollectionSchema,
|
||||
collectionIdParamSchema,
|
||||
moveExamSchema,
|
||||
} from '../validators/collectionSchemas.js'
|
||||
import { examIdParamSchema } from '../validators/examSchemas.js'
|
||||
import type { Request } from 'express'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireAuth)
|
||||
|
||||
// GET /api/collections
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const collections = collectionRepo.listCollectionsByUser(req.user!.userId)
|
||||
res.json(collections)
|
||||
})
|
||||
)
|
||||
|
||||
// POST /api/collections
|
||||
router.post(
|
||||
'/',
|
||||
validateBody(createCollectionSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const { id } = collectionRepo.createCollection(req.user!.userId, req.body.name)
|
||||
res.status(201).json({ id, name: req.body.name })
|
||||
})
|
||||
)
|
||||
|
||||
// PUT /api/collections/:id
|
||||
router.put(
|
||||
'/:id',
|
||||
validateParams(collectionIdParamSchema),
|
||||
validateBody(updateCollectionSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const updated = collectionRepo.updateCollection(id, req.user!.userId, req.body.name)
|
||||
if (!updated) {
|
||||
throw new AppError(404, 'Collection not found')
|
||||
}
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
// DELETE /api/collections/:id
|
||||
router.delete(
|
||||
'/:id',
|
||||
validateParams(collectionIdParamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const deleted = collectionRepo.deleteCollection(id, req.user!.userId)
|
||||
if (!deleted) {
|
||||
throw new AppError(404, 'Collection not found')
|
||||
}
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
// POST /api/collections/move/:examId
|
||||
router.post(
|
||||
'/move/:examId',
|
||||
validateParams(examIdParamSchema),
|
||||
validateBody(moveExamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const examId = Number(req.params.examId)
|
||||
const collectionId = req.body.collectionId ?? null
|
||||
|
||||
// Verify exam belongs to user
|
||||
const exam = examRepo.getExamById(examId, req.user!.userId)
|
||||
if (!exam) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
|
||||
// If collectionId provided, verify it belongs to user
|
||||
if (collectionId !== null) {
|
||||
const collection = collectionRepo.getCollectionById(collectionId, req.user!.userId)
|
||||
if (!collection) {
|
||||
throw new AppError(404, 'Collection not found')
|
||||
}
|
||||
}
|
||||
|
||||
examRepo.setExamCollection(examId, req.user!.userId, collectionId)
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
+132
-4
@@ -4,7 +4,7 @@ import { asyncHandler } from '../utils/asyncHandler.js'
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
import { AppError } from '../middleware/error.js'
|
||||
import * as examRepo from '../repositories/examRepo.js'
|
||||
import { saveExamSchema, updateExamSchema, examIdParamSchema } from '../validators/examSchemas.js'
|
||||
import { saveExamSchema, updateExamSchema, examIdParamSchema, favoriteSchema, shareModeSchema, batchDeleteSchema } from '../validators/examSchemas.js'
|
||||
import { sanitizeExam } from '../utils/sanitizeExam.js'
|
||||
import type { Request } from 'express'
|
||||
|
||||
@@ -33,7 +33,14 @@ router.get(
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
const parsedExam = JSON.parse(exam.exam_json)
|
||||
res.json({ ...parsedExam, id: exam.id })
|
||||
let tags: string[] = []
|
||||
try {
|
||||
tags = JSON.parse(exam.tags ?? '[]')
|
||||
if (!Array.isArray(tags)) tags = []
|
||||
} catch {
|
||||
tags = []
|
||||
}
|
||||
res.json({ ...parsedExam, id: exam.id, tags })
|
||||
})
|
||||
)
|
||||
|
||||
@@ -43,11 +50,13 @@ router.post(
|
||||
validateBody(saveExamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const exam = sanitizeExam(req.body.exam) as typeof req.body.exam
|
||||
const tags = Array.isArray(req.body.tags) ? req.body.tags : []
|
||||
const { id } = examRepo.createExam(
|
||||
req.user!.userId,
|
||||
exam.title,
|
||||
exam.subtitle ?? null,
|
||||
JSON.stringify(exam)
|
||||
JSON.stringify(exam),
|
||||
JSON.stringify(tags)
|
||||
)
|
||||
res.status(201).json({ id, title: exam.title })
|
||||
})
|
||||
@@ -61,12 +70,14 @@ router.put(
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const exam = sanitizeExam(req.body.exam) as typeof req.body.exam
|
||||
const tags = Array.isArray(req.body.tags) ? JSON.stringify(req.body.tags) : null
|
||||
const updated = examRepo.updateExam(
|
||||
id,
|
||||
req.user!.userId,
|
||||
exam.title,
|
||||
exam.subtitle ?? null,
|
||||
JSON.stringify(exam)
|
||||
JSON.stringify(exam),
|
||||
tags
|
||||
)
|
||||
if (!updated) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
@@ -89,4 +100,121 @@ router.delete(
|
||||
})
|
||||
)
|
||||
|
||||
// POST /api/exams/:id/share
|
||||
router.post(
|
||||
'/:id/share',
|
||||
validateParams(examIdParamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const exam = examRepo.getExamById(id, req.user!.userId)
|
||||
if (!exam) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
const token = exam.share_token ?? crypto.randomUUID().replace(/-/g, '')
|
||||
const mode = exam.share_mode ?? 'clone'
|
||||
examRepo.setShareToken(id, req.user!.userId, token, mode)
|
||||
res.json({ shareToken: token, mode })
|
||||
})
|
||||
)
|
||||
|
||||
// PUT /api/exams/:id/share-mode
|
||||
router.put(
|
||||
'/:id/share-mode',
|
||||
validateParams(examIdParamSchema),
|
||||
validateBody(shareModeSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const updated = examRepo.setShareMode(id, req.user!.userId, req.body.mode)
|
||||
if (!updated) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
// GET /api/exams/cloned
|
||||
router.get(
|
||||
'/cloned',
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const exams = examRepo.listClonedExams(req.user!.userId)
|
||||
res.json(exams)
|
||||
})
|
||||
)
|
||||
|
||||
// POST /api/exams/batch-delete
|
||||
router.post(
|
||||
'/batch-delete',
|
||||
validateBody(batchDeleteSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const ids: number[] = req.body.ids
|
||||
let deleted = 0
|
||||
for (const id of ids) {
|
||||
if (examRepo.deleteExam(id, req.user!.userId)) {
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
res.json({ deleted })
|
||||
})
|
||||
)
|
||||
|
||||
// POST /api/exams/:id/unshare
|
||||
router.post(
|
||||
'/:id/unshare',
|
||||
validateParams(examIdParamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const cleared = examRepo.clearShareToken(id, req.user!.userId)
|
||||
if (!cleared) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
// PATCH /api/exams/:id/favorite
|
||||
router.patch(
|
||||
'/:id/favorite',
|
||||
validateParams(examIdParamSchema),
|
||||
validateBody(favoriteSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const updated = examRepo.setFavorite(id, req.user!.userId, req.body.isFavorite)
|
||||
if (!updated) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
// GET /api/exams/:id/export
|
||||
router.get(
|
||||
'/:id/export',
|
||||
validateParams(examIdParamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const raw = examRepo.getExamRawJson(id, req.user!.userId)
|
||||
if (!raw) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(raw.title)}.json"`)
|
||||
res.send(raw.exam_json)
|
||||
})
|
||||
)
|
||||
|
||||
// POST /api/exams/:id/duplicate
|
||||
router.post(
|
||||
'/:id/duplicate',
|
||||
validateParams(examIdParamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const exam = examRepo.getExamById(id, req.user!.userId)
|
||||
if (!exam) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
const { id: newId } = examRepo.duplicateOwnExam(req.user!.userId, exam)
|
||||
res.status(201).json({ id: newId, title: exam.title + ' (copy)' })
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { validateBody } from '../middleware/validate.js'
|
||||
import { asyncHandler } from '../utils/asyncHandler.js'
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
import { gradeQuestionWithAI } from '../services/aiGrading.js'
|
||||
|
||||
const gradeRequestSchema = z.object({
|
||||
type: z.enum(['essay', 'short-answer', 'translation', 'math', 'ordering', 'match']),
|
||||
question: z.record(z.unknown()),
|
||||
answer: z.unknown(),
|
||||
language: z.string().min(1),
|
||||
})
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireAuth)
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
validateBody(gradeRequestSchema),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { type, question, answer, language } = req.body as z.infer<typeof gradeRequestSchema>
|
||||
const result = await gradeQuestionWithAI({ type, question, answer, language })
|
||||
res.json(result)
|
||||
}),
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -4,7 +4,9 @@ import { asyncHandler } from '../utils/asyncHandler.js'
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
import { AppError } from '../middleware/error.js'
|
||||
import * as resultRepo from '../repositories/resultRepo.js'
|
||||
import * as examRepo from '../repositories/examRepo.js'
|
||||
import { submitResultSchema, resultIdParamSchema } from '../validators/resultSchemas.js'
|
||||
import { analyzeResults } from '../services/studyCoach.js'
|
||||
import type { Request } from 'express'
|
||||
|
||||
const router = Router()
|
||||
@@ -78,4 +80,50 @@ router.delete(
|
||||
})
|
||||
)
|
||||
|
||||
// GET /api/results/analyze
|
||||
router.get(
|
||||
'/analyze',
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const rawResults = resultRepo.getResultsWithExamJson(req.user!.userId, 200)
|
||||
const lang = typeof req.query.lang === 'string' ? req.query.lang : 'en'
|
||||
const analysis = await analyzeResults(rawResults, lang)
|
||||
|
||||
// Find weakest exam (lowest score that still exists in workspace)
|
||||
let weakestExamId: number | null = null
|
||||
if (rawResults.length > 0) {
|
||||
const sorted = [...rawResults].sort((a, b) => {
|
||||
const pa = a.total_points > 0 ? a.score_points / a.total_points : 0
|
||||
const pb = b.total_points > 0 ? b.score_points / b.total_points : 0
|
||||
return pa - pb
|
||||
})
|
||||
for (const r of sorted) {
|
||||
if (r.exam_id) {
|
||||
const exam = examRepo.getExamById(r.exam_id, req.user!.userId)
|
||||
if (exam) {
|
||||
weakestExamId = r.exam_id
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ ...analysis, weakestExamId })
|
||||
})
|
||||
)
|
||||
|
||||
// GET /api/results/trends
|
||||
router.get(
|
||||
'/trends',
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const raw = resultRepo.getScoreTrends(req.user!.userId, 30)
|
||||
const trends = raw.map((r) => ({
|
||||
completed_at: r.completed_at,
|
||||
score_pct: r.total_points > 0 ? Math.round((r.score_points / r.total_points) * 100) : 0,
|
||||
title: r.title,
|
||||
exam_id: r.exam_id,
|
||||
}))
|
||||
res.json(trends)
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Router } from 'express'
|
||||
import { asyncHandler } from '../utils/asyncHandler.js'
|
||||
import { AppError } from '../middleware/error.js'
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
import * as examRepo from '../repositories/examRepo.js'
|
||||
import type { Request } from 'express'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// GET /api/share/:token — public, no auth required
|
||||
router.get(
|
||||
'/:token',
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const token = String(req.params.token)
|
||||
const exam = examRepo.getExamByShareToken(token)
|
||||
if (!exam) {
|
||||
throw new AppError(404, 'Shared exam not found')
|
||||
}
|
||||
const parsed = JSON.parse(exam.exam_json)
|
||||
res.json({
|
||||
title: exam.title,
|
||||
subtitle: exam.subtitle,
|
||||
questionCount: JSON.parse(exam.exam_json).questions?.length ?? 0,
|
||||
exam: parsed,
|
||||
shareMode: exam.share_mode ?? 'clone',
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// POST /api/share/:token/clone — requires auth
|
||||
router.post(
|
||||
'/:token/clone',
|
||||
requireAuth,
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const token = String(req.params.token)
|
||||
const source = examRepo.getExamByShareToken(token)
|
||||
if (!source) {
|
||||
throw new AppError(404, 'Shared exam not found')
|
||||
}
|
||||
const { id } = examRepo.cloneExam(req.user!.userId, source, token)
|
||||
res.status(201).json({ id, title: source.title + ' (copy)' })
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,259 @@
|
||||
import { z } from 'zod'
|
||||
import { getAIClient, getAIModel } from './aiClient.js'
|
||||
|
||||
export type QuestionType =
|
||||
| 'multiple-choice'
|
||||
| 'multiple-select'
|
||||
| 'short-answer'
|
||||
| 'translation'
|
||||
| 'math-input'
|
||||
| 'essay'
|
||||
| 'match'
|
||||
| 'order'
|
||||
|
||||
const choiceSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
text: z.string(),
|
||||
})
|
||||
|
||||
const matchItemSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
text: z.string(),
|
||||
})
|
||||
|
||||
const generatedQuestionSchema = z.object({
|
||||
type: z.enum([
|
||||
'multiple-choice',
|
||||
'multiple-select',
|
||||
'short-answer',
|
||||
'translation',
|
||||
'math-input',
|
||||
'essay',
|
||||
'match',
|
||||
'order',
|
||||
]),
|
||||
prompt: z.string().min(1),
|
||||
category: z.string().optional(),
|
||||
points: z.number().int().positive().optional(),
|
||||
explanation: z.string().optional(),
|
||||
answer: z.union([z.string(), z.array(z.string()), z.record(z.string())]).optional(),
|
||||
acceptedForms: z.array(z.string()).optional(),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
sourceLanguage: z.string().optional(),
|
||||
targetLanguage: z.string().optional(),
|
||||
choices: z.array(choiceSchema).optional(),
|
||||
minWords: z.number().int().nonnegative().optional(),
|
||||
rubric: z.array(z.string()).optional(),
|
||||
left: z.array(matchItemSchema).optional(),
|
||||
right: z.array(matchItemSchema).optional(),
|
||||
items: z.array(matchItemSchema).optional(),
|
||||
})
|
||||
|
||||
export type GeneratedQuestion = z.infer<typeof generatedQuestionSchema>
|
||||
|
||||
export interface GenerateQuestionsRequest {
|
||||
topic: string
|
||||
type: QuestionType
|
||||
count: number
|
||||
language: string
|
||||
context?: string
|
||||
}
|
||||
|
||||
function shapeHintForType(type: QuestionType): string {
|
||||
switch (type) {
|
||||
case 'multiple-choice':
|
||||
return `Required fields: prompt (string), choices (array of {id, text}; ids should be short like "a","b","c","d"; provide 3-4 plausible distractors), answer (string id of the correct choice).`
|
||||
case 'multiple-select':
|
||||
return `Required fields: prompt (string), choices (array of {id, text}; ids should be short like "a","b","c","d"), answer (array of correct choice ids).`
|
||||
case 'short-answer':
|
||||
return `Required fields: prompt (string), answer (string), acceptedForms (array of acceptable alternate strings, may be empty), caseSensitive (boolean, usually false).`
|
||||
case 'translation':
|
||||
return `Required fields: prompt (the source text), sourceLanguage (e.g. "en"), targetLanguage (e.g. "es"), answer (the canonical translation), acceptedForms (other valid translations).`
|
||||
case 'math-input':
|
||||
return `Required fields: prompt (string, may include LaTeX), answer (LaTeX string), acceptedForms (array of equivalent LaTeX forms).`
|
||||
case 'essay':
|
||||
return `Required fields: prompt (string), minWords (integer, e.g. 100), rubric (array of 3-5 short rubric criteria strings).`
|
||||
case 'match':
|
||||
return `Required fields: prompt (string), left (array of {id, text} 3-5 items), right (array of {id, text} 3-5 items), answer (object mapping left.id -> right.id).`
|
||||
case 'order':
|
||||
return `Required fields: prompt (string), items (array of {id, text} 3-6 items in any order), answer (array of item ids in the correct order).`
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateQuestions(req: GenerateQuestionsRequest): Promise<GeneratedQuestion[]> {
|
||||
const client = getAIClient()
|
||||
if (!client) {
|
||||
throw new Error('AI is not configured. Please set provider and API key in admin settings.')
|
||||
}
|
||||
|
||||
const count = Math.max(1, Math.min(10, Math.floor(req.count)))
|
||||
const model = getAIModel()
|
||||
const shape = shapeHintForType(req.type)
|
||||
|
||||
const prompt = `You are an exam author. Generate ${count} high-quality "${req.type}" exam question(s) about the following topic.
|
||||
|
||||
Topic: ${req.topic}
|
||||
${req.context ? `\nAdditional context / source material:\n${req.context}\n` : ''}
|
||||
Write all user-facing text (prompts, choices, rubric items, explanations) in ${req.language}.
|
||||
|
||||
${shape}
|
||||
|
||||
Respond ONLY with a JSON object of the form:
|
||||
{ "questions": [ ... ${count} question object(s) ... ] }
|
||||
|
||||
Each question object must have "type": "${req.type}" and the fields listed above. Do not include "id" — that will be assigned later. Do not include any text outside the JSON object.`
|
||||
|
||||
const completion = await client.chat.completions.create({
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.6,
|
||||
max_completion_tokens: 1800,
|
||||
})
|
||||
|
||||
const raw = completion.choices[0]?.message?.content
|
||||
if (!raw) {
|
||||
throw new Error('Empty response from AI provider')
|
||||
}
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
throw new Error('Invalid JSON response from AI provider')
|
||||
}
|
||||
|
||||
const obj = parsed as { questions?: unknown }
|
||||
if (!Array.isArray(obj.questions)) {
|
||||
throw new Error('AI response did not include a "questions" array')
|
||||
}
|
||||
|
||||
const validated: GeneratedQuestion[] = []
|
||||
for (const q of obj.questions) {
|
||||
const result = generatedQuestionSchema.safeParse(q)
|
||||
if (result.success && result.data.type === req.type) {
|
||||
validated.push(result.data)
|
||||
}
|
||||
}
|
||||
|
||||
if (validated.length === 0) {
|
||||
throw new Error('AI returned no valid questions for the requested type')
|
||||
}
|
||||
return validated
|
||||
}
|
||||
|
||||
export interface HintRequest {
|
||||
question: Record<string, unknown>
|
||||
language: string
|
||||
}
|
||||
|
||||
const HINT_STRIP_KEYS = ['answer', 'acceptedForms']
|
||||
|
||||
function stripAnswerFields(question: Record<string, unknown>): Record<string, unknown> {
|
||||
const clone: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(question)) {
|
||||
if (HINT_STRIP_KEYS.includes(k)) continue
|
||||
clone[k] = v
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
export async function getHint(req: HintRequest): Promise<{ hint: string }> {
|
||||
const client = getAIClient()
|
||||
if (!client) {
|
||||
throw new Error('AI is not configured. Please set provider and API key in admin settings.')
|
||||
}
|
||||
|
||||
const safeQuestion = stripAnswerFields(req.question)
|
||||
const model = getAIModel()
|
||||
|
||||
const completion = await client.chat.completions.create({
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `You are a tutor helping a student during an exam. Give exactly one short hint (1-2 sentences) in ${req.language} that nudges the student toward the right approach. Never reveal the answer, never restate the answer in different words, and never give the answer to a multiple-choice option. Focus on the concept or strategy. Respond ONLY with a JSON object: { "hint": string }.`,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Question:\n${JSON.stringify(safeQuestion)}`,
|
||||
},
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.4,
|
||||
max_completion_tokens: 160,
|
||||
})
|
||||
|
||||
const raw = completion.choices[0]?.message?.content
|
||||
if (!raw) {
|
||||
throw new Error('Empty response from AI provider')
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
throw new Error('Invalid JSON response from AI provider')
|
||||
}
|
||||
const obj = parsed as Record<string, unknown>
|
||||
const hint = typeof obj.hint === 'string' ? obj.hint.trim() : ''
|
||||
if (!hint) {
|
||||
throw new Error('AI did not return a hint')
|
||||
}
|
||||
return { hint }
|
||||
}
|
||||
|
||||
export interface TutorChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface TutorChatRequest {
|
||||
exam: Record<string, unknown>
|
||||
state: Record<string, unknown>
|
||||
scoreSummary: {
|
||||
title: string
|
||||
scorePoints: number
|
||||
totalPoints: number
|
||||
correctCount: number
|
||||
gradedCount: number
|
||||
}
|
||||
messages: TutorChatMessage[]
|
||||
language: string
|
||||
}
|
||||
|
||||
export async function tutorChat(req: TutorChatRequest): Promise<{ reply: string }> {
|
||||
const client = getAIClient()
|
||||
if (!client) {
|
||||
throw new Error('AI is not configured. Please set provider and API key in admin settings.')
|
||||
}
|
||||
|
||||
const model = getAIModel()
|
||||
const recent = req.messages.slice(-10)
|
||||
|
||||
const systemPrompt = `You are a friendly, concise tutor reviewing a completed exam attempt with a student. Reply in ${req.language}. Keep answers short (under 200 words). When the student references a question by number, use the exam's question order. You may explain mistakes, give similar practice problems, or clarify rubric criteria. You may reference the student's actual answers and the AI feedback already produced. Never invent score numbers — use only what's in the exam summary.
|
||||
|
||||
Exam summary:
|
||||
- Title: ${req.scoreSummary.title}
|
||||
- Score: ${req.scoreSummary.scorePoints}/${req.scoreSummary.totalPoints}
|
||||
- Correct: ${req.scoreSummary.correctCount}/${req.scoreSummary.gradedCount}
|
||||
|
||||
Exam (JSON): ${JSON.stringify(req.exam).slice(0, 8000)}
|
||||
|
||||
Student's submission state (answers, flags, etc., JSON): ${JSON.stringify(req.state).slice(0, 4000)}`
|
||||
|
||||
const completion = await client.chat.completions.create({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...recent.map((m) => ({ role: m.role, content: m.content })),
|
||||
],
|
||||
temperature: 0.5,
|
||||
max_completion_tokens: 600,
|
||||
})
|
||||
|
||||
const reply = completion.choices[0]?.message?.content?.trim()
|
||||
if (!reply) {
|
||||
throw new Error('Empty response from AI provider')
|
||||
}
|
||||
return { reply }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import OpenAI from 'openai'
|
||||
import { getSetting } from '../repositories/settingsRepo.js'
|
||||
|
||||
export interface AIClientOverride {
|
||||
provider?: string
|
||||
apiKey?: string
|
||||
baseURL?: string
|
||||
}
|
||||
|
||||
export function getAIClient(override?: AIClientOverride): OpenAI | null {
|
||||
const provider = override?.provider ?? getSetting('ai_provider')
|
||||
const apiKey = override?.apiKey ?? getSetting('ai_api_key')
|
||||
if (!provider || !apiKey) return null
|
||||
|
||||
const customBase = override?.baseURL ?? getSetting('ai_base_url')
|
||||
const baseURL = provider === 'openai' ? undefined : (customBase || undefined)
|
||||
return new OpenAI({ apiKey, baseURL })
|
||||
}
|
||||
|
||||
export function getAIModel(override?: string): string {
|
||||
return override || getSetting('ai_model') || 'gpt-4o-mini'
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { getAIClient, getAIModel } from './aiClient.js'
|
||||
|
||||
export interface GradingResult {
|
||||
suggestedGrade: number
|
||||
feedback: string
|
||||
detail?: unknown
|
||||
}
|
||||
|
||||
interface GradeRequest {
|
||||
type: 'essay' | 'short-answer' | 'translation' | 'math' | 'ordering' | 'match'
|
||||
question: Record<string, unknown>
|
||||
answer: unknown
|
||||
language: string
|
||||
}
|
||||
|
||||
function buildPrompt(req: GradeRequest): string {
|
||||
const { type, question, answer, language } = req
|
||||
|
||||
const base = `You are an exam grading assistant. Grade the student's answer and respond ONLY with a JSON object containing:
|
||||
- "suggestedGrade": number between 0.0 and 1.0
|
||||
- "feedback": string with 1-3 sentences explaining the grade in ${language}
|
||||
- "detail": type-specific breakdown object (see below)
|
||||
|
||||
Do not include any text outside the JSON object.`
|
||||
|
||||
switch (type) {
|
||||
case 'essay': {
|
||||
const rubric = (question.rubric as string[] | undefined) ?? []
|
||||
return `${base}
|
||||
|
||||
Question: ${question.prompt}
|
||||
Rubric criteria: ${rubric.join('; ') || 'None provided'}
|
||||
Student answer: ${String(answer ?? '')}
|
||||
|
||||
Evaluate how well the essay covers the rubric criteria, structure, and quality. Provide "detail" as:
|
||||
{ "rubricBreakdown": [{ "item": string, "met": boolean }] }
|
||||
Each item should correspond to a rubric criterion.`
|
||||
}
|
||||
|
||||
case 'short-answer': {
|
||||
const accepted = [question.answer as string, ...(question.acceptedForms as string[] | undefined ?? [])].filter(Boolean)
|
||||
return `${base}
|
||||
|
||||
Question: ${question.prompt}
|
||||
Accepted answers: ${accepted.join(' | ')}
|
||||
Student answer: ${String(answer ?? '')}
|
||||
|
||||
Determine if the student's answer is semantically equivalent to any accepted form. Provide "detail" as:
|
||||
{ "matchedForm": string | null, "reason": string }`
|
||||
}
|
||||
|
||||
case 'translation': {
|
||||
const accepted = [question.answer as string, ...(question.acceptedForms as string[] | undefined ?? [])].filter(Boolean)
|
||||
const source = question.sourceLanguage as string
|
||||
const target = question.targetLanguage as string
|
||||
return `${base}
|
||||
|
||||
Translate from ${source} to ${target}.
|
||||
Question: ${question.prompt}
|
||||
Accepted translations: ${accepted.join(' | ')}
|
||||
Student answer: ${String(answer ?? '')}
|
||||
|
||||
Evaluate meaning accuracy and fluency, not literal word-for-word matching. Provide "detail" as:
|
||||
{ "accuracy": number (0-1), "fluency": number (0-1), "notes": string }`
|
||||
}
|
||||
|
||||
case 'math': {
|
||||
const accepted = [question.answer as string, ...(question.acceptedForms as string[] | undefined ?? [])].filter(Boolean)
|
||||
return `${base}
|
||||
|
||||
Question: ${question.prompt}
|
||||
Accepted answers (LaTeX): ${accepted.join(' | ')}
|
||||
Student answer (LaTeX): ${String(answer ?? '')}
|
||||
|
||||
Determine if the student's LaTeX expression is mathematically equivalent to the accepted answer. Provide "detail" as:
|
||||
{ "equivalent": boolean, "explanation": string }`
|
||||
}
|
||||
|
||||
case 'ordering': {
|
||||
const correctOrder = question.answer as string[]
|
||||
const items = question.items as Array<{ id: string; text: string }> | undefined
|
||||
const itemMap = new Map(items?.map(i => [i.id, i.text]) ?? [])
|
||||
const given = answer as string[]
|
||||
const correctTexts = correctOrder.map(id => itemMap.get(id) ?? id)
|
||||
const givenTexts = given?.map(id => itemMap.get(id) ?? id) ?? []
|
||||
return `${base}
|
||||
|
||||
Question: ${question.prompt}
|
||||
Correct order: ${correctTexts.join(' → ')}
|
||||
Student order: ${givenTexts.join(' → ')}
|
||||
|
||||
Determine how many items are in the correct position. Provide "detail" as:
|
||||
{ "correctPositions": number[], "totalItems": number }
|
||||
correctPositions should be 1-based indices of items in the correct position.`
|
||||
}
|
||||
|
||||
case 'match': {
|
||||
const correctPairs = question.answer as Record<string, string>
|
||||
const left = question.left as Array<{ id: string; text: string }> | undefined
|
||||
const right = question.right as Array<{ id: string; text: string }> | undefined
|
||||
const leftMap = new Map(left?.map(i => [i.id, i.text]) ?? [])
|
||||
const rightMap = new Map(right?.map(i => [i.id, i.text]) ?? [])
|
||||
const given = (answer as Record<string, string> | undefined) ?? {}
|
||||
const correctPairTexts: string[] = []
|
||||
const incorrectPairTexts: string[] = []
|
||||
for (const [lId, rId] of Object.entries(correctPairs)) {
|
||||
const pairText = `${leftMap.get(lId) ?? lId} → ${rightMap.get(rId) ?? rId}`
|
||||
if (given[lId] === rId) {
|
||||
correctPairTexts.push(pairText)
|
||||
} else {
|
||||
const actualR = given[lId] ? (rightMap.get(given[lId]) ?? given[lId]) : '(unanswered)'
|
||||
incorrectPairTexts.push(`${leftMap.get(lId) ?? lId} → ${actualR} (should be ${rightMap.get(rId) ?? rId})`)
|
||||
}
|
||||
}
|
||||
return `${base}
|
||||
|
||||
Question: ${question.prompt}
|
||||
Correct pairings:
|
||||
${Object.entries(correctPairs).map(([l, r]) => `${leftMap.get(l) ?? l} → ${rightMap.get(r) ?? r}`).join('\n')}
|
||||
Student pairings:
|
||||
${Object.entries(given).map(([l, r]) => `${leftMap.get(l) ?? l} → ${rightMap.get(r) ?? r}`).join('\n')}
|
||||
|
||||
Determine partial credit based on correct pairings. Provide "detail" as:
|
||||
{ "correctPairs": string[], "incorrectPairs": string[] }
|
||||
Use the human-readable text labels in the arrays.`
|
||||
}
|
||||
|
||||
default:
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
export async function gradeQuestionWithAI(
|
||||
req: GradeRequest,
|
||||
override?: { provider?: string; apiKey?: string; baseURL?: string; model?: string },
|
||||
): Promise<GradingResult> {
|
||||
const client = getAIClient(override)
|
||||
if (!client) {
|
||||
throw new Error('AI grading is not configured. Please set provider and API key in admin settings.')
|
||||
}
|
||||
|
||||
const model = getAIModel(override?.model)
|
||||
const prompt = buildPrompt(req)
|
||||
|
||||
const completion = await client.chat.completions.create({
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.2,
|
||||
max_completion_tokens: 800,
|
||||
})
|
||||
|
||||
const raw = completion.choices[0]?.message?.content
|
||||
if (!raw) {
|
||||
throw new Error('Empty response from AI provider')
|
||||
}
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
throw new Error('Invalid JSON response from AI provider')
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>
|
||||
const suggestedGrade = typeof obj.suggestedGrade === 'number'
|
||||
? Math.max(0, Math.min(1, obj.suggestedGrade))
|
||||
: 0
|
||||
|
||||
return {
|
||||
suggestedGrade,
|
||||
feedback: typeof obj.feedback === 'string' ? obj.feedback : '',
|
||||
detail: obj.detail,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { getAIClient, getAIModel } from './aiClient.js'
|
||||
|
||||
export interface WeakArea {
|
||||
category: string
|
||||
avgScore: number
|
||||
attempts: number
|
||||
}
|
||||
|
||||
export interface StudyCoachResult {
|
||||
weakAreas: WeakArea[]
|
||||
studyTips: string[]
|
||||
streakDays: number
|
||||
}
|
||||
|
||||
interface ResultSummary {
|
||||
title: string
|
||||
scorePoints: number
|
||||
totalPoints: number
|
||||
correctCount: number
|
||||
gradedCount: number
|
||||
completedAt: number
|
||||
categories: Record<string, { score: number; total: number }>
|
||||
}
|
||||
|
||||
|
||||
function calculateStreak(results: Array<{ completed_at: number }>): number {
|
||||
if (results.length === 0) return 0
|
||||
const days = new Set<string>()
|
||||
for (const r of results) {
|
||||
const d = new Date(r.completed_at * 1000)
|
||||
days.add(`${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`)
|
||||
}
|
||||
const sorted = Array.from(days).sort()
|
||||
if (sorted.length === 0) return 0
|
||||
|
||||
const today = new Date()
|
||||
const todayStr = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`
|
||||
|
||||
let streak = 0
|
||||
for (let i = sorted.length - 1; i >= 0; i--) {
|
||||
const expected = new Date(today)
|
||||
expected.setDate(today.getDate() - (sorted.length - 1 - i))
|
||||
const expectedStr = `${expected.getFullYear()}-${expected.getMonth()}-${expected.getDate()}`
|
||||
if (sorted[i] === expectedStr || (i === sorted.length - 1 && sorted[i] === todayStr)) {
|
||||
streak++
|
||||
} else if (i === sorted.length - 1) {
|
||||
// Most recent is not today — check if it was yesterday
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(today.getDate() - 1)
|
||||
const yestStr = `${yesterday.getFullYear()}-${yesterday.getMonth()}-${yesterday.getDate()}`
|
||||
if (sorted[i] === yestStr) {
|
||||
streak++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return streak
|
||||
}
|
||||
|
||||
export async function analyzeResults(
|
||||
results: Array<{
|
||||
title: string
|
||||
score_points: number
|
||||
total_points: number
|
||||
correct_count: number
|
||||
graded_count: number
|
||||
completed_at: number
|
||||
exam_json: string
|
||||
}>,
|
||||
language = 'en',
|
||||
): Promise<StudyCoachResult> {
|
||||
const streakDays = calculateStreak(results)
|
||||
|
||||
// Build per-category stats from exam_json
|
||||
const categoryStats: Record<string, { score: number; total: number; attempts: number }> = {}
|
||||
for (const r of results) {
|
||||
let exam: { questions?: Array<{ category?: string; points?: number }> } | undefined
|
||||
try {
|
||||
exam = JSON.parse(r.exam_json)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const questions = exam?.questions ?? []
|
||||
// We don't have per-question results in the stored result, so we approximate
|
||||
// using the overall score distributed across categories proportionally
|
||||
const totalQuestionPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0)
|
||||
if (totalQuestionPoints === 0) continue
|
||||
const overallRatio = r.total_points > 0 ? r.score_points / r.total_points : 0
|
||||
for (const q of questions) {
|
||||
const cat = q.category ?? 'Uncategorized'
|
||||
const pts = q.points ?? 1
|
||||
if (!categoryStats[cat]) {
|
||||
categoryStats[cat] = { score: 0, total: 0, attempts: 0 }
|
||||
}
|
||||
categoryStats[cat].score += pts * overallRatio
|
||||
categoryStats[cat].total += pts
|
||||
}
|
||||
// Mark attempt for each unique category in this exam
|
||||
const catsInExam = new Set(questions.map((q) => q.category ?? 'Uncategorized'))
|
||||
for (const cat of catsInExam) {
|
||||
categoryStats[cat].attempts++
|
||||
}
|
||||
}
|
||||
|
||||
const weakAreas: WeakArea[] = Object.entries(categoryStats)
|
||||
.map(([category, stats]) => ({
|
||||
category,
|
||||
avgScore: stats.total > 0 ? Math.round((stats.score / stats.total) * 100) : 0,
|
||||
attempts: stats.attempts,
|
||||
}))
|
||||
.filter((w) => w.attempts >= 1)
|
||||
.sort((a, b) => a.avgScore - b.avgScore)
|
||||
.slice(0, 5)
|
||||
|
||||
const client = getAIClient()
|
||||
if (!client || weakAreas.length === 0) {
|
||||
return { weakAreas, studyTips: [], streakDays }
|
||||
}
|
||||
|
||||
const model = getAIModel()
|
||||
const prompt = `You are a study coach. A student has taken several practice exams. Based on their performance per category, suggest 2-3 concise study tips to help them improve in their weakest areas. Respond ONLY with a JSON object containing:
|
||||
- "studyTips": string[] (2-3 short tips in ${language})
|
||||
|
||||
Do not include any text outside the JSON object.
|
||||
|
||||
Weak areas (score %, attempts):
|
||||
${weakAreas.map((w) => `- ${w.category}: ${w.avgScore}% over ${w.attempts} attempt(s)`).join('\n')}`
|
||||
|
||||
try {
|
||||
const completion = await client.chat.completions.create({
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.4,
|
||||
max_completion_tokens: 400,
|
||||
})
|
||||
|
||||
const raw = completion.choices[0]?.message?.content
|
||||
if (!raw) {
|
||||
return { weakAreas, studyTips: [], streakDays }
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>
|
||||
const tips = Array.isArray(parsed.studyTips)
|
||||
? parsed.studyTips.filter((t): t is string => typeof t === 'string')
|
||||
: []
|
||||
|
||||
return { weakAreas, studyTips: tips, streakDays }
|
||||
} catch {
|
||||
return { weakAreas, studyTips: [], streakDays }
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,16 @@ import { describe, it, expect } from 'vitest'
|
||||
import { updateUserRoleSchema, updateSettingSchema, updateSettingsBatchSchema, listUsersQuerySchema, userIdParamSchema } from './adminSchemas'
|
||||
|
||||
describe('updateUserRoleSchema', () => {
|
||||
it('accepts valid role change', () => {
|
||||
it('accepts valid role change to admin', () => {
|
||||
const result = updateUserRoleSchema.safeParse({ user_id: 1, role: 'admin' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts valid role change to user', () => {
|
||||
const result = updateUserRoleSchema.safeParse({ user_id: 5, role: 'user' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid role', () => {
|
||||
const result = updateUserRoleSchema.safeParse({ user_id: 1, role: 'superadmin' })
|
||||
expect(result.success).toBe(false)
|
||||
@@ -16,6 +21,21 @@ describe('updateUserRoleSchema', () => {
|
||||
const result = updateUserRoleSchema.safeParse({ user_id: 0, role: 'admin' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects negative user_id', () => {
|
||||
const result = updateUserRoleSchema.safeParse({ user_id: -1, role: 'admin' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects non-numeric user_id', () => {
|
||||
const result = updateUserRoleSchema.safeParse({ user_id: '1', role: 'admin' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects missing fields', () => {
|
||||
expect(updateUserRoleSchema.safeParse({ user_id: 1 }).success).toBe(false)
|
||||
expect(updateUserRoleSchema.safeParse({ role: 'admin' }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateSettingSchema', () => {
|
||||
@@ -24,15 +44,38 @@ describe('updateSettingSchema', () => {
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects unknown key', () => {
|
||||
const result = updateSettingSchema.safeParse({ key: 'unknown_key', value: 'x' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty key', () => {
|
||||
const result = updateSettingSchema.safeParse({ key: '', value: 'BrainWave' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty value', () => {
|
||||
const result = updateSettingSchema.safeParse({ key: 'site_name', value: '' })
|
||||
it('accepts empty value (clearing a setting)', () => {
|
||||
const result = updateSettingSchema.safeParse({ key: 'smtp_pass', value: '' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-string value', () => {
|
||||
const result = updateSettingSchema.safeParse({ key: 'site_name', value: 123 })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts each allowed key', () => {
|
||||
const keys = [
|
||||
'site_name', 'site_language', 'maintenance_mode', 'maintenance_message',
|
||||
'allow_guest_mode', 'registration_enabled', 'default_theme',
|
||||
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_secure',
|
||||
'app_url', 'max_upload_mb', 'ai_provider', 'ai_model', 'ai_api_key', 'ai_base_url',
|
||||
]
|
||||
for (const key of keys) {
|
||||
const result = updateSettingSchema.safeParse({ key, value: 'x' })
|
||||
expect(result.success, `key ${key} should be accepted`).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateSettingsBatchSchema', () => {
|
||||
@@ -43,29 +86,39 @@ describe('updateSettingsBatchSchema', () => {
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid key in batch', () => {
|
||||
it('accepts empty settings record', () => {
|
||||
const result = updateSettingsBatchSchema.safeParse({ settings: {} })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts empty value in batch (clearing)', () => {
|
||||
const result = updateSettingsBatchSchema.safeParse({
|
||||
settings: { site_name: 'BrainWave', unknown_key: 'value' },
|
||||
settings: { smtp_pass: '' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-string value in batch', () => {
|
||||
const result = updateSettingsBatchSchema.safeParse({
|
||||
settings: { site_name: 42 },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty value in batch', () => {
|
||||
const result = updateSettingsBatchSchema.safeParse({
|
||||
settings: { site_name: '' },
|
||||
})
|
||||
it('rejects missing settings property', () => {
|
||||
const result = updateSettingsBatchSchema.safeParse({})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('listUsersQuerySchema', () => {
|
||||
it('accepts empty query', () => {
|
||||
it('accepts empty query and defaults page to 1', () => {
|
||||
const result = listUsersQuerySchema.safeParse({})
|
||||
expect(result.success).toBe(true)
|
||||
expect((result as any).data.page).toBe(1)
|
||||
})
|
||||
|
||||
it('accepts page string', () => {
|
||||
it('parses page string into number', () => {
|
||||
const result = listUsersQuerySchema.safeParse({ page: '3' })
|
||||
expect(result.success).toBe(true)
|
||||
expect((result as any).data.page).toBe(3)
|
||||
@@ -76,9 +129,26 @@ describe('listUsersQuerySchema', () => {
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects negative page', () => {
|
||||
const result = listUsersQuerySchema.safeParse({ page: '-1' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects decimal page', () => {
|
||||
const result = listUsersQuerySchema.safeParse({ page: '1.5' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts search', () => {
|
||||
const result = listUsersQuerySchema.safeParse({ search: 'john' })
|
||||
expect(result.success).toBe(true)
|
||||
expect((result as any).data.search).toBe('john')
|
||||
})
|
||||
|
||||
it('accepts both page and search', () => {
|
||||
const result = listUsersQuerySchema.safeParse({ page: '2', search: 'jane' })
|
||||
expect(result.success).toBe(true)
|
||||
expect((result as any).data).toEqual({ page: 2, search: 'jane' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -88,8 +158,24 @@ describe('userIdParamSchema', () => {
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts multi-digit id', () => {
|
||||
const result = userIdParamSchema.safeParse({ id: '12345' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-numeric id', () => {
|
||||
const result = userIdParamSchema.safeParse({ id: 'seven' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty id', () => {
|
||||
const result = userIdParamSchema.safeParse({ id: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects id with non-digit characters', () => {
|
||||
expect(userIdParamSchema.safeParse({ id: '1a' }).success).toBe(false)
|
||||
expect(userIdParamSchema.safeParse({ id: '-1' }).success).toBe(false)
|
||||
expect(userIdParamSchema.safeParse({ id: '1.0' }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -21,18 +21,19 @@ const ALLOWED_SETTING_KEYS = [
|
||||
'smtp_secure',
|
||||
'app_url',
|
||||
'max_upload_mb',
|
||||
'ai_provider',
|
||||
'ai_model',
|
||||
'ai_api_key',
|
||||
'ai_base_url',
|
||||
] as const
|
||||
|
||||
export const updateSettingSchema = z.object({
|
||||
key: z.enum(ALLOWED_SETTING_KEYS),
|
||||
value: z.string().min(1),
|
||||
value: z.string(),
|
||||
})
|
||||
|
||||
export const updateSettingsBatchSchema = z.object({
|
||||
settings: z.record(z.string().min(1)).refine(
|
||||
(obj) => Object.keys(obj).every((k) => ALLOWED_SETTING_KEYS.includes(k as typeof ALLOWED_SETTING_KEYS[number])),
|
||||
{ message: 'One or more setting keys are not allowed' }
|
||||
),
|
||||
settings: z.record(z.string()),
|
||||
})
|
||||
|
||||
export const listUsersQuerySchema = z.object({
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
createCollectionSchema,
|
||||
updateCollectionSchema,
|
||||
collectionIdParamSchema,
|
||||
moveExamSchema,
|
||||
} from './collectionSchemas'
|
||||
|
||||
describe('createCollectionSchema', () => {
|
||||
it('accepts valid name', () => {
|
||||
const result = createCollectionSchema.safeParse({ name: 'Math 101' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects empty name', () => {
|
||||
const result = createCollectionSchema.safeParse({ name: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects missing name', () => {
|
||||
const result = createCollectionSchema.safeParse({})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects name longer than 100 chars', () => {
|
||||
const result = createCollectionSchema.safeParse({ name: 'a'.repeat(101) })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts name at max length (100)', () => {
|
||||
const result = createCollectionSchema.safeParse({ name: 'a'.repeat(100) })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-string name', () => {
|
||||
const result = createCollectionSchema.safeParse({ name: 42 })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateCollectionSchema', () => {
|
||||
it('accepts valid name', () => {
|
||||
const result = updateCollectionSchema.safeParse({ name: 'Renamed' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects empty name', () => {
|
||||
const result = updateCollectionSchema.safeParse({ name: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects name over 100 chars', () => {
|
||||
const result = updateCollectionSchema.safeParse({ name: 'b'.repeat(101) })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectionIdParamSchema', () => {
|
||||
it('accepts numeric id', () => {
|
||||
const result = collectionIdParamSchema.safeParse({ id: '12' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-numeric id', () => {
|
||||
const result = collectionIdParamSchema.safeParse({ id: 'abc' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty id', () => {
|
||||
const result = collectionIdParamSchema.safeParse({ id: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects negative id string', () => {
|
||||
const result = collectionIdParamSchema.safeParse({ id: '-3' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('moveExamSchema', () => {
|
||||
it('accepts numeric collectionId', () => {
|
||||
const result = moveExamSchema.safeParse({ collectionId: 4 })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts null collectionId (moving out of any collection)', () => {
|
||||
const result = moveExamSchema.safeParse({ collectionId: null })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts empty payload (collectionId omitted)', () => {
|
||||
const result = moveExamSchema.safeParse({})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects string collectionId', () => {
|
||||
const result = moveExamSchema.safeParse({ collectionId: '4' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const createCollectionSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(100),
|
||||
})
|
||||
|
||||
export const updateCollectionSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(100),
|
||||
})
|
||||
|
||||
export const collectionIdParamSchema = z.object({
|
||||
id: z.string().regex(/^\d+$/, 'ID must be a number'),
|
||||
})
|
||||
|
||||
export const moveExamSchema = z.object({
|
||||
collectionId: z.number().nullable().optional(),
|
||||
})
|
||||
@@ -1,5 +1,13 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { examSchema, saveExamSchema, updateExamSchema, examIdParamSchema } from './examSchemas'
|
||||
import {
|
||||
examSchema,
|
||||
saveExamSchema,
|
||||
updateExamSchema,
|
||||
examIdParamSchema,
|
||||
favoriteSchema,
|
||||
shareModeSchema,
|
||||
batchDeleteSchema,
|
||||
} from './examSchemas'
|
||||
|
||||
describe('examSchema', () => {
|
||||
it('accepts minimal valid exam', () => {
|
||||
@@ -10,6 +18,14 @@ describe('examSchema', () => {
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects empty title', () => {
|
||||
const result = examSchema.safeParse({
|
||||
title: '',
|
||||
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects missing title', () => {
|
||||
const result = examSchema.safeParse({
|
||||
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
|
||||
@@ -41,6 +57,93 @@ describe('examSchema', () => {
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts each supported question type', () => {
|
||||
const types = [
|
||||
'multiple-choice', 'multiple-select', 'short-answer', 'translation',
|
||||
'math-input', 'essay', 'match', 'order',
|
||||
]
|
||||
for (const type of types) {
|
||||
const result = examSchema.safeParse({
|
||||
title: 'X',
|
||||
questions: [{ id: '1', type, prompt: 'Q' }],
|
||||
})
|
||||
expect(result.success, `type ${type} should be accepted`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects unsupported question type', () => {
|
||||
const result = examSchema.safeParse({
|
||||
title: 'X',
|
||||
questions: [{ id: '1', type: 'true-false', prompt: 'Q' }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts answer as string, array, or record', () => {
|
||||
const variants = ['answer', ['a', 'b'], { left1: 'right1' }]
|
||||
for (const answer of variants) {
|
||||
const result = examSchema.safeParse({
|
||||
title: 'X',
|
||||
questions: [{ id: '1', type: 'short-answer', prompt: 'Q', answer }],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects non-string/array/record answer', () => {
|
||||
const result = examSchema.safeParse({
|
||||
title: 'X',
|
||||
questions: [{ id: '1', type: 'short-answer', prompt: 'Q', answer: 42 }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts null timeLimitMinutes', () => {
|
||||
const result = examSchema.safeParse({
|
||||
title: 'X',
|
||||
timeLimitMinutes: null,
|
||||
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts feedbackMode end and immediate', () => {
|
||||
for (const feedbackMode of ['end', 'immediate'] as const) {
|
||||
const result = examSchema.safeParse({
|
||||
title: 'X',
|
||||
feedbackMode,
|
||||
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid feedbackMode', () => {
|
||||
const result = examSchema.safeParse({
|
||||
title: 'X',
|
||||
feedbackMode: 'never',
|
||||
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts question with rich content (image, audio, code, table)', () => {
|
||||
const result = examSchema.safeParse({
|
||||
title: 'Rich',
|
||||
questions: [{
|
||||
id: '1',
|
||||
type: 'multiple-choice',
|
||||
prompt: 'Pick one',
|
||||
image: { src: 'a.png', alt: 'desc', caption: 'cap' },
|
||||
audio: { src: 'a.mp3' },
|
||||
code: { language: 'js', content: 'x=1' },
|
||||
table: { headers: ['a'], rows: [['b']] },
|
||||
choices: [{ id: 'c1', text: 'one' }, { id: 'c2', text: 'two' }],
|
||||
}],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveExamSchema', () => {
|
||||
@@ -82,4 +185,59 @@ describe('examIdParamSchema', () => {
|
||||
const result = examIdParamSchema.safeParse({ id: 'abc' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty id', () => {
|
||||
const result = examIdParamSchema.safeParse({ id: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('favoriteSchema', () => {
|
||||
it('accepts true', () => {
|
||||
expect(favoriteSchema.safeParse({ isFavorite: true }).success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts false', () => {
|
||||
expect(favoriteSchema.safeParse({ isFavorite: false }).success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-boolean', () => {
|
||||
expect(favoriteSchema.safeParse({ isFavorite: 'true' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects missing field', () => {
|
||||
expect(favoriteSchema.safeParse({}).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shareModeSchema', () => {
|
||||
it('accepts view', () => {
|
||||
expect(shareModeSchema.safeParse({ mode: 'view' }).success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts clone', () => {
|
||||
expect(shareModeSchema.safeParse({ mode: 'clone' }).success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects unknown mode', () => {
|
||||
expect(shareModeSchema.safeParse({ mode: 'edit' }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('batchDeleteSchema', () => {
|
||||
it('accepts array of ids', () => {
|
||||
expect(batchDeleteSchema.safeParse({ ids: [1, 2, 3] }).success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts empty array', () => {
|
||||
expect(batchDeleteSchema.safeParse({ ids: [] }).success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects string ids', () => {
|
||||
expect(batchDeleteSchema.safeParse({ ids: ['1', '2'] }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects missing ids', () => {
|
||||
expect(batchDeleteSchema.safeParse({}).success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -80,14 +80,28 @@ export const examSchema = z.object({
|
||||
|
||||
export const saveExamSchema = z.object({
|
||||
exam: examSchema,
|
||||
tags: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
export const updateExamSchema = z.object({
|
||||
exam: examSchema,
|
||||
tags: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
export const examIdParamSchema = z.object({
|
||||
id: z.string().regex(/^\d+$/, 'ID must be a number'),
|
||||
})
|
||||
|
||||
export const favoriteSchema = z.object({
|
||||
isFavorite: z.boolean(),
|
||||
})
|
||||
|
||||
export const shareModeSchema = z.object({
|
||||
mode: z.enum(['view', 'clone']),
|
||||
})
|
||||
|
||||
export const batchDeleteSchema = z.object({
|
||||
ids: z.array(z.number()),
|
||||
})
|
||||
|
||||
export type ExamInput = z.infer<typeof examSchema>
|
||||
@@ -2,34 +2,97 @@ import { describe, it, expect } from 'vitest'
|
||||
import { setupSchema, setupStatusSchema } from './setupSchemas'
|
||||
|
||||
describe('setupSchema', () => {
|
||||
it('accepts valid setup', () => {
|
||||
const baseValid = {
|
||||
email: 'admin@example.com',
|
||||
password: 'AdminPass123!',
|
||||
displayName: 'Admin',
|
||||
}
|
||||
|
||||
it('accepts minimal valid setup', () => {
|
||||
const result = setupSchema.safeParse(baseValid)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts full setup including SMTP fields', () => {
|
||||
const result = setupSchema.safeParse({
|
||||
email: 'admin@example.com',
|
||||
password: 'AdminPass123!',
|
||||
displayName: 'Admin',
|
||||
...baseValid,
|
||||
siteTitle: 'BrainWave',
|
||||
language: 'en',
|
||||
language: 'nl',
|
||||
smtpHost: 'smtp.example.com',
|
||||
smtpPort: '587',
|
||||
smtpUser: 'user',
|
||||
smtpPass: 'pass',
|
||||
smtpFrom: 'noreply@example.com',
|
||||
smtpSecure: 'auto',
|
||||
appUrl: 'https://example.com',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid email', () => {
|
||||
const result = setupSchema.safeParse({
|
||||
email: 'bad',
|
||||
password: 'AdminPass123!',
|
||||
displayName: 'Admin',
|
||||
})
|
||||
const result = setupSchema.safeParse({ ...baseValid, email: 'bad' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects short password', () => {
|
||||
const result = setupSchema.safeParse({
|
||||
email: 'admin@example.com',
|
||||
password: '123',
|
||||
displayName: 'Admin',
|
||||
})
|
||||
const result = setupSchema.safeParse({ ...baseValid, password: '123' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects password missing complexity (no special char)', () => {
|
||||
const result = setupSchema.safeParse({ ...baseValid, password: 'AdminPass123' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects password missing uppercase', () => {
|
||||
const result = setupSchema.safeParse({ ...baseValid, password: 'adminpass123!' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects password missing digit', () => {
|
||||
const result = setupSchema.safeParse({ ...baseValid, password: 'AdminPass!' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty displayName', () => {
|
||||
const result = setupSchema.safeParse({ ...baseValid, displayName: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects displayName over 100 chars', () => {
|
||||
const result = setupSchema.safeParse({ ...baseValid, displayName: 'a'.repeat(101) })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects email longer than 255 chars', () => {
|
||||
const longLocal = 'a'.repeat(250)
|
||||
const result = setupSchema.safeParse({ ...baseValid, email: `${longLocal}@x.com` })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid language', () => {
|
||||
const result = setupSchema.safeParse({ ...baseValid, language: 'fr' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts each language enum value', () => {
|
||||
for (const language of ['en', 'nl'] as const) {
|
||||
const result = setupSchema.safeParse({ ...baseValid, language })
|
||||
expect(result.success).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid smtpSecure value', () => {
|
||||
const result = setupSchema.safeParse({ ...baseValid, smtpSecure: 'maybe' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts each smtpSecure enum value', () => {
|
||||
for (const smtpSecure of ['auto', 'true', 'false'] as const) {
|
||||
const result = setupSchema.safeParse({ ...baseValid, smtpSecure })
|
||||
expect(result.success).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('setupStatusSchema', () => {
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/*.spec.ts"]
|
||||
}
|
||||
@@ -3,5 +3,7 @@ import { defineConfig } from 'vitest/config'
|
||||
export default defineConfig({
|
||||
test: {
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
},
|
||||
})
|
||||
Binary file not shown.
+25
-11
@@ -35,6 +35,7 @@ import AdminDashboard from "./components/AdminDashboard";
|
||||
import SetupWizard from "./components/SetupWizard";
|
||||
import Workspace from "./components/Workspace";
|
||||
import ExamEditor from "./components/ExamEditor";
|
||||
import ShareView from "./components/ShareView";
|
||||
|
||||
const withNotes = (state: ExamState): ExamState => ({
|
||||
...state,
|
||||
@@ -49,6 +50,7 @@ function AppInner() {
|
||||
const { pathname } = useLocation();
|
||||
const [exam, setExam] = useState<Exam | null>(null);
|
||||
const [examState, setExamState] = useState<ExamState | null>(null);
|
||||
const [reviewResultId, setReviewResultId] = useState<number | null>(null);
|
||||
const [history, setHistory] = useState<HistoryEntry[]>(() => loadHistory());
|
||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
@@ -97,14 +99,20 @@ function AppInner() {
|
||||
if (pathname.startsWith("/review/") && !exam) {
|
||||
const reviewExam = localStorage.getItem('brainwave.review.exam')
|
||||
const reviewState = localStorage.getItem('brainwave.review.state')
|
||||
const reviewResultIdRaw = localStorage.getItem('brainwave.review.resultId')
|
||||
if (reviewExam && reviewState) {
|
||||
try {
|
||||
const parsedExam = JSON.parse(reviewExam) as Exam
|
||||
const parsedState = JSON.parse(reviewState) as ExamState
|
||||
setExam(parsedExam)
|
||||
setExamState(withNotes(parsedState))
|
||||
if (reviewResultIdRaw) {
|
||||
const id = Number(reviewResultIdRaw)
|
||||
if (Number.isFinite(id) && id > 0) setReviewResultId(id)
|
||||
}
|
||||
localStorage.removeItem('brainwave.review.exam')
|
||||
localStorage.removeItem('brainwave.review.state')
|
||||
localStorage.removeItem('brainwave.review.resultId')
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
@@ -255,7 +263,7 @@ function AppInner() {
|
||||
setExamState(null);
|
||||
savedExamIdRef.current = null;
|
||||
sessionStorage.removeItem('brainwave.pendingResultId');
|
||||
navigate("/");
|
||||
navigate(user && isAuthenticated() ? "/workspace" : "/");
|
||||
};
|
||||
|
||||
const restart = () => {
|
||||
@@ -335,7 +343,7 @@ function AppInner() {
|
||||
}, [pathname, exam, examState, user, navigate, pendingResultId]);
|
||||
|
||||
const handleAuthFlowDone = () => {
|
||||
navigate("/");
|
||||
navigate(user && isAuthenticated() ? "/workspace" : "/");
|
||||
setAuthModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -473,14 +481,18 @@ function AppInner() {
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Landing
|
||||
onLoadExam={loadExam}
|
||||
history={history}
|
||||
onResume={resume}
|
||||
onClearHistory={clearAllHistory}
|
||||
onReviewAttempt={reviewAttempt}
|
||||
onRetakeAttempt={retakeAttempt}
|
||||
/>
|
||||
user && isAuthenticated() ? (
|
||||
<Navigate to="/workspace" replace />
|
||||
) : (
|
||||
<Landing
|
||||
onLoadExam={loadExam}
|
||||
history={history}
|
||||
onResume={resume}
|
||||
onClearHistory={clearAllHistory}
|
||||
onReviewAttempt={reviewAttempt}
|
||||
onRetakeAttempt={retakeAttempt}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
@@ -525,6 +537,7 @@ function AppInner() {
|
||||
onRestart={restart}
|
||||
onExit={exit}
|
||||
reviewOnly={true}
|
||||
resultId={reviewResultId}
|
||||
/>
|
||||
) : (
|
||||
<Navigate to="/" replace />
|
||||
@@ -541,9 +554,10 @@ function AppInner() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route path="/workspace" element={<Navigate to="/workspace/exams" replace />} />
|
||||
<Route path="/workspace" element={<Navigate to="/workspace/overview" replace />} />
|
||||
<Route path="/workspace/:tab" element={<Workspace onLoadExam={loadExam} />} />
|
||||
<Route path="/exam-editor/:id" element={<ExamEditor />} />
|
||||
<Route path="/share/:token" element={<ShareView onLoadExam={loadExam} />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { apiFetch } from '../utils/api.js'
|
||||
import type { GradingResult } from './gradeApi.js'
|
||||
|
||||
export interface AdminUser {
|
||||
id: number
|
||||
@@ -42,6 +43,10 @@ export interface SiteSettings {
|
||||
smtp_from: string
|
||||
smtp_secure: string
|
||||
app_url: string
|
||||
ai_provider: string
|
||||
ai_model: string
|
||||
ai_api_key: string
|
||||
ai_base_url: string
|
||||
}
|
||||
|
||||
export function getStats(): Promise<DashboardStats> {
|
||||
@@ -90,3 +95,17 @@ export function sendTestEmail(to: string | undefined, smtp: SmtpConfig): Promise
|
||||
body: JSON.stringify({ to, smtp }),
|
||||
})
|
||||
}
|
||||
|
||||
export interface AiTestPayload {
|
||||
provider: string
|
||||
apiKey: string
|
||||
model: string
|
||||
baseURL?: string
|
||||
}
|
||||
|
||||
export function testAiConnection(payload: AiTestPayload): Promise<GradingResult> {
|
||||
return apiFetch('/api/admin/test-ai', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { apiFetch } from '../utils/api.js'
|
||||
import type { Question, QuestionType } from '../types'
|
||||
|
||||
export interface GenerateQuestionsRequest {
|
||||
topic: string
|
||||
type: QuestionType
|
||||
count: number
|
||||
language: string
|
||||
context?: string
|
||||
}
|
||||
|
||||
export interface GeneratedQuestion extends Omit<Question, 'id'> {
|
||||
id?: string
|
||||
}
|
||||
|
||||
export interface GenerateQuestionsResponse {
|
||||
questions: GeneratedQuestion[]
|
||||
}
|
||||
|
||||
export function generateQuestions(req: GenerateQuestionsRequest): Promise<GenerateQuestionsResponse> {
|
||||
return apiFetch('/api/ai/generate-question', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(req),
|
||||
})
|
||||
}
|
||||
|
||||
export interface HintRequest {
|
||||
question: Record<string, unknown>
|
||||
language: string
|
||||
}
|
||||
|
||||
export interface HintResponse {
|
||||
hint: string
|
||||
}
|
||||
|
||||
export function getHint(req: HintRequest): Promise<HintResponse> {
|
||||
return apiFetch('/api/ai/hint', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(req),
|
||||
})
|
||||
}
|
||||
|
||||
export interface TutorChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface TutorChatRequest {
|
||||
resultId: number
|
||||
messages: TutorChatMessage[]
|
||||
language: string
|
||||
}
|
||||
|
||||
export interface TutorChatResponse {
|
||||
reply: string
|
||||
}
|
||||
|
||||
export function tutorChat(req: TutorChatRequest): Promise<TutorChatResponse> {
|
||||
return apiFetch('/api/ai/tutor-chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(req),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { apiFetch } from '../utils/api.js'
|
||||
|
||||
export interface Collection {
|
||||
id: number
|
||||
name: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
exam_count: number
|
||||
}
|
||||
|
||||
export function listCollections(): Promise<Collection[]> {
|
||||
return apiFetch('/api/collections')
|
||||
}
|
||||
|
||||
export function createCollection(name: string): Promise<{ id: number; name: string }> {
|
||||
return apiFetch('/api/collections', { method: 'POST', body: JSON.stringify({ name }) })
|
||||
}
|
||||
|
||||
export function updateCollection(id: number, name: string): Promise<{ success: boolean }> {
|
||||
return apiFetch(`/api/collections/${id}`, { method: 'PUT', body: JSON.stringify({ name }) })
|
||||
}
|
||||
|
||||
export function deleteCollection(id: number): Promise<{ success: boolean }> {
|
||||
return apiFetch(`/api/collections/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function moveExamToCollection(examId: number, collectionId: number | null): Promise<{ success: boolean }> {
|
||||
return apiFetch(`/api/collections/move/${examId}`, { method: 'POST', body: JSON.stringify({ collectionId }) })
|
||||
}
|
||||
@@ -8,24 +8,85 @@ export interface ExamListItem {
|
||||
created_at: number
|
||||
updated_at: number
|
||||
question_count: number
|
||||
share_token: string | null
|
||||
is_favorite: number
|
||||
tags: string
|
||||
collection_id: number | null
|
||||
}
|
||||
|
||||
export function listExams(): Promise<ExamListItem[]> {
|
||||
return apiFetch('/api/exams')
|
||||
}
|
||||
|
||||
export function getExam(id: number): Promise<Exam> {
|
||||
export function getExam(id: number): Promise<Exam & { tags?: string[] }> {
|
||||
return apiFetch(`/api/exams/${id}`)
|
||||
}
|
||||
|
||||
export function createExam(exam: Exam): Promise<{ id: number; title: string }> {
|
||||
return apiFetch('/api/exams', { method: 'POST', body: JSON.stringify({ exam }) })
|
||||
export function createExam(exam: Exam, tags?: string[]): Promise<{ id: number; title: string }> {
|
||||
return apiFetch('/api/exams', { method: 'POST', body: JSON.stringify({ exam, tags }) })
|
||||
}
|
||||
|
||||
export function updateExam(id: number, exam: Exam): Promise<{ success: boolean }> {
|
||||
return apiFetch(`/api/exams/${id}`, { method: 'PUT', body: JSON.stringify({ exam }) })
|
||||
export function updateExam(id: number, exam: Exam, tags?: string[]): Promise<{ success: boolean }> {
|
||||
return apiFetch(`/api/exams/${id}`, { method: 'PUT', body: JSON.stringify({ exam, tags }) })
|
||||
}
|
||||
|
||||
export function deleteExam(id: number): Promise<{ success: boolean }> {
|
||||
return apiFetch(`/api/exams/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function shareExam(id: number): Promise<{ shareToken: string }> {
|
||||
return apiFetch(`/api/exams/${id}/share`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function unshareExam(id: number): Promise<{ success: boolean }> {
|
||||
return apiFetch(`/api/exams/${id}/unshare`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function getSharedExam(token: string): Promise<{ title: string; subtitle: string | null; questionCount: number; exam: unknown; shareMode: string }> {
|
||||
return apiFetch(`/api/share/${token}`)
|
||||
}
|
||||
|
||||
export function cloneSharedExam(token: string): Promise<{ id: number; title: string }> {
|
||||
return apiFetch(`/api/share/${token}/clone`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function setFavorite(id: number, isFavorite: boolean): Promise<{ success: boolean }> {
|
||||
return apiFetch(`/api/exams/${id}/favorite`, { method: 'PATCH', body: JSON.stringify({ isFavorite }) })
|
||||
}
|
||||
|
||||
export function exportExam(id: number): Promise<Blob> {
|
||||
return fetch(`/api/exams/${id}/export`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('brainwave.auth.token') || ''}`,
|
||||
},
|
||||
}).then((res) => {
|
||||
if (!res.ok) throw new Error('Export failed')
|
||||
return res.blob()
|
||||
})
|
||||
}
|
||||
|
||||
export function duplicateExam(id: number): Promise<{ id: number; title: string }> {
|
||||
return apiFetch(`/api/exams/${id}/duplicate`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export interface ClonedExamItem {
|
||||
id: number
|
||||
title: string
|
||||
subtitle: string | null
|
||||
created_at: number
|
||||
updated_at: number
|
||||
question_count: number
|
||||
cloned_from_share_token: string
|
||||
}
|
||||
|
||||
export function listClonedExams(): Promise<ClonedExamItem[]> {
|
||||
return apiFetch('/api/exams/cloned')
|
||||
}
|
||||
|
||||
export function setShareMode(id: number, mode: 'view' | 'clone'): Promise<{ success: boolean }> {
|
||||
return apiFetch(`/api/exams/${id}/share-mode`, { method: 'PUT', body: JSON.stringify({ mode }) })
|
||||
}
|
||||
|
||||
export function batchDelete(ids: number[]): Promise<{ deleted: number }> {
|
||||
return apiFetch('/api/exams/batch-delete', { method: 'POST', body: JSON.stringify({ ids }) })
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { apiFetch } from '../utils/api.js'
|
||||
|
||||
export interface GradingResult {
|
||||
suggestedGrade: number
|
||||
feedback: string
|
||||
detail?: unknown
|
||||
}
|
||||
|
||||
export interface GradeRequest {
|
||||
type: 'essay' | 'short-answer' | 'translation' | 'math' | 'ordering' | 'match'
|
||||
question: Record<string, unknown>
|
||||
answer: unknown
|
||||
language: string
|
||||
}
|
||||
|
||||
export function gradeQuestion(req: GradeRequest): Promise<GradingResult> {
|
||||
return apiFetch('/api/grade', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(req),
|
||||
})
|
||||
}
|
||||
@@ -41,3 +41,31 @@ export function submitResult(data: SubmitResultData): Promise<{ id: number }> {
|
||||
export function deleteResult(id: number): Promise<{ success: boolean }> {
|
||||
return apiFetch(`/api/results/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export interface WeakArea {
|
||||
category: string
|
||||
avgScore: number
|
||||
attempts: number
|
||||
}
|
||||
|
||||
export interface StudyCoachResult {
|
||||
weakAreas: WeakArea[]
|
||||
studyTips: string[]
|
||||
streakDays: number
|
||||
weakestExamId: number | null
|
||||
}
|
||||
|
||||
export interface ScoreTrend {
|
||||
completed_at: number
|
||||
score_pct: number
|
||||
title: string
|
||||
exam_id: number | null
|
||||
}
|
||||
|
||||
export function analyzeResults(): Promise<StudyCoachResult> {
|
||||
return apiFetch('/api/results/analyze')
|
||||
}
|
||||
|
||||
export function getScoreTrends(): Promise<ScoreTrend[]> {
|
||||
return apiFetch('/api/results/trends')
|
||||
}
|
||||
@@ -28,6 +28,11 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const [testEmailSending, setTestEmailSending] = useState(false);
|
||||
const [testAiMsg, setTestAiMsg] = useState<{
|
||||
ok: boolean;
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const [testAiSending, setTestAiSending] = useState(false);
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
@@ -125,6 +130,26 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
||||
}
|
||||
};
|
||||
|
||||
const sendTestAi = async () => {
|
||||
if (!settingsDraft) return;
|
||||
setTestAiSending(true);
|
||||
setTestAiMsg(null);
|
||||
try {
|
||||
await adminApi.testAiConnection({
|
||||
provider: settingsDraft.ai_provider,
|
||||
apiKey: settingsDraft.ai_api_key,
|
||||
model: settingsDraft.ai_model || 'gpt-4o-mini',
|
||||
baseURL: settingsDraft.ai_base_url || undefined,
|
||||
});
|
||||
setTestAiMsg({ ok: true, text: t("admin.aiGrading.testSuccess") });
|
||||
} catch (e) {
|
||||
setTestAiMsg({ ok: false, text: (e as Error).message });
|
||||
} finally {
|
||||
setTestAiSending(false);
|
||||
setTimeout(() => setTestAiMsg(null), 6000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
void loadUsers(userSearch);
|
||||
@@ -591,6 +616,92 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-panel">
|
||||
<div className="admin-panel-header">
|
||||
<h3>{t("admin.aiGrading.title")}</h3>
|
||||
</div>
|
||||
<div className="admin-panel-body admin-settings-form">
|
||||
<div className="admin-setting-row">
|
||||
<label className="admin-setting-label" htmlFor="setting-ai-provider">
|
||||
<span className="admin-setting-title">{t("admin.aiGrading.provider")}</span>
|
||||
</label>
|
||||
<select
|
||||
id="setting-ai-provider"
|
||||
className="input admin-setting-input"
|
||||
value={settingsDraft.ai_provider || ''}
|
||||
onChange={(e) => updateDraft("ai_provider", e.target.value)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="openai-compatible">OpenAI-compatible</option>
|
||||
</select>
|
||||
</div>
|
||||
{settingsDraft.ai_provider === 'openai-compatible' && (
|
||||
<div className="admin-setting-row">
|
||||
<label className="admin-setting-label" htmlFor="setting-ai-base-url">
|
||||
<span className="admin-setting-title">{t("admin.aiGrading.baseUrl")}</span>
|
||||
<span className="admin-setting-desc">
|
||||
{t("admin.aiGrading.baseUrlDesc")}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="setting-ai-base-url"
|
||||
className="input admin-setting-input"
|
||||
placeholder="https://api.example.com/v1"
|
||||
value={settingsDraft.ai_base_url || ''}
|
||||
onChange={(e) => updateDraft("ai_base_url", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="admin-setting-row">
|
||||
<label className="admin-setting-label" htmlFor="setting-ai-model">
|
||||
<span className="admin-setting-title">{t("admin.aiGrading.model")}</span>
|
||||
</label>
|
||||
<input
|
||||
id="setting-ai-model"
|
||||
className="input admin-setting-input"
|
||||
placeholder="gpt-4o-mini"
|
||||
value={settingsDraft.ai_model || ''}
|
||||
onChange={(e) => updateDraft("ai_model", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-setting-row">
|
||||
<label className="admin-setting-label" htmlFor="setting-ai-key">
|
||||
<span className="admin-setting-title">{t("admin.aiGrading.apiKey")}</span>
|
||||
</label>
|
||||
<input
|
||||
id="setting-ai-key"
|
||||
className="input admin-setting-input"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={settingsDraft.ai_api_key || ''}
|
||||
onChange={(e) => updateDraft("ai_api_key", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-setting-row admin-setting-row-stacked">
|
||||
<div className="admin-setting-label">
|
||||
<span className="admin-setting-title">{t("admin.aiGrading.testButton")}</span>
|
||||
</div>
|
||||
<div className="admin-test-email-row">
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => void sendTestAi()}
|
||||
disabled={testAiSending || !settingsDraft?.ai_provider || !settingsDraft?.ai_api_key || (settingsDraft?.ai_provider === 'openai-compatible' && !settingsDraft?.ai_base_url)}
|
||||
>
|
||||
{testAiSending ? t("admin.sending") : t("admin.aiGrading.testButton")}
|
||||
</button>
|
||||
</div>
|
||||
{testAiMsg && (
|
||||
<div
|
||||
className={`admin-alert ${testAiMsg.ok ? "admin-alert-success" : "admin-alert-error"} admin-alert-inline`}
|
||||
>
|
||||
{testAiMsg.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-panel">
|
||||
<div className="admin-panel-header">
|
||||
<h3>{t("admin.maintenance")}</h3>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as examApi from '../api/examApi'
|
||||
import * as aiApi from '../api/aiApi'
|
||||
import type { Exam, Question, QuestionType, Choice } from '../types'
|
||||
|
||||
const QUESTION_TYPES: { value: QuestionType; labelKey: string }[] = [
|
||||
@@ -58,6 +59,8 @@ export default function ExamEditor() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null)
|
||||
const [hasChanges, setHasChanges] = useState(isNew)
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const [aiPanelOpen, setAiPanelOpen] = useState(false)
|
||||
|
||||
// Load existing exam
|
||||
useEffect(() => {
|
||||
@@ -66,6 +69,7 @@ export default function ExamEditor() {
|
||||
examApi.getExam(examId!)
|
||||
.then((data) => {
|
||||
setExam(data)
|
||||
setTags(data.tags ?? [])
|
||||
setHasChanges(false)
|
||||
})
|
||||
.catch((e) => setError((e as Error).message))
|
||||
@@ -90,6 +94,22 @@ export default function ExamEditor() {
|
||||
setSelectedIdx(exam.questions.length)
|
||||
}
|
||||
|
||||
const appendGeneratedQuestions = useCallback((generated: aiApi.GeneratedQuestion[]) => {
|
||||
let firstNewIdx = -1
|
||||
updateExam((draft) => {
|
||||
firstNewIdx = draft.questions.length
|
||||
for (const g of generated) {
|
||||
const idx = draft.questions.length
|
||||
const merged: Question = {
|
||||
...(g as Omit<Question, 'id'>),
|
||||
id: `q${idx + 1}`,
|
||||
}
|
||||
draft.questions.push(merged)
|
||||
}
|
||||
})
|
||||
if (firstNewIdx >= 0) setSelectedIdx(firstNewIdx)
|
||||
}, [updateExam])
|
||||
|
||||
const removeQuestion = (idx: number) => {
|
||||
updateExam((draft) => {
|
||||
draft.questions.splice(idx, 1)
|
||||
@@ -113,12 +133,12 @@ export default function ExamEditor() {
|
||||
setError(null)
|
||||
try {
|
||||
if (isNew) {
|
||||
const data = await examApi.createExam(exam)
|
||||
const data = await examApi.createExam(exam, tags)
|
||||
setSaveMsg(t('editor.examCreated'))
|
||||
setHasChanges(false)
|
||||
navigate(`/exam-editor/${data.id}`, { replace: true })
|
||||
} else {
|
||||
await examApi.updateExam(examId!, exam)
|
||||
await examApi.updateExam(examId!, exam, tags)
|
||||
setSaveMsg(t('editor.examSaved'))
|
||||
setHasChanges(false)
|
||||
}
|
||||
@@ -165,6 +185,12 @@ export default function ExamEditor() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<div style={{ padding: '8px 16px', borderBottom: '1px solid var(--line-2)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<TagsEditor tags={tags} onChange={(newTags) => { setTags(newTags); setHasChanges(true) }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="exam-editor-loading">{t('common.loading')}</div>
|
||||
) : (
|
||||
@@ -194,6 +220,21 @@ export default function ExamEditor() {
|
||||
|
||||
<div className="exam-editor-add">
|
||||
<div className="sidebar-section-label" style={{ marginTop: 24 }}>{t('editor.addQuestion')}</div>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
style={{ width: '100%', marginBottom: 8 }}
|
||||
onClick={() => setAiPanelOpen((v) => !v)}
|
||||
>
|
||||
{aiPanelOpen ? t('editor.aiClose') : t('editor.aiGenerate')}
|
||||
</button>
|
||||
{aiPanelOpen && (
|
||||
<AIGeneratePanel
|
||||
onGenerated={(qs) => {
|
||||
appendGeneratedQuestions(qs)
|
||||
setAiPanelOpen(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="exam-editor-type-grid">
|
||||
{QUESTION_TYPES.map((tType) => (
|
||||
<button
|
||||
@@ -234,6 +275,175 @@ export default function ExamEditor() {
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Tags Editor ─── */
|
||||
|
||||
function TagsEditor({ tags, onChange }: { tags: string[]; onChange: (tags: string[]) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const [input, setInput] = useState('')
|
||||
|
||||
const addTag = () => {
|
||||
const raw = input.trim().toLowerCase().replace(/[^a-z0-9\-_]/g, '')
|
||||
if (!raw) return
|
||||
if (tags.includes(raw)) return
|
||||
onChange([...tags, raw])
|
||||
setInput('')
|
||||
}
|
||||
|
||||
const removeTag = (tag: string) => {
|
||||
onChange(tags.filter((t) => t !== tag))
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', flex: 1 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-3)' }}>{t('editor.tagsLabel')}</span>
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
background: 'var(--paper)',
|
||||
color: 'var(--ink-3)',
|
||||
border: '1px solid var(--line-2)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
onClick={() => removeTag(tag)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontSize: 11, color: 'var(--wrong)' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
addTag()
|
||||
}
|
||||
}}
|
||||
placeholder={t('editor.tagsPlaceholder')}
|
||||
style={{ width: 140, fontSize: 12 }}
|
||||
/>
|
||||
<button className="btn btn-sm" onClick={addTag} style={{ fontSize: 11 }}>
|
||||
{t('editor.addTag')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── AI Generate Panel ─── */
|
||||
|
||||
const AI_GENERATE_TYPES: { value: QuestionType; labelKey: string }[] = QUESTION_TYPES
|
||||
|
||||
function AIGeneratePanel({ onGenerated }: { onGenerated: (qs: aiApi.GeneratedQuestion[]) => void }) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [topic, setTopic] = useState('')
|
||||
const [type, setType] = useState<QuestionType>('short-answer')
|
||||
const [count, setCount] = useState(3)
|
||||
const [context, setContext] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
const submit = async () => {
|
||||
if (!topic.trim()) {
|
||||
setErr(t('editor.aiTopicRequired'))
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
const resp = await aiApi.generateQuestions({
|
||||
topic: topic.trim(),
|
||||
type,
|
||||
count,
|
||||
language: i18n.language || 'en',
|
||||
context: context.trim() || undefined,
|
||||
})
|
||||
onGenerated(resp.questions)
|
||||
setTopic('')
|
||||
setContext('')
|
||||
} catch (e) {
|
||||
setErr((e as Error).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
marginBottom: 12,
|
||||
border: '1px solid var(--line-2)',
|
||||
borderRadius: 6,
|
||||
background: 'var(--paper)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)' }}>{t('editor.aiPanelTitle')}</div>
|
||||
<input
|
||||
className="input"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
placeholder={t('editor.aiTopicPlaceholder')}
|
||||
disabled={busy}
|
||||
style={{ fontSize: 12 }}
|
||||
/>
|
||||
<textarea
|
||||
className="input"
|
||||
value={context}
|
||||
onChange={(e) => setContext(e.target.value)}
|
||||
placeholder={t('editor.aiContextPlaceholder')}
|
||||
disabled={busy}
|
||||
rows={3}
|
||||
style={{ fontSize: 12, resize: 'vertical' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<select
|
||||
className="input"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as QuestionType)}
|
||||
disabled={busy}
|
||||
style={{ flex: 1, fontSize: 12 }}
|
||||
>
|
||||
{AI_GENERATE_TYPES.map((tt) => (
|
||||
<option key={tt.value} value={tt.value}>
|
||||
{t(tt.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={count}
|
||||
onChange={(e) => setCount(Math.max(1, Math.min(10, Number(e.target.value) || 1)))}
|
||||
disabled={busy}
|
||||
style={{ width: 60, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
{err && <div style={{ fontSize: 11, color: 'var(--wrong)' }}>{err}</div>}
|
||||
<button className="btn btn-primary btn-sm" onClick={() => void submit()} disabled={busy}>
|
||||
{busy ? t('editor.aiGenerating') : t('editor.aiGenerateNow')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Question Editor ─── */
|
||||
|
||||
interface QEProps {
|
||||
@@ -332,13 +542,12 @@ function MultipleChoiceEditor({ q, onChange }: { q: Question; onChange: QEProps[
|
||||
const setAnswer = (a: string) => onChange((q) => { q.answer = a })
|
||||
|
||||
const addChoice = () => {
|
||||
const nextId = String.fromCharCode(97 + choices.length) // a, b, c...
|
||||
const nextId = String.fromCharCode(97 + choices.length)
|
||||
setChoices([...choices, { id: nextId, text: '' }])
|
||||
}
|
||||
|
||||
const removeChoice = (idx: number) => {
|
||||
const next = choices.filter((_, i) => i !== idx)
|
||||
// Renumber
|
||||
const renumbered = next.map((c, i) => ({ ...c, id: String.fromCharCode(97 + i) }))
|
||||
setChoices(renumbered)
|
||||
if (q.answer === choices[idx]?.id) setAnswer(renumbered[0]?.id ?? '')
|
||||
@@ -752,7 +961,6 @@ function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
|
||||
<p className="faint" style={{ fontSize: 12, marginTop: 4 }}>
|
||||
{t('editor.correctOrderHint')}
|
||||
</p>
|
||||
{/* Sync answer to current item order */}
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
style={{ marginTop: 8 }}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import type { Exam, ExamState, Question } from "../types";
|
||||
import { gradeQuestion } from "../utils/grading";
|
||||
import { formatTime } from "../utils/formatting";
|
||||
import * as aiApi from "../api/aiApi";
|
||||
import Tex from "./Tex";
|
||||
import QuestionMedia from "./QuestionMedia";
|
||||
import QuestionRenderer from "./questions/QuestionRenderer";
|
||||
@@ -27,7 +28,7 @@ export default function ExamRunner({
|
||||
onNavigate,
|
||||
n,
|
||||
}: Readonly<Props>) {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const urlIdx = Math.max(0, (Number((n ?? "").replace(/^q/, "")) || 1) - 1);
|
||||
const { answers, flags, startedAt, feedbackMode } = state;
|
||||
const currentIdx = urlIdx;
|
||||
@@ -40,6 +41,35 @@ export default function ExamRunner({
|
||||
setRevealed(!!state.revealed?.[q.id]);
|
||||
}, [currentIdx, q.id, state.revealed]);
|
||||
|
||||
const [hintCache, setHintCache] = useState<Record<string, string>>({});
|
||||
const [hintLoading, setHintLoading] = useState(false);
|
||||
const [hintError, setHintError] = useState<string | null>(null);
|
||||
const currentHint = hintCache[q.id];
|
||||
|
||||
useEffect(() => {
|
||||
setHintError(null);
|
||||
}, [q.id]);
|
||||
|
||||
const requestHint = async () => {
|
||||
if (currentHint || hintLoading) return;
|
||||
setHintLoading(true);
|
||||
setHintError(null);
|
||||
try {
|
||||
const safeQ: Record<string, unknown> = { ...(q as unknown as Record<string, unknown>) };
|
||||
delete safeQ.answer;
|
||||
delete safeQ.acceptedForms;
|
||||
const res = await aiApi.getHint({
|
||||
question: safeQ,
|
||||
language: i18n.language || 'en',
|
||||
});
|
||||
setHintCache((prev) => ({ ...prev, [q.id]: res.hint }));
|
||||
} catch (e) {
|
||||
setHintError((e as Error).message);
|
||||
} finally {
|
||||
setHintLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setAnswer = (val: unknown) =>
|
||||
setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: val } }));
|
||||
|
||||
@@ -225,6 +255,26 @@ export default function ExamRunner({
|
||||
graded={graded}
|
||||
/>
|
||||
|
||||
{(currentHint || hintError) && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: 12,
|
||||
borderLeft: '3px solid var(--accent)',
|
||||
background: 'var(--paper)',
|
||||
fontSize: 13,
|
||||
color: 'var(--ink-2)',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginBottom: 4 }}>
|
||||
{t('exam.hintLabel')}
|
||||
</div>
|
||||
{currentHint && <Tex>{currentHint}</Tex>}
|
||||
{hintError && <span style={{ color: 'var(--wrong)' }}>{hintError}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{graded && result && q.type !== "essay" && (
|
||||
<div className={`feedback ${result.correct ? "correct" : "wrong"}`}>
|
||||
<div className="feedback-label">
|
||||
@@ -259,6 +309,20 @@ export default function ExamRunner({
|
||||
<button className="btn btn-ghost" onClick={toggleFlag}>
|
||||
{flags[q.id] ? t('exam.flagged') : t('exam.flagForReview')}
|
||||
</button>
|
||||
{!revealed && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => void requestHint()}
|
||||
disabled={hintLoading || !!currentHint}
|
||||
title={t('exam.hintTitle')}
|
||||
>
|
||||
{hintLoading
|
||||
? t('exam.hintLoading')
|
||||
: currentHint
|
||||
? t('exam.hintShown')
|
||||
: t('exam.needHint')}
|
||||
</button>
|
||||
)}
|
||||
<span className="spacer" />
|
||||
{feedbackMode === "immediate" &&
|
||||
!revealed &&
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as examApi from '../api/examApi'
|
||||
import * as collectionApi from '../api/collectionApi'
|
||||
import type { Exam } from '../types'
|
||||
|
||||
interface SavedExam {
|
||||
@@ -11,27 +12,60 @@ interface SavedExam {
|
||||
question_count: number
|
||||
created_at: number
|
||||
updated_at: number
|
||||
share_token: string | null
|
||||
is_favorite: number
|
||||
tags: string
|
||||
collection_id: number | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onLoadExam: (exam: Exam, saveToWorkspace?: boolean, examId?: number | null) => void
|
||||
}
|
||||
|
||||
export default function MyExams({ onLoadExam }: Props) {
|
||||
type CollectionFilter = number | null | 'all' | 'uncategorized'
|
||||
|
||||
export default function MyExams({ onLoadExam }: Readonly<Props>) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [exams, setExams] = useState<SavedExam[]>([])
|
||||
const [collections, setCollections] = useState<collectionApi.Collection[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
|
||||
const [startingId, setStartingId] = useState<number | null>(null)
|
||||
const [sharingId, setSharingId] = useState<number | null>(null)
|
||||
const [duplicatingId, setDuplicatingId] = useState<number | null>(null)
|
||||
const [exportingId, setExportingId] = useState<number | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [activeFilter, setActiveFilter] = useState<CollectionFilter>('all')
|
||||
const [showNewCollection, setShowNewCollection] = useState(false)
|
||||
const [newCollectionName, setNewCollectionName] = useState('')
|
||||
const [creatingCollection, setCreatingCollection] = useState(false)
|
||||
const [movingExamId, setMovingExamId] = useState<number | null>(null)
|
||||
const [menuOpenId, setMenuOpenId] = useState<number | null>(null)
|
||||
const [menuSection, setMenuSection] = useState<'main' | 'move' | 'share'>('main')
|
||||
|
||||
const loadExams = useCallback(async () => {
|
||||
const closeMenu = () => { setMenuOpenId(null); setMenuSection('main') }
|
||||
const openMenu = (id: number) => { setMenuOpenId(id); setMenuSection('main') }
|
||||
const [editingCollectionId, setEditingCollectionId] = useState<number | null>(null)
|
||||
const [editingCollectionName, setEditingCollectionName] = useState('')
|
||||
const [deleteCollectionId, setDeleteCollectionId] = useState<number | null>(null)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [batchMoveOpen, setBatchMoveOpen] = useState(false)
|
||||
const [batchDeleting, setBatchDeleting] = useState(false)
|
||||
const [batchExporting, setBatchExporting] = useState(false)
|
||||
const [batchMoving, setBatchMoving] = useState(false)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await examApi.listExams()
|
||||
setExams(data)
|
||||
const [examData, collectionData] = await Promise.all([
|
||||
examApi.listExams(),
|
||||
collectionApi.listCollections(),
|
||||
])
|
||||
setExams(examData)
|
||||
setCollections(collectionData)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
@@ -40,14 +74,14 @@ export default function MyExams({ onLoadExam }: Props) {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadExams()
|
||||
}, [loadExams])
|
||||
void loadData()
|
||||
}, [loadData])
|
||||
|
||||
const deleteExam = async (id: number) => {
|
||||
try {
|
||||
await examApi.deleteExam(id)
|
||||
setDeleteConfirm(null)
|
||||
void loadExams()
|
||||
void loadData()
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
}
|
||||
@@ -68,97 +102,725 @@ export default function MyExams({ onLoadExam }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleShare = async (id: number) => {
|
||||
setSharingId(id)
|
||||
try {
|
||||
const res = await examApi.shareExam(id)
|
||||
const url = `${window.location.origin}/share/${res.shareToken}`
|
||||
await navigator.clipboard.writeText(url)
|
||||
closeMenu()
|
||||
setExams((prev) => prev.map((e) => (e.id === id ? { ...e, share_token: res.shareToken } : e)))
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setSharingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnshare = async (id: number) => {
|
||||
try {
|
||||
await examApi.unshareExam(id)
|
||||
closeMenu()
|
||||
setExams((prev) => prev.map((e) => (e.id === id ? { ...e, share_token: null } : e)))
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFavorite = async (id: number, isFavorite: boolean) => {
|
||||
try {
|
||||
await examApi.setFavorite(id, isFavorite)
|
||||
setExams((prev) => prev.map((e) => {
|
||||
if (e.id !== id) return e
|
||||
const updated = { ...e, is_favorite: isFavorite ? 1 : 0 }
|
||||
return updated
|
||||
}).sort((a, b) => (b.is_favorite - a.is_favorite) || (b.updated_at - a.updated_at)))
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = async (id: number, title: string) => {
|
||||
setExportingId(id)
|
||||
try {
|
||||
const blob = await examApi.exportExam(id)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setExportingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDuplicate = async (id: number) => {
|
||||
setDuplicatingId(id)
|
||||
try {
|
||||
await examApi.duplicateExam(id)
|
||||
void loadData()
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setDuplicatingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateCollection = async () => {
|
||||
const name = newCollectionName.trim()
|
||||
if (!name) return
|
||||
setCreatingCollection(true)
|
||||
try {
|
||||
await collectionApi.createCollection(name)
|
||||
setNewCollectionName('')
|
||||
setShowNewCollection(false)
|
||||
void loadData()
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setCreatingCollection(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMoveExam = async (examId: number, collectionId: number | null) => {
|
||||
setMovingExamId(examId)
|
||||
try {
|
||||
await collectionApi.moveExamToCollection(examId, collectionId)
|
||||
closeMenu()
|
||||
setExams((prev) => prev.map((e) => e.id === examId ? { ...e, collection_id: collectionId } : e))
|
||||
setCollections((prev) => prev.map((c) => {
|
||||
if (collectionId !== null && c.id === collectionId) return { ...c, exam_count: c.exam_count + 1 }
|
||||
const oldExam = exams.find((e) => e.id === examId)
|
||||
if (oldExam?.collection_id !== null && c.id === oldExam?.collection_id) return { ...c, exam_count: Math.max(0, c.exam_count - 1) }
|
||||
return c
|
||||
}))
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setMovingExamId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRenameCollection = async (id: number) => {
|
||||
const name = editingCollectionName.trim()
|
||||
if (!name) {
|
||||
setEditingCollectionId(null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await collectionApi.updateCollection(id, name)
|
||||
setCollections((prev) => prev.map((c) => c.id === id ? { ...c, name } : c))
|
||||
setEditingCollectionId(null)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteCollection = async (id: number) => {
|
||||
try {
|
||||
await collectionApi.deleteCollection(id)
|
||||
setDeleteCollectionId(null)
|
||||
setExams((prev) => prev.map((e) => e.collection_id === id ? { ...e, collection_id: null } : e))
|
||||
setCollections((prev) => prev.filter((c) => c.id !== id))
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelect = (id: number) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const selectAllFiltered = () => {
|
||||
setSelectedIds(new Set(filteredExams.map((e) => e.id)))
|
||||
}
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedIds.size === 0) return
|
||||
if (!window.confirm(t('workspace.batchDeleteConfirm'))) return
|
||||
setBatchDeleting(true)
|
||||
try {
|
||||
await examApi.batchDelete(Array.from(selectedIds))
|
||||
setSelectedIds(new Set())
|
||||
void loadData()
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setBatchDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBatchExport = async () => {
|
||||
if (selectedIds.size === 0) return
|
||||
setBatchExporting(true)
|
||||
try {
|
||||
for (const id of selectedIds) {
|
||||
const exam = exams.find((e) => e.id === id)
|
||||
if (exam) {
|
||||
const blob = await examApi.exportExam(id)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${exam.title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setBatchExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBatchMove = async (collectionId: number | null) => {
|
||||
if (selectedIds.size === 0) return
|
||||
setBatchMoving(true)
|
||||
try {
|
||||
for (const id of selectedIds) {
|
||||
await collectionApi.moveExamToCollection(id, collectionId)
|
||||
}
|
||||
setBatchMoveOpen(false)
|
||||
setSelectedIds(new Set())
|
||||
void loadData()
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setBatchMoving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (ts: number) => {
|
||||
const d = new Date(ts * 1000)
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
const parseTags = (tagsJson: string): string[] => {
|
||||
try {
|
||||
const parsed = JSON.parse(tagsJson)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const filteredExams = exams.filter((exam) => {
|
||||
if (!searchQuery.trim()) return true
|
||||
const q = searchQuery.toLowerCase()
|
||||
const tags = parseTags(exam.tags)
|
||||
return (
|
||||
exam.title.toLowerCase().includes(q) ||
|
||||
(exam.subtitle?.toLowerCase().includes(q) ?? false) ||
|
||||
tags.some((t) => t.toLowerCase().includes(q))
|
||||
)
|
||||
}).filter((exam) => {
|
||||
if (activeFilter === 'all') return true
|
||||
if (activeFilter === 'uncategorized') return exam.collection_id === null
|
||||
return exam.collection_id === activeFilter
|
||||
})
|
||||
|
||||
const uncategorizedCount = exams.filter((e) => e.collection_id === null).length
|
||||
|
||||
return (
|
||||
<div className="workspace-section fade-in">
|
||||
<div className="workspace-section-header">
|
||||
<div>
|
||||
<div className="workspace-section-eyebrow">{t('workspace.myWorkspace')}</div>
|
||||
<h2 className="workspace-section-title">{t('workspace.myExams')}</h2>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={createNewExam}>
|
||||
+ {t('workspace.newExam')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="workspace-alert workspace-alert-error">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="workspace-alert-close">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="workspace-empty">{t('common.loading')}</div>
|
||||
) : exams.length === 0 ? (
|
||||
<div className="workspace-empty">
|
||||
<div className="workspace-empty-icon">◇</div>
|
||||
<div className="workspace-empty-title">{t('workspace.noSavedExams')}</div>
|
||||
<div className="workspace-empty-desc">
|
||||
{t('workspace.noSavedExamsDesc')}
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={createNewExam} style={{ marginTop: 16 }}>
|
||||
{t('workspace.createFirstExam')}
|
||||
<div className="workspace-section fade-in" style={{ display: 'flex', gap: 20 }}>
|
||||
{/* Collections Sidebar */}
|
||||
<div style={{ width: 220, flexShrink: 0 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--ink-3)', marginBottom: 10, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
{t('workspace.collections')}
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ fontSize: 16, padding: '2px 6px', lineHeight: 1 }}
|
||||
onClick={() => setShowNewCollection(true)}
|
||||
title={t('workspace.newCollection')}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="workspace-list">
|
||||
{exams.map((exam) => (
|
||||
<div key={exam.id} className="workspace-card">
|
||||
<div className="workspace-card-body">
|
||||
<div className="workspace-card-title">{exam.title}</div>
|
||||
{exam.subtitle && (
|
||||
<div className="workspace-card-sub">{exam.subtitle}</div>
|
||||
)}
|
||||
<div className="workspace-card-meta">
|
||||
<span>{exam.question_count} {exam.question_count === 1 ? t('common.question') : t('common.questions')}</span>
|
||||
<span>·</span>
|
||||
<span>{t('workspace.updated')} {formatDate(exam.updated_at)}</span>
|
||||
|
||||
{showNewCollection && (
|
||||
<div style={{ marginBottom: 8, display: 'flex', gap: 4 }}>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder={t('workspace.collectionNamePlaceholder')}
|
||||
value={newCollectionName}
|
||||
onChange={(e) => setNewCollectionName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') void handleCreateCollection() }}
|
||||
style={{ fontSize: 12, padding: '4px 8px', flex: 1 }}
|
||||
autoFocus
|
||||
/>
|
||||
<button className="btn btn-sm btn-primary" onClick={() => void handleCreateCollection()} disabled={creatingCollection} style={{ fontSize: 11, padding: '4px 8px' }}>
|
||||
{t('workspace.create')}
|
||||
</button>
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => { setShowNewCollection(false); setNewCollectionName('') }} style={{ fontSize: 11, padding: '4px 8px' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-sm${activeFilter === 'all' ? ' active' : ''}`}
|
||||
onClick={() => setActiveFilter('all')}
|
||||
style={{ justifyContent: 'space-between', textAlign: 'left', fontWeight: activeFilter === 'all' ? 600 : 400 }}
|
||||
>
|
||||
<span>{t('workspace.allExams')}</span>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--ink-3)' }}>{exams.length}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-ghost btn-sm${activeFilter === 'uncategorized' ? ' active' : ''}`}
|
||||
onClick={() => setActiveFilter('uncategorized')}
|
||||
style={{ justifyContent: 'space-between', textAlign: 'left', fontWeight: activeFilter === 'uncategorized' ? 600 : 400 }}
|
||||
>
|
||||
<span>{t('workspace.uncategorized')}</span>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--ink-3)' }}>{uncategorizedCount}</span>
|
||||
</button>
|
||||
{collections.map((col) => (
|
||||
<div key={col.id} style={{ position: 'relative' }}>
|
||||
{editingCollectionId === col.id ? (
|
||||
<div style={{ display: 'flex', gap: 4, padding: '2px 0' }}>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={editingCollectionName}
|
||||
onChange={(e) => setEditingCollectionName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void handleRenameCollection(col.id)
|
||||
if (e.key === 'Escape') setEditingCollectionId(null)
|
||||
}}
|
||||
onBlur={() => void handleRenameCollection(col.id)}
|
||||
style={{ fontSize: 12, padding: '3px 6px', flex: 1 }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="workspace-card-actions">
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => void startExam(exam.id)}
|
||||
disabled={startingId === exam.id}
|
||||
className={`btn btn-ghost btn-sm${activeFilter === col.id ? ' active' : ''}`}
|
||||
onClick={() => setActiveFilter(col.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
setEditingCollectionName(col.name)
|
||||
setEditingCollectionId(col.id)
|
||||
}}
|
||||
style={{ justifyContent: 'space-between', textAlign: 'left', fontWeight: activeFilter === col.id ? 600 : 400 }}
|
||||
>
|
||||
{startingId === exam.id ? t('common.loading') : t('workspace.takeExam')}
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{col.name}</span>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--ink-3)', flexShrink: 0 }}>{col.exam_count}</span>
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => navigate(`/exam-editor/${exam.id}`)}
|
||||
disabled={startingId === exam.id}
|
||||
>
|
||||
{t('common.edit')}
|
||||
</button>
|
||||
{deleteConfirm === exam.id ? (
|
||||
<div className="workspace-delete-confirm">
|
||||
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteExam(exam.id)}>
|
||||
)}
|
||||
{activeFilter === col.id && editingCollectionId !== col.id && (
|
||||
<div style={{ display: 'flex', gap: 4, padding: '0 8px 4px', marginTop: -2 }}>
|
||||
<button className="btn btn-ghost btn-sm" style={{ fontSize: 10, padding: '2px 6px' }} onClick={() => { setEditingCollectionName(col.name); setEditingCollectionId(col.id) }}>
|
||||
{t('workspace.rename')}
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm admin-btn-danger-ghost" style={{ fontSize: 10, padding: '2px 6px' }} onClick={() => setDeleteCollectionId(col.id)}>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{deleteCollectionId === col.id && (
|
||||
<div style={{ padding: '4px 8px', fontSize: 11, background: 'var(--wrong-weak)', borderRadius: 6, margin: '2px 0' }}>
|
||||
<div style={{ marginBottom: 4 }}>{t('workspace.deleteCollectionConfirm')}</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button className="btn btn-sm admin-btn-danger" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => void handleDeleteCollection(col.id)}>
|
||||
{t('common.confirm')}
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>
|
||||
<button className="btn btn-sm btn-ghost" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => setDeleteCollectionId(null)}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-ghost btn-sm admin-btn-danger-ghost"
|
||||
onClick={() => setDeleteConfirm(exam.id)}
|
||||
title={t('common.delete')}
|
||||
disabled={startingId === exam.id}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Exams List */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="workspace-section-header">
|
||||
<div>
|
||||
<div className="workspace-section-eyebrow">{t('workspace.myWorkspace')}</div>
|
||||
<h2 className="workspace-section-title">{t('workspace.myExams')}</h2>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={createNewExam}>
|
||||
+ {t('workspace.newExam')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder={t('workspace.searchExams')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
style={{ maxWidth: 400 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Batch toolbar */}
|
||||
{!loading && filteredExams.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, cursor: 'pointer', userSelect: 'none' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.size > 0 && selectedIds.size === filteredExams.length}
|
||||
onChange={() => {
|
||||
if (selectedIds.size === filteredExams.length) clearSelection()
|
||||
else selectAllFiltered()
|
||||
}}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = selectedIds.size > 0 && selectedIds.size < filteredExams.length
|
||||
}}
|
||||
/>
|
||||
{selectedIds.size === 0 ? t('workspace.batchSelectAll') : t('workspace.batchSelected', { count: selectedIds.size })}
|
||||
</label>
|
||||
{selectedIds.size > 0 && (
|
||||
<>
|
||||
<div style={{ width: 1, height: 16, background: 'var(--line-2)' }} />
|
||||
<button
|
||||
className="btn btn-sm admin-btn-danger-ghost"
|
||||
onClick={() => void handleBatchDelete()}
|
||||
disabled={batchDeleting}
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
{batchDeleting ? t('common.loading') : `${t('common.delete')} (${selectedIds.size})`}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-ghost"
|
||||
onClick={() => void handleBatchExport()}
|
||||
disabled={batchExporting}
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
{batchExporting ? t('common.loading') : `${t('workspace.export')} (${selectedIds.size})`}
|
||||
</button>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
className="btn btn-sm btn-ghost"
|
||||
onClick={() => setBatchMoveOpen((v) => !v)}
|
||||
disabled={batchMoving}
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
{batchMoving ? t('common.loading') : `${t('workspace.moveTo')} (${selectedIds.size})`}
|
||||
</button>
|
||||
{batchMoveOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 'calc(100% + 4px)',
|
||||
zIndex: 10,
|
||||
minWidth: 180,
|
||||
background: 'var(--paper)',
|
||||
border: '1px solid var(--line-2)',
|
||||
borderRadius: 8,
|
||||
boxShadow: 'var(--shadow-md)',
|
||||
padding: '6px 0',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="user-menu-item"
|
||||
style={{ fontSize: 13 }}
|
||||
onClick={() => void handleBatchMove(null)}
|
||||
>
|
||||
{t('workspace.uncategorized')}
|
||||
</button>
|
||||
{collections.map((col) => (
|
||||
<button
|
||||
key={col.id}
|
||||
className="user-menu-item"
|
||||
style={{ fontSize: 13 }}
|
||||
onClick={() => void handleBatchMove(col.id)}
|
||||
>
|
||||
{col.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="workspace-alert workspace-alert-error">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="workspace-alert-close">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="workspace-empty">{t('common.loading')}</div>
|
||||
) : filteredExams.length === 0 ? (
|
||||
<div className="workspace-empty">
|
||||
<div className="workspace-empty-icon">◇</div>
|
||||
<div className="workspace-empty-title">
|
||||
{searchQuery || activeFilter !== 'all' ? t('workspace.noSearchResults') : t('workspace.noSavedExams')}
|
||||
</div>
|
||||
<div className="workspace-empty-desc">
|
||||
{searchQuery || activeFilter !== 'all' ? t('workspace.noSearchResultsDesc') : t('workspace.noSavedExamsDesc')}
|
||||
</div>
|
||||
{!searchQuery && activeFilter === 'all' && (
|
||||
<button className="btn btn-primary" onClick={createNewExam} style={{ marginTop: 16 }}>
|
||||
{t('workspace.createFirstExam')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="workspace-list">
|
||||
{filteredExams.map((exam) => {
|
||||
const tags = parseTags(exam.tags)
|
||||
return (
|
||||
<div key={exam.id} className={`workspace-card${exam.is_favorite ? ' workspace-card-favorite' : ''}`}>
|
||||
<div className="workspace-card-body">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(exam.id)}
|
||||
onChange={() => toggleSelect(exam.id)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => void handleFavorite(exam.id, !exam.is_favorite)}
|
||||
title={exam.is_favorite ? t('workspace.unpin') : t('workspace.pin')}
|
||||
style={{ fontSize: 16, padding: '2px 6px', lineHeight: 1 }}
|
||||
>
|
||||
{exam.is_favorite ? '★' : '☆'}
|
||||
</button>
|
||||
<div className="workspace-card-title">{exam.title}</div>
|
||||
</div>
|
||||
{exam.subtitle && (
|
||||
<div className="workspace-card-sub">{exam.subtitle}</div>
|
||||
)}
|
||||
<div className="workspace-card-meta">
|
||||
<span>{exam.question_count} {exam.question_count === 1 ? t('common.question') : t('common.questions')}</span>
|
||||
<span>·</span>
|
||||
<span>{t('workspace.updated')} {formatDate(exam.updated_at)}</span>
|
||||
</div>
|
||||
{tags.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 6, flexWrap: 'wrap' }}>
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
background: 'var(--paper)',
|
||||
color: 'var(--ink-3)',
|
||||
border: '1px solid var(--line-2)',
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="workspace-card-actions">
|
||||
{deleteConfirm === exam.id ? (
|
||||
<div className="workspace-delete-confirm">
|
||||
<span style={{ fontSize: 13, color: 'var(--ink-3)', marginRight: 4 }}>
|
||||
{t('workspace.batchDeleteConfirm')}
|
||||
</span>
|
||||
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteExam(exam.id)}>
|
||||
{t('common.confirm')}
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => void startExam(exam.id)}
|
||||
disabled={startingId === exam.id}
|
||||
>
|
||||
{startingId === exam.id ? t('common.loading') : t('workspace.takeExam')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => navigate(`/exam-editor/${exam.id}`)}
|
||||
disabled={startingId === exam.id}
|
||||
>
|
||||
{t('common.edit')}
|
||||
</button>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm workspace-menu-trigger"
|
||||
onClick={() => menuOpenId === exam.id ? closeMenu() : openMenu(exam.id)}
|
||||
disabled={startingId === exam.id}
|
||||
title="More actions"
|
||||
aria-label="More actions"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpenId === exam.id}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
{menuOpenId === exam.id && (
|
||||
<>
|
||||
<button
|
||||
className="workspace-menu-backdrop"
|
||||
onClick={closeMenu}
|
||||
aria-label="Close menu"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<div className="workspace-menu" role="menu">
|
||||
{menuSection === 'main' && (
|
||||
<>
|
||||
<button
|
||||
className="user-menu-item workspace-menu-item"
|
||||
onClick={() => { closeMenu(); void handleDuplicate(exam.id) }}
|
||||
disabled={duplicatingId === exam.id}
|
||||
>
|
||||
<span className="workspace-menu-icon">⧉</span>
|
||||
<span>{duplicatingId === exam.id ? t('common.loading') : t('workspace.duplicate')}</span>
|
||||
</button>
|
||||
<button
|
||||
className="user-menu-item workspace-menu-item"
|
||||
onClick={() => { closeMenu(); void handleExport(exam.id, exam.title) }}
|
||||
disabled={exportingId === exam.id}
|
||||
>
|
||||
<span className="workspace-menu-icon">↓</span>
|
||||
<span>{exportingId === exam.id ? t('common.loading') : t('workspace.export')}</span>
|
||||
</button>
|
||||
<button
|
||||
className="user-menu-item workspace-menu-item"
|
||||
onClick={() => setMenuSection('move')}
|
||||
disabled={movingExamId === exam.id}
|
||||
>
|
||||
<span className="workspace-menu-icon">▤</span>
|
||||
<span>{t('workspace.moveTo')}</span>
|
||||
<span className="workspace-menu-chevron">›</span>
|
||||
</button>
|
||||
<button
|
||||
className="user-menu-item workspace-menu-item"
|
||||
onClick={() => setMenuSection('share')}
|
||||
disabled={sharingId === exam.id}
|
||||
>
|
||||
<span className="workspace-menu-icon">↗</span>
|
||||
<span>
|
||||
{sharingId === exam.id
|
||||
? t('common.loading')
|
||||
: (exam.share_token ? t('share.shared') : t('share.share'))}
|
||||
</span>
|
||||
<span className="workspace-menu-chevron">›</span>
|
||||
</button>
|
||||
<div className="workspace-menu-divider" />
|
||||
<button
|
||||
className="user-menu-item workspace-menu-item workspace-menu-item-danger"
|
||||
onClick={() => { closeMenu(); setDeleteConfirm(exam.id) }}
|
||||
>
|
||||
<span className="workspace-menu-icon">✕</span>
|
||||
<span>{t('common.delete')}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{menuSection === 'move' && (
|
||||
<>
|
||||
<button
|
||||
className="user-menu-item workspace-menu-back"
|
||||
onClick={() => setMenuSection('main')}
|
||||
>
|
||||
‹ {t('common.back')}
|
||||
</button>
|
||||
<div className="workspace-menu-divider" />
|
||||
{exam.collection_id !== null && (
|
||||
<button
|
||||
className="user-menu-item workspace-menu-item"
|
||||
onClick={() => void handleMoveExam(exam.id, null)}
|
||||
>
|
||||
{t('workspace.removeFromCollection')}
|
||||
</button>
|
||||
)}
|
||||
{collections.map((col) => (
|
||||
col.id !== exam.collection_id && (
|
||||
<button
|
||||
key={col.id}
|
||||
className="user-menu-item workspace-menu-item"
|
||||
onClick={() => void handleMoveExam(exam.id, col.id)}
|
||||
>
|
||||
{col.name}
|
||||
</button>
|
||||
)
|
||||
))}
|
||||
{collections.length === 0 && exam.collection_id === null && (
|
||||
<div className="user-menu-item workspace-menu-item" style={{ color: 'var(--ink-3)', cursor: 'default' }}>
|
||||
{t('workspace.collections')}: 0
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{menuSection === 'share' && (
|
||||
<>
|
||||
<button
|
||||
className="user-menu-item workspace-menu-back"
|
||||
onClick={() => setMenuSection('main')}
|
||||
>
|
||||
‹ {t('common.back')}
|
||||
</button>
|
||||
<div className="workspace-menu-divider" />
|
||||
{exam.share_token ? (
|
||||
<>
|
||||
<button
|
||||
className="user-menu-item workspace-menu-item"
|
||||
onClick={() => {
|
||||
const url = `${window.location.origin}/share/${exam.share_token}`
|
||||
void navigator.clipboard.writeText(url)
|
||||
closeMenu()
|
||||
}}
|
||||
>
|
||||
{t('share.copyLink')}
|
||||
</button>
|
||||
<button
|
||||
className="user-menu-item workspace-menu-item workspace-menu-item-danger"
|
||||
onClick={() => void handleUnshare(exam.id)}
|
||||
>
|
||||
{t('share.revokeLink')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="user-menu-item workspace-menu-item"
|
||||
onClick={() => void handleShare(exam.id)}
|
||||
>
|
||||
{sharingId === exam.id ? t('common.loading') : t('share.generateLink')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -85,6 +85,7 @@ export default function MyResults({ onLoadExam }: Props) {
|
||||
...(state || {}),
|
||||
completedAt: typeof completedAt === 'number' ? completedAt * 1000 : Date.now(),
|
||||
}))
|
||||
localStorage.setItem('brainwave.review.resultId', String(item.id))
|
||||
navigate(`/review/${getExamUrlId(data.exam as unknown as { title: string; id?: string })}`)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import * as resultApi from '../api/resultApi'
|
||||
import * as examApi from '../api/examApi'
|
||||
import { loadState } from '../utils/storage'
|
||||
import { validateExam, parseExam } from '../utils/validation'
|
||||
import type { Exam } from '../types'
|
||||
import StudyCoach from './StudyCoach'
|
||||
|
||||
interface Props {
|
||||
examCount: number
|
||||
resultCount: number
|
||||
recentResults: resultApi.ResultListItem[]
|
||||
onLoadExam: (exam: Exam, saveToWorkspace?: boolean, examId?: number | null) => void
|
||||
}
|
||||
|
||||
export default function Overview({ examCount, resultCount, recentResults, onLoadExam }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const [uploadErr, setUploadErr] = useState<string | null>(null)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const [trends, setTrends] = useState<resultApi.ScoreTrend[]>([])
|
||||
const [trendsLoading, setTrendsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
resultApi.getScoreTrends()
|
||||
.then((data) => setTrends(data))
|
||||
.catch(() => {/* ignore */})
|
||||
.finally(() => setTrendsLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
setUploadErr(null)
|
||||
try {
|
||||
const text = await file.text()
|
||||
const data = JSON.parse(text) as unknown
|
||||
const msg = validateExam(data)
|
||||
if (msg) throw new Error(msg)
|
||||
onLoadExam(parseExam(data), true)
|
||||
} catch (e) {
|
||||
setUploadErr(t('landing.loadError', { message: (e as Error).message }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRetakeWeakest = async (examId: number) => {
|
||||
try {
|
||||
const exam = await examApi.getExam(examId)
|
||||
onLoadExam(exam, false, examId)
|
||||
} catch {
|
||||
setUploadErr(t('landing.loadError', { message: 'Could not load exam' }))
|
||||
}
|
||||
}
|
||||
|
||||
const resumable = loadState()
|
||||
const totalQuestions = recentResults.reduce((sum, r) => sum + (r.graded_count || 0), 0)
|
||||
const avgScore = recentResults.length > 0
|
||||
? Math.round(recentResults.reduce((sum, r) => sum + (r.total_points ? (r.score_points / r.total_points) * 100 : 0), 0) / recentResults.length)
|
||||
: 0
|
||||
|
||||
// SVG chart dimensions
|
||||
const margin = { top: 10, right: 20, bottom: 30, left: 40 }
|
||||
const viewW = 520
|
||||
const viewH = 200
|
||||
const cw = viewW - margin.left - margin.right
|
||||
const ch = viewH - margin.top - margin.bottom
|
||||
|
||||
const chartData = trends.slice(-20)
|
||||
const hasChartData = chartData.length >= 2
|
||||
|
||||
const xMin = chartData.length ? chartData[0].completed_at : 0
|
||||
const xMax = chartData.length ? chartData[chartData.length - 1].completed_at : 0
|
||||
const xRange = xMax - xMin || 1
|
||||
|
||||
const xScale = (v: number) => margin.left + ((v - xMin) / xRange) * cw
|
||||
const yScale = (v: number) => margin.top + ch - (v / 100) * ch
|
||||
|
||||
const linePoints = chartData.map((d, i) => {
|
||||
const x = xScale(d.completed_at)
|
||||
const y = yScale(d.score_pct)
|
||||
return `${i === 0 ? 'M' : 'L'} ${x} ${y}`
|
||||
}).join(' ')
|
||||
|
||||
const gridLines = [0, 25, 50, 75, 100].map((pct) => ({
|
||||
y: yScale(pct),
|
||||
label: `${pct}%`,
|
||||
}))
|
||||
|
||||
const xLabelIndices = [...new Set([0, Math.floor(chartData.length / 2), chartData.length - 1])]
|
||||
|
||||
return (
|
||||
<div className="workspace-section fade-in">
|
||||
{/* Dashboard Stats */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12, marginBottom: 24 }}>
|
||||
<div className="workspace-card" style={{ padding: 16, textAlign: 'center' }}>
|
||||
<div className="mono" style={{ fontSize: 24, fontWeight: 700 }}>{examCount}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4 }}>{t('workspace.myExams')}</div>
|
||||
</div>
|
||||
<div className="workspace-card" style={{ padding: 16, textAlign: 'center' }}>
|
||||
<div className="mono" style={{ fontSize: 24, fontWeight: 700 }}>{resultCount}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4 }}>{t('workspace.totalAttempts')}</div>
|
||||
</div>
|
||||
<div className="workspace-card" style={{ padding: 16, textAlign: 'center' }}>
|
||||
<div className="mono" style={{ fontSize: 24, fontWeight: 700 }}>{avgScore}%</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4 }}>{t('workspace.avgScore')}</div>
|
||||
</div>
|
||||
<div className="workspace-card" style={{ padding: 16, textAlign: 'center' }}>
|
||||
<div className="mono" style={{ fontSize: 24, fontWeight: 700 }}>{totalQuestions}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4 }}>{t('workspace.questionsAnswered')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 24, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`dropzone${dragOver ? ' drag-over' : ''}`}
|
||||
style={{ flex: 1, minWidth: 220, padding: 16 }}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault(); setDragOver(false)
|
||||
const f = e.dataTransfer.files?.[0]
|
||||
if (f) void handleFile(f)
|
||||
}}
|
||||
>
|
||||
<div className="dropzone-icon" style={{ marginBottom: 6 }}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 3v12M7 8l5-5 5 5M5 21h14" />
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>{t('workspace.uploadExam')}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>{t('workspace.uploadDropzone')}</div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) void handleFile(f) }}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="workspace-card"
|
||||
style={{ flex: 1, minWidth: 220, padding: 16, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', background: 'var(--paper-2)' }}
|
||||
onClick={() => navigate('/exam-editor/new')}
|
||||
>
|
||||
<div style={{ fontSize: 20, marginBottom: 6 }}>+</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>{t('workspace.newExam')}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>{t('workspace.createFirstExam')}</div>
|
||||
</button>
|
||||
|
||||
{resumable && (
|
||||
<button
|
||||
className="workspace-card"
|
||||
style={{ flex: 1, minWidth: 220, padding: 16, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', background: 'var(--paper-2)', borderColor: 'var(--accent)' }}
|
||||
onClick={() => onLoadExam(resumable.exam, false, null)}
|
||||
>
|
||||
<div style={{ fontSize: 20, marginBottom: 6 }}>▶</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>{t('landing.resumeTitle')}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>
|
||||
{resumable.exam.title}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{uploadErr && (
|
||||
<div style={{ marginBottom: 16, padding: '10px 14px', border: '1px solid var(--wrong)', borderRadius: 8, background: 'var(--wrong-weak)', color: 'var(--wrong)', fontSize: 13 }}>
|
||||
{uploadErr}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Study Coach */}
|
||||
<StudyCoach onRetakeWeakest={handleRetakeWeakest} />
|
||||
|
||||
{/* Performance Trends */}
|
||||
{hasChartData && !trendsLoading && (
|
||||
<div className="workspace-card" style={{ marginBottom: 24 }}>
|
||||
<div style={{ padding: '16px 20px 4px' }}>
|
||||
<h3 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600 }}>{t('workspace.performanceTrends')}</h3>
|
||||
<svg viewBox={`0 0 ${viewW} ${viewH}`} style={{ width: '100%', height: 'auto', overflow: 'visible' }}>
|
||||
{/* Grid lines */}
|
||||
{gridLines.map((g) => (
|
||||
<g key={g.label}>
|
||||
<line
|
||||
x1={margin.left}
|
||||
x2={margin.left + cw}
|
||||
y1={g.y}
|
||||
y2={g.y}
|
||||
stroke="var(--line-2)"
|
||||
strokeDasharray="2,2"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text x={margin.left - 6} y={g.y + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)">
|
||||
{g.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{/* Trend line */}
|
||||
<path
|
||||
d={linePoints}
|
||||
fill="none"
|
||||
stroke="var(--accent)"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Data points */}
|
||||
{chartData.map((d, i) => {
|
||||
const x = xScale(d.completed_at)
|
||||
const y = yScale(d.score_pct)
|
||||
const date = new Date(d.completed_at * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
return (
|
||||
<g key={i}>
|
||||
<circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={4}
|
||||
fill="var(--accent)"
|
||||
stroke="var(--paper-2)"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<title>{`${d.title} — ${d.score_pct}% (${date})`}</title>
|
||||
</circle>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
{/* X-axis labels */}
|
||||
{xLabelIndices.map((idx) => {
|
||||
const d = chartData[idx]
|
||||
if (!d) return null
|
||||
const x = xScale(d.completed_at)
|
||||
const date = new Date(d.completed_at * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
return (
|
||||
<text key={idx} x={x} y={viewH - 6} textAnchor="middle" fontSize={10} fill="var(--ink-3)">
|
||||
{date}
|
||||
</text>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Results */}
|
||||
{recentResults.length > 0 && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 12 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 600 }}>{t('workspace.recentResults')}</h3>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => navigate('/workspace/results')}>
|
||||
{t('workspace.viewAll')} →
|
||||
</button>
|
||||
</div>
|
||||
<div className="workspace-list">
|
||||
{recentResults.map((r) => {
|
||||
const pct = r.total_points ? Math.round((r.score_points / r.total_points) * 100) : 0
|
||||
const passed = pct >= (r.passing_score ?? 60)
|
||||
const d = new Date(r.completed_at * 1000)
|
||||
const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
return (
|
||||
<div key={r.id} className="workspace-card" style={{ display: 'flex', alignItems: 'center', gap: 12, padding: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>{r.title}</div>
|
||||
<div className="muted" style={{ fontSize: 11 }}>{dateStr}</div>
|
||||
</div>
|
||||
<div className={`score-pill ${passed ? 'pass' : 'fail'}`} style={{ fontSize: 11 }}>{pct}%</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!user && (
|
||||
<div className="workspace-card" style={{ padding: 20, textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 14, marginBottom: 8 }}>{t('workspace.signInPrompt')}</div>
|
||||
<button className="btn btn-primary" onClick={() => { /* handled by parent */ }}>
|
||||
{t('auth.signIn')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Exam, ExamState } from '../types'
|
||||
import type { Exam, ExamState, Question } from '../types'
|
||||
import { gradeQuestion } from '../utils/grading'
|
||||
import { formatTime } from '../utils/formatting'
|
||||
import Tex from './Tex'
|
||||
import QuestionMedia from './QuestionMedia'
|
||||
import QuestionRenderer from './questions/QuestionRenderer'
|
||||
import * as gradeApi from '../api/gradeApi'
|
||||
import * as aiApi from '../api/aiApi'
|
||||
|
||||
interface Props {
|
||||
exam: Exam
|
||||
@@ -14,40 +16,112 @@ interface Props {
|
||||
onExit: () => void
|
||||
onRedoFailed?: () => void
|
||||
reviewOnly?: boolean
|
||||
resultId?: number | null
|
||||
}
|
||||
|
||||
export default function Results({ exam, state, onRestart, onExit, onRedoFailed, reviewOnly }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const AI_TYPES = new Set(['essay', 'short-answer', 'translation', 'math-input', 'match', 'order'])
|
||||
|
||||
function isAiApplicable(q: Question): boolean {
|
||||
return AI_TYPES.has(q.type)
|
||||
}
|
||||
|
||||
function mapTypeForApi(q: Question): gradeApi.GradeRequest['type'] | null {
|
||||
switch (q.type) {
|
||||
case 'essay': return 'essay'
|
||||
case 'short-answer': return 'short-answer'
|
||||
case 'translation': return 'translation'
|
||||
case 'math-input': return 'math'
|
||||
case 'match': return 'match'
|
||||
case 'order': return 'ordering'
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
export default function Results({ exam, state, onRestart, onExit, onRedoFailed, reviewOnly, resultId }: Props) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { answers, startedAt, completedAt } = state
|
||||
const elapsedMs = (completedAt ?? Date.now()) - startedAt
|
||||
|
||||
const graded = exam.questions.map((q) => ({
|
||||
q,
|
||||
userAnswer: answers[q.id],
|
||||
result: gradeQuestion(q, answers[q.id]),
|
||||
}))
|
||||
const [aiReviews, setAiReviews] = useState<Record<string, gradeApi.GradingResult>>({})
|
||||
const [aiLoading, setAiLoading] = useState<Record<string, boolean>>({})
|
||||
const [aiErrors, setAiErrors] = useState<Record<string, string>>({})
|
||||
const [acceptedGrades, setAcceptedGrades] = useState<Record<string, number>>({})
|
||||
|
||||
const graded = exam.questions.map((q) => {
|
||||
const accepted = acceptedGrades[q.id]
|
||||
const base = gradeQuestion(q, answers[q.id])
|
||||
if (accepted != null) {
|
||||
const pts = q.points ?? 1
|
||||
return {
|
||||
...base,
|
||||
status: (accepted >= 0.5 ? 'correct' : 'wrong') as typeof base.status,
|
||||
correct: accepted >= 0.5,
|
||||
points: Math.round(pts * accepted),
|
||||
}
|
||||
}
|
||||
return base
|
||||
})
|
||||
|
||||
const totalPoints = exam.questions.reduce((a, q) => a + (q.points ?? 1), 0)
|
||||
const scorePoints = graded.reduce((a, g) => a + g.result.points, 0)
|
||||
const gradedCount = graded.filter((g) => g.result.status !== 'ungraded').length
|
||||
const correctCount = graded.filter((g) => g.result.correct).length
|
||||
const scorePoints = graded.reduce((a, g) => a + g.points, 0)
|
||||
const gradedCount = graded.filter((g) => g.status !== 'ungraded').length
|
||||
const correctCount = graded.filter((g) => g.correct).length
|
||||
const failedCount = gradedCount - correctCount
|
||||
const pct = totalPoints ? Math.round((scorePoints / totalPoints) * 100) : 0
|
||||
const passing = exam.passingScore ?? 60
|
||||
const passed = pct >= passing
|
||||
|
||||
const byCat: Record<string, { earned: number; possible: number; correct: number; total: number }> = {}
|
||||
graded.forEach((g) => {
|
||||
const cat = g.q.category ?? 'Uncategorized'
|
||||
graded.forEach((g, i) => {
|
||||
const cat = exam.questions[i].category ?? 'Uncategorized'
|
||||
if (!byCat[cat]) byCat[cat] = { earned: 0, possible: 0, correct: 0, total: 0 }
|
||||
byCat[cat].possible += g.q.points ?? 1
|
||||
byCat[cat].earned += g.result.points
|
||||
byCat[cat].possible += exam.questions[i].points ?? 1
|
||||
byCat[cat].earned += g.points
|
||||
byCat[cat].total += 1
|
||||
if (g.result.correct) byCat[cat].correct += 1
|
||||
if (g.correct) byCat[cat].correct += 1
|
||||
})
|
||||
|
||||
const [openIdx, setOpenIdx] = useState<number | null>(null)
|
||||
|
||||
const runAiReview = async (q: Question) => {
|
||||
const type = mapTypeForApi(q)
|
||||
if (!type) return
|
||||
setAiLoading((prev) => ({ ...prev, [q.id]: true }))
|
||||
setAiErrors((prev) => { const n = { ...prev }; delete n[q.id]; return n })
|
||||
try {
|
||||
const result = await gradeApi.gradeQuestion({
|
||||
type,
|
||||
question: q as unknown as Record<string, unknown>,
|
||||
answer: answers[q.id],
|
||||
language: i18n.language === 'nl' ? 'nl' : 'en',
|
||||
})
|
||||
setAiReviews((prev) => ({ ...prev, [q.id]: result }))
|
||||
} catch (e) {
|
||||
setAiErrors((prev) => ({ ...prev, [q.id]: (e as Error).message }))
|
||||
} finally {
|
||||
setAiLoading((prev) => { const n = { ...prev }; delete n[q.id]; return n })
|
||||
}
|
||||
}
|
||||
|
||||
const acceptGrade = (qid: string, grade: number) => {
|
||||
setAcceptedGrades((prev) => ({ ...prev, [qid]: grade }))
|
||||
}
|
||||
|
||||
const keepOriginal = (qid: string) => {
|
||||
setAcceptedGrades((prev) => { const n = { ...prev }; delete n[qid]; return n })
|
||||
setAiReviews((prev) => { const n = { ...prev }; delete n[qid]; return n })
|
||||
}
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'correct': return t('exam.correct')
|
||||
case 'wrong': return t('exam.notQuite')
|
||||
case 'ungraded': return 'Ungraded'
|
||||
case 'unanswered': return 'Unanswered'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="results">
|
||||
<div className="results-eyebrow">
|
||||
@@ -95,40 +169,101 @@ export default function Results({ exam, state, onRestart, onExit, onRedoFailed,
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resultId != null && <TutorChatPanel resultId={resultId} />}
|
||||
|
||||
<h2 className="review-heading">{t('results.review')}</h2>
|
||||
{graded.map((g, i) => {
|
||||
const promptText = g.q.prompt
|
||||
{exam.questions.map((q, i) => {
|
||||
const g = graded[i]
|
||||
const promptText = q.prompt
|
||||
.replace(/\$\$([^$]+)\$\$/g, '[math]')
|
||||
.replace(/\$([^$]+)\$/g, '[math]')
|
||||
const aiResult = aiReviews[q.id]
|
||||
const aiBusy = aiLoading[q.id]
|
||||
const aiErr = aiErrors[q.id]
|
||||
const accepted = acceptedGrades[q.id]
|
||||
const applicable = isAiApplicable(q)
|
||||
|
||||
return (
|
||||
<div key={g.q.id} className={`review-item${openIdx === i ? ' open' : ''}`}>
|
||||
<div key={q.id} className={`review-item${openIdx === i ? ' open' : ''}`}>
|
||||
<div className="review-item-header" onClick={() => setOpenIdx(openIdx === i ? null : i)}>
|
||||
<span className="review-num">{String(i + 1).padStart(2, '0')}</span>
|
||||
<span className="review-title">
|
||||
<Tex>{promptText.slice(0, 90)}</Tex>
|
||||
{promptText.length > 90 ? '…' : ''}
|
||||
</span>
|
||||
<span className={`review-status ${g.result.status}`}>{g.result.status}</span>
|
||||
<span className={`review-status ${g.status}`}>
|
||||
{statusLabel(g.status)}
|
||||
{accepted != null && ' ✦'}
|
||||
</span>
|
||||
<span style={{ color: 'var(--ink-4)' }}>{openIdx === i ? '−' : '+'}</span>
|
||||
</div>
|
||||
{openIdx === i && (
|
||||
<div className="review-body">
|
||||
<div className="q-prompt" style={{ fontSize: 20, marginBottom: 20 }}>
|
||||
<Tex>{g.q.prompt}</Tex>
|
||||
<Tex>{q.prompt}</Tex>
|
||||
</div>
|
||||
<QuestionMedia q={g.q} />
|
||||
<QuestionRenderer key={g.q.id} q={g.q} value={g.userAnswer} onChange={() => {}} locked graded />
|
||||
{g.q.explanation && (
|
||||
<div className="feedback" style={{ marginTop: 24 }}>
|
||||
<div className="feedback-label">{t('results.explanation')}</div>
|
||||
<div className="feedback-body"><Tex>{g.q.explanation}</Tex></div>
|
||||
<QuestionMedia q={q} />
|
||||
<QuestionRenderer key={q.id} q={q} value={answers[q.id]} onChange={() => {}} locked graded />
|
||||
|
||||
{applicable && !reviewOnly && (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
{!aiResult && !aiBusy && (
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={(e) => { e.stopPropagation(); void runAiReview(q) }}
|
||||
>
|
||||
{t('results.ai.button')} ▶
|
||||
</button>
|
||||
)}
|
||||
{aiBusy && (
|
||||
<span className="muted" style={{ fontSize: 13 }}>{t('common.loading')}</span>
|
||||
)}
|
||||
{aiErr && (
|
||||
<div style={{ marginTop: 8, padding: '8px 12px', borderRadius: 6, background: 'var(--wrong-weak)', color: 'var(--wrong)', fontSize: 13 }}>
|
||||
{t('results.ai.error')}: {aiErr}
|
||||
</div>
|
||||
)}
|
||||
{aiResult && (
|
||||
<div style={{ marginTop: 12, padding: 16, border: '1px solid var(--line-2)', borderRadius: 10, background: 'var(--paper-2)' }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 4 }}>
|
||||
{t('results.ai.suggestedGrade')}: {Math.round(aiResult.suggestedGrade * 100)}%
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-2)', lineHeight: 1.6, marginBottom: 12 }}>
|
||||
{aiResult.feedback}
|
||||
</div>
|
||||
{!!aiResult.detail && (
|
||||
<AiDetail detail={aiResult.detail} type={q.type} />
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 12 }}>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={(e) => { e.stopPropagation(); acceptGrade(q.id, aiResult.suggestedGrade) }}
|
||||
>
|
||||
{t('results.ai.accept')} ✓
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={(e) => { e.stopPropagation(); keepOriginal(q.id) }}
|
||||
>
|
||||
{t('results.ai.keepOriginal')} ✗
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{g.q.rubric && (
|
||||
|
||||
{q.explanation && (
|
||||
<div className="feedback" style={{ marginTop: 24 }}>
|
||||
<div className="feedback-label">{t('results.explanation')}</div>
|
||||
<div className="feedback-body"><Tex>{q.explanation}</Tex></div>
|
||||
</div>
|
||||
)}
|
||||
{q.rubric && (
|
||||
<div style={{ marginTop: 20, fontSize: 13.5 }}>
|
||||
<div className="meta-label" style={{ marginBottom: 8 }}>{t('results.rubric')}</div>
|
||||
<ul style={{ paddingLeft: 18, margin: 0, lineHeight: 1.7, color: 'var(--ink-2)' }}>
|
||||
{g.q.rubric.map((r, j) => <li key={j}><Tex>{r}</Tex></li>)}
|
||||
{q.rubric.map((r, j) => <li key={j}><Tex>{r}</Tex></li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
@@ -154,3 +289,200 @@ export default function Results({ exam, state, onRestart, onExit, onRedoFailed,
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AiDetail({ detail, type }: { detail: unknown; type: string }) {
|
||||
const { t } = useTranslation()
|
||||
if (!detail || typeof detail !== 'object') return null
|
||||
|
||||
const d = detail as Record<string, unknown>
|
||||
|
||||
if (type === 'essay' && Array.isArray(d.rubricBreakdown)) {
|
||||
return (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div className="meta-label" style={{ marginBottom: 6, fontSize: 12 }}>{t('results.ai.rubricBreakdown')}</div>
|
||||
<table style={{ fontSize: 12, width: '100%', borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
{(d.rubricBreakdown as Array<{ item: string; met: boolean }>).map((r, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid var(--line-1)' }}>
|
||||
<td style={{ padding: '4px 0' }}>{r.item}</td>
|
||||
<td style={{ textAlign: 'right', color: r.met ? 'var(--right)' : 'var(--wrong)' }}>
|
||||
{r.met ? '✓' : '✗'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if ((type === 'ordering' || type === 'order') && Array.isArray(d.correctPositions)) {
|
||||
return (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--ink-2)' }}>
|
||||
{t('results.ai.suggestedGrade')}: {(d.correctPositions as number[]).length} / {Number(d.totalItems ?? 0)} {t('results.correct').toLowerCase()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'match' && (Array.isArray(d.correctPairs) || Array.isArray(d.incorrectPairs))) {
|
||||
return (
|
||||
<div style={{ marginTop: 8, fontSize: 12 }}>
|
||||
{Array.isArray(d.correctPairs) && (d.correctPairs as string[]).length > 0 && (
|
||||
<div style={{ color: 'var(--right)', marginBottom: 4 }}>
|
||||
✓ {(d.correctPairs as string[]).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{Array.isArray(d.incorrectPairs) && (d.incorrectPairs as string[]).length > 0 && (
|
||||
<div style={{ color: 'var(--wrong)' }}>
|
||||
✗ {(d.incorrectPairs as string[]).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'math-input' && typeof d.equivalent === 'boolean') {
|
||||
return (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--ink-2)' }}>
|
||||
{d.explanation as string}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'translation' && (typeof d.accuracy === 'number' || typeof d.fluency === 'number')) {
|
||||
return (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--ink-2)' }}>
|
||||
Accuracy: {Math.round((d.accuracy as number) * 100)}% · Fluency: {Math.round((d.fluency as number) * 100)}%
|
||||
{!!d.notes && <div style={{ marginTop: 4 }}>{d.notes as string}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'short-answer' && d.matchedForm != null) {
|
||||
return (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--ink-2)' }}>
|
||||
{d.reason as string}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function TutorChatPanel({ resultId }: { resultId: number }) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [messages, setMessages] = useState<aiApi.TutorChatMessage[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
const send = async () => {
|
||||
const text = input.trim()
|
||||
if (!text || busy) return
|
||||
const next: aiApi.TutorChatMessage[] = [...messages, { role: 'user', content: text }]
|
||||
setMessages(next)
|
||||
setInput('')
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
const res = await aiApi.tutorChat({
|
||||
resultId,
|
||||
messages: next,
|
||||
language: i18n.language || 'en',
|
||||
})
|
||||
setMessages((prev) => [...prev, { role: 'assistant', content: res.reply }])
|
||||
} catch (e) {
|
||||
setErr((e as Error).message)
|
||||
setMessages((prev) => prev.slice(0, -1))
|
||||
setInput(text)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ margin: '24px 0', border: '1px solid var(--line-2)', borderRadius: 10, background: 'var(--paper-2)' }}>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{
|
||||
width: '100%',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: '14px 16px',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--ink-1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<span>{t('results.tutor.title')}</span>
|
||||
<span style={{ color: 'var(--ink-4)' }}>{open ? '−' : '+'}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ padding: '0 16px 16px' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginBottom: 10 }}>
|
||||
{t('results.tutor.subtitle')}
|
||||
</div>
|
||||
{messages.length === 0 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-4)', marginBottom: 10, fontStyle: 'italic' }}>
|
||||
{t('results.tutor.placeholder')}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 10 }}>
|
||||
{messages.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
alignSelf: m.role === 'user' ? 'flex-end' : 'flex-start',
|
||||
maxWidth: '85%',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
background: m.role === 'user' ? 'var(--accent-weak, var(--paper))' : 'var(--paper)',
|
||||
border: '1px solid var(--line-1)',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{m.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{err && (
|
||||
<div style={{ marginBottom: 8, padding: '6px 10px', borderRadius: 6, background: 'var(--wrong-weak)', color: 'var(--wrong)', fontSize: 12 }}>
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
className="input"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
void send()
|
||||
}
|
||||
}}
|
||||
placeholder={t('results.tutor.inputPlaceholder')}
|
||||
disabled={busy}
|
||||
style={{ flex: 1, fontSize: 13 }}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => void send()}
|
||||
disabled={busy || !input.trim()}
|
||||
>
|
||||
{busy ? t('common.loading') : t('results.tutor.send')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import * as examApi from '../api/examApi'
|
||||
import type { Exam } from '../types'
|
||||
|
||||
export default function ShareView({ onLoadExam }: { onLoadExam: (exam: Exam, saveToWorkspace?: boolean, examId?: number | null) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const { token } = useParams<{ token: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [data, setData] = useState<{ title: string; subtitle: string | null; questionCount: number; exam: Exam; shareMode: string } | null>(null)
|
||||
const [cloning, setCloning] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError('Invalid link')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
examApi.getSharedExam(token)
|
||||
.then((res) => {
|
||||
setData({
|
||||
title: res.title,
|
||||
subtitle: res.subtitle,
|
||||
questionCount: res.questionCount,
|
||||
exam: res.exam as Exam,
|
||||
shareMode: res.shareMode ?? 'clone',
|
||||
})
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((e) => {
|
||||
setError((e as Error).message)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [token])
|
||||
|
||||
const handleTake = () => {
|
||||
if (!data) return
|
||||
onLoadExam(data.exam, false, null)
|
||||
}
|
||||
|
||||
const handleClone = async () => {
|
||||
if (!token || !data) return
|
||||
if (!user) {
|
||||
navigate('/')
|
||||
return
|
||||
}
|
||||
setCloning(true)
|
||||
try {
|
||||
await examApi.cloneSharedExam(token)
|
||||
navigate('/workspace/exams')
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
setCloning(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="shell">
|
||||
<div className="workspace-empty">{t('common.loading')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="shell">
|
||||
<div className="workspace-empty">
|
||||
<div className="workspace-empty-icon">!</div>
|
||||
<div className="workspace-empty-title">{error || 'Not found'}</div>
|
||||
<button className="btn btn-primary" onClick={() => navigate('/')} style={{ marginTop: 16 }}>
|
||||
{t('results.backToHome')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<div className="landing" style={{ maxWidth: 640, margin: '0 auto', paddingTop: 40 }}>
|
||||
<div className="landing-eyebrow">{t('common.appName')}</div>
|
||||
<h1 className="landing-title">{data.title}</h1>
|
||||
{data.subtitle && <p className="landing-sub">{data.subtitle}</p>}
|
||||
|
||||
<div style={{ marginTop: 24, padding: 20, border: '1px solid var(--line-2)', borderRadius: 12, background: 'var(--paper-2)' }}>
|
||||
<div style={{ fontSize: 14, marginBottom: 8 }}>
|
||||
<span className="mono" style={{ color: 'var(--ink-3)' }}>{data.questionCount}</span> {data.questionCount === 1 ? t('common.question') : t('common.questions')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 16 }}>
|
||||
<button className="btn btn-primary" onClick={handleTake}>
|
||||
{t('share.takeExam')}
|
||||
</button>
|
||||
{user && data.shareMode !== 'view' ? (
|
||||
<button className="btn btn-ghost" onClick={() => void handleClone()} disabled={cloning}>
|
||||
{cloning ? t('common.loading') : t('share.saveToWorkspace')}
|
||||
</button>
|
||||
) : user ? (
|
||||
<span className="muted" style={{ fontSize: 12, alignSelf: 'center' }}>
|
||||
{t('workspace.shareModeView')}
|
||||
</span>
|
||||
) : (
|
||||
<button className="btn btn-ghost" onClick={() => navigate('/')}>
|
||||
{t('auth.signIn')} →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as examApi from '../api/examApi'
|
||||
import type { Exam } from '../types'
|
||||
|
||||
interface Props {
|
||||
onLoadExam: (exam: Exam, saveToWorkspace?: boolean, examId?: number | null) => void
|
||||
}
|
||||
|
||||
export default function SharedWithMe({ onLoadExam }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [exams, setExams] = useState<examApi.ClonedExamItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await examApi.listClonedExams()
|
||||
setExams(data)
|
||||
} catch {
|
||||
setExams([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="workspace-section">
|
||||
<div className="workspace-empty" style={{ padding: 40 }}>{t('common.loading')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (exams.length === 0) {
|
||||
return (
|
||||
<div className="workspace-section fade-in">
|
||||
<h2 style={{ margin: '0 0 16px', fontSize: 18, fontWeight: 600 }}>{t('workspace.sharedWithMe')}</h2>
|
||||
<div className="workspace-empty" style={{ padding: 40 }}>
|
||||
<div className="workspace-empty-icon">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="workspace-empty-title">{t('workspace.noSharedExams')}</div>
|
||||
<div className="workspace-empty-desc">{t('workspace.noSharedExamsDesc')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="workspace-section fade-in">
|
||||
<h2 style={{ margin: '0 0 16px', fontSize: 18, fontWeight: 600 }}>{t('workspace.sharedWithMe')}</h2>
|
||||
<div className="workspace-list" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{exams.map((exam) => (
|
||||
<div
|
||||
key={exam.id}
|
||||
className="workspace-card"
|
||||
style={{ padding: 16, display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
void examApi.getExam(exam.id).then((e) => onLoadExam(e, false, exam.id))
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{exam.title}</div>
|
||||
<div className="muted" style={{ fontSize: 12, marginTop: 2 }}>
|
||||
{exam.question_count} {exam.question_count === 1 ? t('common.question') : t('common.questions')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
navigate(`/exam-editor/${exam.id}`)
|
||||
}}
|
||||
>
|
||||
{t('common.edit')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import * as resultApi from '../api/resultApi'
|
||||
|
||||
interface Props {
|
||||
onRetakeWeakest?: (examId: number) => void
|
||||
}
|
||||
|
||||
export default function StudyCoach({ onRetakeWeakest }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [data, setData] = useState<resultApi.StudyCoachResult | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
resultApi.analyzeResults()
|
||||
.then((res) => setData(res))
|
||||
.catch(() => {/* ignore */})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="workspace-card" style={{ marginBottom: 24 }}>
|
||||
<div className="workspace-empty" style={{ padding: 24 }}>{t('common.loading')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || (data.weakAreas.length === 0 && data.studyTips.length === 0 && !data.weakestExamId)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="workspace-card" style={{ marginBottom: 24, borderLeft: '3px solid var(--accent)' }}>
|
||||
<div style={{ padding: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 18 }}>🤖</span>
|
||||
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 600 }}>{t('workspace.studyCoachTitle')}</h3>
|
||||
{data.streakDays > 0 && (
|
||||
<span className="mono" style={{ fontSize: 12, color: 'var(--ink-3)', marginLeft: 'auto' }}>
|
||||
🔥 {data.streakDays} {data.streakDays === 1 ? t('workspace.streakDay') : t('workspace.streakDays')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data.weakestExamId && onRetakeWeakest && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
style={{ background: 'var(--wrong)', color: 'white', borderColor: 'var(--wrong)' }}
|
||||
onClick={() => onRetakeWeakest(data.weakestExamId!)}
|
||||
>
|
||||
🔄 {t('workspace.retakeWeakest')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.weakAreas.length > 0 && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
{data.weakAreas.slice(0, 3).map((area) => (
|
||||
<div key={area.category} style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
|
||||
<div style={{ flex: 1, height: 6, background: 'var(--line-2)', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
width: `${area.avgScore}%`,
|
||||
height: '100%',
|
||||
background: area.avgScore >= 60 ? 'var(--ok)' : 'var(--wrong)',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="mono" style={{ fontSize: 12, minWidth: 40, textAlign: 'right' }}>
|
||||
{area.avgScore}%
|
||||
</span>
|
||||
<span style={{ fontSize: 13, minWidth: 120 }}>{area.category}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.studyTips.length > 0 && (
|
||||
<div style={{ padding: 12, background: 'var(--paper)', borderRadius: 8 }}>
|
||||
<div className="mono" style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--ink-3)', marginBottom: 8 }}>
|
||||
{t('workspace.studyTips')}
|
||||
</div>
|
||||
<ul style={{ margin: 0, paddingLeft: 18, fontSize: 13, lineHeight: 1.7, color: 'var(--ink-2)' }}>
|
||||
{data.studyTips.map((tip, i) => (
|
||||
<li key={i}>{tip}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ marginTop: 12 }}
|
||||
onClick={() => navigate('/workspace/results')}
|
||||
>
|
||||
{t('workspace.viewAllResults')} →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useNavigate, useParams, Navigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
@@ -9,11 +10,24 @@ import MyExams from './MyExams'
|
||||
import MyResults from './MyResults'
|
||||
import Settings from './Settings'
|
||||
import StorageManager from './StorageManager'
|
||||
import Overview from './Overview'
|
||||
import SharedWithMe from './SharedWithMe'
|
||||
|
||||
type TabKey = 'exams' | 'results' | 'storage' | 'settings'
|
||||
type TabKey = 'overview' | 'exams' | 'shared' | 'results' | 'storage' | 'settings'
|
||||
|
||||
const TAB_KEYS: { key: TabKey; labelKey: string; icon: string }[] = [
|
||||
const TAB_KEYS: { key: TabKey; labelKey: string; icon: ReactNode }[] = [
|
||||
{ key: 'overview', labelKey: 'workspace.overview', icon: '◈' },
|
||||
{ key: 'exams', labelKey: 'workspace.myExams', icon: '◇' },
|
||||
{
|
||||
key: 'shared',
|
||||
labelKey: 'workspace.sharedWithMe',
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{ key: 'results', labelKey: 'workspace.results', icon: '✦' },
|
||||
{ key: 'storage', labelKey: 'workspace.storage', icon: '◉' },
|
||||
{ key: 'settings', labelKey: 'workspace.settings', icon: '⚙' },
|
||||
@@ -28,9 +42,10 @@ export default function Workspace({ onLoadExam }: Props) {
|
||||
const { user, loading: authLoading } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const { tab } = useParams<{ tab: string }>()
|
||||
const activeTab = (tab as TabKey) ?? 'exams'
|
||||
const activeTab = (tab as TabKey) ?? 'overview'
|
||||
const [examCount, setExamCount] = useState(0)
|
||||
const [resultCount, setResultCount] = useState(0)
|
||||
const [recentResults, setRecentResults] = useState<resultApi.ResultListItem[]>([])
|
||||
|
||||
const loadCounts = useCallback(async () => {
|
||||
try {
|
||||
@@ -38,6 +53,7 @@ export default function Workspace({ onLoadExam }: Props) {
|
||||
setExamCount(exams.length)
|
||||
const results = await resultApi.listResults()
|
||||
setResultCount(results.total)
|
||||
setRecentResults(results.results.slice(0, 5))
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
@@ -47,11 +63,11 @@ export default function Workspace({ onLoadExam }: Props) {
|
||||
if (user) void loadCounts()
|
||||
}, [user, loadCounts])
|
||||
|
||||
const setTab = (key: TabKey) => navigate(`/workspace/${key}`)
|
||||
|
||||
if (authLoading) return null
|
||||
if (!user) return <Navigate to="/" replace />
|
||||
|
||||
const setTab = (key: TabKey) => navigate(`/workspace/${key}`)
|
||||
|
||||
return (
|
||||
<div className="workspace-shell">
|
||||
<aside className="workspace-sidebar">
|
||||
@@ -60,7 +76,6 @@ export default function Workspace({ onLoadExam }: Props) {
|
||||
{user.displayName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="workspace-name">{user.displayName}</div>
|
||||
<div className="workspace-email">{user.email}</div>
|
||||
</div>
|
||||
|
||||
<nav className="workspace-nav">
|
||||
@@ -84,7 +99,16 @@ export default function Workspace({ onLoadExam }: Props) {
|
||||
</aside>
|
||||
|
||||
<main className="workspace-main">
|
||||
{activeTab === 'overview' && (
|
||||
<Overview
|
||||
examCount={examCount}
|
||||
resultCount={resultCount}
|
||||
recentResults={recentResults}
|
||||
onLoadExam={onLoadExam}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'exams' && <MyExams onLoadExam={onLoadExam} />}
|
||||
{activeTab === 'shared' && <SharedWithMe onLoadExam={onLoadExam} />}
|
||||
{activeTab === 'results' && <MyResults onLoadExam={onLoadExam} />}
|
||||
{activeTab === 'storage' && <StorageManager />}
|
||||
{activeTab === 'settings' && <Settings />}
|
||||
|
||||
+100
-3
@@ -108,9 +108,30 @@
|
||||
"flagHint": "flag",
|
||||
"selectHint": "select",
|
||||
"exit": "Exit",
|
||||
"untimed": "Untimed"
|
||||
"untimed": "Untimed",
|
||||
"needHint": "💡 Need a hint?",
|
||||
"hintLoading": "Thinking…",
|
||||
"hintShown": "💡 Hint shown",
|
||||
"hintTitle": "Get a hint without revealing the answer",
|
||||
"hintLabel": "Hint"
|
||||
},
|
||||
"results": {
|
||||
"ai": {
|
||||
"button": "AI Review",
|
||||
"suggestedGrade": "AI Suggested Grade",
|
||||
"feedback": "AI Feedback",
|
||||
"accept": "Accept",
|
||||
"keepOriginal": "Keep original",
|
||||
"rubricBreakdown": "Rubric breakdown",
|
||||
"error": "AI grading failed — please grade manually"
|
||||
},
|
||||
"tutor": {
|
||||
"title": "💬 Ask the tutor",
|
||||
"subtitle": "Ask follow-up questions about this attempt — explanations, similar problems, or rubric details.",
|
||||
"placeholder": "e.g. \"Why was Q2 wrong?\" or \"Give me a similar problem to practice.\"",
|
||||
"inputPlaceholder": "Ask a question…",
|
||||
"send": "Send"
|
||||
},
|
||||
"reviewing": "Reviewing past attempt",
|
||||
"examComplete": "Exam complete",
|
||||
"scored": "You scored ",
|
||||
@@ -145,6 +166,7 @@
|
||||
},
|
||||
"workspace": {
|
||||
"myWorkspace": "My Workspace",
|
||||
"overview": "Overview",
|
||||
"myExams": "My Exams",
|
||||
"results": "Results",
|
||||
"settings": "Settings",
|
||||
@@ -153,6 +175,30 @@
|
||||
"noSavedExamsDesc": "Upload an exam from the home page, or create a new one to get started.",
|
||||
"createFirstExam": "Create Your First Exam",
|
||||
"takeExam": "Take exam",
|
||||
"totalAttempts": "Total Attempts",
|
||||
"avgScore": "Avg. Score",
|
||||
"questionsAnswered": "Questions Answered",
|
||||
"uploadExam": "Upload Exam",
|
||||
"uploadDropzone": "Drop JSON or click to browse",
|
||||
"studyCoachTitle": "Study Coach",
|
||||
"streakDay": "day streak",
|
||||
"streakDays": "day streak",
|
||||
"studyTips": "Study Tips",
|
||||
"retakeWeakest": "Retake Weakest Exam",
|
||||
"performanceTrends": "Performance Trends",
|
||||
"sharedWithMe": "Shared with Me",
|
||||
"noSharedExams": "No shared exams yet",
|
||||
"noSharedExamsDesc": "Exams shared by others will appear here when you save them to your workspace.",
|
||||
"shareMode": "Share mode",
|
||||
"shareModeClone": "Cloneable",
|
||||
"shareModeView": "View only",
|
||||
"importFromUrl": "Import from URL",
|
||||
"importUrlPlaceholder": "Paste a raw JSON URL…",
|
||||
"import": "Import",
|
||||
"importing": "Importing…",
|
||||
"viewAllResults": "View all results",
|
||||
"recentResults": "Recent Results",
|
||||
"viewAll": "View all",
|
||||
"noAttempts": "No attempts yet",
|
||||
"noAttemptsDesc": "Take an exam to see your results here.",
|
||||
"attempts": "attempt",
|
||||
@@ -202,9 +248,40 @@
|
||||
"deleteExamConfirm": "Delete this exam? This cannot be undone.",
|
||||
"deleteResultConfirm": "Delete this result? This cannot be undone.",
|
||||
"storageWarningHigh": "You are using over 80% of your storage.",
|
||||
"storageWarningCritical": "You are using over 95% of your storage. Delete some items to free up space."
|
||||
"storageWarningCritical": "You are using over 95% of your storage. Delete some items to free up space.",
|
||||
"searchExams": "Search exams…",
|
||||
"noSearchResults": "No exams match your search",
|
||||
"noSearchResultsDesc": "Try a different keyword or clear the search.",
|
||||
"pin": "Pin to top",
|
||||
"unpin": "Unpin",
|
||||
"duplicate": "Duplicate",
|
||||
"export": "Export JSON",
|
||||
"collections": "Collections",
|
||||
"newCollection": "New Collection",
|
||||
"allExams": "All Exams",
|
||||
"uncategorized": "Uncategorized",
|
||||
"moveTo": "Move to",
|
||||
"removeFromCollection": "Remove from collection",
|
||||
"collectionNamePlaceholder": "Collection name…",
|
||||
"create": "Create",
|
||||
"rename": "Rename",
|
||||
"deleteCollectionConfirm": "Delete this collection? Exams will be moved to uncategorized.",
|
||||
"batchSelectAll": "Select all",
|
||||
"batchSelected": "{{count}} selected",
|
||||
"batchDeleteConfirm": "Delete selected exams? This cannot be undone."
|
||||
},
|
||||
"admin": {
|
||||
"aiGrading": {
|
||||
"title": "AI Grading",
|
||||
"provider": "Provider",
|
||||
"model": "Model",
|
||||
"apiKey": "API Key",
|
||||
"baseUrl": "Base URL",
|
||||
"baseUrlDesc": "Custom API base URL for OpenAI-compatible endpoints",
|
||||
"testButton": "Test connection",
|
||||
"testSuccess": "Connection successful",
|
||||
"testFailed": "Connection failed"
|
||||
},
|
||||
"administration": "Administration",
|
||||
"dashboard": "Dashboard",
|
||||
"backToSite": "← Back to site",
|
||||
@@ -326,6 +403,9 @@
|
||||
"correctOrder": "Correct Order",
|
||||
"correctOrderHint": "The correct order is the order shown above. Reorder items to set the correct sequence.",
|
||||
"setCurrentOrder": "Set Current Order as Correct",
|
||||
"tagsLabel": "Tags",
|
||||
"tagsPlaceholder": "Add tag…",
|
||||
"addTag": "Add",
|
||||
"examTitleRequired": "Exam title is required",
|
||||
"atLeastOneQuestion": "Add at least one question",
|
||||
"sourceLanguage": "Source Language",
|
||||
@@ -336,7 +416,15 @@
|
||||
"choicePlaceholder": "Choice {{id}}",
|
||||
"addItem": "+ Add Item",
|
||||
"leftItemPlaceholder": "Left item {{index}}",
|
||||
"rightItemPlaceholder": "Right item {{index}}"
|
||||
"rightItemPlaceholder": "Right item {{index}}",
|
||||
"aiGenerate": "✨ Generate with AI",
|
||||
"aiClose": "✕ Close AI",
|
||||
"aiPanelTitle": "Generate questions",
|
||||
"aiTopicPlaceholder": "Topic, e.g. \"Pythagorean theorem\"",
|
||||
"aiContextPlaceholder": "Optional notes or source material to ground the questions in…",
|
||||
"aiGenerateNow": "Generate",
|
||||
"aiGenerating": "Generating…",
|
||||
"aiTopicRequired": "Please enter a topic"
|
||||
},
|
||||
"setup": {
|
||||
"welcome": "Welcome to BrainWave",
|
||||
@@ -378,6 +466,15 @@
|
||||
"backToHome": "Back to Home",
|
||||
"noToken": "No verification token found."
|
||||
},
|
||||
"share": {
|
||||
"share": "Share",
|
||||
"shared": "Shared",
|
||||
"generateLink": "Generate share link",
|
||||
"copyLink": "Copy link",
|
||||
"revokeLink": "Revoke link",
|
||||
"takeExam": "Take this exam",
|
||||
"saveToWorkspace": "Save to my workspace"
|
||||
},
|
||||
"resetPassword": {
|
||||
"resetPassword": "Reset Password",
|
||||
"enterEmail": "Enter your email address and we'll send you a link to reset your password.",
|
||||
|
||||
@@ -108,9 +108,30 @@
|
||||
"flagHint": "markeren",
|
||||
"selectHint": "selecteren",
|
||||
"exit": "Afsluiten",
|
||||
"untimed": "Ongetimed"
|
||||
"untimed": "Ongetimed",
|
||||
"needHint": "💡 Hint nodig?",
|
||||
"hintLoading": "Aan het denken…",
|
||||
"hintShown": "💡 Hint getoond",
|
||||
"hintTitle": "Krijg een hint zonder dat het antwoord wordt verklapt",
|
||||
"hintLabel": "Hint"
|
||||
},
|
||||
"results": {
|
||||
"ai": {
|
||||
"button": "AI-nakijken",
|
||||
"suggestedGrade": "AI-suggestie",
|
||||
"feedback": "AI-feedback",
|
||||
"accept": "Accepteren",
|
||||
"keepOriginal": "Origineel houden",
|
||||
"rubricBreakdown": "Rubric-uitsplitsing",
|
||||
"error": "AI-beoordeling mislukt — beoordeel handmatig"
|
||||
},
|
||||
"tutor": {
|
||||
"title": "💬 Vraag de tutor",
|
||||
"subtitle": "Stel vervolgvragen over deze poging — uitleg, vergelijkbare opgaven of rubric-details.",
|
||||
"placeholder": "bijv. \"Waarom was vraag 2 fout?\" of \"Geef me een vergelijkbare opgave.\"",
|
||||
"inputPlaceholder": "Stel een vraag…",
|
||||
"send": "Verstuur"
|
||||
},
|
||||
"reviewing": "Eerdere poging bekijken",
|
||||
"examComplete": "Examen voltooid",
|
||||
"scored": "Je score: ",
|
||||
@@ -153,6 +174,30 @@
|
||||
"noSavedExamsDesc": "Upload een examen vanaf de homepagina, of maak een nieuw examen aan om te beginnen.",
|
||||
"createFirstExam": "Maak je eerste examen",
|
||||
"takeExam": "Examen maken",
|
||||
"totalAttempts": "Totaal Pogingen",
|
||||
"avgScore": "Gem. Score",
|
||||
"questionsAnswered": "Beantwoorde Vragen",
|
||||
"uploadExam": "Examen Uploaden",
|
||||
"uploadDropzone": "Sleep JSON of klik om te bladeren",
|
||||
"studyCoachTitle": "Study Coach",
|
||||
"streakDay": "dag streak",
|
||||
"streakDays": "dagen streak",
|
||||
"studyTips": "Studietips",
|
||||
"retakeWeakest": "Zwakste Examen Opnieuw",
|
||||
"performanceTrends": "Prestatietrends",
|
||||
"sharedWithMe": "Gedeeld met mij",
|
||||
"noSharedExams": "Nog geen gedeelde examens",
|
||||
"noSharedExamsDesc": "Examens die anderen met je delen, verschijnen hier wanneer je ze opslaat in je werkruimte.",
|
||||
"shareMode": "Deelmodus",
|
||||
"shareModeClone": "Kloonbaar",
|
||||
"shareModeView": "Alleen bekijken",
|
||||
"importFromUrl": "Importeren vanaf URL",
|
||||
"importUrlPlaceholder": "Plak een ruwe JSON-URL…",
|
||||
"import": "Importeren",
|
||||
"importing": "Importeren…",
|
||||
"viewAllResults": "Alle resultaten bekijken",
|
||||
"recentResults": "Recente Resultaten",
|
||||
"viewAll": "Alles bekijken",
|
||||
"noAttempts": "Nog geen pogingen",
|
||||
"noAttemptsDesc": "Maak een examen om hier je resultaten te zien.",
|
||||
"attempts": "poging",
|
||||
@@ -202,9 +247,40 @@
|
||||
"deleteExamConfirm": "Dit examen verwijderen? Dit kan niet ongedaan worden gemaakt.",
|
||||
"deleteResultConfirm": "Dit resultaat verwijderen? Dit kan niet ongedaan worden gemaakt.",
|
||||
"storageWarningHigh": "Je gebruikt meer dan 80% van je opslag.",
|
||||
"storageWarningCritical": "Je gebruikt meer dan 95% van je opslag. Verwijder enkele items om ruimte vrij te maken."
|
||||
"storageWarningCritical": "Je gebruikt meer dan 95% van je opslag. Verwijder enkele items om ruimte vrij te maken.",
|
||||
"searchExams": "Zoek examens…",
|
||||
"noSearchResults": "Geen examens gevonden",
|
||||
"noSearchResultsDesc": "Probeer een ander trefwoord of wis de zoekopdracht.",
|
||||
"pin": "Vastpinnen",
|
||||
"unpin": "Losmaken",
|
||||
"duplicate": "Dupliceren",
|
||||
"export": "Exporteer JSON",
|
||||
"collections": "Collecties",
|
||||
"newCollection": "Nieuwe Collectie",
|
||||
"allExams": "Alle Examens",
|
||||
"uncategorized": "Niet-gecategoriseerd",
|
||||
"moveTo": "Verplaats naar",
|
||||
"removeFromCollection": "Verwijder uit collectie",
|
||||
"collectionNamePlaceholder": "Collectienaam…",
|
||||
"create": "Aanmaken",
|
||||
"rename": "Hernoemen",
|
||||
"deleteCollectionConfirm": "Deze collectie verwijderen? Examens worden niet-gecategoriseerd.",
|
||||
"batchSelectAll": "Alles selecteren",
|
||||
"batchSelected": "{{count}} geselecteerd",
|
||||
"batchDeleteConfirm": "Geselecteerde examens verwijderen? Dit kan niet ongedaan worden gemaakt."
|
||||
},
|
||||
"admin": {
|
||||
"aiGrading": {
|
||||
"title": "AI-beoordeling",
|
||||
"provider": "Provider",
|
||||
"model": "Model",
|
||||
"apiKey": "API-sleutel",
|
||||
"baseUrl": "Basis-URL",
|
||||
"baseUrlDesc": "Aangepaste API basis-URL voor OpenAI-compatible endpoints",
|
||||
"testButton": "Verbinding testen",
|
||||
"testSuccess": "Verbinding geslaagd",
|
||||
"testFailed": "Verbinding mislukt"
|
||||
},
|
||||
"administration": "Beheer",
|
||||
"dashboard": "Dashboard",
|
||||
"backToSite": "← Terug naar site",
|
||||
@@ -325,6 +401,9 @@
|
||||
"correctOrder": "Correcte volgorde",
|
||||
"correctOrderHint": "De correcte volgorde is de volgorde hierboven. Rangschik de items om de juiste volgorde in te stellen.",
|
||||
"setCurrentOrder": "Huidige volgorde instellen als correct",
|
||||
"tagsLabel": "Labels",
|
||||
"tagsPlaceholder": "Label toevoegen…",
|
||||
"addTag": "Toevoegen",
|
||||
"examTitleRequired": "Examennaam is verplicht",
|
||||
"atLeastOneQuestion": "Voeg minimaal één vraag toe",
|
||||
"sourceLanguage": "Brontaal",
|
||||
@@ -335,7 +414,15 @@
|
||||
"choicePlaceholder": "Keuze {{id}}",
|
||||
"addItem": "+ Item toevoegen",
|
||||
"leftItemPlaceholder": "Link item {{index}}",
|
||||
"rightItemPlaceholder": "Recht item {{index}}"
|
||||
"rightItemPlaceholder": "Recht item {{index}}",
|
||||
"aiGenerate": "✨ Genereer met AI",
|
||||
"aiClose": "✕ AI sluiten",
|
||||
"aiPanelTitle": "Vragen genereren",
|
||||
"aiTopicPlaceholder": "Onderwerp, bijv. \"Stelling van Pythagoras\"",
|
||||
"aiContextPlaceholder": "Optionele aantekeningen of bronmateriaal om de vragen op te baseren…",
|
||||
"aiGenerateNow": "Genereer",
|
||||
"aiGenerating": "Bezig met genereren…",
|
||||
"aiTopicRequired": "Voer een onderwerp in"
|
||||
},
|
||||
"setup": {
|
||||
"welcome": "Welkom bij BrainWave",
|
||||
@@ -377,6 +464,15 @@
|
||||
"backToHome": "Terug naar home",
|
||||
"noToken": "Geen verificatietoken gevonden."
|
||||
},
|
||||
"share": {
|
||||
"share": "Delen",
|
||||
"shared": "Gedeeld",
|
||||
"generateLink": "Deellink genereren",
|
||||
"copyLink": "Link kopiëren",
|
||||
"revokeLink": "Link intrekken",
|
||||
"takeExam": "Dit examen maken",
|
||||
"saveToWorkspace": "Opslaan in mijn werkruimte"
|
||||
},
|
||||
"resetPassword": {
|
||||
"resetPassword": "Wachtwoord resetten",
|
||||
"enterEmail": "Voer je e-mailadres in en we sturen je een link om je wachtwoord te resetten.",
|
||||
|
||||
@@ -2532,9 +2532,107 @@ select {
|
||||
|
||||
.workspace-delete-confirm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Overflow (⋯) menu trigger */
|
||||
.workspace-menu-trigger {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
padding: 4px 10px;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
/* Click-away backdrop for the overflow menu */
|
||||
.workspace-menu-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: default;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Overflow menu panel */
|
||||
.workspace-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 6px);
|
||||
z-index: 10;
|
||||
min-width: 220px;
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: 6px;
|
||||
overflow: hidden;
|
||||
animation: workspace-menu-in 120ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes workspace-menu-in {
|
||||
from { opacity: 0; transform: translateY(-2px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.workspace-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
border-radius: 6px;
|
||||
transition: background 80ms ease, color 80ms ease;
|
||||
}
|
||||
|
||||
.workspace-menu-item:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.workspace-menu-icon {
|
||||
width: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--ink-3);
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workspace-menu-chevron {
|
||||
margin-left: auto;
|
||||
color: var(--ink-4);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.workspace-menu-divider {
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
margin: 4px 6px;
|
||||
}
|
||||
|
||||
.workspace-menu-item-danger {
|
||||
color: var(--wrong);
|
||||
}
|
||||
.workspace-menu-item-danger .workspace-menu-icon {
|
||||
color: var(--wrong);
|
||||
}
|
||||
.workspace-menu-item-danger:hover {
|
||||
background: var(--wrong-weak);
|
||||
color: var(--wrong);
|
||||
}
|
||||
|
||||
.workspace-menu-back {
|
||||
color: var(--ink-3);
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.workspace-empty {
|
||||
text-align: center;
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"version":"4.1.5","results":[[":frontend/src/utils/grading.test.ts",{"duration":7.442099999999982,"failed":false}],[":backend/dist/validators/resultSchemas.test.js",{"duration":5.80680000000001,"failed":false}],[":backend/src/validators/authSchemas.test.ts",{"duration":12.700600000000009,"failed":false}],[":backend/src/validators/resultSchemas.test.ts",{"duration":6.035500000000013,"failed":false}],[":backend/dist/validators/authSchemas.test.js",{"duration":5.9454000000000065,"failed":false}],[":frontend/src/utils/storage.test.ts",{"duration":6.9056999999999675,"failed":false}],[":backend/dist/validators/examSchemas.test.js",{"duration":7.014700000000005,"failed":false}],[":backend/dist/config.test.js",{"duration":4.3396999999999935,"failed":false}],[":backend/src/validators/adminSchemas.test.ts",{"duration":7.116100000000017,"failed":false}],[":frontend/src/utils/latex.test.ts",{"duration":11.073699999999974,"failed":false}],[":backend/dist/validators/adminSchemas.test.js",{"duration":6.721499999999992,"failed":false}],[":backend/src/config.test.ts",{"duration":4.0815000000000055,"failed":false}],[":backend/src/validators/examSchemas.test.ts",{"duration":6.222700000000032,"failed":false}],[":backend/dist/utils/tokens.test.js",{"duration":166.78979999999999,"failed":false}],[":frontend/src/utils/validation.test.ts",{"duration":4.239000000000004,"failed":false}],[":backend/src/utils/tokens.test.ts",{"duration":118.4222,"failed":false}],[":frontend/src/utils/slug.test.ts",{"duration":4.551799999999986,"failed":false}],[":backend/src/utils/asyncHandler.test.ts",{"duration":5.303799999999995,"failed":false}],[":backend/src/validators/setupSchemas.test.ts",{"duration":5.920199999999994,"failed":false}],[":backend/dist/validators/setupSchemas.test.js",{"duration":6.263800000000003,"failed":false}],[":backend/dist/middleware/error.test.js",{"duration":2.775499999999994,"failed":false}],[":backend/dist/utils/asyncHandler.test.js",{"duration":5.524900000000002,"failed":false}],[":frontend/src/utils/formatting.test.ts",{"duration":4.508899999999983,"failed":false}],[":backend/src/middleware/error.test.ts",{"duration":3.431699999999978,"failed":false}],[":frontend/src/utils/api.test.ts",{"duration":2.2516999999999996,"failed":false}],[":backend/src/repositories/storageRepo.test.ts",{"duration":10.521900000000016,"failed":false}]]}
|
||||
{"version":"4.1.5","results":[[":frontend/src/utils/grading.test.ts",{"duration":0,"failed":true}],[":backend/dist/validators/resultSchemas.test.js",{"duration":0,"failed":true}],[":backend/src/validators/authSchemas.test.ts",{"duration":0,"failed":true}],[":backend/src/validators/resultSchemas.test.ts",{"duration":0,"failed":true}],[":backend/dist/validators/authSchemas.test.js",{"duration":0,"failed":true}],[":frontend/src/utils/storage.test.ts",{"duration":0,"failed":true}],[":backend/dist/validators/examSchemas.test.js",{"duration":0,"failed":true}],[":backend/dist/config.test.js",{"duration":0,"failed":true}],[":backend/src/validators/adminSchemas.test.ts",{"duration":0,"failed":true}],[":frontend/src/utils/latex.test.ts",{"duration":0,"failed":true}],[":backend/dist/validators/adminSchemas.test.js",{"duration":0,"failed":true}],[":backend/src/config.test.ts",{"duration":0,"failed":true}],[":backend/src/validators/examSchemas.test.ts",{"duration":0,"failed":true}],[":backend/dist/utils/tokens.test.js",{"duration":0,"failed":true}],[":frontend/src/utils/validation.test.ts",{"duration":0,"failed":true}],[":backend/src/utils/tokens.test.ts",{"duration":0,"failed":true}],[":frontend/src/utils/slug.test.ts",{"duration":0,"failed":true}],[":backend/src/utils/asyncHandler.test.ts",{"duration":0,"failed":true}],[":backend/src/validators/setupSchemas.test.ts",{"duration":0,"failed":true}],[":backend/dist/validators/setupSchemas.test.js",{"duration":0,"failed":true}],[":backend/dist/middleware/error.test.js",{"duration":0,"failed":true}],[":backend/dist/utils/asyncHandler.test.js",{"duration":0,"failed":true}],[":frontend/src/utils/formatting.test.ts",{"duration":0,"failed":true}],[":backend/src/middleware/error.test.ts",{"duration":0,"failed":true}],[":frontend/src/utils/api.test.ts",{"duration":0,"failed":true}],[":backend/src/repositories/storageRepo.test.ts",{"duration":0,"failed":true}],[":backend/dist/repositories/storageRepo.test.js",{"duration":0,"failed":true}]]}
|
||||
Reference in new issue
Block a user