Added registration and settings page

This commit is contained in:
Andreas Dahm committed 2026-04-23 09:04:12 +02:00
1 parent b34234caa1
commit 2b5f77f0b9
12 files changed
+899 -136

No files matched your search

+153 -112
View File
@@ -8,14 +8,17 @@
"name": "tobeplayed-frontend",
"version": "0.0.1",
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@mdi/font": "~7.4.47",
"@tailwindcss/vite": "~4.2.2",
"i18next": "~26.0.0",
"i18next-http-backend": "~3.0.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.73.1",
"react-i18next": "~17.0.0",
"react-router-dom": "~7.13.2"
"react-router-dom": "~7.13.2",
"zod": "^3.25.76"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.1",
@@ -35,20 +38,20 @@
}
},
"node_modules/@emnapi/core": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.0",
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -56,15 +59,24 @@
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
"integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@hookform/resolvers": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
"integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==",
"license": "MIT",
"peerDependencies": {
"react-hook-form": "^7.0.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -117,34 +129,36 @@
"license": "Apache-2.0"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@oxc-project/types": {
"version": "0.120.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz",
"integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==",
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
"integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz",
"integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
"cpu": [
"arm64"
],
@@ -158,9 +172,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz",
"integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
"cpu": [
"arm64"
],
@@ -174,9 +188,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz",
"integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
"integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
"cpu": [
"x64"
],
@@ -190,9 +204,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz",
"integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
"integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
"cpu": [
"x64"
],
@@ -206,9 +220,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz",
"integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
"integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
"cpu": [
"arm"
],
@@ -222,9 +236,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz",
"integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
"cpu": [
"arm64"
],
@@ -238,9 +252,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz",
"integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
"integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
"cpu": [
"arm64"
],
@@ -254,9 +268,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz",
"integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
"cpu": [
"ppc64"
],
@@ -270,9 +284,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz",
"integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
"cpu": [
"s390x"
],
@@ -286,9 +300,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz",
"integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
"cpu": [
"x64"
],
@@ -302,9 +316,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz",
"integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
"integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
"cpu": [
"x64"
],
@@ -318,9 +332,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz",
"integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
"cpu": [
"arm64"
],
@@ -334,25 +348,27 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz",
"integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
"integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^1.1.1"
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": ">=14.0.0"
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz",
"integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
"integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
"cpu": [
"arm64"
],
@@ -366,9 +382,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz",
"integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
"integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
"cpu": [
"x64"
],
@@ -800,12 +816,12 @@
}
},
"node_modules/cross-fetch": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz",
"integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.6.12"
"node-fetch": "^2.7.0"
}
},
"node_modules/detect-libc": {
@@ -939,12 +955,12 @@
}
},
"node_modules/i18next-http-backend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz",
"integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==",
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.5.tgz",
"integrity": "sha512-QaWHnsxieEDcqKe+vo/RFqpiIFRi/KBqlOSPcUlvinBaISCeiTRCbtrazHAjtHtsLC66oDsROAH8frWkQzfMMQ==",
"license": "MIT",
"dependencies": {
"cross-fetch": "4.0.0"
"cross-fetch": "4.1.0"
}
},
"node_modules/jiti": {
@@ -1278,9 +1294,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"funding": [
{
"type": "opencollective",
@@ -1333,6 +1349,22 @@
"react": "^19.2.4"
}
},
"node_modules/react-hook-form": {
"version": "7.73.1",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.73.1.tgz",
"integrity": "sha512-VAfVYOPcx3piiEVQy95vyFmBwbVUsP/AUIN+mpFG8h11yshDd444nn0VyfaGWSRnhOLVgiDu7HIuBtAIzxn9dA==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-i18next": {
"version": "17.0.0",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.0.tgz",
@@ -1399,13 +1431,13 @@
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz",
"integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
"integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.120.0",
"@rolldown/pluginutils": "1.0.0-rc.10"
"@oxc-project/types": "=0.127.0",
"@rolldown/pluginutils": "1.0.0-rc.17"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -1414,27 +1446,27 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.10",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.10",
"@rolldown/binding-darwin-x64": "1.0.0-rc.10",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.10",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.10",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.10",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.10",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10"
"@rolldown/binding-android-arm64": "1.0.0-rc.17",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
"@rolldown/binding-darwin-x64": "1.0.0-rc.17",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
}
},
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz",
"integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
"integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
"license": "MIT"
},
"node_modules/scheduler": {
@@ -1478,13 +1510,13 @@
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
@@ -1547,16 +1579,16 @@
}
},
"node_modules/vite": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz",
"integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==",
"version": "8.0.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.8",
"rolldown": "1.0.0-rc.10",
"tinyglobby": "^0.2.15"
"picomatch": "^4.0.4",
"postcss": "^8.5.10",
"rolldown": "1.0.0-rc.17",
"tinyglobby": "^0.2.16"
},
"bin": {
"vite": "bin/vite.js"
@@ -1573,7 +1605,7 @@
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0",
"esbuild": "^0.27.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
@@ -1647,6 +1679,15 @@
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+4 -1
View File
@@ -9,14 +9,17 @@
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@mdi/font": "~7.4.47",
"@tailwindcss/vite": "~4.2.2",
"i18next": "~26.0.0",
"i18next-http-backend": "~3.0.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.73.1",
"react-i18next": "~17.0.0",
"react-router-dom": "~7.13.2"
"react-router-dom": "~7.13.2",
"zod": "^3.25.76"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.1",
+49 -1
View File
@@ -1,4 +1,4 @@
{
{
"IMPRINT": {
"TITLE": "Impressum",
"LEGAL": "Angaben gemäß § 5 DDG",
@@ -9,9 +9,57 @@
"EMAIL": "Email",
"PASSWORD": "Passwort",
"SUBMIT": "Spiel betreten",
"NO_ACCOUNT": "Noch kein Konto?",
"ERROR_EMAIL": "Gib eine gültige Email-Adresse ein mit '@' und mit '.'.",
"ERROR_PASSWORD": "Das Passwort darf nicht leer sein."
},
"REGISTRATION": {
"TITLE": "Registrieren",
"EMAIL": "Email",
"PASSWORD": "Passwort",
"PASSWORD_CONFIRMATION": "Passwort bestätigen",
"INVITE_CODE": "Einladungscode",
"STEAM_ID": "Steam-ID",
"SUBMIT": "Konto erstellen",
"SUCCESS": "Konto erstellt. Weiterleitung zum Login …",
"HAVE_ACCOUNT": "Bereits ein Konto?",
"ERROR_GENERIC": "Registrierung fehlgeschlagen. Bitte erneut versuchen.",
"ERROR_EMAIL_IN_USE": "Diese Email-Adresse ist bereits registriert.",
"ERROR_INVALID_INVITE_CODE": "Der Einladungscode ist ungültig oder wurde bereits verwendet.",
"ERROR_STEAM_ID_IN_USE": "Diese Steam-ID ist bereits mit einem anderen Konto verknüpft."
},
"SETTINGS": {
"TITLE": "Einstellungen",
"LOADING": "Einstellungen werden geladen …",
"ACCOUNT": "Konto",
"PREFERENCES": "Einstellungen",
"STEAM": "Steam",
"EMAIL": "Email",
"ROLE": "Rolle",
"CREATED_DATE": "Mitglied seit",
"LAST_LOGIN": "Letzter Login",
"DARK_THEME": "Dunkles Design",
"LANGUAGE": "Sprache",
"LANGUAGE_OPTIONS": {
"EN": "Englisch",
"DE": "Deutsch"
},
"STEAM_ID": "Steam-ID",
"SAVE_STEAM_ID": "Steam-ID speichern",
"SAVED": "Einstellungen gespeichert.",
"ERROR_SAVE": "Einstellungen konnten nicht gespeichert werden."
},
"VALIDATION": {
"EMAIL_REQUIRED": "Email ist erforderlich.",
"EMAIL_INVALID": "Gib eine gültige Email-Adresse ein.",
"PASSWORD_TOO_SHORT": "Passwort muss mindestens 8 Zeichen haben.",
"PASSWORD_CONFIRMATION_REQUIRED": "Bitte Passwort bestätigen.",
"PASSWORD_MISMATCH": "Die Passwörter stimmen nicht überein.",
"INVITE_CODE_REQUIRED": "Einladungscode ist erforderlich.",
"STEAM_ID_REQUIRED": "Steam-ID ist erforderlich.",
"STEAM_ID_INVALID": "Steam-ID muss eine positive Zahl sein.",
"LANGUAGE_INVALID": "Nicht unterstützte Sprache."
},
"INFO": {
"ERROR": {
"LOAD_PROFILE": "Fehler beim Laden der Profildaten.",
+49 -1
View File
@@ -1,4 +1,4 @@
{
{
"IMPRINT": {
"TITLE": "Imprint",
"LEGAL": "Information according to § 5 TMG.",
@@ -9,9 +9,57 @@
"EMAIL": "Email",
"PASSWORD": "Password",
"SUBMIT": "Enter the game",
"NO_ACCOUNT": "No account yet?",
"ERROR_EMAIL": "Enter a valid email address with '@' and with '.'.",
"ERROR_PASSWORD": "The password must have at least one character."
},
"REGISTRATION": {
"TITLE": "Register",
"EMAIL": "Email",
"PASSWORD": "Password",
"PASSWORD_CONFIRMATION": "Confirm password",
"INVITE_CODE": "Invite code",
"STEAM_ID": "Steam ID",
"SUBMIT": "Create account",
"SUCCESS": "Account created. Redirecting to login …",
"HAVE_ACCOUNT": "Already have an account?",
"ERROR_GENERIC": "Registration failed. Please try again.",
"ERROR_EMAIL_IN_USE": "This email address is already registered.",
"ERROR_INVALID_INVITE_CODE": "The invite code is invalid or has already been used.",
"ERROR_STEAM_ID_IN_USE": "This Steam ID is already linked to another account."
},
"SETTINGS": {
"TITLE": "Settings",
"LOADING": "Loading settings …",
"ACCOUNT": "Account",
"PREFERENCES": "Preferences",
"STEAM": "Steam",
"EMAIL": "Email",
"ROLE": "Role",
"CREATED_DATE": "Member since",
"LAST_LOGIN": "Last login",
"DARK_THEME": "Dark theme",
"LANGUAGE": "Language",
"LANGUAGE_OPTIONS": {
"EN": "English",
"DE": "German"
},
"STEAM_ID": "Steam ID",
"SAVE_STEAM_ID": "Save Steam ID",
"SAVED": "Settings saved.",
"ERROR_SAVE": "Could not save settings."
},
"VALIDATION": {
"EMAIL_REQUIRED": "Email is required.",
"EMAIL_INVALID": "Enter a valid email address.",
"PASSWORD_TOO_SHORT": "Password must be at least 8 characters.",
"PASSWORD_CONFIRMATION_REQUIRED": "Please confirm your password.",
"PASSWORD_MISMATCH": "Passwords do not match.",
"INVITE_CODE_REQUIRED": "Invite code is required.",
"STEAM_ID_REQUIRED": "Steam ID is required.",
"STEAM_ID_INVALID": "Steam ID must be a positive number.",
"LANGUAGE_INVALID": "Unsupported language."
},
"INFO": {
"ERROR": {
"LOAD_PROFILE": "Error during loading of the user profile data.",
+13 -16
View File
@@ -6,10 +6,13 @@ import LandingPage from './pages/LandingPage';
import LoginPage from './pages/LoginPage';
import ImprintPage from './pages/ImprintPage';
import Dashboard from "./pages/Dashboard.jsx";
import RegistrationPage from './pages/RegistrationPage.jsx';
import SettingsPage from './pages/SettingsPage.jsx';
import {usePreferences} from './context/PreferencesContext.jsx';
function App() {
const {t, i18n} = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(true);
const {isDarkMode, setDarkMode, setLanguage} = usePreferences();
const [isAuthenticated, setIsAuthenticated] = useState(false);
// Hooks for routing
@@ -21,21 +24,8 @@ function App() {
setIsAuthenticated(!!token); // Converts the token string (or null) to a boolean
}, [location]);
// Sync React state with the HTML class required by Tailwind CSS
useEffect(() => {
if (isDarkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [isDarkMode]);
const toggleLanguage = () => {
if (i18n.language === 'de') {
i18n.changeLanguage('en').then(_ => console.log("Changed language to 'en'"));
} else {
i18n.changeLanguage('de').then(_ => console.log("Changed language to 'de'"));
}
setLanguage(i18n.language === 'de' ? 'en' : 'de');
};
const handleLogout = () => {
@@ -56,14 +46,19 @@ function App() {
{i18n.language.toUpperCase()}
</button>
<button onClick={() => {
setIsDarkMode(!isDarkMode);
setDarkMode(!isDarkMode);
}} className="text-xl text-slate-600 dark:text-slate-300 hover:text-blue-500 transition-colors">
<i className={isDarkMode ? "mdi mdi-weather-sunny" : "mdi mdi-weather-night"}></i>
</button>
{isAuthenticated ? (
<>
<Link to="/settings" className="text-sm font-bold text-slate-600 dark:text-slate-300 hover:text-blue-500 transition-colors">
<i className="mdi mdi-cog text-xl"></i>
</Link>
<button onClick={handleLogout} className="bg-red-600 hover:bg-red-500 text-white px-5 py-2 rounded-lg font-bold transition-all shadow-md shadow-red-500/20">
{t('MAIN.LOGOUT')}
</button>
</>
) : (
<Link to="/login" className="bg-blue-600 hover:bg-blue-500 text-white px-5 py-2 rounded-lg font-bold transition-all shadow-md shadow-blue-500/20">
{t('MAIN.LOGIN')}
@@ -76,7 +71,9 @@ function App() {
<Routes>
<Route path="/" element={<LandingPage/>}/>
<Route path="/login" element={<LoginPage/>}/>
<Route path="/register" element={<RegistrationPage/>}/>
<Route path="/dashboard" element={<Dashboard/>}/>
<Route path="/settings" element={<SettingsPage/>}/>
<Route path="/impressum" element={<ImprintPage/>}/>
</Routes>
</main>
+47
View File
@@ -0,0 +1,47 @@
import {createContext, useContext, useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
const PreferencesContext = createContext(null);
export function PreferencesProvider({children}) {
const {i18n} = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(() => {
const stored = localStorage.getItem(DARK_MODE_STORAGE_KEY);
return stored === null ? true : stored === 'true';
});
useEffect(() => {
if (isDarkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
localStorage.setItem(DARK_MODE_STORAGE_KEY, String(isDarkMode));
}, [isDarkMode]);
const setLanguage = (lang) => i18n.changeLanguage(lang);
return (
<PreferencesContext.Provider
value={{
isDarkMode,
setDarkMode: setIsDarkMode,
language: i18n.language,
setLanguage,
}}
>
{children}
</PreferencesContext.Provider>
);
}
export function usePreferences() {
const ctx = useContext(PreferencesContext);
if (!ctx) {
throw new Error('usePreferences must be used inside <PreferencesProvider>');
}
return ctx;
}
+3
View File
@@ -2,6 +2,7 @@ import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import {BrowserRouter} from 'react-router-dom';
import App from './App.jsx';
import {PreferencesProvider} from './context/PreferencesContext.jsx';
import './i18n';
import './style.css';
import '@mdi/font/css/materialdesignicons.css';
@@ -9,7 +10,9 @@ import '@mdi/font/css/materialdesignicons.css';
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter>
<PreferencesProvider>
<App/>
</PreferencesProvider>
</BrowserRouter>
</StrictMode>
);
+10 -4
View File
@@ -1,5 +1,5 @@
import React, {useEffect, useState} from 'react';
import {useNavigate} from 'react-router-dom';
import {Link, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
const Dashboard = () => {
@@ -83,9 +83,15 @@ const Dashboard = () => {
<div className="bg-white dark:bg-slate-800 h-40 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 flex items-center justify-center">
<span className="text-slate-400 dark:text-slate-500">Tile 2 (Coming soon)</span>
</div>
<div className="bg-white dark:bg-slate-800 h-40 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 flex items-center justify-center">
<span className="text-slate-400 dark:text-slate-500">Settings (Coming soon)</span>
</div>
<Link
to="/settings"
className="bg-white dark:bg-slate-800 h-40 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 flex flex-col items-center justify-center hover:border-blue-500 dark:hover:border-blue-500 transition-colors"
>
<i className="mdi mdi-cog text-4xl text-slate-500 dark:text-slate-400 mb-2"></i>
<span className="text-slate-700 dark:text-slate-300 font-semibold">
{t('SETTINGS.TITLE')}
</span>
</Link>
</div>
</div>
+8 -1
View File
@@ -1,6 +1,6 @@
import {useTranslation} from 'react-i18next';
import {useState} from 'react';
import {useNavigate} from 'react-router-dom';
import {Link, useNavigate} from 'react-router-dom';
export default function LoginPage() {
const {t} = useTranslation();
@@ -131,6 +131,13 @@ export default function LoginPage() {
</div>
)}
<p className="mt-6 text-sm text-center text-slate-600 dark:text-slate-400">
{t('LOGIN.NO_ACCOUNT')}{' '}
<Link to="/register" className="text-blue-600 hover:text-blue-500 font-semibold">
{t('REGISTRATION.TITLE')}
</Link>
</p>
</div>
</div>
);
+185
View File
@@ -0,0 +1,185 @@
import {useState} from 'react';
import {Link, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod';
import {registrationSchema} from '../utils/validation.js';
const BACKEND_ERROR_KEY = {
EMAIL_ALREADY_IN_USE: 'REGISTRATION.ERROR_EMAIL_IN_USE',
INVALID_INVITATION_CODE: 'REGISTRATION.ERROR_INVALID_INVITE_CODE',
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
};
const inputClass =
'w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:outline-none';
const inputErrorClass =
'w-full px-4 py-2 border border-red-500 dark:border-red-500 rounded-lg bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-red-500 focus:outline-none';
export default function RegistrationPage() {
const {t} = useTranslation();
const navigate = useNavigate();
const [feedback, setFeedback] = useState({message: '', isError: false});
const {
register,
handleSubmit,
formState: {errors, isSubmitting},
} = useForm({
resolver: zodResolver(registrationSchema),
defaultValues: {
email: '',
password: '',
passwordConfirmation: '',
inviteCode: '',
steamId: '',
},
});
const onSubmit = async (values) => {
setFeedback({message: '', isError: false});
try {
const apiUrl = `${import.meta.env.VITE_API_URL}/api/registration/new`;
const response = await fetch(apiUrl, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
email: values.email,
password: values.password,
inviteCode: values.inviteCode,
steamId: values.steamId,
}),
});
if (response.ok) {
setFeedback({message: t('REGISTRATION.SUCCESS'), isError: false});
setTimeout(() => navigate('/login'), 800);
return;
}
const data = await response.json().catch(() => ({}));
const mappedKey = BACKEND_ERROR_KEY[data.code];
setFeedback({
message: mappedKey ? t(mappedKey) : data.message || t('REGISTRATION.ERROR_GENERIC'),
isError: true,
});
} catch (err) {
console.error('Registration API error:', err);
setFeedback({message: t('INFO.ERROR.NETWORK_FAILURE'), isError: true});
}
};
const renderError = (field) =>
errors[field] && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{t(errors[field].message)}</p>
);
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50 dark:bg-slate-900 px-4 py-8">
<div className="w-full max-w-md bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-6 text-center">
{t('REGISTRATION.TITLE')}
</h2>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div>
<label htmlFor="email" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{t('REGISTRATION.EMAIL')}
</label>
<input
id="email"
type="email"
autoComplete="email"
className={errors.email ? inputErrorClass : inputClass}
{...register('email')}
/>
{renderError('email')}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{t('REGISTRATION.PASSWORD')}
</label>
<input
id="password"
type="password"
autoComplete="new-password"
className={errors.password ? inputErrorClass : inputClass}
{...register('password')}
/>
{renderError('password')}
</div>
<div>
<label htmlFor="passwordConfirmation" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{t('REGISTRATION.PASSWORD_CONFIRMATION')}
</label>
<input
id="passwordConfirmation"
type="password"
autoComplete="new-password"
className={errors.passwordConfirmation ? inputErrorClass : inputClass}
{...register('passwordConfirmation')}
/>
{renderError('passwordConfirmation')}
</div>
<div>
<label htmlFor="inviteCode" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{t('REGISTRATION.INVITE_CODE')}
</label>
<input
id="inviteCode"
type="text"
className={errors.inviteCode ? inputErrorClass : inputClass}
{...register('inviteCode')}
/>
{renderError('inviteCode')}
</div>
<div>
<label htmlFor="steamId" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{t('REGISTRATION.STEAM_ID')}
</label>
<input
id="steamId"
type="text"
inputMode="numeric"
className={errors.steamId ? inputErrorClass : inputClass}
{...register('steamId')}
/>
{renderError('steamId')}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-semibold rounded-lg transition-colors duration-200"
>
{isSubmitting ? '...' : t('REGISTRATION.SUBMIT')}
</button>
</form>
{feedback.message && (
<div
className={`mt-6 p-4 rounded-lg text-sm text-center ${
feedback.isError
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
}`}
>
{feedback.message}
</div>
)}
<p className="mt-6 text-sm text-center text-slate-600 dark:text-slate-400">
{t('REGISTRATION.HAVE_ACCOUNT')}{' '}
<Link to="/login" className="text-blue-600 hover:text-blue-500 font-semibold">
{t('MAIN.LOGIN')}
</Link>
</p>
</div>
</div>
);
}
+334
View File
@@ -0,0 +1,334 @@
import {useEffect, useState} from 'react';
import {useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod';
import {settingsSchema} from '../utils/validation.js';
import {usePreferences} from '../context/PreferencesContext.jsx';
const BACKEND_ERROR_KEY = {
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
};
const SUPPORTED_LANGUAGES = ['en', 'de'];
const inputClass =
'w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:outline-none';
const inputErrorClass =
'w-full px-4 py-2 border border-red-500 dark:border-red-500 rounded-lg bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-red-500 focus:outline-none';
const readOnlyClass =
'w-full px-4 py-2 border border-slate-200 dark:border-slate-700 rounded-lg bg-slate-100 dark:bg-slate-900 text-slate-500 dark:text-slate-400 cursor-not-allowed';
const formatDate = (value, locale) => {
if (!value) return '—';
try {
return new Date(value).toLocaleString(locale);
} catch {
return value;
}
};
export default function SettingsPage() {
const {t, i18n} = useTranslation();
const navigate = useNavigate();
const {isDarkMode, setDarkMode, setLanguage} = usePreferences();
const [profile, setProfile] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState(null);
const [feedback, setFeedback] = useState({message: '', isError: false});
const {
register,
handleSubmit,
reset,
formState: {errors, isSubmitting},
} = useForm({
resolver: zodResolver(settingsSchema),
defaultValues: {useDarkTheme: true, language: 'en', steamId: ''},
});
useEffect(() => {
const token = localStorage.getItem('tbp_jwt');
if (!token) {
navigate('/login');
return;
}
const load = async () => {
try {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (response.status === 401) {
localStorage.removeItem('tbp_jwt');
navigate('/login');
return;
}
if (!response.ok) {
setLoadError(t('INFO.ERROR.LOAD_PROFILE'));
return;
}
const data = await response.json();
setProfile(data);
reset({
useDarkTheme: !!data.settings?.useDarkTheme,
language: data.settings?.language || 'en',
steamId: data.steamId ? String(data.steamId) : '',
});
} catch (err) {
console.error('Settings load error:', err);
setLoadError(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setIsLoading(false);
}
};
load();
}, [navigate, reset, t]);
const persistSettings = async (payload) => {
const token = localStorage.getItem('tbp_jwt');
if (!token) {
navigate('/login');
return {ok: false};
}
try {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/users/settings`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (response.status === 401) {
localStorage.removeItem('tbp_jwt');
navigate('/login');
return {ok: false};
}
if (!response.ok) {
const data = await response.json().catch(() => ({}));
const mappedKey = BACKEND_ERROR_KEY[data.code];
setFeedback({
message: mappedKey ? t(mappedKey) : data.message || t('SETTINGS.ERROR_SAVE'),
isError: true,
});
return {ok: false};
}
setFeedback({message: t('SETTINGS.SAVED'), isError: false});
return {ok: true};
} catch (err) {
console.error('Settings save error:', err);
setFeedback({message: t('INFO.ERROR.NETWORK_FAILURE'), isError: true});
return {ok: false};
}
};
const handleDarkThemeToggle = async () => {
const next = !isDarkMode;
setDarkMode(next); // apply on the fly
await persistSettings({
useDarkTheme: next,
language: i18n.language,
steamId: profile?.steamId ?? 0,
});
};
const handleLanguageChange = async (event) => {
const next = event.target.value;
setLanguage(next); // apply on the fly
await persistSettings({
useDarkTheme: isDarkMode,
language: next,
steamId: profile?.steamId ?? 0,
});
};
const onSubmitSteamId = async (values) => {
const result = await persistSettings({
useDarkTheme: isDarkMode,
language: i18n.language,
steamId: values.steamId,
});
if (result.ok) {
setProfile((p) => (p ? {...p, steamId: values.steamId} : p));
}
};
const renderError = (field) =>
errors[field] && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{t(errors[field].message)}</p>
);
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50 dark:bg-slate-900 text-slate-900 dark:text-white">
{t('SETTINGS.LOADING')}
</div>
);
}
if (loadError) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50 dark:bg-slate-900 text-red-500">
{loadError}
</div>
);
}
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 p-8">
<div className="max-w-2xl mx-auto space-y-8">
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 border border-slate-200 dark:border-slate-700">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white mb-6">
{t('SETTINGS.TITLE')}
</h1>
{/* Read-only account info */}
<section className="space-y-4 mb-8">
<h2 className="text-lg font-semibold text-slate-700 dark:text-slate-200">
{t('SETTINGS.ACCOUNT')}
</h2>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{t('SETTINGS.EMAIL')}
</label>
<input type="text" value={profile?.email ?? ''} readOnly className={readOnlyClass}/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{t('SETTINGS.ROLE')}
</label>
<input type="text" value={profile?.role ?? ''} readOnly className={readOnlyClass}/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{t('SETTINGS.CREATED_DATE')}
</label>
<input
type="text"
value={formatDate(profile?.createdDate, i18n.language)}
readOnly
className={readOnlyClass}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{t('SETTINGS.LAST_LOGIN')}
</label>
<input
type="text"
value={formatDate(profile?.lastLogin, i18n.language)}
readOnly
className={readOnlyClass}
/>
</div>
</section>
{/* Preferences — live */}
<section className="space-y-4 mb-8">
<h2 className="text-lg font-semibold text-slate-700 dark:text-slate-200">
{t('SETTINGS.PREFERENCES')}
</h2>
<div className="flex items-center justify-between">
<label htmlFor="useDarkTheme" className="text-sm font-medium text-slate-700 dark:text-slate-300">
{t('SETTINGS.DARK_THEME')}
</label>
<button
id="useDarkTheme"
type="button"
role="switch"
aria-checked={isDarkMode}
onClick={handleDarkThemeToggle}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
isDarkMode ? 'bg-blue-600' : 'bg-slate-300 dark:bg-slate-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
isDarkMode ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
<div>
<label htmlFor="language" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{t('SETTINGS.LANGUAGE')}
</label>
<select
id="language"
value={i18n.language}
onChange={handleLanguageChange}
className={inputClass}
>
{SUPPORTED_LANGUAGES.map((lang) => (
<option key={lang} value={lang}>
{t(`SETTINGS.LANGUAGE_OPTIONS.${lang.toUpperCase()}`)}
</option>
))}
</select>
</div>
</section>
{/* Steam ID — explicit save */}
<section>
<h2 className="text-lg font-semibold text-slate-700 dark:text-slate-200 mb-4">
{t('SETTINGS.STEAM')}
</h2>
<form onSubmit={handleSubmit(onSubmitSteamId)} className="space-y-4" noValidate>
<div>
<label htmlFor="steamId" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{t('SETTINGS.STEAM_ID')}
</label>
<input
id="steamId"
type="text"
inputMode="numeric"
className={errors.steamId ? inputErrorClass : inputClass}
{...register('steamId')}
/>
{renderError('steamId')}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-semibold rounded-lg transition-colors duration-200"
>
{isSubmitting ? '...' : t('SETTINGS.SAVE_STEAM_ID')}
</button>
</form>
</section>
{feedback.message && (
<div
className={`mt-6 p-4 rounded-lg text-sm text-center ${
feedback.isError
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
}`}
>
{feedback.message}
</div>
)}
</div>
</div>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import {z} from 'zod';
// Error messages are i18n keys — translate them in components via t(error.message)
const emailField = z
.string()
.min(1, 'VALIDATION.EMAIL_REQUIRED')
.email('VALIDATION.EMAIL_INVALID');
const passwordField = z
.string()
.min(8, 'VALIDATION.PASSWORD_TOO_SHORT');
const inviteCodeField = z
.string()
.min(1, 'VALIDATION.INVITE_CODE_REQUIRED');
// Backend stores steamId as a primitive long; Steam IDs are positive 64-bit integers.
const steamIdField = z
.union([z.string(), z.number()])
.transform((v) => (typeof v === 'string' ? v.trim() : String(v)))
.refine((v) => v.length > 0, {message: 'VALIDATION.STEAM_ID_REQUIRED'})
.refine((v) => /^\d+$/.test(v), {message: 'VALIDATION.STEAM_ID_INVALID'})
.transform((v) => Number(v))
.refine((n) => Number.isFinite(n) && n > 0, {message: 'VALIDATION.STEAM_ID_INVALID'});
export const registrationSchema = z
.object({
email: emailField,
password: passwordField,
passwordConfirmation: z.string().min(1, 'VALIDATION.PASSWORD_CONFIRMATION_REQUIRED'),
inviteCode: inviteCodeField,
steamId: steamIdField,
})
.refine((d) => d.password === d.passwordConfirmation, {
path: ['passwordConfirmation'],
message: 'VALIDATION.PASSWORD_MISMATCH',
});
export const settingsSchema = z.object({
useDarkTheme: z.boolean(),
language: z.enum(['en', 'de'], {message: 'VALIDATION.LANGUAGE_INVALID'}),
steamId: steamIdField,
});