Merge pull request 'feature/add-settings-and-registration' (#3) from feature/add-settings-and-registration into main
Build, Test & Push Frontend / quality-check (push) Successful in 8s
Build, Test & Push Frontend / docker (push) Successful in 22s

Reviewed-on: https://codeberg.org/LoboTheDark/ToBePlayed-Frontend/pulls/3
This commit is contained in:
Andreas Dahm committed 2026-05-04 11:53:38 +02:00
commit 950f7d2b80
18 files changed
+1380 -151

No files matched your search

+1
View File
@@ -1,2 +1,3 @@
# For local development (npm run dev) # For local development (npm run dev)
VITE_API_URL=http://localhost:8080 VITE_API_URL=http://localhost:8080
VITE_CORE_API_URL=http://localhost:8090
+1
View File
@@ -1,3 +1,4 @@
# For the docker image on the server # For the docker image on the server
# Uses the variable AUTH_HOST from your docker structure [cite: 3] # Uses the variable AUTH_HOST from your docker structure [cite: 3]
VITE_API_URL=https://auth.tbp.andreas-dahm.eu VITE_API_URL=https://auth.tbp.andreas-dahm.eu
VITE_CORE_API_URL=https://api.tbp.andreas-dahm.eu
+153 -112
View File
@@ -8,14 +8,17 @@
"name": "tobeplayed-frontend", "name": "tobeplayed-frontend",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.10.0",
"@mdi/font": "~7.4.47", "@mdi/font": "~7.4.47",
"@tailwindcss/vite": "~4.2.2", "@tailwindcss/vite": "~4.2.2",
"i18next": "~26.0.0", "i18next": "~26.0.0",
"i18next-http-backend": "~3.0.2", "i18next-http-backend": "~3.0.2",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-hook-form": "^7.73.1",
"react-i18next": "~17.0.0", "react-i18next": "~17.0.0",
"react-router-dom": "~7.13.2" "react-router-dom": "~7.13.2",
"zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
@@ -35,20 +38,20 @@
} }
}, },
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.9.1", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/wasi-threads": "1.2.0", "@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.9.1", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -56,15 +59,24 @@
} }
}, },
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.0", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"tslib": "^2.4.0" "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": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -117,34 +129,36 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1" "@tybys/wasm-util": "^0.10.1"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/Brooooooklyn" "url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
} }
}, },
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.120.0", "version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
"integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==", "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/Boshen" "url": "https://github.com/sponsors/Boshen"
} }
}, },
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==", "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -158,9 +172,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-arm64": { "node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==", "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -174,9 +188,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-x64": { "node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
"integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==", "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -190,9 +204,9 @@
} }
}, },
"node_modules/@rolldown/binding-freebsd-x64": { "node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
"integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==", "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -206,9 +220,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm-gnueabihf": { "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
"integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==", "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -222,9 +236,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-gnu": { "node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==", "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -238,9 +252,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-musl": { "node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
"integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==", "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -254,9 +268,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-ppc64-gnu": { "node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==", "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -270,9 +284,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-s390x-gnu": { "node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==", "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -286,9 +300,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-gnu": { "node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==", "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -302,9 +316,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-musl": { "node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
"integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==", "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -318,9 +332,9 @@
} }
}, },
"node_modules/@rolldown/binding-openharmony-arm64": { "node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -334,25 +348,27 @@
} }
}, },
"node_modules/@rolldown/binding-wasm32-wasi": { "node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
"integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "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": { "engines": {
"node": ">=14.0.0" "node": "^20.19.0 || >=22.12.0"
} }
}, },
"node_modules/@rolldown/binding-win32-arm64-msvc": { "node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
"integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -366,9 +382,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-x64-msvc": { "node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
"integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -800,12 +816,12 @@
} }
}, },
"node_modules/cross-fetch": { "node_modules/cross-fetch": {
"version": "4.0.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz",
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"node-fetch": "^2.6.12" "node-fetch": "^2.7.0"
} }
}, },
"node_modules/detect-libc": { "node_modules/detect-libc": {
@@ -939,12 +955,12 @@
} }
}, },
"node_modules/i18next-http-backend": { "node_modules/i18next-http-backend": {
"version": "3.0.2", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz", "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.5.tgz",
"integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==", "integrity": "sha512-QaWHnsxieEDcqKe+vo/RFqpiIFRi/KBqlOSPcUlvinBaISCeiTRCbtrazHAjtHtsLC66oDsROAH8frWkQzfMMQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cross-fetch": "4.0.0" "cross-fetch": "4.1.0"
} }
}, },
"node_modules/jiti": { "node_modules/jiti": {
@@ -1278,9 +1294,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.8", "version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -1333,6 +1349,22 @@
"react": "^19.2.4" "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": { "node_modules/react-i18next": {
"version": "17.0.0", "version": "17.0.0",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.0.tgz", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.0.tgz",
@@ -1399,13 +1431,13 @@
} }
}, },
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
"integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==", "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.120.0", "@oxc-project/types": "=0.127.0",
"@rolldown/pluginutils": "1.0.0-rc.10" "@rolldown/pluginutils": "1.0.0-rc.17"
}, },
"bin": { "bin": {
"rolldown": "bin/cli.mjs" "rolldown": "bin/cli.mjs"
@@ -1414,27 +1446,27 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.10", "@rolldown/binding-android-arm64": "1.0.0-rc.17",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
"@rolldown/binding-darwin-x64": "1.0.0-rc.10", "@rolldown/binding-darwin-x64": "1.0.0-rc.17",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.10", "@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
} }
}, },
"node_modules/rolldown/node_modules/@rolldown/pluginutils": { "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
"integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==", "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/scheduler": { "node_modules/scheduler": {
@@ -1478,13 +1510,13 @@
} }
}, },
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fdir": "^6.5.0", "fdir": "^6.5.0",
"picomatch": "^4.0.3" "picomatch": "^4.0.4"
}, },
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
@@ -1547,16 +1579,16 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "8.0.1", "version": "8.0.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
"integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.3", "picomatch": "^4.0.4",
"postcss": "^8.5.8", "postcss": "^8.5.10",
"rolldown": "1.0.0-rc.10", "rolldown": "1.0.0-rc.17",
"tinyglobby": "^0.2.15" "tinyglobby": "^0.2.16"
}, },
"bin": { "bin": {
"vite": "bin/vite.js" "vite": "bin/vite.js"
@@ -1573,7 +1605,7 @@
"peerDependencies": { "peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0", "@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0", "@vitejs/devtools": "^0.1.0",
"esbuild": "^0.27.0", "esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0", "jiti": ">=1.21.0",
"less": "^4.0.0", "less": "^4.0.0",
"sass": "^1.70.0", "sass": "^1.70.0",
@@ -1647,6 +1679,15 @@
"tr46": "~0.0.3", "tr46": "~0.0.3",
"webidl-conversions": "^3.0.0" "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" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.10.0",
"@mdi/font": "~7.4.47", "@mdi/font": "~7.4.47",
"@tailwindcss/vite": "~4.2.2", "@tailwindcss/vite": "~4.2.2",
"i18next": "~26.0.0", "i18next": "~26.0.0",
"i18next-http-backend": "~3.0.2", "i18next-http-backend": "~3.0.2",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-hook-form": "^7.73.1",
"react-i18next": "~17.0.0", "react-i18next": "~17.0.0",
"react-router-dom": "~7.13.2" "react-router-dom": "~7.13.2",
"zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
+66 -2
View File
@@ -1,4 +1,4 @@
{ {
"IMPRINT": { "IMPRINT": {
"TITLE": "Impressum", "TITLE": "Impressum",
"LEGAL": "Angaben gemäß § 5 DDG", "LEGAL": "Angaben gemäß § 5 DDG",
@@ -9,9 +9,57 @@
"EMAIL": "Email", "EMAIL": "Email",
"PASSWORD": "Passwort", "PASSWORD": "Passwort",
"SUBMIT": "Spiel betreten", "SUBMIT": "Spiel betreten",
"NO_ACCOUNT": "Noch kein Konto?",
"ERROR_EMAIL": "Gib eine gültige Email-Adresse ein mit '@' und mit '.'.", "ERROR_EMAIL": "Gib eine gültige Email-Adresse ein mit '@' und mit '.'.",
"ERROR_PASSWORD": "Das Passwort darf nicht leer sein." "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": { "INFO": {
"ERROR": { "ERROR": {
"LOAD_PROFILE": "Fehler beim Laden der Profildaten.", "LOAD_PROFILE": "Fehler beim Laden der Profildaten.",
@@ -20,7 +68,23 @@
}, },
"DASHBOARD": { "DASHBOARD": {
"LOADING": "Lade 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": { "MAIN": {
"WELCOME": "Dein Game-Backlog unter Kontrolle", "WELCOME": "Dein Game-Backlog unter Kontrolle",
+66 -2
View File
@@ -1,4 +1,4 @@
{ {
"IMPRINT": { "IMPRINT": {
"TITLE": "Imprint", "TITLE": "Imprint",
"LEGAL": "Information according to § 5 TMG.", "LEGAL": "Information according to § 5 TMG.",
@@ -9,9 +9,57 @@
"EMAIL": "Email", "EMAIL": "Email",
"PASSWORD": "Password", "PASSWORD": "Password",
"SUBMIT": "Enter the game", "SUBMIT": "Enter the game",
"NO_ACCOUNT": "No account yet?",
"ERROR_EMAIL": "Enter a valid email address with '@' and with '.'.", "ERROR_EMAIL": "Enter a valid email address with '@' and with '.'.",
"ERROR_PASSWORD": "The password must have at least one character." "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": { "INFO": {
"ERROR": { "ERROR": {
"LOAD_PROFILE": "Error during loading of the user profile data.", "LOAD_PROFILE": "Error during loading of the user profile data.",
@@ -20,7 +68,23 @@
}, },
"DASHBOARD": { "DASHBOARD": {
"LOADING": "Loading 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": { "MAIN": {
"WELCOME": "Control your game backlog", "WELCOME": "Control your game backlog",
+23 -19
View File
@@ -6,10 +6,14 @@ import LandingPage from './pages/LandingPage';
import LoginPage from './pages/LoginPage'; import LoginPage from './pages/LoginPage';
import ImprintPage from './pages/ImprintPage'; import ImprintPage from './pages/ImprintPage';
import Dashboard from "./pages/Dashboard.jsx"; 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() { function App() {
const {t, i18n} = useTranslation(); const {t, i18n} = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(true); const {isDarkMode, setDarkMode, setLanguage} = usePreferences();
const [isAuthenticated, setIsAuthenticated] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false);
// Hooks for routing // Hooks for routing
@@ -17,25 +21,17 @@ function App() {
const location = useLocation(); const location = useLocation();
useEffect(() => { 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'); const token = localStorage.getItem('tbp_jwt');
setIsAuthenticated(!!token); // Converts the token string (or null) to a boolean setIsAuthenticated(!!token); // Converts the token string (or null) to a boolean
}, [location]); }, [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 = () => { const toggleLanguage = () => {
if (i18n.language === 'de') { setLanguage(i18n.language === 'de' ? 'en' : 'de');
i18n.changeLanguage('en').then(_ => console.log("Changed language to 'en'"));
} else {
i18n.changeLanguage('de').then(_ => console.log("Changed language to 'de'"));
}
}; };
const handleLogout = () => { const handleLogout = () => {
@@ -56,14 +52,19 @@ function App() {
{i18n.language.toUpperCase()} {i18n.language.toUpperCase()}
</button> </button>
<button onClick={() => { <button onClick={() => {
setIsDarkMode(!isDarkMode); setDarkMode(!isDarkMode);
}} className="text-xl text-slate-600 dark:text-slate-300 hover:text-blue-500 transition-colors"> }} 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> <i className={isDarkMode ? "mdi mdi-weather-sunny" : "mdi mdi-weather-night"}></i>
</button> </button>
{isAuthenticated ? ( {isAuthenticated ? (
<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')} <Link to="/settings" className="text-sm font-bold text-slate-600 dark:text-slate-300 hover:text-blue-500 transition-colors">
</button> <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"> <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')} {t('MAIN.LOGIN')}
@@ -76,7 +77,10 @@ function App() {
<Routes> <Routes>
<Route path="/" element={<LandingPage/>}/> <Route path="/" element={<LandingPage/>}/>
<Route path="/login" element={<LoginPage/>}/> <Route path="/login" element={<LoginPage/>}/>
<Route path="/register" element={<RegistrationPage/>}/>
<Route path="/dashboard" element={<Dashboard/>}/> <Route path="/dashboard" element={<Dashboard/>}/>
<Route path="/library" element={<GameLibrary/>}/>
<Route path="/settings" element={<SettingsPage/>}/>
<Route path="/impressum" element={<ImprintPage/>}/> <Route path="/impressum" element={<ImprintPage/>}/>
</Routes> </Routes>
</main> </main>
+44
View File
@@ -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;
+45
View File
@@ -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;
}
+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;
}
+91
View File
@@ -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,
};
}
+4 -1
View File
@@ -2,6 +2,7 @@ import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client'; import {createRoot} from 'react-dom/client';
import {BrowserRouter} from 'react-router-dom'; import {BrowserRouter} from 'react-router-dom';
import App from './App.jsx'; import App from './App.jsx';
import {PreferencesProvider} from './context/PreferencesContext.jsx';
import './i18n'; import './i18n';
import './style.css'; import './style.css';
import '@mdi/font/css/materialdesignicons.css'; import '@mdi/font/css/materialdesignicons.css';
@@ -9,7 +10,9 @@ import '@mdi/font/css/materialdesignicons.css';
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<BrowserRouter> <BrowserRouter>
<App/> <PreferencesProvider>
<App/>
</PreferencesProvider>
</BrowserRouter> </BrowserRouter>
</StrictMode> </StrictMode>
); );
+66 -13
View File
@@ -1,14 +1,36 @@
import React, {useEffect, useState} from 'react'; import React, {useCallback, useEffect, useState} from 'react';
import {useNavigate} from 'react-router-dom'; import {Link, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; 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 Dashboard = () => {
const {t} = useTranslation(); const {t} = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [userData, setUserData] = useState(null); const [userData, setUserData] = useState(null);
const [steamData, setSteamData] = useState({count: 0, hasSteamId: false});
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null); 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(() => { useEffect(() => {
const fetchUserData = async () => { const fetchUserData = async () => {
const token = localStorage.getItem('tbp_jwt'); const token = localStorage.getItem('tbp_jwt');
@@ -19,7 +41,7 @@ const Dashboard = () => {
} }
try { 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', method: 'GET',
headers: { headers: {
'Authorization': `Bearer ${token}`, 'Authorization': `Bearer ${token}`,
@@ -27,10 +49,14 @@ const Dashboard = () => {
} }
}); });
if (response.ok) { if (profileResponse.ok) {
const data = await response.json(); const data = await profileResponse.json();
setUserData(data); setUserData(data);
} else if (response.status === 401) {
if (data.steamId) {
await fetchSteamCount();
}
} else if (profileResponse.status === 401) {
localStorage.removeItem('tbp_jwt'); localStorage.removeItem('tbp_jwt');
navigate('/login'); navigate('/login');
} else { } else {
@@ -45,7 +71,14 @@ const Dashboard = () => {
}; };
fetchUserData(); fetchUserData();
}, [navigate]); }, [navigate, t, fetchSteamCount]);
const expectInitialSync = !!userData?.steamId && steamData.count === 0;
const sync = useLibrarySync({
enabled: !!userData?.steamId,
expectInitialSync,
onComplete: fetchSteamCount,
});
if (isLoading) { if (isLoading) {
return ( return (
@@ -74,18 +107,38 @@ const Dashboard = () => {
<p className="text-lg text-slate-600 dark:text-slate-300"> <p className="text-lg text-slate-600 dark:text-slate-300">
{userData?.dashboardMessage} {userData?.dashboardMessage}
</p> </p>
{userData?.steamId && (
<div className="mt-4">
<SyncStatusBadge
status={sync.status}
lastSyncedAt={sync.lastSyncedAt}
isRunning={sync.isRunning}
/>
</div>
)}
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <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"> <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> <span className="text-slate-400 dark:text-slate-500">Tile 1 (Coming soon)</span>
</div> </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"> {!steamData.hasSteamId && (
<span className="text-slate-400 dark:text-slate-500">Tile 2 (Coming soon)</span> <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">
</div> <span className="text-slate-400 dark:text-slate-500">Tile 2 (Coming soon)</span>
<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"> </div>
<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>
</div> </div>
+198
View File
@@ -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>
);
}
+8 -1
View File
@@ -1,6 +1,6 @@
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {useState} from 'react'; import {useState} from 'react';
import {useNavigate} from 'react-router-dom'; import {Link, useNavigate} from 'react-router-dom';
export default function LoginPage() { export default function LoginPage() {
const {t} = useTranslation(); const {t} = useTranslation();
@@ -131,6 +131,13 @@ export default function LoginPage() {
</div> </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>
</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,
});