feat: custom JWT auth + i18n with Chinese default
Replace Clerk with self-contained JWT auth (bcryptjs + jose), remove all external auth dependencies. Add full i18n system with Chinese as default language and English toggle — React Context + JSON dictionaries, no external i18n library. Auth: - Add passwordHash to User model, generate JWT secret - Create /api/auth/register, login, logout, me endpoints - Rewrite proxy.ts middleware for custom session validation - Add AuthProvider + useAuth hook, sign-in/sign-up pages - Wire auth into layout and all API routes i18n: - React Context I18nProvider with localStorage + cookie persistence - dictionaries/zh.ts (default) and dictionaries/en.ts - LanguageSwitcher component, server-side createServerT() utility - CJK font stack via [lang="zh"] CSS selector - HTML lang attribute synced on language change - All pages, components, TabBar, StageBadge translated - Engine fallback strings (reflection-analyzer, summary-generator) - API routes pass locale from cookie to engines Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
4e83ce95cd
commit
00db3f6c96
@@ -41,3 +41,4 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
/src/generated/prisma
|
/src/generated/prisma
|
||||||
|
.gstack/
|
||||||
|
|||||||
@@ -5,9 +5,12 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "inner-os",
|
"name": "inner-os",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/nextjs": "^7.3.7",
|
"@prisma/adapter-better-sqlite3": "^7.8.0",
|
||||||
"@prisma/adapter-pg": "^7.8.0",
|
"@prisma/adapter-pg": "^7.8.0",
|
||||||
"@prisma/client": "^7.8.0",
|
"@prisma/client": "^7.8.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"better-sqlite3": "^12.10.0",
|
||||||
|
"jose": "^6.2.3",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"openai": "^6.38.0",
|
"openai": "^6.38.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
@@ -15,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/node": "^20.19.41",
|
"@types/node": "^20.19.41",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
@@ -62,14 +66,6 @@
|
|||||||
|
|
||||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, ""],
|
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, ""],
|
||||||
|
|
||||||
"@clerk/backend": ["@clerk/backend@3.4.11", "", { "dependencies": { "@clerk/shared": "^4.12.2", "standardwebhooks": "^1.0.0", "tslib": "2.8.1" } }, "sha512-WT+4FrcMMofxe+irQYUkawoRWaHHXT9/wiRYhRbSb30cOPjN95ViydqPhAyp8ZWAHInHg4mILynQQRJH14SKZQ=="],
|
|
||||||
|
|
||||||
"@clerk/nextjs": ["@clerk/nextjs@7.3.7", "", { "dependencies": { "@clerk/backend": "^3.4.11", "@clerk/react": "^6.6.6", "@clerk/shared": "^4.12.2", "server-only": "0.0.1", "tslib": "2.8.1" }, "peerDependencies": { "next": "^15.2.8 || ^15.3.8 || ^15.4.10 || ^15.5.9 || ^15.6.0-0 || ^16.0.10 || ^16.1.0-0", "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" } }, "sha512-CLG63zKPHditk52Z/+c6BJ47V1Q68ACjeps0xHDRW3X/Yv/FZdM+IUKu9Egb5PJ/TkRFxsI1nU5SOY2B8o/WxA=="],
|
|
||||||
|
|
||||||
"@clerk/react": ["@clerk/react@6.6.6", "", { "dependencies": { "@clerk/shared": "^4.12.2", "tslib": "2.8.1" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" } }, "sha512-MVHLDZeGobSbGSZgAdb4G1BbB8ZU5XAmBBdIVLXiPOLEst2TYM5bP137WRA76y9GlmVmKYdKzph/TUi87P/JOA=="],
|
|
||||||
|
|
||||||
"@clerk/shared": ["@clerk/shared@4.12.2", "", { "dependencies": { "@tanstack/query-core": "^5.100.6", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.5", "std-env": "^3.9.0" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" } }, "sha512-jDkip8tKTzYz/cPKMCsjOoACH3Xh37zcbCrssMRTYOq3GZypIpZ6WAs4m4G82URL0WY+yz5frrHVjRrHyAb6LA=="],
|
|
||||||
|
|
||||||
"@electric-sql/pglite": ["@electric-sql/pglite@0.4.1", "", {}, "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q=="],
|
"@electric-sql/pglite": ["@electric-sql/pglite@0.4.1", "", {}, "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q=="],
|
||||||
|
|
||||||
"@electric-sql/pglite-socket": ["@electric-sql/pglite-socket@0.1.1", "", { "peerDependencies": { "@electric-sql/pglite": "0.4.1" }, "bin": { "pglite-server": "dist/scripts/server.js" } }, "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw=="],
|
"@electric-sql/pglite-socket": ["@electric-sql/pglite-socket@0.1.1", "", { "peerDependencies": { "@electric-sql/pglite": "0.4.1" }, "bin": { "pglite-server": "dist/scripts/server.js" } }, "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw=="],
|
||||||
@@ -152,6 +148,8 @@
|
|||||||
|
|
||||||
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, ""],
|
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, ""],
|
||||||
|
|
||||||
|
"@prisma/adapter-better-sqlite3": ["@prisma/adapter-better-sqlite3@7.8.0", "", { "dependencies": { "@prisma/driver-adapter-utils": "7.8.0", "better-sqlite3": "^12.6.0" } }, "sha512-9p0HvQNaRoOaUForDcp1MPIjylmCamYQjQpEogpdesLr14g3NwAsYR3bHL9wFi8tn21TPDVQPzcZi+SxXEokSA=="],
|
||||||
|
|
||||||
"@prisma/adapter-pg": ["@prisma/adapter-pg@7.8.0", "", { "dependencies": { "@prisma/driver-adapter-utils": "7.8.0", "@types/pg": "^8.16.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg=="],
|
"@prisma/adapter-pg": ["@prisma/adapter-pg@7.8.0", "", { "dependencies": { "@prisma/driver-adapter-utils": "7.8.0", "@types/pg": "^8.16.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg=="],
|
||||||
|
|
||||||
"@prisma/client": ["@prisma/client@7.8.0", "", { "dependencies": { "@prisma/client-runtime-utils": "7.8.0" }, "peerDependencies": { "prisma": "*", "typescript": ">=5.4.0" } }, "sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw=="],
|
"@prisma/client": ["@prisma/client@7.8.0", "", { "dependencies": { "@prisma/client-runtime-utils": "7.8.0" }, "peerDependencies": { "prisma": "*", "typescript": ">=5.4.0" } }, "sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw=="],
|
||||||
@@ -198,8 +196,6 @@
|
|||||||
|
|
||||||
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, ""],
|
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, ""],
|
||||||
|
|
||||||
"@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="],
|
|
||||||
|
|
||||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, ""],
|
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, ""],
|
||||||
@@ -212,7 +208,7 @@
|
|||||||
|
|
||||||
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.0", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "postcss": "^8.5.10", "tailwindcss": "4.3.0" } }, ""],
|
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.0", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "postcss": "^8.5.10", "tailwindcss": "4.3.0" } }, ""],
|
||||||
|
|
||||||
"@tanstack/query-core": ["@tanstack/query-core@5.100.11", "", {}, "sha512-lmE0994apShXPj8CUxgx4ch5yUJhE9k/+tVwihBvPOyerACWdBocfFg24t8+0RhtlTd7tEgchDkhlCxNssvDxw=="],
|
"@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.9", "", {}, ""],
|
"@types/estree": ["@types/estree@1.0.9", "", {}, ""],
|
||||||
|
|
||||||
@@ -292,16 +288,28 @@
|
|||||||
|
|
||||||
"balanced-match": ["balanced-match@1.0.2", "", {}, ""],
|
"balanced-match": ["balanced-match@1.0.2", "", {}, ""],
|
||||||
|
|
||||||
|
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||||
|
|
||||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.31", "", { "bin": "dist/cli.cjs" }, ""],
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.31", "", { "bin": "dist/cli.cjs" }, ""],
|
||||||
|
|
||||||
|
"bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
|
||||||
|
|
||||||
"better-result": ["better-result@2.9.2", "", {}, "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q=="],
|
"better-result": ["better-result@2.9.2", "", {}, "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q=="],
|
||||||
|
|
||||||
|
"better-sqlite3": ["better-sqlite3@12.10.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ=="],
|
||||||
|
|
||||||
|
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
|
||||||
|
|
||||||
|
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
|
||||||
|
|
||||||
"brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, ""],
|
"brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, ""],
|
||||||
|
|
||||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, ""],
|
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, ""],
|
||||||
|
|
||||||
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": "cli.js" }, ""],
|
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": "cli.js" }, ""],
|
||||||
|
|
||||||
|
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
|
||||||
|
|
||||||
"c12": ["c12@3.3.4", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.4", "defu": "^6.1.6", "dotenv": "^17.3.1", "exsolve": "^1.0.8", "giget": "^3.2.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "pkg-types": "^2.3.0", "rc9": "^3.0.1" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA=="],
|
"c12": ["c12@3.3.4", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.4", "defu": "^6.1.6", "dotenv": "^17.3.1", "exsolve": "^1.0.8", "giget": "^3.2.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "pkg-types": "^2.3.0", "rc9": "^3.0.1" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA=="],
|
||||||
|
|
||||||
"call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, ""],
|
"call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, ""],
|
||||||
@@ -320,6 +328,8 @@
|
|||||||
|
|
||||||
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||||
|
|
||||||
|
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
|
||||||
|
|
||||||
"client-only": ["client-only@0.0.1", "", {}, ""],
|
"client-only": ["client-only@0.0.1", "", {}, ""],
|
||||||
|
|
||||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, ""],
|
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, ""],
|
||||||
@@ -346,6 +356,10 @@
|
|||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, ""],
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, ""],
|
||||||
|
|
||||||
|
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
|
||||||
|
|
||||||
|
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
|
||||||
|
|
||||||
"deep-is": ["deep-is@0.1.4", "", {}, ""],
|
"deep-is": ["deep-is@0.1.4", "", {}, ""],
|
||||||
|
|
||||||
"deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="],
|
"deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="],
|
||||||
@@ -358,8 +372,6 @@
|
|||||||
|
|
||||||
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
|
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
|
||||||
|
|
||||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
|
||||||
|
|
||||||
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
|
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, ""],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, ""],
|
||||||
@@ -378,6 +390,8 @@
|
|||||||
|
|
||||||
"empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
|
"empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
|
||||||
|
|
||||||
|
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||||
|
|
||||||
"enhanced-resolve": ["enhanced-resolve@5.21.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, ""],
|
"enhanced-resolve": ["enhanced-resolve@5.21.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, ""],
|
||||||
|
|
||||||
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||||
@@ -434,6 +448,8 @@
|
|||||||
|
|
||||||
"esutils": ["esutils@2.0.3", "", {}, ""],
|
"esutils": ["esutils@2.0.3", "", {}, ""],
|
||||||
|
|
||||||
|
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
|
||||||
|
|
||||||
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
|
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
|
||||||
|
|
||||||
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
|
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
|
||||||
@@ -446,8 +462,6 @@
|
|||||||
|
|
||||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, ""],
|
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, ""],
|
||||||
|
|
||||||
"fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="],
|
|
||||||
|
|
||||||
"fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
|
"fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
|
||||||
|
|
||||||
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, ""],
|
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, ""],
|
||||||
@@ -456,6 +470,8 @@
|
|||||||
|
|
||||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, ""],
|
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, ""],
|
||||||
|
|
||||||
|
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
|
||||||
|
|
||||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, ""],
|
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, ""],
|
||||||
|
|
||||||
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, ""],
|
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, ""],
|
||||||
@@ -468,6 +484,8 @@
|
|||||||
|
|
||||||
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||||
|
|
||||||
|
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
|
||||||
|
|
||||||
"function-bind": ["function-bind@1.1.2", "", {}, ""],
|
"function-bind": ["function-bind@1.1.2", "", {}, ""],
|
||||||
|
|
||||||
"function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, ""],
|
"function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, ""],
|
||||||
@@ -492,9 +510,9 @@
|
|||||||
|
|
||||||
"giget": ["giget@3.2.0", "", { "bin": "dist/cli.mjs" }, "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A=="],
|
"giget": ["giget@3.2.0", "", { "bin": "dist/cli.mjs" }, "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A=="],
|
||||||
|
|
||||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, ""],
|
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
|
||||||
|
|
||||||
"glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
|
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, ""],
|
||||||
|
|
||||||
"globals": ["globals@16.4.0", "", {}, ""],
|
"globals": ["globals@16.4.0", "", {}, ""],
|
||||||
|
|
||||||
@@ -532,12 +550,18 @@
|
|||||||
|
|
||||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||||
|
|
||||||
|
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||||
|
|
||||||
"ignore": ["ignore@5.3.2", "", {}, ""],
|
"ignore": ["ignore@5.3.2", "", {}, ""],
|
||||||
|
|
||||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, ""],
|
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, ""],
|
||||||
|
|
||||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, ""],
|
"imurmurhash": ["imurmurhash@0.1.4", "", {}, ""],
|
||||||
|
|
||||||
|
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||||
|
|
||||||
|
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
|
||||||
|
|
||||||
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, ""],
|
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, ""],
|
||||||
|
|
||||||
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, ""],
|
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, ""],
|
||||||
@@ -602,7 +626,7 @@
|
|||||||
|
|
||||||
"jiti": ["jiti@2.7.0", "", { "bin": "lib/jiti-cli.mjs" }, ""],
|
"jiti": ["jiti@2.7.0", "", { "bin": "lib/jiti-cli.mjs" }, ""],
|
||||||
|
|
||||||
"js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="],
|
"jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
|
||||||
|
|
||||||
"js-tokens": ["js-tokens@4.0.0", "", {}, ""],
|
"js-tokens": ["js-tokens@4.0.0", "", {}, ""],
|
||||||
|
|
||||||
@@ -652,10 +676,14 @@
|
|||||||
|
|
||||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, ""],
|
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, ""],
|
||||||
|
|
||||||
|
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
|
||||||
|
|
||||||
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, ""],
|
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, ""],
|
||||||
|
|
||||||
"minimist": ["minimist@1.2.8", "", {}, ""],
|
"minimist": ["minimist@1.2.8", "", {}, ""],
|
||||||
|
|
||||||
|
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, ""],
|
"ms": ["ms@2.1.3", "", {}, ""],
|
||||||
|
|
||||||
"mysql2": ["mysql2@3.15.3", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg=="],
|
"mysql2": ["mysql2@3.15.3", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg=="],
|
||||||
@@ -664,12 +692,16 @@
|
|||||||
|
|
||||||
"nanoid": ["nanoid@3.3.12", "", { "bin": "bin/nanoid.cjs" }, ""],
|
"nanoid": ["nanoid@3.3.12", "", { "bin": "bin/nanoid.cjs" }, ""],
|
||||||
|
|
||||||
|
"napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
|
||||||
|
|
||||||
"napi-postinstall": ["napi-postinstall@0.3.4", "", { "bin": "lib/cli.js" }, ""],
|
"napi-postinstall": ["napi-postinstall@0.3.4", "", { "bin": "lib/cli.js" }, ""],
|
||||||
|
|
||||||
"natural-compare": ["natural-compare@1.4.0", "", {}, ""],
|
"natural-compare": ["natural-compare@1.4.0", "", {}, ""],
|
||||||
|
|
||||||
"next": ["next@16.2.6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": "dist/bin/next" }, ""],
|
"next": ["next@16.2.6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": "dist/bin/next" }, ""],
|
||||||
|
|
||||||
|
"node-abi": ["node-abi@3.92.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ=="],
|
||||||
|
|
||||||
"node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, ""],
|
"node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, ""],
|
||||||
|
|
||||||
"node-releases": ["node-releases@2.0.44", "", {}, ""],
|
"node-releases": ["node-releases@2.0.44", "", {}, ""],
|
||||||
@@ -692,6 +724,8 @@
|
|||||||
|
|
||||||
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
|
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
|
||||||
|
|
||||||
|
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||||
|
|
||||||
"openai": ["openai@6.38.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws"], "bin": "bin/cli" }, "sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g=="],
|
"openai": ["openai@6.38.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws"], "bin": "bin/cli" }, "sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g=="],
|
||||||
|
|
||||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, ""],
|
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, ""],
|
||||||
@@ -750,6 +784,8 @@
|
|||||||
|
|
||||||
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
|
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
|
||||||
|
|
||||||
|
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
|
||||||
|
|
||||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, ""],
|
"prelude-ls": ["prelude-ls@1.2.1", "", {}, ""],
|
||||||
|
|
||||||
"prisma": ["prisma@7.8.0", "", { "dependencies": { "@prisma/config": "7.8.0", "@prisma/dev": "0.24.3", "@prisma/engines": "7.8.0", "@prisma/studio-core": "0.27.3", "mysql2": "3.15.3", "postgres": "3.4.7" }, "peerDependencies": { "better-sqlite3": ">=9.0.0", "typescript": ">=5.4.0" }, "optionalPeers": ["better-sqlite3"], "bin": "build/index.js" }, "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw=="],
|
"prisma": ["prisma@7.8.0", "", { "dependencies": { "@prisma/config": "7.8.0", "@prisma/dev": "0.24.3", "@prisma/engines": "7.8.0", "@prisma/studio-core": "0.27.3", "mysql2": "3.15.3", "postgres": "3.4.7" }, "peerDependencies": { "better-sqlite3": ">=9.0.0", "typescript": ">=5.4.0" }, "optionalPeers": ["better-sqlite3"], "bin": "build/index.js" }, "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw=="],
|
||||||
@@ -758,12 +794,16 @@
|
|||||||
|
|
||||||
"proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
|
"proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
|
||||||
|
|
||||||
|
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
|
||||||
|
|
||||||
"punycode": ["punycode@2.3.1", "", {}, ""],
|
"punycode": ["punycode@2.3.1", "", {}, ""],
|
||||||
|
|
||||||
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
|
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
|
||||||
|
|
||||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, ""],
|
"queue-microtask": ["queue-microtask@1.2.3", "", {}, ""],
|
||||||
|
|
||||||
|
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
|
||||||
|
|
||||||
"rc9": ["rc9@3.0.1", "", { "dependencies": { "defu": "^6.1.6", "destr": "^2.0.5" } }, "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ=="],
|
"rc9": ["rc9@3.0.1", "", { "dependencies": { "defu": "^6.1.6", "destr": "^2.0.5" } }, "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ=="],
|
||||||
|
|
||||||
"react": ["react@19.2.4", "", {}, ""],
|
"react": ["react@19.2.4", "", {}, ""],
|
||||||
@@ -772,6 +812,8 @@
|
|||||||
|
|
||||||
"react-is": ["react-is@16.13.1", "", {}, ""],
|
"react-is": ["react-is@16.13.1", "", {}, ""],
|
||||||
|
|
||||||
|
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||||
|
|
||||||
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||||
|
|
||||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, ""],
|
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, ""],
|
||||||
@@ -796,6 +838,8 @@
|
|||||||
|
|
||||||
"safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, ""],
|
"safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, ""],
|
||||||
|
|
||||||
|
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||||
|
|
||||||
"safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, ""],
|
"safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, ""],
|
||||||
|
|
||||||
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, ""],
|
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, ""],
|
||||||
@@ -808,8 +852,6 @@
|
|||||||
|
|
||||||
"seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="],
|
"seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="],
|
||||||
|
|
||||||
"server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
|
|
||||||
|
|
||||||
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, ""],
|
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, ""],
|
||||||
|
|
||||||
"set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, ""],
|
"set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, ""],
|
||||||
@@ -832,6 +874,10 @@
|
|||||||
|
|
||||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||||
|
|
||||||
|
"simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
|
||||||
|
|
||||||
|
"simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, ""],
|
"source-map-js": ["source-map-js@1.2.1", "", {}, ""],
|
||||||
|
|
||||||
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||||
@@ -840,8 +886,6 @@
|
|||||||
|
|
||||||
"stable-hash": ["stable-hash@0.0.5", "", {}, ""],
|
"stable-hash": ["stable-hash@0.0.5", "", {}, ""],
|
||||||
|
|
||||||
"standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="],
|
|
||||||
|
|
||||||
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||||
|
|
||||||
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, ""],
|
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, ""],
|
||||||
@@ -858,6 +902,8 @@
|
|||||||
|
|
||||||
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, ""],
|
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, ""],
|
||||||
|
|
||||||
|
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||||
|
|
||||||
"strip-bom": ["strip-bom@3.0.0", "", {}, ""],
|
"strip-bom": ["strip-bom@3.0.0", "", {}, ""],
|
||||||
|
|
||||||
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, ""],
|
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, ""],
|
||||||
@@ -872,6 +918,10 @@
|
|||||||
|
|
||||||
"tapable": ["tapable@2.3.3", "", {}, ""],
|
"tapable": ["tapable@2.3.3", "", {}, ""],
|
||||||
|
|
||||||
|
"tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
|
||||||
|
|
||||||
|
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
|
||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, ""],
|
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, ""],
|
||||||
|
|
||||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, ""],
|
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, ""],
|
||||||
@@ -882,6 +932,8 @@
|
|||||||
|
|
||||||
"tslib": ["tslib@2.8.1", "", {}, ""],
|
"tslib": ["tslib@2.8.1", "", {}, ""],
|
||||||
|
|
||||||
|
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
|
||||||
|
|
||||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, ""],
|
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, ""],
|
||||||
|
|
||||||
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, ""],
|
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, ""],
|
||||||
@@ -906,6 +958,8 @@
|
|||||||
|
|
||||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, ""],
|
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, ""],
|
||||||
|
|
||||||
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
"valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" } }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="],
|
"valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" } }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="],
|
||||||
|
|
||||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, ""],
|
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, ""],
|
||||||
@@ -920,6 +974,8 @@
|
|||||||
|
|
||||||
"word-wrap": ["word-wrap@1.2.5", "", {}, ""],
|
"word-wrap": ["word-wrap@1.2.5", "", {}, ""],
|
||||||
|
|
||||||
|
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||||
|
|
||||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||||
|
|
||||||
"yallist": ["yallist@3.1.1", "", {}, ""],
|
"yallist": ["yallist@3.1.1", "", {}, ""],
|
||||||
@@ -968,10 +1024,14 @@
|
|||||||
|
|
||||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, ""],
|
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, ""],
|
||||||
|
|
||||||
|
"node-abi/semver": ["semver@7.8.0", "", { "bin": "bin/semver.js" }, ""],
|
||||||
|
|
||||||
"pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
"pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
||||||
|
|
||||||
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||||
|
|
||||||
|
"rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
|
||||||
|
|
||||||
"sharp/semver": ["semver@7.8.0", "", { "bin": "bin/semver.js" }, ""],
|
"sharp/semver": ["semver@7.8.0", "", { "bin": "bin/semver.js" }, ""],
|
||||||
|
|
||||||
"@prisma/streams-local/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
"@prisma/streams-local/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import type { Dict } from "@/lib/i18n/types";
|
||||||
|
|
||||||
|
const en: Dict = {
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Navigation (TabBar)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
nav: {
|
||||||
|
today: "Today",
|
||||||
|
progress: "Progress",
|
||||||
|
rewards: "Rewards",
|
||||||
|
summary: "Summary",
|
||||||
|
settings: "Settings",
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Shared labels
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
shared: {
|
||||||
|
loading: "Loading...",
|
||||||
|
back: "← Back",
|
||||||
|
|
||||||
|
task_type: {
|
||||||
|
attention: "Attention",
|
||||||
|
emotion: "Emotion",
|
||||||
|
stress: "Stress",
|
||||||
|
clarity: "Clarity",
|
||||||
|
integration: "Integration",
|
||||||
|
},
|
||||||
|
|
||||||
|
difficulty: {
|
||||||
|
beginner: "Beginner",
|
||||||
|
intermediate: "Intermediate",
|
||||||
|
advanced: "Advanced",
|
||||||
|
},
|
||||||
|
|
||||||
|
stage: {
|
||||||
|
beginner: "Beginner",
|
||||||
|
foundation: "Foundation",
|
||||||
|
growing: "Growing",
|
||||||
|
established: "Established",
|
||||||
|
advanced: "Advanced",
|
||||||
|
},
|
||||||
|
|
||||||
|
reward_type: {
|
||||||
|
daily_completion: "Daily Practice",
|
||||||
|
streak_3: "3-Day Streak",
|
||||||
|
streak_7: "7-Day Streak",
|
||||||
|
streak_14: "14-Day Streak",
|
||||||
|
streak_30: "30-Day Streak",
|
||||||
|
stage_up: "Stage Advance",
|
||||||
|
first_session: "First Practice",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Home page
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
home: {
|
||||||
|
greeting: {
|
||||||
|
morning: "Good morning",
|
||||||
|
afternoon: "Good afternoon",
|
||||||
|
evening: "Good evening",
|
||||||
|
fallback_name: "there",
|
||||||
|
subtitle: "Your practice is waiting.",
|
||||||
|
},
|
||||||
|
|
||||||
|
onboarding: {
|
||||||
|
title: "One step left",
|
||||||
|
description: "Complete onboarding in Settings to start your practice.",
|
||||||
|
cta: "Go to Settings",
|
||||||
|
},
|
||||||
|
|
||||||
|
stats: {
|
||||||
|
streak: "Streak",
|
||||||
|
streak_unit: "days",
|
||||||
|
sessions: "Sessions",
|
||||||
|
sessions_unit: "total",
|
||||||
|
},
|
||||||
|
|
||||||
|
metrics: {
|
||||||
|
title: "Core metrics",
|
||||||
|
attention: "Attention",
|
||||||
|
emotion: "Emotion",
|
||||||
|
stress: "Stress",
|
||||||
|
consistency: "Consistency",
|
||||||
|
},
|
||||||
|
|
||||||
|
task: {
|
||||||
|
heading: "Today's practice",
|
||||||
|
no_task: "No task for today yet. Generate one to start your practice.",
|
||||||
|
generate: "Generate today's task",
|
||||||
|
generating: "Generating...",
|
||||||
|
begin: "Begin practice",
|
||||||
|
why_prefix: "", // unused in English, kept for key parity with zh
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Progress page
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
progress: {
|
||||||
|
title: "Your progress",
|
||||||
|
metrics_title: "Core metrics",
|
||||||
|
stage_title: "Stage progress",
|
||||||
|
|
||||||
|
attention: "Attention",
|
||||||
|
emotional_stability: "Emotional Stability",
|
||||||
|
stress_level: "Stress Level",
|
||||||
|
consistency: "Consistency",
|
||||||
|
|
||||||
|
stages: {
|
||||||
|
beginner: "Beginner",
|
||||||
|
foundation: "Foundation",
|
||||||
|
growing: "Growing",
|
||||||
|
established: "Established",
|
||||||
|
advanced: "Advanced",
|
||||||
|
},
|
||||||
|
|
||||||
|
stage_prefix: "Stage",
|
||||||
|
onboarding_needed: "Complete onboarding to see your progress.",
|
||||||
|
loading: "Loading...",
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Rewards page
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
rewards: {
|
||||||
|
title: "Rewards",
|
||||||
|
empty_subtitle: "Your rewards will appear here as you practice.",
|
||||||
|
empty_message: "Complete your first practice to earn a reward.",
|
||||||
|
earned_one: "{count} reward earned",
|
||||||
|
earned_other: "{count} rewards earned",
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Summary page
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
summary: {
|
||||||
|
title: "Weekly summary",
|
||||||
|
week_of: "Week of {date}",
|
||||||
|
empty_subtitle: "Generated each week from your practice history",
|
||||||
|
empty_message:
|
||||||
|
"Your first weekly summary will be generated after you complete at least one practice session. Summaries run automatically each week.",
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Settings page
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
settings: {
|
||||||
|
title: "Settings",
|
||||||
|
|
||||||
|
onboarding: {
|
||||||
|
title: "Welcome to Inner OS",
|
||||||
|
description:
|
||||||
|
"Inner OS generates one personalized mental training task each day, guided by your practice history and inner development stage. Your reflections shape what comes next. Everything is private — just you and your practice.",
|
||||||
|
cta: "Get started",
|
||||||
|
setting_up: "Setting up...",
|
||||||
|
done: "You're all set. Head home to start your practice.",
|
||||||
|
error: "Something went wrong. Try again.",
|
||||||
|
},
|
||||||
|
|
||||||
|
profile: {
|
||||||
|
title: "Profile",
|
||||||
|
signed_in_as: "Signed in as",
|
||||||
|
stage_and_sessions: "Stage {stage} · {count} sessions",
|
||||||
|
},
|
||||||
|
|
||||||
|
about: {
|
||||||
|
title: "About Inner OS",
|
||||||
|
p1: "Inner OS is a private inner development practice. You receive one personalized mental training task each day, tailored to your current state and development stage.",
|
||||||
|
p2: "Your reflections are private. Your data is yours. There are no leaderboards, no social features, no competition — just you and the work of becoming more aware, more stable, and more yourself.",
|
||||||
|
},
|
||||||
|
|
||||||
|
sign_out: "Sign out",
|
||||||
|
signing_out: "Signing out...",
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Practice page
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
practice: {
|
||||||
|
title: "Your practice",
|
||||||
|
why_prefix: "Why this task: ",
|
||||||
|
start: "Start",
|
||||||
|
finish_early: "Finish early",
|
||||||
|
complete: "Practice complete",
|
||||||
|
reflect: "Reflect on your practice",
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Reflection page
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
reflect: {
|
||||||
|
no_session: "No session to reflect on. Go home.",
|
||||||
|
title: "Reflect on your practice",
|
||||||
|
label: "What did you notice during your practice?",
|
||||||
|
placeholder:
|
||||||
|
"Were you focused or distracted? What emotions came up? Did anything surprise you?",
|
||||||
|
submit: "Complete practice",
|
||||||
|
submitting: "Saving...",
|
||||||
|
error: "Failed to save reflection",
|
||||||
|
generic_error: "Something went wrong",
|
||||||
|
|
||||||
|
result_title: "Practice complete",
|
||||||
|
focus: "focus",
|
||||||
|
emotional_awareness: "emotional awareness",
|
||||||
|
back_home: "Back home",
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Sign-in page
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
sign_in: {
|
||||||
|
title: "Sign in",
|
||||||
|
subtitle: "Welcome back to Inner OS",
|
||||||
|
email: "Email",
|
||||||
|
password: "Password",
|
||||||
|
submit: "Sign in",
|
||||||
|
submitting: "Signing in...",
|
||||||
|
fallback_error: "Login failed",
|
||||||
|
network_error: "Network error. Try again.",
|
||||||
|
no_account: "No account?",
|
||||||
|
sign_up_link: "Sign up",
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Sign-up page
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
sign_up: {
|
||||||
|
title: "Sign up",
|
||||||
|
subtitle: "Start your inner development practice",
|
||||||
|
email: "Email",
|
||||||
|
password: "Password",
|
||||||
|
confirm_password: "Confirm password",
|
||||||
|
submit: "Create account",
|
||||||
|
submitting: "Creating account...",
|
||||||
|
password_mismatch: "Passwords do not match",
|
||||||
|
password_short: "Password must be at least 6 characters",
|
||||||
|
fallback_error: "Registration failed",
|
||||||
|
network_error: "Network error. Try again.",
|
||||||
|
has_account: "Already have an account?",
|
||||||
|
sign_in_link: "Sign in",
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Engine fallback strings (server-side, not React)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
engine: {
|
||||||
|
reflection: {
|
||||||
|
keyword_blocked: "Thank you for sharing.",
|
||||||
|
no_reflection: "You completed your practice today.",
|
||||||
|
safety_blocked: "You completed your practice today.",
|
||||||
|
llm_failed: "You showed up today. That's what matters.",
|
||||||
|
},
|
||||||
|
|
||||||
|
summary: {
|
||||||
|
no_sessions:
|
||||||
|
"No completed practice sessions this week. Every week is a fresh start — your practice is here when you're ready.",
|
||||||
|
safety_fallback: "Your practice summary for this week.",
|
||||||
|
fallback_template:
|
||||||
|
"You completed {count} practice session{plural} this week. Showing up consistently is the foundation everything else is built on. Your current streak is {streak} day{streak_plural}. See you next week.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default en;
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import type { Dict } from "@/lib/i18n/types";
|
||||||
|
|
||||||
|
const zh: Dict = {
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 导航 (TabBar)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
nav: {
|
||||||
|
today: "今日",
|
||||||
|
progress: "进度",
|
||||||
|
rewards: "奖励",
|
||||||
|
summary: "周报",
|
||||||
|
settings: "设置",
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 共享标签
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
shared: {
|
||||||
|
loading: "加载中...",
|
||||||
|
back: "← 返回",
|
||||||
|
|
||||||
|
task_type: {
|
||||||
|
attention: "注意力",
|
||||||
|
emotion: "情绪",
|
||||||
|
stress: "压力",
|
||||||
|
clarity: "清晰度",
|
||||||
|
integration: "整合",
|
||||||
|
},
|
||||||
|
|
||||||
|
difficulty: {
|
||||||
|
beginner: "入门",
|
||||||
|
intermediate: "进阶",
|
||||||
|
advanced: "高级",
|
||||||
|
},
|
||||||
|
|
||||||
|
stage: {
|
||||||
|
beginner: "入门",
|
||||||
|
foundation: "基础",
|
||||||
|
growing: "成长",
|
||||||
|
established: "稳固",
|
||||||
|
advanced: "高级",
|
||||||
|
},
|
||||||
|
|
||||||
|
reward_type: {
|
||||||
|
daily_completion: "每日练习",
|
||||||
|
streak_3: "连续3天",
|
||||||
|
streak_7: "连续7天",
|
||||||
|
streak_14: "连续14天",
|
||||||
|
streak_30: "连续30天",
|
||||||
|
stage_up: "阶段提升",
|
||||||
|
first_session: "首次练习",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 首页
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
home: {
|
||||||
|
greeting: {
|
||||||
|
morning: "早上好",
|
||||||
|
afternoon: "下午好",
|
||||||
|
evening: "晚上好",
|
||||||
|
fallback_name: "朋友",
|
||||||
|
subtitle: "你的练习在等你。",
|
||||||
|
},
|
||||||
|
|
||||||
|
onboarding: {
|
||||||
|
title: "还差一步",
|
||||||
|
description: "在设置中完成引导,开始你的练习之旅。",
|
||||||
|
cta: "前往设置",
|
||||||
|
},
|
||||||
|
|
||||||
|
stats: {
|
||||||
|
streak: "连续天数",
|
||||||
|
streak_unit: "天",
|
||||||
|
sessions: "练习次数",
|
||||||
|
sessions_unit: "次",
|
||||||
|
},
|
||||||
|
|
||||||
|
metrics: {
|
||||||
|
title: "核心指标",
|
||||||
|
attention: "注意力",
|
||||||
|
emotion: "情绪",
|
||||||
|
stress: "压力",
|
||||||
|
consistency: "持续性",
|
||||||
|
},
|
||||||
|
|
||||||
|
task: {
|
||||||
|
heading: "今日练习",
|
||||||
|
no_task: "今日还没有练习任务,生成一个开始吧。",
|
||||||
|
generate: "生成今日任务",
|
||||||
|
generating: "生成中...",
|
||||||
|
begin: "开始练习",
|
||||||
|
why_prefix: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 进度页
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
progress: {
|
||||||
|
title: "我的进度",
|
||||||
|
metrics_title: "核心指标",
|
||||||
|
stage_title: "阶段进度",
|
||||||
|
|
||||||
|
attention: "注意力",
|
||||||
|
emotional_stability: "情绪稳定性",
|
||||||
|
stress_level: "压力水平",
|
||||||
|
consistency: "持续性",
|
||||||
|
|
||||||
|
stages: {
|
||||||
|
beginner: "入门",
|
||||||
|
foundation: "基础",
|
||||||
|
growing: "成长",
|
||||||
|
established: "稳固",
|
||||||
|
advanced: "高级",
|
||||||
|
},
|
||||||
|
|
||||||
|
stage_prefix: "阶段",
|
||||||
|
onboarding_needed: "完成引导后即可查看进度。",
|
||||||
|
loading: "加载中...",
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 奖励页
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
rewards: {
|
||||||
|
title: "奖励",
|
||||||
|
empty_subtitle: "练习后奖励会在这里出现。",
|
||||||
|
empty_message: "完成第一次练习即可获得奖励。",
|
||||||
|
earned_one: "已获得 {count} 个奖励",
|
||||||
|
earned_other: "已获得 {count} 个奖励",
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 周报页
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
summary: {
|
||||||
|
title: "每周总结",
|
||||||
|
week_of: "{date}当周",
|
||||||
|
empty_subtitle: "每周根据练习记录自动生成",
|
||||||
|
empty_message: "完成至少一次练习后,你的第一份每周总结将会生成。总结每周自动运行。",
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 设置页
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
settings: {
|
||||||
|
title: "设置",
|
||||||
|
|
||||||
|
onboarding: {
|
||||||
|
title: "欢迎来到 Inner OS",
|
||||||
|
description:
|
||||||
|
"Inner OS 每天为你生成一个个性化的心理训练任务,根据你的练习历史和内在发展阶段量身定制。你的反思会塑造接下来的内容。一切完全私密——只有你和你的练习。",
|
||||||
|
cta: "开始使用",
|
||||||
|
setting_up: "设置中...",
|
||||||
|
done: "一切就绪,返回首页开始练习吧。",
|
||||||
|
error: "出了点问题,请重试。",
|
||||||
|
},
|
||||||
|
|
||||||
|
profile: {
|
||||||
|
title: "个人信息",
|
||||||
|
signed_in_as: "已登录:",
|
||||||
|
stage_and_sessions: "阶段 {stage} · {count} 次练习",
|
||||||
|
},
|
||||||
|
|
||||||
|
about: {
|
||||||
|
title: "关于 Inner OS",
|
||||||
|
p1: "Inner OS 是一个私密的内在成长练习工具。每天根据你当前的状态和发展阶段,为你量身定制一个心理训练任务。",
|
||||||
|
p2: "你的反思是私密的。你的数据属于你。没有排行榜,没有社交功能,没有竞争——只有你和更加自觉、更加稳定、更加真实的自己。",
|
||||||
|
},
|
||||||
|
|
||||||
|
sign_out: "退出登录",
|
||||||
|
signing_out: "退出中...",
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 练习页
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
practice: {
|
||||||
|
title: "你的练习",
|
||||||
|
why_prefix: "任务说明:",
|
||||||
|
start: "开始",
|
||||||
|
finish_early: "提前结束",
|
||||||
|
complete: "练习完成",
|
||||||
|
reflect: "记录感受",
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 反思页
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
reflect: {
|
||||||
|
no_session: "没有可记录的练习,返回首页。",
|
||||||
|
title: "记录你的感受",
|
||||||
|
label: "练习中你注意到了什么?",
|
||||||
|
placeholder: "你专注还是走神了?出现了什么情绪?有什么让你意外的吗?",
|
||||||
|
submit: "完成练习",
|
||||||
|
submitting: "保存中...",
|
||||||
|
error: "保存失败",
|
||||||
|
generic_error: "出了点问题",
|
||||||
|
|
||||||
|
result_title: "练习完成",
|
||||||
|
focus: "专注度",
|
||||||
|
emotional_awareness: "情绪觉察",
|
||||||
|
back_home: "返回首页",
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 登录页
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
sign_in: {
|
||||||
|
title: "登录",
|
||||||
|
subtitle: "欢迎回到 Inner OS",
|
||||||
|
email: "邮箱",
|
||||||
|
password: "密码",
|
||||||
|
submit: "登录",
|
||||||
|
submitting: "登录中...",
|
||||||
|
fallback_error: "登录失败",
|
||||||
|
network_error: "网络错误,请重试。",
|
||||||
|
no_account: "还没有账号?",
|
||||||
|
sign_up_link: "注册",
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 注册页
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
sign_up: {
|
||||||
|
title: "注册",
|
||||||
|
subtitle: "开始你的内在成长练习",
|
||||||
|
email: "邮箱",
|
||||||
|
password: "密码",
|
||||||
|
confirm_password: "确认密码",
|
||||||
|
submit: "创建账号",
|
||||||
|
submitting: "创建中...",
|
||||||
|
password_mismatch: "两次密码不一致",
|
||||||
|
password_short: "密码至少需要6位字符",
|
||||||
|
fallback_error: "注册失败",
|
||||||
|
network_error: "网络错误,请重试。",
|
||||||
|
has_account: "已有账号?",
|
||||||
|
sign_in_link: "登录",
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 引擎回退字符串 (服务端使用)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
engine: {
|
||||||
|
reflection: {
|
||||||
|
keyword_blocked: "感谢你的分享。",
|
||||||
|
no_reflection: "你完成了今天的练习。",
|
||||||
|
safety_blocked: "你完成了今天的练习。",
|
||||||
|
llm_failed: "你今天来了,这本身就是最重要的。",
|
||||||
|
},
|
||||||
|
|
||||||
|
summary: {
|
||||||
|
no_sessions: "本周没有完成的练习。每一周都是全新的开始——你的练习随时等你回来。",
|
||||||
|
safety_fallback: "本周练习总结。",
|
||||||
|
fallback_template: "本周你完成了 {count} 次练习。持续出现是所有进步的基础。当前连续 {streak} 天。下周见。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default zh;
|
||||||
+6
-1
@@ -9,9 +9,13 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/nextjs": "^7.3.7",
|
|
||||||
|
"@prisma/adapter-better-sqlite3": "^7.8.0",
|
||||||
"@prisma/adapter-pg": "^7.8.0",
|
"@prisma/adapter-pg": "^7.8.0",
|
||||||
"@prisma/client": "^7.8.0",
|
"@prisma/client": "^7.8.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"better-sqlite3": "^12.10.0",
|
||||||
|
"jose": "^6.2.3",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"openai": "^6.38.0",
|
"openai": "^6.38.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
@@ -19,6 +23,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/node": "^20.19.41",
|
"@types/node": "^20.19.41",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ generator client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "sqlite"
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TaskType {
|
enum TaskType {
|
||||||
@@ -32,8 +32,9 @@ enum RewardEventType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id // Clerk user ID
|
id String @id @default(uuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
|
passwordHash String? @map("password_hash")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
state UserState? @relation("UserState")
|
state UserState? @relation("UserState")
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { verifyPassword, setAuthCookie } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
if (!body || typeof body !== "object") {
|
||||||
|
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email, password } = body as { email?: string; password?: string };
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Email and password are required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { email } });
|
||||||
|
if (!user || !user.passwordHash) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid email or password" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await verifyPassword(password, user.passwordHash);
|
||||||
|
if (!valid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid email or password" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await setAuthCookie(user.id);
|
||||||
|
|
||||||
|
return NextResponse.json({ userId: user.id, email: user.email });
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { clearAuthCookie } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
await clearAuthCookie();
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.userId },
|
||||||
|
select: { email: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ email: user.email });
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { hashPassword, setAuthCookie } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
if (!body || typeof body !== "object") {
|
||||||
|
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email, password } = body as { email?: string; password?: string };
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Email and password are required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof email !== "string" || !email.includes("@")) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid email address" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Password must be at least 6 characters" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.user.findUnique({ where: { email } });
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "An account with this email already exists" },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hashPassword(password);
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { email, passwordHash },
|
||||||
|
});
|
||||||
|
|
||||||
|
await setAuthCookie(user.id);
|
||||||
|
|
||||||
|
return NextResponse.json({ userId: user.id, email: user.email });
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const results = await generateAllWeeklySummaries(openai);
|
const results = await generateAllWeeklySummaries(openai, "zh");
|
||||||
const succeeded = results.filter((r) => r.success).length;
|
const succeeded = results.filter((r) => r.success).length;
|
||||||
const failed = results.filter((r) => !r.success).length;
|
const failed = results.filter((r) => !r.success).length;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { auth } from "@clerk/nextjs/server";
|
import { getSession } from "@/lib/auth";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
const { userId } = await auth();
|
const session = await getSession();
|
||||||
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
const userId = session.userId;
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 50);
|
const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 50);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { auth } from "@clerk/nextjs/server";
|
import { getSession } from "@/lib/auth";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { openai } from "@/lib/openai";
|
import { openai } from "@/lib/openai";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -7,8 +7,9 @@ import { computeStateUpdate } from "@/lib/engines/progress-tracker";
|
|||||||
import { generateRewards, persistRewards } from "@/lib/engines/reward-generator";
|
import { generateRewards, persistRewards } from "@/lib/engines/reward-generator";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const { userId } = await auth();
|
const session = await getSession();
|
||||||
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
const userId = session.userId;
|
||||||
|
|
||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
if (!body || typeof body !== "object") {
|
if (!body || typeof body !== "object") {
|
||||||
@@ -37,18 +38,30 @@ export async function POST(req: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current state
|
// Get or create user state (first session auto-creates defaults)
|
||||||
const currentState = await prisma.userState.findUnique({
|
const currentState = await prisma.userState.upsert({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
|
create: {
|
||||||
|
userId,
|
||||||
|
attentionLevel: 50,
|
||||||
|
emotionStability: 50,
|
||||||
|
stressLevel: 50,
|
||||||
|
practiceStage: 0,
|
||||||
|
consistencyScore: 0,
|
||||||
|
streakDays: 0,
|
||||||
|
totalSessionsCompleted: 0,
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!currentState) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "User state not found. Complete onboarding first." },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Determine user's language preference from cookie
|
||||||
|
const cookieLang = req.headers
|
||||||
|
.get("cookie")
|
||||||
|
?.split("; ")
|
||||||
|
.find((c) => c.startsWith("inneros-lang="))
|
||||||
|
?.split("=")[1];
|
||||||
|
const locale: "zh" | "en" = cookieLang === "en" ? "en" : "zh";
|
||||||
|
|
||||||
// Process reflection through LLM #2
|
// Process reflection through LLM #2
|
||||||
const reflection = await processReflection(
|
const reflection = await processReflection(
|
||||||
@@ -63,7 +76,8 @@ export async function POST(req: Request) {
|
|||||||
emotionStability: currentState.emotionStability,
|
emotionStability: currentState.emotionStability,
|
||||||
stressLevel: currentState.stressLevel,
|
stressLevel: currentState.stressLevel,
|
||||||
},
|
},
|
||||||
openai
|
openai,
|
||||||
|
locale
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get recent session dates for consistency computation
|
// Get recent session dates for consistency computation
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { auth } from "@clerk/nextjs/server";
|
import { getSession } from "@/lib/auth";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import OpenAI from "openai";
|
import OpenAI from "openai";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const { userId } = await auth();
|
const session = await getSession();
|
||||||
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
const userId = session.userId;
|
||||||
|
|
||||||
const summary = await prisma.weeklySummary.findUnique({
|
const summary = await prisma.weeklySummary.findUnique({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { auth } from "@clerk/nextjs/server";
|
import { getSession } from "@/lib/auth";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const { userId } = await auth();
|
const session = await getSession();
|
||||||
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
const userId = session.userId;
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { auth } from "@clerk/nextjs/server";
|
import { getSession } from "@/lib/auth";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { openai } from "@/lib/openai";
|
import { openai } from "@/lib/openai";
|
||||||
import { generateDailyTask } from "@/lib/engines/task-selector";
|
import { generateDailyTask } from "@/lib/engines/task-selector";
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
const { userId } = await auth();
|
const session = await getSession();
|
||||||
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
const userId = session.userId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await generateDailyTask(userId, openai);
|
const result = await generateDailyTask(userId, openai);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { auth } from "@clerk/nextjs/server";
|
import { getSession } from "@/lib/auth";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
const { userId } = await auth();
|
const session = await getSession();
|
||||||
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
const userId = session.userId;
|
||||||
|
|
||||||
// Mark onboarding complete
|
// Mark onboarding complete
|
||||||
const state = await prisma.userState.upsert({
|
const state = await prisma.userState.upsert({
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { auth } from "@clerk/nextjs/server";
|
import { getSession } from "@/lib/auth";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const { userId } = await auth();
|
const session = await getSession();
|
||||||
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
const userId = session.userId;
|
||||||
|
|
||||||
const state = await prisma.userState.findUnique({
|
const state = await prisma.userState.findUnique({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
@@ -35,8 +36,9 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function PATCH(req: Request) {
|
export async function PATCH(req: Request) {
|
||||||
const { userId } = await auth();
|
const session = await getSession();
|
||||||
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
const userId = session.userId;
|
||||||
|
|
||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
if (!body || typeof body !== "object") {
|
if (!body || typeof body !== "object") {
|
||||||
|
|||||||
@@ -44,3 +44,13 @@ body {
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* CJK font stack — wider characters read better at slightly smaller sizes */
|
||||||
|
[lang="zh"] {
|
||||||
|
--font-display: "Noto Serif SC", "Source Han Serif SC", var(--font-fraunces), Georgia, serif;
|
||||||
|
--font-body: "Noto Sans SC", "Source Han Sans SC", "PingFang SC", "Microsoft YaHei", var(--font-dm-sans), system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
[lang="zh"] body {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|||||||
+13
-6
@@ -1,7 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { ClerkProvider } from "@clerk/nextjs";
|
import { cookies } from "next/headers";
|
||||||
import { DM_Sans, Fraunces } from "next/font/google";
|
import { DM_Sans, Fraunces } from "next/font/google";
|
||||||
import TabBar from "@/components/TabBar";
|
import TabBar from "@/components/TabBar";
|
||||||
|
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||||
|
import { I18nProvider } from "@/lib/i18n/context";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const fraunces = Fraunces({
|
const fraunces = Fraunces({
|
||||||
@@ -21,24 +23,29 @@ export const metadata: Metadata = {
|
|||||||
description: "Your private inner development practice.",
|
description: "Your private inner development practice.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const langCookie = cookieStore.get("inneros-lang")?.value;
|
||||||
|
const lang = langCookie === "zh" || langCookie === "en" ? langCookie : "zh";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClerkProvider>
|
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang={lang}
|
||||||
className={`${fraunces.variable} ${dmSans.variable} h-full antialiased`}
|
className={`${fraunces.variable} ${dmSans.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col pb-16 bg-cream dark:bg-[#1E1A14] text-ink dark:text-[#F3EFE8]">
|
<body className="min-h-full flex flex-col pb-16 bg-cream dark:bg-[#1E1A14] text-ink dark:text-[#F3EFE8]">
|
||||||
<main className="flex-1 max-w-2xl mx-auto w-full px-5 pt-8 pb-4">
|
<I18nProvider>
|
||||||
|
<main className="flex-1 max-w-2xl mx-auto w-full px-5 pt-8 pb-4 relative">
|
||||||
|
<LanguageSwitcher />
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<TabBar />
|
<TabBar />
|
||||||
|
</I18nProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
</ClerkProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-48
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useUser } from "@clerk/nextjs";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import StatBar from "@/components/StatBar";
|
import StatBar from "@/components/StatBar";
|
||||||
|
import { useI18n } from "@/lib/i18n/context";
|
||||||
|
import { formatTaskType, formatDifficulty } from "@/lib/i18n/utils";
|
||||||
|
|
||||||
interface StateData {
|
interface StateData {
|
||||||
attentionLevel: number;
|
attentionLevel: number;
|
||||||
@@ -26,8 +28,9 @@ interface TaskData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const { user, isLoaded: userLoaded } = useUser();
|
const { user, isLoaded: userLoaded } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
const [state, setState] = useState<StateData | null>(null);
|
const [state, setState] = useState<StateData | null>(null);
|
||||||
const [task, setTask] = useState<TaskData | null>(null);
|
const [task, setTask] = useState<TaskData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -69,7 +72,7 @@ export default function HomePage() {
|
|||||||
if (!userLoaded || loading) {
|
if (!userLoaded || loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[60vh]">
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
<div className="animate-pulse text-muted text-sm">Loading...</div>
|
<div className="animate-pulse text-muted text-sm">{t("shared.loading")}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -80,7 +83,7 @@ export default function HomePage() {
|
|||||||
if (state && !state.onboardingCompleted) {
|
if (state && !state.onboardingCompleted) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<Greeting firstName={user.firstName} />
|
<Greeting firstName={null} />
|
||||||
<div className="p-5 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528] space-y-4 text-center">
|
<div className="p-5 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528] space-y-4 text-center">
|
||||||
<div className="w-16 h-16 mx-auto rounded-full bg-amber/10 border-2 border-amber flex items-center justify-center">
|
<div className="w-16 h-16 mx-auto rounded-full bg-amber/10 border-2 border-amber flex items-center justify-center">
|
||||||
<svg
|
<svg
|
||||||
@@ -100,17 +103,17 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-medium text-ink dark:text-[#F3EFE8]">
|
<h2 className="text-base font-medium text-ink dark:text-[#F3EFE8]">
|
||||||
One step left
|
{t("home.onboarding.title")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-muted mt-1 max-w-xs mx-auto">
|
<p className="text-sm text-muted mt-1 max-w-xs mx-auto">
|
||||||
Complete onboarding in Settings to start your practice.
|
{t("home.onboarding.description")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push("/settings")}
|
onClick={() => router.push("/settings")}
|
||||||
className="py-2.5 px-5 rounded-lg bg-amber text-white font-medium text-sm transition-all hover:bg-amber/90 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-amber focus:ring-offset-2"
|
className="py-2.5 px-5 rounded-lg bg-amber text-white font-medium text-sm transition-all hover:bg-amber/90 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-amber focus:ring-offset-2"
|
||||||
>
|
>
|
||||||
Go to Settings
|
{t("home.onboarding.cta")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,37 +122,37 @@ export default function HomePage() {
|
|||||||
|
|
||||||
const metrics = state
|
const metrics = state
|
||||||
? [
|
? [
|
||||||
{ label: "Attention", value: state.attentionLevel, color: "amber" as const },
|
{ label: t("home.metrics.attention"), value: state.attentionLevel, color: "amber" as const },
|
||||||
{ label: "Emotion", value: state.emotionStability, color: "sage" as const },
|
{ label: t("home.metrics.emotion"), value: state.emotionStability, color: "sage" as const },
|
||||||
{ label: "Stress", value: state.stressLevel, color: "warning" as const },
|
{ label: t("home.metrics.stress"), value: state.stressLevel, color: "warning" as const },
|
||||||
{ label: "Consistency", value: state.consistencyScore, color: "info" as const },
|
{ label: t("home.metrics.consistency"), value: state.consistencyScore, color: "info" as const },
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<Greeting firstName={user.firstName} />
|
<Greeting firstName={null} />
|
||||||
|
|
||||||
{/* State overview */}
|
{/* State overview */}
|
||||||
{state && (
|
{state && (
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="p-4 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528]">
|
<div className="p-4 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528]">
|
||||||
<p className="text-xs text-muted uppercase tracking-wider font-medium">
|
<p className="text-xs text-muted uppercase tracking-wider font-medium">
|
||||||
Streak
|
{t("home.stats.streak")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-3xl font-bold tabular-nums text-amber font-[family-name:var(--font-fraunces)]">
|
<p className="text-3xl font-bold tabular-nums text-amber font-[family-name:var(--font-fraunces)]">
|
||||||
{state.streakDays}
|
{state.streakDays}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted mt-0.5">days</p>
|
<p className="text-xs text-muted mt-0.5">{t("home.stats.streak_unit")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528]">
|
<div className="p-4 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528]">
|
||||||
<p className="text-xs text-muted uppercase tracking-wider font-medium">
|
<p className="text-xs text-muted uppercase tracking-wider font-medium">
|
||||||
Sessions
|
{t("home.stats.sessions")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-3xl font-bold tabular-nums text-sage font-[family-name:var(--font-fraunces)]">
|
<p className="text-3xl font-bold tabular-nums text-sage font-[family-name:var(--font-fraunces)]">
|
||||||
{state.totalSessionsCompleted}
|
{state.totalSessionsCompleted}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted mt-0.5">total</p>
|
<p className="text-xs text-muted mt-0.5">{t("home.stats.sessions_unit")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -158,7 +161,7 @@ export default function HomePage() {
|
|||||||
{state && (
|
{state && (
|
||||||
<div className="p-5 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528] space-y-4">
|
<div className="p-5 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528] space-y-4">
|
||||||
<h2 className="text-sm font-medium text-ink dark:text-[#F3EFE8]">
|
<h2 className="text-sm font-medium text-ink dark:text-[#F3EFE8]">
|
||||||
Core metrics
|
{t("home.metrics.title")}
|
||||||
</h2>
|
</h2>
|
||||||
{metrics.map((m) => (
|
{metrics.map((m) => (
|
||||||
<StatBar
|
<StatBar
|
||||||
@@ -174,22 +177,22 @@ export default function HomePage() {
|
|||||||
{/* Today's task */}
|
{/* Today's task */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h2 className="text-sm font-medium text-ink dark:text-[#F3EFE8]">
|
<h2 className="text-sm font-medium text-ink dark:text-[#F3EFE8]">
|
||||||
Today’s practice
|
{t("home.task.heading")}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{task ? (
|
{task ? (
|
||||||
<div className="p-5 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528] space-y-4">
|
<div className="p-5 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528] space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs font-medium uppercase tracking-wider px-2 py-0.5 rounded-full bg-amber/10 text-amber">
|
<span className="text-xs font-medium uppercase tracking-wider px-2 py-0.5 rounded-full bg-amber/10 text-amber">
|
||||||
{formatTaskType(task.type)}
|
{formatTaskType(task.type, t)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted">
|
<span className="text-xs text-muted">
|
||||||
{formatDifficulty(task.difficulty)} · {task.duration}m
|
{formatDifficulty(task.difficulty, t)} · {task.duration}m
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium text-ink dark:text-[#F3EFE8] font-[family-name:var(--font-fraunces)] italic">
|
<h3 className="text-lg font-medium text-ink dark:text-[#F3EFE8] font-[family-name:var(--font-fraunces)] italic">
|
||||||
{formatTaskType(task.type)} practice
|
{formatTaskType(task.type, t)} practice
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted mt-2 leading-relaxed">
|
<p className="text-sm text-muted mt-2 leading-relaxed">
|
||||||
{task.description}
|
{task.description}
|
||||||
@@ -201,16 +204,16 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push(`/practice?taskId=${task.id}`)}
|
onClick={() => router.push(`/practice?id=${task.id}`)}
|
||||||
className="w-full py-3 px-6 rounded-lg bg-ink dark:bg-[#F3EFE8] text-cream dark:text-[#1E1A14] font-medium text-base transition-all hover:opacity-90 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-amber focus:ring-offset-2"
|
className="w-full py-3 px-6 rounded-lg bg-ink dark:bg-[#F3EFE8] text-cream dark:text-[#1E1A14] font-medium text-base transition-all hover:opacity-90 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-amber focus:ring-offset-2"
|
||||||
>
|
>
|
||||||
Begin practice
|
{t("home.task.begin")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-5 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528] space-y-4 text-center">
|
<div className="p-5 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528] space-y-4 text-center">
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
No task for today yet. Generate one to start your practice.
|
{t("home.task.no_task")}
|
||||||
</p>
|
</p>
|
||||||
{genError && (
|
{genError && (
|
||||||
<p className="text-sm text-error">{genError}</p>
|
<p className="text-sm text-error">{genError}</p>
|
||||||
@@ -220,7 +223,7 @@ export default function HomePage() {
|
|||||||
disabled={generating}
|
disabled={generating}
|
||||||
className="w-full py-3 px-6 rounded-lg bg-amber text-white font-medium text-base transition-all hover:bg-amber/90 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-amber focus:ring-offset-2 disabled:opacity-50"
|
className="w-full py-3 px-6 rounded-lg bg-amber text-white font-medium text-base transition-all hover:bg-amber/90 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-amber focus:ring-offset-2 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{generating ? "Generating..." : "Generate today's task"}
|
{generating ? t("home.task.generating") : t("home.task.generate")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -230,36 +233,19 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Greeting({ firstName }: { firstName: string | null }) {
|
function Greeting({ firstName }: { firstName: string | null }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const hour = new Date().getHours();
|
const hour = new Date().getHours();
|
||||||
const greeting = hour < 12 ? "Good morning" : hour < 18 ? "Good afternoon" : "Good evening";
|
const greetingKey =
|
||||||
const name = firstName || "there";
|
hour < 12 ? "home.greeting.morning" : hour < 18 ? "home.greeting.afternoon" : "home.greeting.evening";
|
||||||
|
const greeting = t(greetingKey);
|
||||||
|
const name = firstName || t("home.greeting.fallback_name");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="font-[family-name:var(--font-fraunces)] text-3xl italic text-ink dark:text-[#F3EFE8]">
|
<h1 className="font-[family-name:var(--font-fraunces)] text-3xl italic text-ink dark:text-[#F3EFE8]">
|
||||||
{greeting}, {name}
|
{greeting}, {name}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted">Your practice is waiting.</p>
|
<p className="text-sm text-muted">{t("home.greeting.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTaskType(type: string): string {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
attention_training: "Attention",
|
|
||||||
emotion_awareness: "Emotion",
|
|
||||||
stress_regulation: "Stress",
|
|
||||||
cognitive_clarity: "Clarity",
|
|
||||||
integration: "Integration",
|
|
||||||
};
|
|
||||||
return labels[type] ?? type;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDifficulty(difficulty: number): string {
|
|
||||||
const labels: Record<number, string> = {
|
|
||||||
1: "Beginner",
|
|
||||||
2: "Intermediate",
|
|
||||||
3: "Advanced",
|
|
||||||
};
|
|
||||||
return labels[difficulty] ?? String(difficulty);
|
|
||||||
}
|
|
||||||
|
|||||||
+11
-19
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { Suspense, useEffect, useState, useCallback } from "react";
|
import { Suspense, useEffect, useState, useCallback } from "react";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
|
import { useI18n } from "@/lib/i18n/context";
|
||||||
|
import { formatTaskType } from "@/lib/i18n/utils";
|
||||||
|
|
||||||
interface TaskData {
|
interface TaskData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -31,6 +33,7 @@ function LoadingFallback() {
|
|||||||
function PracticeContent() {
|
function PracticeContent() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
const taskId = searchParams.get("id");
|
const taskId = searchParams.get("id");
|
||||||
|
|
||||||
const [task, setTask] = useState<TaskData | null>(null);
|
const [task, setTask] = useState<TaskData | null>(null);
|
||||||
@@ -105,24 +108,24 @@ function PracticeContent() {
|
|||||||
onClick={() => router.push("/")}
|
onClick={() => router.push("/")}
|
||||||
className="text-sm text-muted hover:text-ink dark:hover:text-[#F3EFE8] transition-colors"
|
className="text-sm text-muted hover:text-ink dark:hover:text-[#F3EFE8] transition-colors"
|
||||||
>
|
>
|
||||||
← Back
|
{t("shared.back")}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Task display */}
|
{/* Task display */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-amber/10 text-amber">
|
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-amber/10 text-amber">
|
||||||
{formatTaskType(task.type)}
|
{formatTaskType(task.type, t)}
|
||||||
</span>
|
</span>
|
||||||
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl italic text-ink dark:text-[#F3EFE8] mt-3">
|
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl italic text-ink dark:text-[#F3EFE8] mt-3">
|
||||||
Your practice
|
{t("practice.title")}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-5 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528] space-y-4">
|
<div className="p-5 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528] space-y-4">
|
||||||
<p className="text-lg leading-relaxed">{task.description}</p>
|
<p className="text-lg leading-relaxed">{task.description}</p>
|
||||||
<p className="text-sm text-muted italic leading-relaxed border-t border-stone dark:border-[#3D3528] pt-3">
|
<p className="text-sm text-muted italic leading-relaxed border-t border-stone dark:border-[#3D3528] pt-3">
|
||||||
Why this task: {task.reason}
|
{t("practice.why_prefix")}{task.reason}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,7 +137,7 @@ function PracticeContent() {
|
|||||||
onClick={handleStart}
|
onClick={handleStart}
|
||||||
className="w-32 h-32 rounded-full bg-amber text-white font-medium text-lg flex items-center justify-center transition-all hover:scale-105 active:scale-95 focus:outline-none focus:ring-4 focus:ring-amber/30"
|
className="w-32 h-32 rounded-full bg-amber text-white font-medium text-lg flex items-center justify-center transition-all hover:scale-105 active:scale-95 focus:outline-none focus:ring-4 focus:ring-amber/30"
|
||||||
>
|
>
|
||||||
Start
|
{t("practice.start")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -168,7 +171,7 @@ function PracticeContent() {
|
|||||||
onClick={handleFinish}
|
onClick={handleFinish}
|
||||||
className="text-sm text-muted hover:text-ink dark:hover:text-[#F3EFE8] transition-colors underline underline-offset-4"
|
className="text-sm text-muted hover:text-ink dark:hover:text-[#F3EFE8] transition-colors underline underline-offset-4"
|
||||||
>
|
>
|
||||||
Finish early
|
{t("practice.finish_early")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -190,12 +193,12 @@ function PracticeContent() {
|
|||||||
<polyline points="20 6 9 17 4 12" />
|
<polyline points="20 6 9 17 4 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted">Practice complete</p>
|
<p className="text-sm text-muted">{t("practice.complete")}</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleFinish}
|
onClick={handleFinish}
|
||||||
className="py-3 px-8 rounded-lg bg-ink dark:bg-[#F3EFE8] text-cream dark:text-[#1E1A14] font-medium text-base transition-all hover:opacity-90 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-amber focus:ring-offset-2"
|
className="py-3 px-8 rounded-lg bg-ink dark:bg-[#F3EFE8] text-cream dark:text-[#1E1A14] font-medium text-base transition-all hover:opacity-90 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-amber focus:ring-offset-2"
|
||||||
>
|
>
|
||||||
Reflect on your practice
|
{t("practice.reflect")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -203,14 +206,3 @@ function PracticeContent() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTaskType(type: string): string {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
attention_training: "Attention",
|
|
||||||
emotion_awareness: "Emotion",
|
|
||||||
stress_regulation: "Stress",
|
|
||||||
cognitive_clarity: "Clarity",
|
|
||||||
integration: "Integration",
|
|
||||||
};
|
|
||||||
return labels[type] ?? type;
|
|
||||||
}
|
|
||||||
|
|||||||
+23
-21
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useUser } from "@clerk/nextjs";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import StatBar from "@/components/StatBar";
|
import StatBar from "@/components/StatBar";
|
||||||
import StageBadge from "@/components/StageBadge";
|
import StageBadge from "@/components/StageBadge";
|
||||||
|
import { useI18n } from "@/lib/i18n/context";
|
||||||
|
|
||||||
interface StateData {
|
interface StateData {
|
||||||
attentionLevel: number;
|
attentionLevel: number;
|
||||||
@@ -16,7 +17,8 @@ interface StateData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProgressPage() {
|
export default function ProgressPage() {
|
||||||
const { isLoaded } = useUser();
|
const { isLoaded } = useAuth();
|
||||||
|
const { t } = useI18n();
|
||||||
const [state, setState] = useState<StateData | null>(null);
|
const [state, setState] = useState<StateData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ export default function ProgressPage() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[60vh]">
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
<div className="animate-pulse text-muted text-sm">Loading...</div>
|
<div className="animate-pulse text-muted text-sm">{t("shared.loading")}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -40,26 +42,26 @@ export default function ProgressPage() {
|
|||||||
if (!state) {
|
if (!state) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-2">
|
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-2">
|
||||||
<p className="text-muted text-sm">Complete onboarding to see your progress.</p>
|
<p className="text-muted text-sm">{t("progress.onboarding_needed")}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const metrics = [
|
const metrics = [
|
||||||
{ label: "Attention", value: state.attentionLevel, color: "amber" as const },
|
{ label: t("progress.attention"), value: state.attentionLevel, color: "amber" as const },
|
||||||
{ label: "Emotional Stability", value: state.emotionStability, color: "sage" as const },
|
{ label: t("progress.emotional_stability"), value: state.emotionStability, color: "sage" as const },
|
||||||
{ label: "Stress Level", value: state.stressLevel, color: "warning" as const },
|
{ label: t("progress.stress_level"), value: state.stressLevel, color: "warning" as const },
|
||||||
{ label: "Consistency", value: state.consistencyScore, color: "info" as const },
|
{ label: t("progress.consistency"), value: state.consistencyScore, color: "info" as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="font-[family-name:var(--font-fraunces)] text-3xl italic text-ink dark:text-[#F3EFE8]">
|
<h1 className="font-[family-name:var(--font-fraunces)] text-3xl italic text-ink dark:text-[#F3EFE8]">
|
||||||
Your progress
|
{t("progress.title")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
Stage <StageBadge stage={state.practiceStage} />
|
{t("progress.stage_prefix")} <StageBadge stage={state.practiceStage} />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -67,28 +69,28 @@ export default function ProgressPage() {
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="p-4 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528]">
|
<div className="p-4 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528]">
|
||||||
<p className="text-xs text-muted uppercase tracking-wider font-medium">
|
<p className="text-xs text-muted uppercase tracking-wider font-medium">
|
||||||
Streak
|
{t("home.stats.streak")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-3xl font-bold tabular-nums text-amber font-[family-name:var(--font-fraunces)]">
|
<p className="text-3xl font-bold tabular-nums text-amber font-[family-name:var(--font-fraunces)]">
|
||||||
{state.streakDays}
|
{state.streakDays}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted mt-0.5">days</p>
|
<p className="text-xs text-muted mt-0.5">{t("home.stats.streak_unit")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528]">
|
<div className="p-4 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528]">
|
||||||
<p className="text-xs text-muted uppercase tracking-wider font-medium">
|
<p className="text-xs text-muted uppercase tracking-wider font-medium">
|
||||||
Sessions
|
{t("home.stats.sessions")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-3xl font-bold tabular-nums text-sage font-[family-name:var(--font-fraunces)]">
|
<p className="text-3xl font-bold tabular-nums text-sage font-[family-name:var(--font-fraunces)]">
|
||||||
{state.totalSessionsCompleted}
|
{state.totalSessionsCompleted}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted mt-0.5">total</p>
|
<p className="text-xs text-muted mt-0.5">{t("home.stats.sessions_unit")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metric bars */}
|
{/* Metric bars */}
|
||||||
<div className="p-5 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528] space-y-5">
|
<div className="p-5 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528] space-y-5">
|
||||||
<h2 className="text-sm font-medium text-ink dark:text-[#F3EFE8]">
|
<h2 className="text-sm font-medium text-ink dark:text-[#F3EFE8]">
|
||||||
Core metrics
|
{t("progress.metrics_title")}
|
||||||
</h2>
|
</h2>
|
||||||
{metrics.map((m) => (
|
{metrics.map((m) => (
|
||||||
<StatBar
|
<StatBar
|
||||||
@@ -103,7 +105,7 @@ export default function ProgressPage() {
|
|||||||
{/* Stage info */}
|
{/* Stage info */}
|
||||||
<div className="p-5 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528] space-y-2">
|
<div className="p-5 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528] space-y-2">
|
||||||
<h2 className="text-sm font-medium text-ink dark:text-[#F3EFE8]">
|
<h2 className="text-sm font-medium text-ink dark:text-[#F3EFE8]">
|
||||||
Stage progress
|
{t("progress.stage_title")}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
{[0, 1, 2, 3, 4].map((s) => (
|
{[0, 1, 2, 3, 4].map((s) => (
|
||||||
@@ -118,11 +120,11 @@ export default function ProgressPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-[10px] text-faded">
|
<div className="flex justify-between text-[10px] text-faded">
|
||||||
<span>Beginner</span>
|
<span>{t("progress.stages.beginner")}</span>
|
||||||
<span>Foundation</span>
|
<span>{t("progress.stages.foundation")}</span>
|
||||||
<span>Growing</span>
|
<span>{t("progress.stages.growing")}</span>
|
||||||
<span>Established</span>
|
<span>{t("progress.stages.established")}</span>
|
||||||
<span>Advanced</span>
|
<span>{t("progress.stages.advanced")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+16
-24
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
import { useState, Suspense } from "react";
|
import { useState, Suspense } from "react";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
|
import { useI18n } from "@/lib/i18n/context";
|
||||||
|
import { formatTaskType } from "@/lib/i18n/utils";
|
||||||
|
|
||||||
function ReflectForm() {
|
function ReflectForm() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const taskId = searchParams.get("taskId") || "";
|
const taskId = searchParams.get("taskId") || "";
|
||||||
const duration = parseInt(searchParams.get("duration") || "0", 10);
|
const duration = parseInt(searchParams.get("duration") || "0", 10);
|
||||||
@@ -39,13 +42,13 @@ function ReflectForm() {
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
throw new Error(json.error || "Failed to save reflection");
|
throw new Error(json.error || t("reflect.error"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
setResult(json.reflection);
|
setResult(json.reflection);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "Something went wrong");
|
setError(e instanceof Error ? e.message : t("reflect.generic_error"));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -58,7 +61,7 @@ function ReflectForm() {
|
|||||||
onClick={() => router.push("/")}
|
onClick={() => router.push("/")}
|
||||||
className="text-muted text-sm underline"
|
className="text-muted text-sm underline"
|
||||||
>
|
>
|
||||||
No session to reflect on. Go home.
|
{t("reflect.no_session")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -85,7 +88,7 @@ function ReflectForm() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl italic text-ink dark:text-[#F3EFE8]">
|
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl italic text-ink dark:text-[#F3EFE8]">
|
||||||
Practice complete
|
{t("reflect.result_title")}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -98,13 +101,13 @@ function ReflectForm() {
|
|||||||
<span className="text-ink dark:text-[#F3EFE8] font-bold">
|
<span className="text-ink dark:text-[#F3EFE8] font-bold">
|
||||||
{result.focusScore}/5
|
{result.focusScore}/5
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
focus
|
{t("reflect.focus")}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<span className="text-ink dark:text-[#F3EFE8] font-bold">
|
<span className="text-ink dark:text-[#F3EFE8] font-bold">
|
||||||
{result.emotionScore}/5
|
{result.emotionScore}/5
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
emotional awareness
|
{t("reflect.emotional_awareness")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,7 +116,7 @@ function ReflectForm() {
|
|||||||
onClick={() => router.push("/")}
|
onClick={() => router.push("/")}
|
||||||
className="w-full py-3 px-6 rounded-lg bg-ink dark:bg-[#F3EFE8] text-cream dark:text-[#1E1A14] font-medium text-base transition-all hover:opacity-90 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-amber focus:ring-offset-2"
|
className="w-full py-3 px-6 rounded-lg bg-ink dark:bg-[#F3EFE8] text-cream dark:text-[#1E1A14] font-medium text-base transition-all hover:opacity-90 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-amber focus:ring-offset-2"
|
||||||
>
|
>
|
||||||
Back home
|
{t("reflect.back_home")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -126,13 +129,13 @@ function ReflectForm() {
|
|||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
className="text-sm text-muted hover:text-ink dark:hover:text-[#F3EFE8] transition-colors"
|
className="text-sm text-muted hover:text-ink dark:hover:text-[#F3EFE8] transition-colors"
|
||||||
>
|
>
|
||||||
← Back
|
{t("shared.back")}
|
||||||
</button>
|
</button>
|
||||||
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl italic text-ink dark:text-[#F3EFE8] mt-3">
|
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl italic text-ink dark:text-[#F3EFE8] mt-3">
|
||||||
Reflect on your practice
|
{t("reflect.title")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
{formatTaskType(taskType)} · {Math.floor(duration / 60)}m{" "}
|
{formatTaskType(taskType, t)} · {Math.floor(duration / 60)}m{" "}
|
||||||
{duration % 60}s
|
{duration % 60}s
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,14 +145,14 @@ function ReflectForm() {
|
|||||||
htmlFor="reflection-notes"
|
htmlFor="reflection-notes"
|
||||||
className="block text-sm font-medium text-muted"
|
className="block text-sm font-medium text-muted"
|
||||||
>
|
>
|
||||||
What did you notice during your practice?
|
{t("reflect.label")}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="reflection-notes"
|
id="reflection-notes"
|
||||||
value={notes}
|
value={notes}
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
rows={5}
|
rows={5}
|
||||||
placeholder="Were you focused or distracted? What emotions came up? Did anything surprise you?"
|
placeholder={t("reflect.placeholder")}
|
||||||
className="w-full p-4 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528] text-ink dark:text-[#F3EFE8] text-base placeholder:text-faded resize-none focus:outline-none focus:ring-2 focus:ring-amber"
|
className="w-full p-4 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528] text-ink dark:text-[#F3EFE8] text-base placeholder:text-faded resize-none focus:outline-none focus:ring-2 focus:ring-amber"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
@@ -165,7 +168,7 @@ function ReflectForm() {
|
|||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="w-full py-3 px-6 rounded-lg bg-ink dark:bg-[#F3EFE8] text-cream dark:text-[#1E1A14] font-medium text-base transition-all hover:opacity-90 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-amber focus:ring-offset-2 disabled:opacity-50"
|
className="w-full py-3 px-6 rounded-lg bg-ink dark:bg-[#F3EFE8] text-cream dark:text-[#1E1A14] font-medium text-base transition-all hover:opacity-90 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-amber focus:ring-offset-2 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{submitting ? "Saving..." : "Complete practice"}
|
{submitting ? t("reflect.submitting") : t("reflect.submit")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -185,14 +188,3 @@ export default function ReflectPage() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTaskType(type: string): string {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
attention_training: "Attention",
|
|
||||||
emotion_awareness: "Emotion",
|
|
||||||
stress_regulation: "Stress",
|
|
||||||
cognitive_clarity: "Clarity",
|
|
||||||
integration: "Integration",
|
|
||||||
};
|
|
||||||
return labels[type] ?? type;
|
|
||||||
}
|
|
||||||
|
|||||||
+13
-21
@@ -1,7 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useUser } from "@clerk/nextjs";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { useI18n } from "@/lib/i18n/context";
|
||||||
|
import { formatRewardType } from "@/lib/i18n/utils";
|
||||||
|
|
||||||
interface Reward {
|
interface Reward {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,7 +13,8 @@ interface Reward {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RewardsPage() {
|
export default function RewardsPage() {
|
||||||
const { isLoaded } = useUser();
|
const { isLoaded } = useAuth();
|
||||||
|
const { t } = useI18n();
|
||||||
const [rewards, setRewards] = useState<Reward[]>([]);
|
const [rewards, setRewards] = useState<Reward[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
@@ -27,7 +30,7 @@ export default function RewardsPage() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[60vh]">
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
<div className="animate-pulse text-muted text-sm">Loading...</div>
|
<div className="animate-pulse text-muted text-sm">{t("shared.loading")}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -36,12 +39,14 @@ export default function RewardsPage() {
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="font-[family-name:var(--font-fraunces)] text-3xl italic text-ink dark:text-[#F3EFE8]">
|
<h1 className="font-[family-name:var(--font-fraunces)] text-3xl italic text-ink dark:text-[#F3EFE8]">
|
||||||
Rewards
|
{t("rewards.title")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
{rewards.length === 0
|
{rewards.length === 0
|
||||||
? "Your rewards will appear here as you practice."
|
? t("rewards.empty_subtitle")
|
||||||
: `${rewards.length} reward${rewards.length === 1 ? "" : "s"} earned`}
|
: rewards.length === 1
|
||||||
|
? t("rewards.earned_one", { count: rewards.length })
|
||||||
|
: t("rewards.earned_other", { count: rewards.length })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -63,7 +68,7 @@ export default function RewardsPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted mt-4">
|
<p className="text-sm text-muted mt-4">
|
||||||
Complete your first practice to earn a reward.
|
{t("rewards.empty_message")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -78,7 +83,7 @@ export default function RewardsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm font-medium text-ink dark:text-[#F3EFE8]">
|
<p className="text-sm font-medium text-ink dark:text-[#F3EFE8]">
|
||||||
{formatRewardType(reward.type)}
|
{formatRewardType(reward.type, t)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted leading-relaxed">
|
<p className="text-sm text-muted leading-relaxed">
|
||||||
{reward.message}
|
{reward.message}
|
||||||
@@ -156,16 +161,3 @@ function RewardIcon({ type }: { type: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRewardType(type: string): string {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
daily_completion: "Daily Practice",
|
|
||||||
streak_3: "3-Day Streak",
|
|
||||||
streak_7: "7-Day Streak",
|
|
||||||
streak_14: "14-Day Streak",
|
|
||||||
streak_30: "30-Day Streak",
|
|
||||||
stage_up: "Stage Advance",
|
|
||||||
first_session: "First Practice",
|
|
||||||
};
|
|
||||||
return labels[type] ?? type;
|
|
||||||
}
|
|
||||||
|
|||||||
+34
-32
@@ -1,7 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useUser, SignOutButton } from "@clerk/nextjs";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useI18n } from "@/lib/i18n/context";
|
||||||
|
|
||||||
interface StateData {
|
interface StateData {
|
||||||
attentionLevel: number;
|
attentionLevel: number;
|
||||||
@@ -15,11 +17,14 @@ interface StateData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { user, isLoaded: userLoaded } = useUser();
|
const { user, isLoaded: userLoaded } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
const [state, setState] = useState<StateData | null>(null);
|
const [state, setState] = useState<StateData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [completing, setCompleting] = useState(false);
|
const [completing, setCompleting] = useState(false);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [signingOut, setSigningOut] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userLoaded) return;
|
if (!userLoaded) return;
|
||||||
@@ -38,18 +43,24 @@ export default function SettingsPage() {
|
|||||||
if (!res.ok) throw new Error("Failed");
|
if (!res.ok) throw new Error("Failed");
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
setState(json.state);
|
setState(json.state);
|
||||||
setMessage("You're all set. Head home to start your practice.");
|
setMessage("settings.onboarding.done");
|
||||||
} catch {
|
} catch {
|
||||||
setMessage("Something went wrong. Try again.");
|
setMessage("settings.onboarding.error");
|
||||||
} finally {
|
} finally {
|
||||||
setCompleting(false);
|
setCompleting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
setSigningOut(true);
|
||||||
|
await fetch("/api/auth/logout", { method: "POST" });
|
||||||
|
router.push("/sign-in");
|
||||||
|
};
|
||||||
|
|
||||||
if (!userLoaded || loading) {
|
if (!userLoaded || loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[60vh]">
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
<div className="animate-pulse text-muted text-sm">Loading...</div>
|
<div className="animate-pulse text-muted text-sm">{t("shared.loading")}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -62,7 +73,7 @@ export default function SettingsPage() {
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="font-[family-name:var(--font-fraunces)] text-3xl italic text-ink dark:text-[#F3EFE8]">
|
<h1 className="font-[family-name:var(--font-fraunces)] text-3xl italic text-ink dark:text-[#F3EFE8]">
|
||||||
Settings
|
{t("settings.title")}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -71,13 +82,10 @@ export default function SettingsPage() {
|
|||||||
<div className="p-5 rounded-lg bg-amber/5 border border-amber/20 space-y-4">
|
<div className="p-5 rounded-lg bg-amber/5 border border-amber/20 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-medium text-ink dark:text-[#F3EFE8]">
|
<h2 className="text-base font-medium text-ink dark:text-[#F3EFE8]">
|
||||||
Welcome to Inner OS
|
{t("settings.onboarding.title")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-muted mt-1">
|
<p className="text-sm text-muted mt-1">
|
||||||
Inner OS generates one personalized mental training task each day,
|
{t("settings.onboarding.description")}
|
||||||
guided by your practice history and inner development stage. Your
|
|
||||||
reflections shape what comes next. Everything is private — just you
|
|
||||||
and your practice.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -85,10 +93,10 @@ export default function SettingsPage() {
|
|||||||
disabled={completing}
|
disabled={completing}
|
||||||
className="py-2.5 px-5 rounded-lg bg-amber text-white font-medium text-sm transition-all hover:bg-amber/90 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-amber focus:ring-offset-2 disabled:opacity-50"
|
className="py-2.5 px-5 rounded-lg bg-amber text-white font-medium text-sm transition-all hover:bg-amber/90 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-amber focus:ring-offset-2 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{completing ? "Setting up..." : "Get started"}
|
{completing ? t("settings.onboarding.setting_up") : t("settings.onboarding.cta")}
|
||||||
</button>
|
</button>
|
||||||
{message && (
|
{message && (
|
||||||
<p className="text-sm text-sage">{message}</p>
|
<p className="text-sm text-sage">{t(message)}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -96,18 +104,18 @@ export default function SettingsPage() {
|
|||||||
{/* Profile */}
|
{/* Profile */}
|
||||||
<div className="p-5 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528] space-y-3">
|
<div className="p-5 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528] space-y-3">
|
||||||
<h2 className="text-sm font-medium text-ink dark:text-[#F3EFE8]">
|
<h2 className="text-sm font-medium text-ink dark:text-[#F3EFE8]">
|
||||||
Profile
|
{t("settings.profile.title")}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-1 text-sm">
|
<div className="space-y-1 text-sm">
|
||||||
<p className="text-muted">
|
<p className="text-muted">
|
||||||
Signed in as{" "}
|
{t("settings.profile.signed_in_as")}{" "}
|
||||||
<span className="text-ink dark:text-[#F3EFE8] font-medium">
|
<span className="text-ink dark:text-[#F3EFE8] font-medium">
|
||||||
{user.primaryEmailAddress?.emailAddress || user.firstName || "User"}
|
{user?.email || "User"}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
{state?.onboardingCompleted && (
|
{state?.onboardingCompleted && (
|
||||||
<p className="text-muted">
|
<p className="text-muted">
|
||||||
Stage {state.practiceStage} · {state.totalSessionsCompleted} sessions
|
{t("settings.profile.stage_and_sessions", { stage: state.practiceStage, count: state.totalSessionsCompleted })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -116,29 +124,23 @@ export default function SettingsPage() {
|
|||||||
{/* About */}
|
{/* About */}
|
||||||
<div className="p-5 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528] space-y-2">
|
<div className="p-5 rounded-lg bg-sand dark:bg-[#292418] border border-stone dark:border-[#3D3528] space-y-2">
|
||||||
<h2 className="text-sm font-medium text-ink dark:text-[#F3EFE8]">
|
<h2 className="text-sm font-medium text-ink dark:text-[#F3EFE8]">
|
||||||
About Inner OS
|
{t("settings.about.title")}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="text-sm text-muted space-y-2">
|
<div className="text-sm text-muted space-y-2">
|
||||||
<p>
|
<p>{t("settings.about.p1")}</p>
|
||||||
Inner OS is a private inner development practice. You receive one
|
<p>{t("settings.about.p2")}</p>
|
||||||
personalized mental training task each day, tailored to your current
|
|
||||||
state and development stage.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Your reflections are private. Your data is yours. There are no
|
|
||||||
leaderboards, no social features, no competition — just you and the
|
|
||||||
work of becoming more aware, more stable, and more yourself.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sign out */}
|
{/* Sign out */}
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<SignOutButton>
|
<button
|
||||||
<button className="w-full py-3 px-6 rounded-lg border border-stone dark:border-[#3D3528] text-muted hover:text-error transition-colors font-medium text-sm">
|
onClick={handleSignOut}
|
||||||
Sign out
|
disabled={signingOut}
|
||||||
|
className="w-full py-3 px-6 rounded-lg border border-stone dark:border-[#3D3528] text-muted hover:text-error transition-colors font-medium text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{signingOut ? t("settings.signing_out") : t("settings.sign_out")}
|
||||||
</button>
|
</button>
|
||||||
</SignOutButton>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useI18n } from "@/lib/i18n/context";
|
||||||
|
|
||||||
|
export default function SignInPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const json = await res.json().catch(() => null);
|
||||||
|
setError(json?.error || t("sign_in.fallback_error"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push("/");
|
||||||
|
} catch {
|
||||||
|
setError(t("sign_in.network_error"));
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-sm mx-auto space-y-6">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="font-[family-name:var(--font-fraunces)] text-3xl italic text-ink dark:text-[#F3EFE8]">
|
||||||
|
{t("sign_in.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted">{t("sign_in.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-sm font-medium text-ink dark:text-[#F3EFE8]">
|
||||||
|
{t("sign_in.email")}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-stone dark:border-[#3D3528] bg-white dark:bg-[#292418] text-ink dark:text-[#F3EFE8] text-sm focus:outline-none focus:ring-2 focus:ring-amber focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-sm font-medium text-ink dark:text-[#F3EFE8]">
|
||||||
|
{t("sign_in.password")}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-stone dark:border-[#3D3528] bg-white dark:bg-[#292418] text-ink dark:text-[#F3EFE8] text-sm focus:outline-none focus:ring-2 focus:ring-amber focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-error">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full py-2.5 rounded-lg bg-amber text-white font-medium text-sm transition-all hover:bg-amber/90 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-amber focus:ring-offset-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? t("sign_in.submitting") : t("sign_in.submit")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted text-center">
|
||||||
|
{t("sign_in.no_account")}{" "}
|
||||||
|
<Link href="/sign-up" className="text-amber hover:underline font-medium">
|
||||||
|
{t("sign_in.sign_up_link")}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useI18n } from "@/lib/i18n/context";
|
||||||
|
|
||||||
|
export default function SignUpPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirm, setConfirm] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (password !== confirm) {
|
||||||
|
setError(t("sign_up.password_mismatch"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
setError(t("sign_up.password_short"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const json = await res.json().catch(() => null);
|
||||||
|
setError(json?.error || t("sign_up.fallback_error"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push("/settings");
|
||||||
|
} catch {
|
||||||
|
setError(t("sign_up.network_error"));
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-sm mx-auto space-y-6">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="font-[family-name:var(--font-fraunces)] text-3xl italic text-ink dark:text-[#F3EFE8]">
|
||||||
|
{t("sign_up.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted">{t("sign_up.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-sm font-medium text-ink dark:text-[#F3EFE8]">
|
||||||
|
{t("sign_up.email")}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-stone dark:border-[#3D3528] bg-white dark:bg-[#292418] text-ink dark:text-[#F3EFE8] text-sm focus:outline-none focus:ring-2 focus:ring-amber focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-sm font-medium text-ink dark:text-[#F3EFE8]">
|
||||||
|
{t("sign_up.password")}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-stone dark:border-[#3D3528] bg-white dark:bg-[#292418] text-ink dark:text-[#F3EFE8] text-sm focus:outline-none focus:ring-2 focus:ring-amber focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-sm font-medium text-ink dark:text-[#F3EFE8]">
|
||||||
|
{t("sign_up.confirm_password")}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-stone dark:border-[#3D3528] bg-white dark:bg-[#292418] text-ink dark:text-[#F3EFE8] text-sm focus:outline-none focus:ring-2 focus:ring-amber focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-error">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full py-2.5 rounded-lg bg-amber text-white font-medium text-sm transition-all hover:bg-amber/90 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-amber focus:ring-offset-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? t("sign_up.submitting") : t("sign_up.submit")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted text-center">
|
||||||
|
{t("sign_up.has_account")}{" "}
|
||||||
|
<Link href="/sign-in" className="text-amber hover:underline font-medium">
|
||||||
|
{t("sign_up.sign_in_link")}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useUser } from "@clerk/nextjs";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { useI18n } from "@/lib/i18n/context";
|
||||||
|
|
||||||
interface SummaryData {
|
interface SummaryData {
|
||||||
summary: string;
|
summary: string;
|
||||||
@@ -10,7 +11,8 @@ interface SummaryData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SummaryPage() {
|
export default function SummaryPage() {
|
||||||
const { isLoaded } = useUser();
|
const { isLoaded } = useAuth();
|
||||||
|
const { t } = useI18n();
|
||||||
const [summary, setSummary] = useState<SummaryData | null>(null);
|
const [summary, setSummary] = useState<SummaryData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
@@ -26,7 +28,7 @@ export default function SummaryPage() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[60vh]">
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
<div className="animate-pulse text-muted text-sm">Loading...</div>
|
<div className="animate-pulse text-muted text-sm">{t("shared.loading")}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -35,15 +37,17 @@ export default function SummaryPage() {
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="font-[family-name:var(--font-fraunces)] text-3xl italic text-ink dark:text-[#F3EFE8]">
|
<h1 className="font-[family-name:var(--font-fraunces)] text-3xl italic text-ink dark:text-[#F3EFE8]">
|
||||||
Weekly summary
|
{t("summary.title")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
{summary
|
{summary
|
||||||
? `Week of ${new Date(summary.weekStart).toLocaleDateString("en-US", {
|
? t("summary.week_of", {
|
||||||
|
date: new Date(summary.weekStart).toLocaleDateString("en-US", {
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
})}`
|
}),
|
||||||
: "Generated each week from your practice history"}
|
})
|
||||||
|
: t("summary.empty_subtitle")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -73,7 +77,7 @@ export default function SummaryPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted max-w-xs mx-auto">
|
<p className="text-sm text-muted max-w-xs mx-auto">
|
||||||
Your first weekly summary will be generated after you complete at least one practice session. Summaries run automatically each week.
|
{t("summary.empty_message")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
user: { email: string } | null;
|
||||||
|
isLoaded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue>({
|
||||||
|
user: null,
|
||||||
|
isLoaded: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useAuthContext() {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuthProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const auth = useAuth();
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useI18n } from "@/lib/i18n/context";
|
||||||
|
|
||||||
|
export default function LanguageSwitcher() {
|
||||||
|
const { lang, setLang } = useI18n();
|
||||||
|
const next = lang === "zh" ? "en" : "zh";
|
||||||
|
const label = lang === "zh" ? "EN" : "中文";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setLang(next)}
|
||||||
|
className="absolute top-3 right-5 text-xs font-medium text-muted hover:text-ink dark:hover:text-[#F3EFE8] transition-colors tracking-wider uppercase"
|
||||||
|
aria-label={`Switch to ${next === "zh" ? "Chinese" : "English"}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,16 @@
|
|||||||
const stageLabels: Record<number, { label: string; color: string }> = {
|
import { useI18n } from "@/lib/i18n/context";
|
||||||
0: { label: "Beginner", color: "text-muted bg-stone" },
|
import { formatStage, getStageColor } from "@/lib/i18n/utils";
|
||||||
1: { label: "Foundation", color: "text-amber bg-amber/10" },
|
|
||||||
2: { label: "Growing", color: "text-sage bg-sage/10" },
|
|
||||||
3: { label: "Established", color: "text-info bg-info/10" },
|
|
||||||
4: { label: "Advanced", color: "text-ink bg-amber/15" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function StageBadge({ stage }: { stage: number }) {
|
export default function StageBadge({ stage }: { stage: number }) {
|
||||||
const info = stageLabels[stage] ?? stageLabels[0];
|
const { t } = useI18n();
|
||||||
|
const color = getStageColor(stage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium ${info.color}`}
|
className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium ${color}`}
|
||||||
>
|
>
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-current opacity-60" />
|
<span className="w-1.5 h-1.5 rounded-full bg-current opacity-60" />
|
||||||
{info.label}
|
{formatStage(stage, t)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,19 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useI18n } from "@/lib/i18n/context";
|
||||||
const tabs = [
|
|
||||||
{ href: "/", label: "Today", icon: SunIcon },
|
|
||||||
{ href: "/progress", label: "Progress", icon: ChartIcon },
|
|
||||||
{ href: "/rewards", label: "Rewards", icon: StarIcon },
|
|
||||||
{ href: "/summary", label: "Summary", icon: BookIcon },
|
|
||||||
{ href: "/settings", label: "Settings", icon: GearIcon },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function TabBar() {
|
export default function TabBar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ href: "/", label: t("nav.today"), icon: SunIcon },
|
||||||
|
{ href: "/progress", label: t("nav.progress"), icon: ChartIcon },
|
||||||
|
{ href: "/rewards", label: t("nav.rewards"), icon: StarIcon },
|
||||||
|
{ href: "/summary", label: t("nav.summary"), icon: BookIcon },
|
||||||
|
{ href: "/settings", label: t("nav.settings"), icon: GearIcon },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/auth/me")
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error("Unauthorized");
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then((json) => setUser({ email: json.email }))
|
||||||
|
.catch(() => setUser(null))
|
||||||
|
.finally(() => setIsLoaded(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { user, isLoaded };
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { SignJWT, jwtVerify } from "jose";
|
||||||
|
import { compare, hash } from "bcryptjs";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { cache } from "react";
|
||||||
|
|
||||||
|
const COOKIE_NAME = "inneros-auth";
|
||||||
|
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "dev-secret-change-me");
|
||||||
|
const EXPIRY = "7d";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Password helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
return hash(password, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(
|
||||||
|
password: string,
|
||||||
|
hashed: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
return compare(password, hashed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// JWT helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function signToken(userId: string): Promise<string> {
|
||||||
|
return new SignJWT({ sub: userId })
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime(EXPIRY)
|
||||||
|
.sign(JWT_SECRET);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyToken(token: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, JWT_SECRET);
|
||||||
|
return (payload.sub as string) || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Session helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function setAuthCookie(userId: string): Promise<void> {
|
||||||
|
const token = await signToken(userId);
|
||||||
|
const jar = await cookies();
|
||||||
|
jar.set(COOKIE_NAME, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearAuthCookie(): Promise<void> {
|
||||||
|
const jar = await cookies();
|
||||||
|
jar.delete(COOKIE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// getSession — server-side auth check (cached per request)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const getSession = cache(async (): Promise<{ userId: string } | null> => {
|
||||||
|
const jar = await cookies();
|
||||||
|
const token = jar.get(COOKIE_NAME)?.value;
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const userId = await verifyToken(token);
|
||||||
|
if (!userId) return null;
|
||||||
|
|
||||||
|
return { userId };
|
||||||
|
});
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import OpenAI from "openai";
|
import OpenAI from "openai";
|
||||||
import { keywordCheck, llmSafetyCheck, type SafetyResult } from "@/lib/safety";
|
import { keywordCheck, llmSafetyCheck, type SafetyResult } from "@/lib/safety";
|
||||||
|
import type { Lang } from "@/lib/i18n/types";
|
||||||
|
import { createServerT } from "@/lib/i18n/utils";
|
||||||
|
import zh from "../../../dictionaries/zh";
|
||||||
|
import en from "../../../dictionaries/en";
|
||||||
|
|
||||||
|
const DICTS = { zh, en } as const;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -83,15 +89,18 @@ Be generous but honest. These scores guide their development path.`;
|
|||||||
|
|
||||||
export async function analyzeReflection(
|
export async function analyzeReflection(
|
||||||
input: ReflectionInput,
|
input: ReflectionInput,
|
||||||
openai: OpenAI
|
openai: OpenAI,
|
||||||
|
locale: Lang = "zh"
|
||||||
): Promise<ReflectionOutput> {
|
): Promise<ReflectionOutput> {
|
||||||
|
const t = createServerT(DICTS[locale]);
|
||||||
|
|
||||||
// Safety check on user input
|
// Safety check on user input
|
||||||
const keywordResult = keywordCheck(input.notes);
|
const keywordResult = keywordCheck(input.notes);
|
||||||
if (!keywordResult.safe) {
|
if (!keywordResult.safe) {
|
||||||
return {
|
return {
|
||||||
focusScore: 3,
|
focusScore: 3,
|
||||||
emotionScore: 3,
|
emotionScore: 3,
|
||||||
insight: "Thank you for sharing.",
|
insight: t("engine.reflection.keyword_blocked"),
|
||||||
safety: keywordResult,
|
safety: keywordResult,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -143,7 +152,7 @@ export async function analyzeReflection(
|
|||||||
const insight =
|
const insight =
|
||||||
typeof parsed.insight === "string" && parsed.insight.length > 0
|
typeof parsed.insight === "string" && parsed.insight.length > 0
|
||||||
? parsed.insight
|
? parsed.insight
|
||||||
: "You completed your practice today.";
|
: t("engine.reflection.no_reflection");
|
||||||
|
|
||||||
// LLM safety check on generated insight
|
// LLM safety check on generated insight
|
||||||
const llmSafety = await llmSafetyCheck(insight, openai as any);
|
const llmSafety = await llmSafetyCheck(insight, openai as any);
|
||||||
@@ -151,7 +160,7 @@ export async function analyzeReflection(
|
|||||||
return {
|
return {
|
||||||
focusScore,
|
focusScore,
|
||||||
emotionScore,
|
emotionScore,
|
||||||
insight: llmSafety.safe ? insight : "You completed your practice today.",
|
insight: llmSafety.safe ? insight : t("engine.reflection.safety_blocked"),
|
||||||
safety: llmSafety,
|
safety: llmSafety,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
@@ -159,7 +168,7 @@ export async function analyzeReflection(
|
|||||||
return {
|
return {
|
||||||
focusScore: 3,
|
focusScore: 3,
|
||||||
emotionScore: 3,
|
emotionScore: 3,
|
||||||
insight: "You showed up today. That's what matters.",
|
insight: t("engine.reflection.llm_failed"),
|
||||||
safety: { safe: true },
|
safety: { safe: true },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -186,9 +195,10 @@ export async function processReflection(
|
|||||||
emotionStability: number;
|
emotionStability: number;
|
||||||
stressLevel: number;
|
stressLevel: number;
|
||||||
},
|
},
|
||||||
openai: OpenAI
|
openai: OpenAI,
|
||||||
|
locale: Lang = "zh"
|
||||||
): Promise<ProcessedReflection> {
|
): Promise<ProcessedReflection> {
|
||||||
const analysis = await analyzeReflection(input, openai);
|
const analysis = await analyzeReflection(input, openai, locale);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
focusScore: analysis.focusScore,
|
focusScore: analysis.focusScore,
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import OpenAI from "openai";
|
import OpenAI from "openai";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { keywordCheck, llmSafetyCheck, type SafetyResult } from "@/lib/safety";
|
import { keywordCheck, llmSafetyCheck, type SafetyResult } from "@/lib/safety";
|
||||||
|
import type { Lang } from "@/lib/i18n/types";
|
||||||
|
import { createServerT } from "@/lib/i18n/utils";
|
||||||
|
import zh from "../../../dictionaries/zh";
|
||||||
|
import en from "../../../dictionaries/en";
|
||||||
|
|
||||||
|
const DICTS = { zh, en } as const;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -56,8 +62,11 @@ Voice rules:
|
|||||||
|
|
||||||
export async function generateWeeklySummary(
|
export async function generateWeeklySummary(
|
||||||
userId: string,
|
userId: string,
|
||||||
openai: OpenAI
|
openai: OpenAI,
|
||||||
|
locale: Lang = "zh"
|
||||||
): Promise<GeneratedSummary> {
|
): Promise<GeneratedSummary> {
|
||||||
|
const t = createServerT(DICTS[locale]);
|
||||||
|
|
||||||
// Gather week data
|
// Gather week data
|
||||||
const weekAgo = new Date();
|
const weekAgo = new Date();
|
||||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||||
@@ -85,8 +94,7 @@ export async function generateWeeklySummary(
|
|||||||
|
|
||||||
if (!userState || sessions.length === 0) {
|
if (!userState || sessions.length === 0) {
|
||||||
return {
|
return {
|
||||||
summary:
|
summary: t("engine.summary.no_sessions"),
|
||||||
"No completed practice sessions this week. Every week is a fresh start — your practice is here when you're ready.",
|
|
||||||
safety: { safe: true },
|
safety: { safe: true },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -124,7 +132,7 @@ export async function generateWeeklySummary(
|
|||||||
// Safety check
|
// Safety check
|
||||||
const keywordResult = keywordCheck(summary);
|
const keywordResult = keywordCheck(summary);
|
||||||
if (!keywordResult.safe) {
|
if (!keywordResult.safe) {
|
||||||
return { summary: "Your practice summary for this week.", safety: keywordResult };
|
return { summary: t("engine.summary.safety_fallback"), safety: keywordResult };
|
||||||
}
|
}
|
||||||
|
|
||||||
const llmSafety = await llmSafetyCheck(summary, openai as any);
|
const llmSafety = await llmSafetyCheck(summary, openai as any);
|
||||||
@@ -133,25 +141,34 @@ export async function generateWeeklySummary(
|
|||||||
const weekStart = new Date(weekAgo);
|
const weekStart = new Date(weekAgo);
|
||||||
weekStart.setHours(0, 0, 0, 0);
|
weekStart.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const safetyFallback = t("engine.summary.safety_fallback");
|
||||||
|
|
||||||
await prisma.weeklySummary.upsert({
|
await prisma.weeklySummary.upsert({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
create: {
|
create: {
|
||||||
userId,
|
userId,
|
||||||
summary: llmSafety.safe ? summary : "Your practice summary for this week.",
|
summary: llmSafety.safe ? summary : safetyFallback,
|
||||||
weekStart,
|
weekStart,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
summary: llmSafety.safe ? summary : "Your practice summary for this week.",
|
summary: llmSafety.safe ? summary : safetyFallback,
|
||||||
weekStart,
|
weekStart,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
summary: llmSafety.safe ? summary : "Your practice summary for this week.",
|
summary: llmSafety.safe ? summary : safetyFallback,
|
||||||
safety: llmSafety,
|
safety: llmSafety,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
const fallback = `You completed ${sessions.length} practice session${sessions.length === 1 ? "" : "s"} this week. Showing up consistently is the foundation everything else is built on. Your current streak is ${userState.streakDays} day${userState.streakDays === 1 ? "" : "s"}. See you next week.`;
|
const count = sessions.length;
|
||||||
|
const streak = userState.streakDays;
|
||||||
|
const fallback = t("engine.summary.fallback_template", {
|
||||||
|
count,
|
||||||
|
plural: count === 1 ? "" : "s",
|
||||||
|
streak,
|
||||||
|
streak_plural: streak === 1 ? "" : "s",
|
||||||
|
});
|
||||||
return { summary: fallback, safety: { safe: true } };
|
return { summary: fallback, safety: { safe: true } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,7 +178,8 @@ export async function generateWeeklySummary(
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function generateAllWeeklySummaries(
|
export async function generateAllWeeklySummaries(
|
||||||
openai: OpenAI
|
openai: OpenAI,
|
||||||
|
locale: Lang = "zh"
|
||||||
): Promise<{ userId: string; success: boolean }[]> {
|
): Promise<{ userId: string; success: boolean }[]> {
|
||||||
// Find users who had sessions in the last 7 days
|
// Find users who had sessions in the last 7 days
|
||||||
const weekAgo = new Date();
|
const weekAgo = new Date();
|
||||||
@@ -180,7 +198,7 @@ export async function generateAllWeeklySummaries(
|
|||||||
|
|
||||||
for (const { userId } of activeUsers) {
|
for (const { userId } of activeUsers) {
|
||||||
try {
|
try {
|
||||||
await generateWeeklySummary(userId, openai);
|
await generateWeeklySummary(userId, openai, locale);
|
||||||
results.push({ userId, success: true });
|
results.push({ userId, success: true });
|
||||||
} catch {
|
} catch {
|
||||||
results.push({ userId, success: false });
|
results.push({ userId, success: false });
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import OpenAI from "openai";
|
import OpenAI from "openai";
|
||||||
|
import { readFile } from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { keywordCheck, llmSafetyCheck, type SafetyResult } from "@/lib/safety";
|
import { keywordCheck, llmSafetyCheck, type SafetyResult } from "@/lib/safety";
|
||||||
import type { TaskType } from "@/generated/prisma/client";
|
import type { TaskType } from "@/generated/prisma/client";
|
||||||
@@ -43,8 +45,8 @@ let _templates: TaskTemplate[] | null = null;
|
|||||||
|
|
||||||
async function loadTemplates(): Promise<TaskTemplate[]> {
|
async function loadTemplates(): Promise<TaskTemplate[]> {
|
||||||
if (_templates) return _templates;
|
if (_templates) return _templates;
|
||||||
// Templates are bundled at build time from data/task-templates.json
|
const templatesPath = path.resolve(process.cwd(), "data", "task-templates.json");
|
||||||
_templates = (await import("@/../data/task-templates.json")).default as TaskTemplate[];
|
_templates = JSON.parse(await readFile(templatesPath, "utf-8")) as TaskTemplate[];
|
||||||
return _templates!;
|
return _templates!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +234,8 @@ export async function generateDailyTask(
|
|||||||
safety: SafetyResult;
|
safety: SafetyResult;
|
||||||
fromTemplate: boolean;
|
fromTemplate: boolean;
|
||||||
}> {
|
}> {
|
||||||
|
// User record is created at registration — no upsert needed
|
||||||
|
|
||||||
// Gather user state
|
// Gather user state
|
||||||
const [userState, recentSessions, recentTasks] = await Promise.all([
|
const [userState, recentSessions, recentTasks] = await Promise.all([
|
||||||
prisma.userState.findUnique({ where: { userId } }),
|
prisma.userState.findUnique({ where: { userId } }),
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import type { Lang, Dict, I18nContextValue } from "./types";
|
||||||
|
import { getStoredLang, persistLang, getDictValue, interpolate } from "./utils";
|
||||||
|
import zh from "../../../dictionaries/zh";
|
||||||
|
import en from "../../../dictionaries/en";
|
||||||
|
|
||||||
|
const DICTS: Record<Lang, Dict> = { zh, en };
|
||||||
|
|
||||||
|
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||||
|
|
||||||
|
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [lang, setLangState] = useState<Lang>("zh");
|
||||||
|
|
||||||
|
// Hydrate from localStorage on mount (avoids SSR mismatch)
|
||||||
|
useEffect(() => {
|
||||||
|
setLangState(getStoredLang());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Sync HTML lang attribute on language change (for CSS [lang] selectors)
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
}, [lang]);
|
||||||
|
|
||||||
|
const setLang = useCallback((next: Lang) => {
|
||||||
|
persistLang(next);
|
||||||
|
setLangState(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const t = useCallback(
|
||||||
|
(key: string, vars?: Record<string, string | number>): string => {
|
||||||
|
const dict = DICTS[lang];
|
||||||
|
return interpolate(getDictValue(dict, key), vars);
|
||||||
|
},
|
||||||
|
[lang]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<I18nContext.Provider value={{ lang, t, setLang }}>
|
||||||
|
{children}
|
||||||
|
</I18nContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useI18n(): I18nContextValue {
|
||||||
|
const ctx = useContext(I18nContext);
|
||||||
|
if (!ctx) throw new Error("useI18n must be used within I18nProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export type Lang = "zh" | "en";
|
||||||
|
|
||||||
|
export interface Dict {
|
||||||
|
[key: string]: string | Dict;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TFunc = (key: string, vars?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
export type Formatter = Record<string, (n: number) => string>;
|
||||||
|
|
||||||
|
export interface I18nContextValue {
|
||||||
|
lang: Lang;
|
||||||
|
t: TFunc;
|
||||||
|
setLang: (lang: Lang) => void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import type { Dict, TFunc, Lang } from "./types";
|
||||||
|
|
||||||
|
const LANG_STORAGE_KEY = "inneros-lang";
|
||||||
|
|
||||||
|
/** Read language preference from localStorage (client only). Falls back to "zh". */
|
||||||
|
export function getStoredLang(): Lang {
|
||||||
|
if (typeof window === "undefined") return "zh";
|
||||||
|
const stored = localStorage.getItem(LANG_STORAGE_KEY);
|
||||||
|
if (stored === "zh" || stored === "en") return stored;
|
||||||
|
return "zh";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist language to both localStorage and cookie. */
|
||||||
|
export function persistLang(lang: Lang): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
localStorage.setItem(LANG_STORAGE_KEY, lang);
|
||||||
|
// Set non-HTTP-only cookie so layout can read it for <html lang>
|
||||||
|
document.cookie = `inneros-lang=${lang};path=/;max-age=${365 * 24 * 60 * 60};SameSite=Lax`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a dotted key path to a value in the dictionary.
|
||||||
|
* Returns the key itself if not found.
|
||||||
|
*/
|
||||||
|
export function getDictValue(dict: Dict, key: string): string {
|
||||||
|
const parts = key.split(".");
|
||||||
|
let current: string | Dict = dict;
|
||||||
|
for (const part of parts) {
|
||||||
|
if (typeof current !== "object" || current === null) return key;
|
||||||
|
current = current[part];
|
||||||
|
}
|
||||||
|
return typeof current === "string" ? current : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace {variable} placeholders in a template string.
|
||||||
|
* Example: interpolate("Hello, {name}", { name: "World" }) → "Hello, World"
|
||||||
|
*/
|
||||||
|
export function interpolate(
|
||||||
|
template: string,
|
||||||
|
vars?: Record<string, string | number>
|
||||||
|
): string {
|
||||||
|
if (!vars) return template;
|
||||||
|
return template.replace(/\{(\w+)\}/g, (_, key) => String(vars[key] ?? `{${key}}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a server-side t() function from a dictionary.
|
||||||
|
* Useful in API routes and engine files where React context is unavailable.
|
||||||
|
*/
|
||||||
|
export function createServerT(dict: Dict): TFunc {
|
||||||
|
return (key, vars) => interpolate(getDictValue(dict, key), vars);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared formatters — consolidated from duplicated page-level functions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const TASK_TYPE_KEYS: Record<string, string> = {
|
||||||
|
attention_training: "shared.task_type.attention",
|
||||||
|
emotion_awareness: "shared.task_type.emotion",
|
||||||
|
stress_regulation: "shared.task_type.stress",
|
||||||
|
cognitive_clarity: "shared.task_type.clarity",
|
||||||
|
integration: "shared.task_type.integration",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatTaskType(type: string, t: TFunc): string {
|
||||||
|
const key = TASK_TYPE_KEYS[type];
|
||||||
|
return key ? t(key) : type;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIFFICULTY_KEYS: Record<number, string> = {
|
||||||
|
1: "shared.difficulty.beginner",
|
||||||
|
2: "shared.difficulty.intermediate",
|
||||||
|
3: "shared.difficulty.advanced",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatDifficulty(difficulty: number, t: TFunc): string {
|
||||||
|
const key = DIFFICULTY_KEYS[difficulty];
|
||||||
|
return key ? t(key) : String(difficulty);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGE_KEYS: Record<number, string> = {
|
||||||
|
0: "shared.stage.beginner",
|
||||||
|
1: "shared.stage.foundation",
|
||||||
|
2: "shared.stage.growing",
|
||||||
|
3: "shared.stage.established",
|
||||||
|
4: "shared.stage.advanced",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatStage(stage: number, t: TFunc): string {
|
||||||
|
return t(STAGE_KEYS[stage] ?? "shared.stage.beginner");
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGE_COLORS: Record<number, string> = {
|
||||||
|
0: "text-muted bg-stone",
|
||||||
|
1: "text-amber bg-amber/10",
|
||||||
|
2: "text-sage bg-sage/10",
|
||||||
|
3: "text-info bg-info/10",
|
||||||
|
4: "text-ink bg-amber/15",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getStageColor(stage: number): string {
|
||||||
|
return STAGE_COLORS[stage] ?? STAGE_COLORS[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const REWARD_TYPE_KEYS: Record<string, string> = {
|
||||||
|
daily_completion: "shared.reward_type.daily_completion",
|
||||||
|
streak_3: "shared.reward_type.streak_3",
|
||||||
|
streak_7: "shared.reward_type.streak_7",
|
||||||
|
streak_14: "shared.reward_type.streak_14",
|
||||||
|
streak_30: "shared.reward_type.streak_30",
|
||||||
|
stage_up: "shared.reward_type.stage_up",
|
||||||
|
first_session: "shared.reward_type.first_session",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatRewardType(type: string, t: TFunc): string {
|
||||||
|
const key = REWARD_TYPE_KEYS[type];
|
||||||
|
return key ? t(key) : type;
|
||||||
|
}
|
||||||
+8
-6
@@ -1,16 +1,18 @@
|
|||||||
import { PrismaClient } from "@/generated/prisma/client";
|
import { PrismaClient } from "@/generated/prisma/client";
|
||||||
import { PrismaPg } from "@prisma/adapter-pg";
|
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
const globalForPrisma = globalThis as unknown as {
|
const globalForPrisma = globalThis as unknown as {
|
||||||
prisma: PrismaClient | undefined;
|
prisma: PrismaClient | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createPrismaClient(): PrismaClient {
|
const createPrismaClient = () => {
|
||||||
const adapter = new PrismaPg({
|
// Prisma CLI resolves file:./dev.db relative to prisma.config.ts (project root)
|
||||||
connectionString: process.env.DATABASE_URL!,
|
const dbPath = path.resolve(process.cwd(), "dev.db");
|
||||||
|
return new PrismaClient({
|
||||||
|
adapter: new PrismaBetterSqlite3({ url: dbPath }),
|
||||||
});
|
});
|
||||||
return new PrismaClient({ adapter });
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
|
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
|
||||||
|
|
||||||
|
|||||||
+33
-10
@@ -1,20 +1,43 @@
|
|||||||
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { verifyToken } from "@/lib/auth";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
const isPublicRoute = createRouteMatcher([
|
const PUBLIC_PATHS = [
|
||||||
|
"/sign-in",
|
||||||
|
"/sign-up",
|
||||||
|
"/api/auth/register",
|
||||||
|
"/api/auth/login",
|
||||||
"/api/cron/weekly-summaries",
|
"/api/cron/weekly-summaries",
|
||||||
"/sign-in(.*)",
|
];
|
||||||
"/sign-up(.*)",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export default clerkMiddleware(async (auth, req) => {
|
function isPublic(pathname: string): boolean {
|
||||||
if (!isPublicRoute(req)) {
|
return PUBLIC_PATHS.some((p) => pathname.startsWith(p));
|
||||||
await auth.protect();
|
}
|
||||||
|
|
||||||
|
export default async function middleware(req: NextRequest) {
|
||||||
|
const { pathname } = req.nextUrl;
|
||||||
|
|
||||||
|
if (isPublic(pathname)) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = req.cookies.get("inneros-auth")?.value;
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.redirect(new URL("/sign-in", req.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = await verifyToken(token);
|
||||||
|
if (!userId) {
|
||||||
|
const res = NextResponse.redirect(new URL("/sign-in", req.url));
|
||||||
|
res.cookies.delete("inneros-auth");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
// Skip Next.js internals and static files
|
|
||||||
"/((?!_next/static|_next/image|favicon.ico).*)",
|
"/((?!_next/static|_next/image|favicon.ico).*)",
|
||||||
"/",
|
"/",
|
||||||
"/(api|trpc)(.*)",
|
"/(api|trpc)(.*)",
|
||||||
|
|||||||
Reference in New Issue
Block a user