From 2b5f77f0b9c5bc81100ffe89ce7693344f59c07b Mon Sep 17 00:00:00 2001 From: Andreas Dahm Date: Thu, 23 Apr 2026 09:04:12 +0200 Subject: [PATCH 1/5] Added registration and settings page --- package-lock.json | 265 +++++++++++++---------- package.json | 5 +- public/i18n/de.json | 52 ++++- public/i18n/en.json | 52 ++++- src/App.jsx | 35 ++- src/context/PreferencesContext.jsx | 47 ++++ src/main.jsx | 5 +- src/pages/Dashboard.jsx | 14 +- src/pages/LoginPage.jsx | 9 +- src/pages/RegistrationPage.jsx | 185 ++++++++++++++++ src/pages/SettingsPage.jsx | 334 +++++++++++++++++++++++++++++ src/utils/validation.js | 44 ++++ 12 files changed, 905 insertions(+), 142 deletions(-) create mode 100644 src/context/PreferencesContext.jsx create mode 100644 src/pages/RegistrationPage.jsx create mode 100644 src/pages/SettingsPage.jsx create mode 100644 src/utils/validation.js diff --git a/package-lock.json b/package-lock.json index ae63a25..98dbe18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" + } } } } diff --git a/package.json b/package.json index ea45ce3..268f9bc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/i18n/de.json b/public/i18n/de.json index 53f0e1d..1db8fe8 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -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.", @@ -29,4 +77,4 @@ "LOGIN": "Anmelden", "LOGOUT": "Abmelden" } -} \ No newline at end of file +} diff --git a/public/i18n/en.json b/public/i18n/en.json index 9a503ec..703bcdf 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -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.", @@ -29,4 +77,4 @@ "LOGIN": "Login", "LOGOUT": "Logout" } -} \ No newline at end of file +} diff --git a/src/App.jsx b/src/App.jsx index 39cacdc..50344ca 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -6,10 +6,13 @@ import LandingPage from './pages/LandingPage'; import LoginPage from './pages/LoginPage'; import ImprintPage from './pages/ImprintPage'; import Dashboard from "./pages/Dashboard.jsx"; +import RegistrationPage from './pages/RegistrationPage.jsx'; +import SettingsPage from './pages/SettingsPage.jsx'; +import {usePreferences} from './context/PreferencesContext.jsx'; function App() { const {t, i18n} = useTranslation(); - const [isDarkMode, setIsDarkMode] = useState(true); + const {isDarkMode, setDarkMode, setLanguage} = usePreferences(); const [isAuthenticated, setIsAuthenticated] = useState(false); // Hooks for routing @@ -21,21 +24,8 @@ function App() { setIsAuthenticated(!!token); // Converts the token string (or null) to a boolean }, [location]); - // Sync React state with the HTML class required by Tailwind CSS - useEffect(() => { - if (isDarkMode) { - document.documentElement.classList.add('dark'); - } else { - document.documentElement.classList.remove('dark'); - } - }, [isDarkMode]); - const toggleLanguage = () => { - if (i18n.language === 'de') { - i18n.changeLanguage('en').then(_ => console.log("Changed language to 'en'")); - } else { - i18n.changeLanguage('de').then(_ => console.log("Changed language to 'de'")); - } + setLanguage(i18n.language === 'de' ? 'en' : 'de'); }; const handleLogout = () => { @@ -56,14 +46,19 @@ function App() { {i18n.language.toUpperCase()} {isAuthenticated ? ( - + <> + + + + + ) : ( {t('MAIN.LOGIN')} @@ -76,7 +71,9 @@ function App() { }/> }/> + }/> }/> + }/> }/> diff --git a/src/context/PreferencesContext.jsx b/src/context/PreferencesContext.jsx new file mode 100644 index 0000000..7b43164 --- /dev/null +++ b/src/context/PreferencesContext.jsx @@ -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 ( + + {children} + + ); +} + +export function usePreferences() { + const ctx = useContext(PreferencesContext); + if (!ctx) { + throw new Error('usePreferences must be used inside '); + } + return ctx; +} diff --git a/src/main.jsx b/src/main.jsx index 5480994..eb86d6b 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -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( - + + + ); \ No newline at end of file diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 566a9de..433c81e 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -1,5 +1,5 @@ import React, {useEffect, useState} from 'react'; -import {useNavigate} from 'react-router-dom'; +import {Link, useNavigate} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; const Dashboard = () => { @@ -83,9 +83,15 @@ const Dashboard = () => {
Tile 2 (Coming soon)
-
- Settings (Coming soon) -
+ + + + {t('SETTINGS.TITLE')} + + diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx index 66a6829..3179ee9 100644 --- a/src/pages/LoginPage.jsx +++ b/src/pages/LoginPage.jsx @@ -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() { )} +

+ {t('LOGIN.NO_ACCOUNT')}{' '} + + {t('REGISTRATION.TITLE')} + +

+ ); diff --git a/src/pages/RegistrationPage.jsx b/src/pages/RegistrationPage.jsx new file mode 100644 index 0000000..611f738 --- /dev/null +++ b/src/pages/RegistrationPage.jsx @@ -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] && ( +

{t(errors[field].message)}

+ ); + + return ( +
+
+

+ {t('REGISTRATION.TITLE')} +

+ +
+
+ + + {renderError('email')} +
+ +
+ + + {renderError('password')} +
+ +
+ + + {renderError('passwordConfirmation')} +
+ +
+ + + {renderError('inviteCode')} +
+ +
+ + + {renderError('steamId')} +
+ + +
+ + {feedback.message && ( +
+ {feedback.message} +
+ )} + +

+ {t('REGISTRATION.HAVE_ACCOUNT')}{' '} + + {t('MAIN.LOGIN')} + +

+
+
+ ); +} diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx new file mode 100644 index 0000000..5c9d74f --- /dev/null +++ b/src/pages/SettingsPage.jsx @@ -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] && ( +

{t(errors[field].message)}

+ ); + + if (isLoading) { + return ( +
+ {t('SETTINGS.LOADING')} +
+ ); + } + + if (loadError) { + return ( +
+ {loadError} +
+ ); + } + + return ( +
+
+
+

+ {t('SETTINGS.TITLE')} +

+ + {/* Read-only account info */} +
+

+ {t('SETTINGS.ACCOUNT')} +

+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + {/* Preferences — live */} +
+

+ {t('SETTINGS.PREFERENCES')} +

+ +
+ + +
+ +
+ + +
+
+ + {/* Steam ID — explicit save */} +
+

+ {t('SETTINGS.STEAM')} +

+
+
+ + + {renderError('steamId')} +
+ +
+
+ + {feedback.message && ( +
+ {feedback.message} +
+ )} +
+
+
+ ); +} diff --git a/src/utils/validation.js b/src/utils/validation.js new file mode 100644 index 0000000..46784d9 --- /dev/null +++ b/src/utils/validation.js @@ -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, +}); From 9569bbcd70d5a85127e30bad9699fb0547970800 Mon Sep 17 00:00:00 2001 From: Andreas Dahm Date: Tue, 28 Apr 2026 15:41:38 +0200 Subject: [PATCH 2/5] Added counter for steam game counts --- .env.development | 3 ++- .env.production | 3 ++- public/i18n/de.json | 3 ++- public/i18n/en.json | 3 ++- src/components/SteamGameCount.jsx | 40 +++++++++++++++++++++++++++++ src/pages/Dashboard.jsx | 42 +++++++++++++++++++++++++------ 6 files changed, 82 insertions(+), 12 deletions(-) create mode 100644 src/components/SteamGameCount.jsx diff --git a/.env.development b/.env.development index cb4cb40..24848d3 100644 --- a/.env.development +++ b/.env.development @@ -1,2 +1,3 @@ # For local development (npm run dev) -VITE_API_URL=http://localhost:8080 \ No newline at end of file +VITE_API_URL=http://localhost:8080 +VITE_CORE_API_URL=http://localhost:8090 \ No newline at end of file diff --git a/.env.production b/.env.production index 658e57e..8bf2a6d 100644 --- a/.env.production +++ b/.env.production @@ -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 \ No newline at end of file +VITE_API_URL=https://auth.tbp.andreas-dahm.eu +VITE_CORE_API_URL=https://api.tbp.andreas-dahm.eu \ No newline at end of file diff --git a/public/i18n/de.json b/public/i18n/de.json index 1db8fe8..0b4999e 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -68,7 +68,8 @@ }, "DASHBOARD": { "LOADING": "Lade Dashboard ...", - "HELLO": "Hallo" + "HELLO": "Hallo", + "STEAM_GAMES": "Steam Spiele" }, "MAIN": { "WELCOME": "Dein Game-Backlog unter Kontrolle", diff --git a/public/i18n/en.json b/public/i18n/en.json index 703bcdf..aa9ddff 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -68,7 +68,8 @@ }, "DASHBOARD": { "LOADING": "Loading dashboard ...", - "HELLO": "Hello" + "HELLO": "Hello", + "STEAM_GAMES": "Steam Games" }, "MAIN": { "WELCOME": "Control your game backlog", diff --git a/src/components/SteamGameCount.jsx b/src/components/SteamGameCount.jsx new file mode 100644 index 0000000..092bcc8 --- /dev/null +++ b/src/components/SteamGameCount.jsx @@ -0,0 +1,40 @@ +import React, {useState, useEffect} from 'react'; +import {useTranslation} from 'react-i18next'; + +const SteamGameCount = ({targetCount}) => { + const {t} = useTranslation(); + const [count, setCount] = useState(0); + + useEffect(() => { + let startTime; + const duration = 1000; // 1 second + 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 ( +
+
+ {count} +
+
+ {t('DASHBOARD.STEAM_GAMES')} +
+
+ ); +}; + +export default SteamGameCount; diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 433c81e..832d690 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -1,11 +1,13 @@ import React, {useEffect, useState} from 'react'; import {Link, useNavigate} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; +import SteamGameCount from '../components/SteamGameCount'; 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); @@ -19,7 +21,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 +29,29 @@ 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 user has a steamId, fetch game count from Core Service + if (data.steamId) { + try { + const steamResponse = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/steam/games-count`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + if (steamResponse.ok) { + const steamJson = await steamResponse.json(); + setSteamData(steamJson); + } + } catch (steamErr) { + console.error('Error fetching steam data:', steamErr); + } + } + } else if (profileResponse.status === 401) { localStorage.removeItem('tbp_jwt'); navigate('/login'); } else { @@ -45,7 +66,7 @@ const Dashboard = () => { }; fetchUserData(); - }, [navigate]); + }, [navigate, t]); if (isLoading) { return ( @@ -77,12 +98,17 @@ const Dashboard = () => {
+ {steamData.hasSteamId && ( + + )}
Tile 1 (Coming soon)
-
- Tile 2 (Coming soon) -
+ {!steamData.hasSteamId && ( +
+ Tile 2 (Coming soon) +
+ )} Date: Wed, 29 Apr 2026 10:47:53 +0200 Subject: [PATCH 3/5] Added dev state to ui --- src/App.jsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/App.jsx b/src/App.jsx index 50344ca..fd04487 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -20,6 +20,11 @@ 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]); From 8b290d5a13dbe967923104958bbda4432276e7f8 Mon Sep 17 00:00:00 2001 From: Andreas Dahm Date: Mon, 4 May 2026 11:48:08 +0200 Subject: [PATCH 4/5] Displaying the game library with server side pagination --- public/i18n/de.json | 11 ++ public/i18n/en.json | 11 ++ src/App.jsx | 2 + src/components/SteamGameCount.jsx | 12 +- src/components/SyncStatusBadge.jsx | 45 +++++++ src/hooks/useLibrarySync.js | 91 +++++++++++++ src/pages/Dashboard.jsx | 2 +- src/pages/GameLibrary.jsx | 198 +++++++++++++++++++++++++++++ 8 files changed, 367 insertions(+), 5 deletions(-) create mode 100644 src/components/SyncStatusBadge.jsx create mode 100644 src/hooks/useLibrarySync.js create mode 100644 src/pages/GameLibrary.jsx diff --git a/public/i18n/de.json b/public/i18n/de.json index 0b4999e..a5fa5ae 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -71,6 +71,17 @@ "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" + }, "MAIN": { "WELCOME": "Dein Game-Backlog unter Kontrolle", "DESCRIPTION": "Organisiere deinen Pile of Shame. Verfolge, verwalte und erobere deinen Game-Backlog wie nie zuvor.", diff --git a/public/i18n/en.json b/public/i18n/en.json index aa9ddff..076e52d 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -71,6 +71,17 @@ "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" + }, "MAIN": { "WELCOME": "Control your game backlog", "DESCRIPTION": "Organize your pile of shame. Track, manage, and conquer your gaming backlog like never before.", diff --git a/src/App.jsx b/src/App.jsx index fd04487..e030db0 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -8,6 +8,7 @@ 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() { @@ -78,6 +79,7 @@ function App() { }/> }/> }/> + }/> }/> }/> diff --git a/src/components/SteamGameCount.jsx b/src/components/SteamGameCount.jsx index 092bcc8..1b7ec65 100644 --- a/src/components/SteamGameCount.jsx +++ b/src/components/SteamGameCount.jsx @@ -1,4 +1,5 @@ import React, {useState, useEffect} from 'react'; +import {Link} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; const SteamGameCount = ({targetCount}) => { @@ -7,13 +8,13 @@ const SteamGameCount = ({targetCount}) => { useEffect(() => { let startTime; - const duration = 1000; // 1 second + 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); @@ -26,14 +27,17 @@ const SteamGameCount = ({targetCount}) => { }, [targetCount]); return ( -
+
{count}
{t('DASHBOARD.STEAM_GAMES')}
-
+ ); }; diff --git a/src/components/SyncStatusBadge.jsx b/src/components/SyncStatusBadge.jsx new file mode 100644 index 0000000..52dca05 --- /dev/null +++ b/src/components/SyncStatusBadge.jsx @@ -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 ( +
+ + {t('LIBRARY.SYNCING')} +
+ ); + } + + if (status === 'FAILED') { + return ( +
+ + {t('LIBRARY.SYNC_FAILED')} +
+ ); + } + + if (lastSyncedAt) { + return ( +
+ + {t('LIBRARY.LAST_SYNCED', {when: formatRelative(lastSyncedAt, i18n.language)})} +
+ ); + } + + return null; +} diff --git a/src/hooks/useLibrarySync.js b/src/hooks/useLibrarySync.js new file mode 100644 index 0000000..92ff310 --- /dev/null +++ b/src/hooks/useLibrarySync.js @@ -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, + }; +} diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 832d690..206b2d3 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -36,7 +36,7 @@ const Dashboard = () => { // If user has a steamId, fetch game count from Core Service if (data.steamId) { try { - const steamResponse = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/steam/games-count`, { + const steamResponse = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`, { method: 'GET', headers: { 'Authorization': `Bearer ${token}`, diff --git a/src/pages/GameLibrary.jsx b/src/pages/GameLibrary.jsx new file mode 100644 index 0000000..862add4 --- /dev/null +++ b/src/pages/GameLibrary.jsx @@ -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 ( +
+
+
+
+

+ {t('LIBRARY.TITLE')} +

+ +
+
+ + +
+
+ + {isLoading && ( +
+ {t('LIBRARY.LOADING')} +
+ )} + + {error && ( +
{error}
+ )} + + {!isLoading && !error && data.content.length === 0 && ( +
+ {sync.isRunning ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')} +
+ )} + + {!isLoading && !error && data.content.length > 0 && ( +
    + {data.content.map((g) => { + const icon = iconUrl(g.appid, g.imgIconUrl); + return ( +
  • + {icon ? ( + { e.currentTarget.style.visibility = 'hidden'; }} + /> + ) : ( +
    + )} + + {g.name ?? `App ${g.appid}`} + + + {formatPlaytime(g.playtimeMinutes)} + +
  • + ); + })} +
+ )} + + {!isLoading && !error && data.totalPages > 1 && ( +
+ + + {t('LIBRARY.PAGE_OF', {current: data.page + 1, total: data.totalPages})} + {' · '} + {data.totalElements} {t('LIBRARY.GAMES')} + + +
+ )} +
+
+ ); +} From 459b5b6f2d611de2841483805ea0633da3b19f21 Mon Sep 17 00:00:00 2001 From: Andreas Dahm Date: Mon, 4 May 2026 11:51:11 +0200 Subject: [PATCH 5/5] Added sync status --- public/i18n/de.json | 6 ++++- public/i18n/en.json | 6 ++++- src/pages/Dashboard.jsx | 59 ++++++++++++++++++++++++++++------------- 3 files changed, 50 insertions(+), 21 deletions(-) diff --git a/public/i18n/de.json b/public/i18n/de.json index a5fa5ae..4f1dc50 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -80,7 +80,11 @@ "PREV": "Zurück", "NEXT": "Weiter", "PAGE_OF": "Seite {{current}} von {{total}}", - "GAMES": "Spiele" + "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", diff --git a/public/i18n/en.json b/public/i18n/en.json index 076e52d..d9ecdae 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -80,7 +80,11 @@ "PREV": "Previous", "NEXT": "Next", "PAGE_OF": "Page {{current}} of {{total}}", - "GAMES": "games" + "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", diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 206b2d3..fcebf59 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -1,7 +1,9 @@ -import React, {useEffect, useState} from 'react'; +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(); @@ -11,6 +13,24 @@ const Dashboard = () => { 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'); @@ -33,23 +53,8 @@ const Dashboard = () => { const data = await profileResponse.json(); setUserData(data); - // If user has a steamId, fetch game count from Core Service if (data.steamId) { - try { - const steamResponse = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`, { - method: 'GET', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - } - }); - if (steamResponse.ok) { - const steamJson = await steamResponse.json(); - setSteamData(steamJson); - } - } catch (steamErr) { - console.error('Error fetching steam data:', steamErr); - } + await fetchSteamCount(); } } else if (profileResponse.status === 401) { localStorage.removeItem('tbp_jwt'); @@ -66,7 +71,14 @@ const Dashboard = () => { }; fetchUserData(); - }, [navigate, t]); + }, [navigate, t, fetchSteamCount]); + + const expectInitialSync = !!userData?.steamId && steamData.count === 0; + const sync = useLibrarySync({ + enabled: !!userData?.steamId, + expectInitialSync, + onComplete: fetchSteamCount, + }); if (isLoading) { return ( @@ -95,6 +107,15 @@ const Dashboard = () => {

{userData?.dashboardMessage}

+ {userData?.steamId && ( +
+ +
+ )}
@@ -125,4 +146,4 @@ const Dashboard = () => { ); }; -export default Dashboard; \ No newline at end of file +export default Dashboard;