Public Access
Merge pull request 'feature/add-settings-and-registration' (#3) from feature/add-settings-and-registration into main
Reviewed-on: https://codeberg.org/LoboTheDark/ToBePlayed-Frontend/pulls/3
This commit is contained in:
commit
950f7d2b80
18 files changed
+1373
-144
No files matched your search
@@ -1,2 +1,3 @@
|
||||
# For local development (npm run dev)
|
||||
VITE_API_URL=http://localhost:8080
|
||||
VITE_CORE_API_URL=http://localhost:8090
|
||||
@@ -1,3 +1,4 @@
|
||||
# For the docker image on the server
|
||||
# Uses the variable AUTH_HOST from your docker structure [cite: 3]
|
||||
VITE_API_URL=https://auth.tbp.andreas-dahm.eu
|
||||
VITE_CORE_API_URL=https://api.tbp.andreas-dahm.eu
|
||||
Generated
+153
-112
@@ -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
@@ -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",
|
||||
|
||||
+66
-2
@@ -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.",
|
||||
@@ -20,7 +68,23 @@
|
||||
},
|
||||
"DASHBOARD": {
|
||||
"LOADING": "Lade Dashboard ...",
|
||||
"HELLO": "Hallo"
|
||||
"HELLO": "Hallo",
|
||||
"STEAM_GAMES": "Steam Spiele"
|
||||
},
|
||||
"LIBRARY": {
|
||||
"TITLE": "Spielebibliothek",
|
||||
"LOADING": "Lade Spiele …",
|
||||
"EMPTY": "Noch keine Spiele. Synchronisiere deine Steam-Bibliothek.",
|
||||
"ERROR_LOAD": "Spiele konnten nicht geladen werden.",
|
||||
"PER_PAGE": "Pro Seite:",
|
||||
"PREV": "Zurück",
|
||||
"NEXT": "Weiter",
|
||||
"PAGE_OF": "Seite {{current}} von {{total}}",
|
||||
"GAMES": "Spiele",
|
||||
"SYNCING": "Synchronisiere mit Steam …",
|
||||
"SYNCING_FIRST_TIME": "Erste Synchronisation mit Steam läuft. Deine Spiele erscheinen gleich.",
|
||||
"SYNC_FAILED": "Synchronisation fehlgeschlagen. Bitte später erneut versuchen.",
|
||||
"LAST_SYNCED": "Zuletzt synchronisiert {{when}}"
|
||||
},
|
||||
"MAIN": {
|
||||
"WELCOME": "Dein Game-Backlog unter Kontrolle",
|
||||
|
||||
+66
-2
@@ -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.",
|
||||
@@ -20,7 +68,23 @@
|
||||
},
|
||||
"DASHBOARD": {
|
||||
"LOADING": "Loading dashboard ...",
|
||||
"HELLO": "Hello"
|
||||
"HELLO": "Hello",
|
||||
"STEAM_GAMES": "Steam Games"
|
||||
},
|
||||
"LIBRARY": {
|
||||
"TITLE": "Game Library",
|
||||
"LOADING": "Loading games …",
|
||||
"EMPTY": "No games yet. Try syncing your Steam library.",
|
||||
"ERROR_LOAD": "Could not load your games.",
|
||||
"PER_PAGE": "Per page:",
|
||||
"PREV": "Previous",
|
||||
"NEXT": "Next",
|
||||
"PAGE_OF": "Page {{current}} of {{total}}",
|
||||
"GAMES": "games",
|
||||
"SYNCING": "Syncing with Steam …",
|
||||
"SYNCING_FIRST_TIME": "First sync with Steam in progress. Your games will appear here shortly.",
|
||||
"SYNC_FAILED": "Sync failed. Please try again later.",
|
||||
"LAST_SYNCED": "Last synced {{when}}"
|
||||
},
|
||||
"MAIN": {
|
||||
"WELCOME": "Control your game backlog",
|
||||
|
||||
+20
-16
@@ -6,10 +6,14 @@ 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 GameLibrary from './pages/GameLibrary.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
|
||||
@@ -17,25 +21,17 @@ function App() {
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
// Dev mode: inject dummy token if none exists
|
||||
if (import.meta.env.MODE === 'development' && !localStorage.getItem('tbp_jwt')) {
|
||||
localStorage.setItem('tbp_jwt', 'dev-dummy-token');
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('tbp_jwt');
|
||||
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 +52,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 +77,10 @@ function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage/>}/>
|
||||
<Route path="/login" element={<LoginPage/>}/>
|
||||
<Route path="/register" element={<RegistrationPage/>}/>
|
||||
<Route path="/dashboard" element={<Dashboard/>}/>
|
||||
<Route path="/library" element={<GameLibrary/>}/>
|
||||
<Route path="/settings" element={<SettingsPage/>}/>
|
||||
<Route path="/impressum" element={<ImprintPage/>}/>
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import React, {useState, useEffect} from 'react';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
const SteamGameCount = ({targetCount}) => {
|
||||
const {t} = useTranslation();
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let startTime;
|
||||
const duration = 1000;
|
||||
const startCount = 0;
|
||||
|
||||
const animate = (timestamp) => {
|
||||
if (!startTime) startTime = timestamp;
|
||||
const progress = Math.min((timestamp - startTime) / duration, 1);
|
||||
|
||||
const currentCount = Math.floor(progress * (targetCount - startCount) + startCount);
|
||||
setCount(currentCount);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}, [targetCount]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/library"
|
||||
className="flex flex-col items-center justify-center p-6 bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 hover:border-blue-500 dark:hover:border-blue-500 transition-colors"
|
||||
>
|
||||
<div className="text-4xl font-bold text-blue-600 dark:text-blue-400 mb-2">
|
||||
{count}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
{t('DASHBOARD.STEAM_GAMES')}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default SteamGameCount;
|
||||
@@ -0,0 +1,45 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
const formatRelative = (iso, locale) => {
|
||||
if (!iso) return null;
|
||||
const then = new Date(iso).getTime();
|
||||
const diffSec = Math.round((Date.now() - then) / 1000);
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, {numeric: 'auto'});
|
||||
if (diffSec < 60) return rtf.format(-diffSec, 'second');
|
||||
if (diffSec < 3600) return rtf.format(-Math.round(diffSec / 60), 'minute');
|
||||
if (diffSec < 86400) return rtf.format(-Math.round(diffSec / 3600), 'hour');
|
||||
return rtf.format(-Math.round(diffSec / 86400), 'day');
|
||||
};
|
||||
|
||||
export default function SyncStatusBadge({status, lastSyncedAt, isRunning}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
|
||||
if (isRunning) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400">
|
||||
<i className="mdi mdi-sync mdi-spin"></i>
|
||||
<span>{t('LIBRARY.SYNCING')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'FAILED') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
|
||||
<i className="mdi mdi-alert-circle"></i>
|
||||
<span>{t('LIBRARY.SYNC_FAILED')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (lastSyncedAt) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
<i className="mdi mdi-check-circle"></i>
|
||||
<span>{t('LIBRARY.LAST_SYNCED', {when: formatRelative(lastSyncedAt, i18n.language)})}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
const MAX_POLL_DURATION_MS = 120_000;
|
||||
const INITIAL_SYNC_GRACE_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Polls /api/library/sync-status while a sync is running (or expected),
|
||||
* and fires onComplete once when the run transitions to OK / FAILED.
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {boolean} opts.enabled - skip work entirely when false (e.g. user has no steamId)
|
||||
* @param {boolean} opts.expectInitialSync - if true and no row yet, keep polling (covers the
|
||||
* gap between async sync dispatch and first DB write)
|
||||
* @param {() => void} opts.onComplete - called once per transition to OK/FAILED
|
||||
* @returns {{status: string|null, lastSyncedAt: string|null, isRunning: boolean, error: string|null}}
|
||||
*/
|
||||
export default function useLibrarySync({enabled, expectInitialSync = false, onComplete}) {
|
||||
const [state, setState] = useState({status: null, lastSyncedAt: null, error: null});
|
||||
const previousRunningRef = useRef(null);
|
||||
const onCompleteRef = useRef(onComplete);
|
||||
onCompleteRef.current = onComplete;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
|
||||
let cancelled = false;
|
||||
let timer = null;
|
||||
const startedAt = Date.now();
|
||||
|
||||
const fetchOnce = async () => {
|
||||
const token = localStorage.getItem('tbp_jwt');
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${import.meta.env.VITE_CORE_API_URL}/api/library/sync-status`,
|
||||
{headers: {'Authorization': `Bearer ${token}`}}
|
||||
);
|
||||
if (cancelled) return;
|
||||
|
||||
if (!response.ok) {
|
||||
setState((s) => ({...s, error: 'sync-status fetch failed'}));
|
||||
return;
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
if (cancelled) return;
|
||||
|
||||
const status = json.lastSyncStatus;
|
||||
const lastSyncedAt = json.lastSyncedAt;
|
||||
setState({status, lastSyncedAt, error: null});
|
||||
|
||||
const isRunning = status === 'RUNNING';
|
||||
const wasRunning = previousRunningRef.current;
|
||||
|
||||
if (wasRunning === true && !isRunning) {
|
||||
onCompleteRef.current?.();
|
||||
}
|
||||
previousRunningRef.current = isRunning;
|
||||
|
||||
const elapsed = Date.now() - startedAt;
|
||||
const waitingForInitial =
|
||||
expectInitialSync && status == null && elapsed < INITIAL_SYNC_GRACE_MS;
|
||||
const stillWaiting = isRunning || waitingForInitial;
|
||||
|
||||
if (stillWaiting && elapsed < MAX_POLL_DURATION_MS) {
|
||||
timer = setTimeout(fetchOnce, POLL_INTERVAL_MS);
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
console.error('sync-status poll error:', err);
|
||||
setState((s) => ({...s, error: 'network'}));
|
||||
}
|
||||
};
|
||||
|
||||
fetchOnce();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [enabled, expectInitialSync]);
|
||||
|
||||
return {
|
||||
status: state.status,
|
||||
lastSyncedAt: state.lastSyncedAt,
|
||||
isRunning: state.status === 'RUNNING',
|
||||
error: state.error,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
+63
-10
@@ -1,14 +1,36 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import React, {useCallback, useEffect, useState} from 'react';
|
||||
import {Link, useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import SteamGameCount from '../components/SteamGameCount';
|
||||
import SyncStatusBadge from '../components/SyncStatusBadge.jsx';
|
||||
import useLibrarySync from '../hooks/useLibrarySync.js';
|
||||
|
||||
const Dashboard = () => {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [userData, setUserData] = useState(null);
|
||||
const [steamData, setSteamData] = useState({count: 0, hasSteamId: false});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const fetchSteamCount = useCallback(async () => {
|
||||
const token = localStorage.getItem('tbp_jwt');
|
||||
if (!token) return;
|
||||
try {
|
||||
const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (r.ok) {
|
||||
setSteamData(await r.json());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching steam data:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUserData = async () => {
|
||||
const token = localStorage.getItem('tbp_jwt');
|
||||
@@ -19,7 +41,7 @@ const Dashboard = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
|
||||
const profileResponse = await fetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
@@ -27,10 +49,14 @@ const Dashboard = () => {
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (profileResponse.ok) {
|
||||
const data = await profileResponse.json();
|
||||
setUserData(data);
|
||||
} else if (response.status === 401) {
|
||||
|
||||
if (data.steamId) {
|
||||
await fetchSteamCount();
|
||||
}
|
||||
} else if (profileResponse.status === 401) {
|
||||
localStorage.removeItem('tbp_jwt');
|
||||
navigate('/login');
|
||||
} else {
|
||||
@@ -45,7 +71,14 @@ const Dashboard = () => {
|
||||
};
|
||||
|
||||
fetchUserData();
|
||||
}, [navigate]);
|
||||
}, [navigate, t, fetchSteamCount]);
|
||||
|
||||
const expectInitialSync = !!userData?.steamId && steamData.count === 0;
|
||||
const sync = useLibrarySync({
|
||||
enabled: !!userData?.steamId,
|
||||
expectInitialSync,
|
||||
onComplete: fetchSteamCount,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -74,18 +107,38 @@ const Dashboard = () => {
|
||||
<p className="text-lg text-slate-600 dark:text-slate-300">
|
||||
{userData?.dashboardMessage}
|
||||
</p>
|
||||
{userData?.steamId && (
|
||||
<div className="mt-4">
|
||||
<SyncStatusBadge
|
||||
status={sync.status}
|
||||
lastSyncedAt={sync.lastSyncedAt}
|
||||
isRunning={sync.isRunning}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{steamData.hasSteamId && (
|
||||
<SteamGameCount targetCount={steamData.count} />
|
||||
)}
|
||||
<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 1 (Coming soon)</span>
|
||||
</div>
|
||||
{!steamData.hasSteamId && (
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {useNavigate, useSearchParams} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import useLibrarySync from '../hooks/useLibrarySync.js';
|
||||
import SyncStatusBadge from '../components/SyncStatusBadge.jsx';
|
||||
|
||||
const PAGE_SIZES = [20, 40, 80];
|
||||
const DEFAULT_SIZE = 20;
|
||||
|
||||
const iconUrl = (appid, hash) =>
|
||||
hash
|
||||
? `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${hash}.jpg`
|
||||
: null;
|
||||
|
||||
const formatPlaytime = (minutes) => {
|
||||
if (!minutes || minutes <= 0) return '0h';
|
||||
const hours = minutes / 60;
|
||||
return hours >= 10 ? `${Math.round(hours)}h` : `${hours.toFixed(1)}h`;
|
||||
};
|
||||
|
||||
export default function GameLibrary() {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const page = Math.max(0, parseInt(searchParams.get('page') ?? '0', 10) || 0);
|
||||
const sizeParam = parseInt(searchParams.get('size') ?? String(DEFAULT_SIZE), 10);
|
||||
const size = PAGE_SIZES.includes(sizeParam) ? sizeParam : DEFAULT_SIZE;
|
||||
|
||||
const [data, setData] = useState({content: [], page: 0, size, totalElements: 0, totalPages: 0});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const token = localStorage.getItem('tbp_jwt');
|
||||
if (!token) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?page=${page}&size=${size}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('tbp_jwt');
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
setError(t('LIBRARY.ERROR_LOAD'));
|
||||
return;
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
setData(json);
|
||||
} catch (err) {
|
||||
console.error('Library load error:', err);
|
||||
setError(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [navigate, page, size, t]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const expectInitialSync = !isLoading && !error && data.totalElements === 0;
|
||||
const sync = useLibrarySync({
|
||||
enabled: true,
|
||||
expectInitialSync,
|
||||
onComplete: load,
|
||||
});
|
||||
|
||||
const updateParams = (next) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
Object.entries(next).forEach(([k, v]) => params.set(k, String(v)));
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
const onSizeChange = (event) => {
|
||||
updateParams({size: event.target.value, page: 0});
|
||||
};
|
||||
|
||||
const goToPage = (target) => {
|
||||
if (target < 0 || target >= data.totalPages) return;
|
||||
updateParams({page: target});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||
{t('LIBRARY.TITLE')}
|
||||
</h1>
|
||||
<SyncStatusBadge
|
||||
status={sync.status}
|
||||
lastSyncedAt={sync.lastSyncedAt}
|
||||
isRunning={sync.isRunning}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
<label htmlFor="pageSize">{t('LIBRARY.PER_PAGE')}</label>
|
||||
<select
|
||||
id="pageSize"
|
||||
value={size}
|
||||
onChange={onSizeChange}
|
||||
className="px-3 py-1 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white"
|
||||
>
|
||||
{PAGE_SIZES.map((n) => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="text-center text-slate-600 dark:text-slate-300 py-12">
|
||||
{t('LIBRARY.LOADING')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-center text-red-500 py-12">{error}</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && data.content.length === 0 && (
|
||||
<div className="text-center text-slate-500 dark:text-slate-400 py-12">
|
||||
{sync.isRunning ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && data.content.length > 0 && (
|
||||
<ul className="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{data.content.map((g) => {
|
||||
const icon = iconUrl(g.appid, g.imgIconUrl);
|
||||
return (
|
||||
<li key={g.appid} className="flex items-center gap-4 px-4 py-3">
|
||||
{icon ? (
|
||||
<img
|
||||
src={icon}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded"
|
||||
loading="lazy"
|
||||
onError={(e) => { e.currentTarget.style.visibility = 'hidden'; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded bg-slate-200 dark:bg-slate-700"/>
|
||||
)}
|
||||
<span className="flex-1 text-slate-900 dark:text-white truncate">
|
||||
{g.name ?? `App ${g.appid}`}
|
||||
</span>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400 tabular-nums">
|
||||
{formatPlaytime(g.playtimeMinutes)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && data.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between text-sm text-slate-600 dark:text-slate-300">
|
||||
<button
|
||||
onClick={() => goToPage(page - 1)}
|
||||
disabled={page <= 0}
|
||||
className="px-4 py-2 rounded-lg bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 disabled:opacity-40 hover:border-blue-500 disabled:hover:border-slate-300 dark:disabled:hover:border-slate-600 transition-colors"
|
||||
>
|
||||
{t('LIBRARY.PREV')}
|
||||
</button>
|
||||
<span>
|
||||
{t('LIBRARY.PAGE_OF', {current: data.page + 1, total: data.totalPages})}
|
||||
{' · '}
|
||||
{data.totalElements} {t('LIBRARY.GAMES')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => goToPage(page + 1)}
|
||||
disabled={page >= data.totalPages - 1}
|
||||
className="px-4 py-2 rounded-lg bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 disabled:opacity-40 hover:border-blue-500 disabled:hover:border-slate-300 dark:disabled:hover:border-slate-600 transition-colors"
|
||||
>
|
||||
{t('LIBRARY.NEXT')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
Reference in new issue
Block a user