diff --git a/frontend/fixme.md b/frontend/fixme.md new file mode 100644 index 0000000..7514b5f --- /dev/null +++ b/frontend/fixme.md @@ -0,0 +1,30 @@ +# Fix List (CodeRabbit Review) + +## Bugs — fix immediately + +- [ ] `League.tsx` — add null check before `Object.hasOwn(leagueJson, 'user_id')`, throws if fetch returns null +- [ ] `CreationModal.tsx` — race condition on `errors` state in `handleSubmit`, use local tracking variable instead of reading stale state +- [ ] `CreationModal.tsx` — `validateUnique` is passed `value` instead of `fieldName` as second argument +- [ ] `useResource.ts` — guard `response.items ?? []`, undefined will break rendering +- [ ] `Leagues.tsx` — rename reloads even if API call failed, check response before calling `setReloadKey` +- [ ] `DisplayResourceGeneric.tsx` — add button never shows when `totalPages === 0`, condition should be `totalPages === 0 || pageNum === totalPages` + +## Defensive — good practice + +- [ ] `fetchLeaguesIndex.ts` — add `response.ok` check, 4xx/5xx silently proceed to `.json()` +- [ ] `fetchLeaguesSpecific.ts` — same missing `response.ok` check +- [ ] `renameLeague.ts` — same missing `response.ok` check +- [ ] `errorSlice.ts` — replace `??` with `||` to catch empty string payloads +- [ ] `ProtectedRoutes.tsx` — add cleanup to `useEffect` so loader doesn't get stuck on unmount + +## Polish + +- [ ] `ExtendableContextMenu.tsx` — hardcoded `white`/`black` colours, swap for `var(--color-bg-card)` and `var(--color-border)` +- [ ] `ExtendableContextMenu.tsx` — tautological ternary `'Submit' : 'Submit'`, change to `'Validating...' : 'Submit'` +- [ ] `CreationModal.tsx` — tautological ternary `'Create' : 'Create'`, change to `'Validating...' : 'Create'` +- [ ] `ThemeTest.tsx` — initial dark state hardcoded to `true`, should read from DOM: `!document.documentElement.classList.contains('light')` + +## Skipped + +- `App.tsx` route comments — intentional, ignore +- `ErrorBanner.tsx` aria label — defer to accessibility pass diff --git a/frontend/package-lock.json b/frontend/package-lock.json index da84532..7d9fba5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,16 +24,32 @@ "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react-swc": "^4.1.0", + "autoprefixer": "^10.4.27", "eslint": "^9.36.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.22", "globals": "^16.4.0", + "postcss": "^8.5.9", "prettier": "^3.6.2", + "tailwindcss": "^3.4.19", "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "npm:rolldown-vite@7.1.14" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1639,6 +1655,34 @@ "node": ">=14" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1646,6 +1690,43 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -1678,6 +1759,32 @@ "dev": true, "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.16", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz", + "integrity": "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -1702,6 +1809,40 @@ "node": ">=8" } }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1711,6 +1852,37 @@ "node": ">=6" } }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001786", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz", + "integrity": "sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -1778,6 +1950,44 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1808,6 +2018,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1870,6 +2090,19 @@ "node": ">= 8" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -1945,6 +2178,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -1955,6 +2202,13 @@ "csstype": "^3.0.2" } }, + "node_modules/electron-to-chromium": { + "version": "1.5.332", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.332.tgz", + "integrity": "sha512-7OOtytmh/rINMLwaFTbcMVvYXO3AUm029X0LcyfYk0B557RlPkdpTpnH9+htMlfu5dKwOmT0+Zs2Aw+lnn6TeQ==", + "dev": true, + "license": "ISC" + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -1976,6 +2230,16 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2312,6 +2576,20 @@ "dev": true, "license": "ISC" }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2648,6 +2926,19 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "license": "MIT" }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -2735,6 +3026,16 @@ "dev": true, "license": "ISC" }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3078,6 +3379,19 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -3773,6 +4087,18 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -3799,6 +4125,23 @@ "dev": true, "license": "MIT" }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3808,6 +4151,16 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3979,10 +4332,30 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", "dev": true, "funding": [ { @@ -4008,6 +4381,140 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4238,6 +4745,29 @@ "react-dom": ">=16.6.0" } }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -4540,6 +5070,29 @@ "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", "license": "MIT" }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4565,6 +5118,67 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -4659,6 +5273,13 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -4812,6 +5433,37 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -4845,6 +5497,13 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index d436787..bc0f654 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,11 +26,14 @@ "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react-swc": "^4.1.0", + "autoprefixer": "^10.4.27", "eslint": "^9.36.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.22", "globals": "^16.4.0", + "postcss": "^8.5.9", "prettier": "^3.6.2", + "tailwindcss": "^3.4.19", "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "npm:rolldown-vite@7.1.14" diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/pokeballDark.svg b/frontend/public/pokeballDark.svg new file mode 100644 index 0000000..d24658f --- /dev/null +++ b/frontend/public/pokeballDark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/public/pokeballLight.svg b/frontend/public/pokeballLight.svg new file mode 100644 index 0000000..76cbef4 --- /dev/null +++ b/frontend/public/pokeballLight.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 23c3b00..716a4e4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,44 +15,50 @@ import Login from './pages/home/Login.tsx' import { AuthProvider } from './providers/AuthProvider.tsx' import { ProtectedRoutes } from './components/ProtectedRoutes.tsx' import { LoadingProvider } from './providers/LoadingProvider.tsx' +import { ErrorProvider } from './providers/ErrorProvider.tsx' +import ThemeTest from './pages/test/ThemeTest.tsx' function App() { return ( - -
- + + +
+ - - {/* unprotected routes */} - {/* login / signup */} - } /> - login page - {/* protected routes */} - }> - {/* home routes */} - } /> - home page - } /> - test page - {/* league routes */} - } /> - list of leagues - } /> - specific league - {/* trainer routes */} - } /> - list of trainers in league - } /> - specific trainer - {/* team routes */} - } /> - list of teams for a trainer - } /> - specific team - {/* doc routes */} - } /> - list of docs - } /> - hierarchy for model creation - } /> - leagues information - } /> - trainers information - - -
-
+ + {/* unprotected routes */} + {/* login / signup */} + } />{/* login page */} + {/* DEV TESTING todo: remove these */} + {import.meta.env.DEV && } />}{/* theme test page */} + {/* protected routes */} + }> + {/* home routes */} + } />{/* home page */} + } />{/* test page */} + {/* league routes */} + } />{/* list of leagues */} + } />{/* specific league */} + {/* trainer routes */} + } />{/* list of trainers in league */} + } />{/* specific trainer */} + {/* team routes */} + } />{/* list of teams for a trainer */} + } />{/* specific team */} + {/* doc routes */} + } />{/* list of docs */} + } />{/* hierarchy for model creation */} + } />{/* leagues information */} + } />{/* trainers information */} + + +
+
+
) } diff --git a/frontend/src/Main.tsx b/frontend/src/Main.tsx index 4a8cd6b..586b24f 100644 --- a/frontend/src/Main.tsx +++ b/frontend/src/Main.tsx @@ -1,10 +1,23 @@ import React from 'react' +import './index.css' +import './styles/theme.css' import ReactDOM from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import App from './App.tsx' import { store } from './redux/store.ts' import { Provider } from 'react-redux' +const prefersDark = + typeof window !== 'undefined' && + typeof document !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-color-scheme: dark)').matches + +// only apply light if system explicitly prefers it +if (typeof document !== 'undefined' && !prefersDark) { + document.documentElement.classList.add('light') +} + ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/components/Pokeball.tsx b/frontend/src/components/Pokeball.tsx new file mode 100644 index 0000000..c2a45d3 --- /dev/null +++ b/frontend/src/components/Pokeball.tsx @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react' + +type Props = { + className?: string + alt?: string +} + +/** + * Pokeball image that swaps between light/dark variants based on theme. + * Light variant is slightly scaled up to match dark variant's visual size. + */ +export default function Pokeball({ className, alt = 'pokeball' }: Props) { + const [isDark, setIsDark] = useState(!document.documentElement.classList.contains('light')) + + // watch for theme class changes on + useEffect(() => { + const observer = new MutationObserver(() => { + setIsDark(!document.documentElement.classList.contains('light')) + }) + observer.observe(document.documentElement, { attributeFilter: ['class'] }) + return () => observer.disconnect() + }, []) + + return ( + {alt} + ) +} diff --git a/frontend/src/components/ProtectedRoutes.tsx b/frontend/src/components/ProtectedRoutes.tsx index 2337c58..921bb05 100644 --- a/frontend/src/components/ProtectedRoutes.tsx +++ b/frontend/src/components/ProtectedRoutes.tsx @@ -1,17 +1,25 @@ import { Outlet, Navigate } from 'react-router-dom' import { loggedIn } from '../redux/userSlice.ts' import { useSelector } from 'react-redux' -import ShowLoader from './load/ShowLoader.tsx' import { useAuth } from '../helpers/exports/exportAuth.ts' +import { startLoading, stopLoading } from '../helpers/exports/exportLoading.ts' +import { useEffect } from 'react' // wrapper for every route that requires authentication export function ProtectedRoutes() { const isLoggedIn = useSelector(loggedIn) - - // show loader if useAuth context shows a loading state const { loading } = useAuth() - const loaderElement = ShowLoader(loading,

auth loading

) - if (loaderElement) return loaderElement + + // use global loader for auth checks as it blocks the entire app + useEffect(() => { + if (loading) { + startLoading() + } else { + stopLoading() + } + }, [loading]) + + if (loading) return null if (!isLoggedIn) return ( diff --git a/frontend/src/components/api/DisplayResourceGeneric.tsx b/frontend/src/components/api/DisplayResourceGeneric.tsx new file mode 100644 index 0000000..c113c81 --- /dev/null +++ b/frontend/src/components/api/DisplayResourceGeneric.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState, type MouseEvent } from 'react' +import { Link, useSearchParams } from 'react-router-dom' +import { useResource } from '../../hooks/load/useResource.ts' +import ShowLoader from '../load/ShowLoader.tsx' + +export type Item = { + id: number + name: string +} + +type Props = { + fetchFn: (page: number) => Promise + path: (id: number) => string + onAdd?: () => void + onContextMenu?: (e: MouseEvent, item: Item) => void +} + +/** + * Generic resource list with pagination. + * Handles its own data fetching via useResource. + * Page number is persisted in URL search params. + */ +export default function DisplayResourceGeneric({ fetchFn, path, onAdd, onContextMenu }: Props) { + const [searchParams, setSearchParams] = useSearchParams() + const pageNum = Math.max(1, parseInt(searchParams.get('page') ?? '1') || 1) + const setPageNum = (page: number) => setSearchParams({ page: String(page) }) + + const { items, totalPages, load } = useResource(fetchFn, pageNum) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + setIsLoading(true) + load() + .catch(err => console.error(err)) + .finally(() => setIsLoading(false)) + }, [load]) + + return ( +
+ {/* show loading text while fetching, otherwise show items */} + {ShowLoader(isLoading,
Loading...
)} + {!isLoading && + items.map((item: Item) => ( +
+ onContextMenu?.(e, item)} + > + {item.name} + +
+ ))} + {pageNum === totalPages && onAdd && } +
+ {pageNum > 1 && } + {pageNum > 1 && } + + Page {pageNum} of {totalPages} + + {pageNum < totalPages && } + {pageNum < totalPages && } +
+
+ ) +} diff --git a/frontend/src/components/context/ExtendableContextMenu.tsx b/frontend/src/components/context/ExtendableContextMenu.tsx new file mode 100644 index 0000000..1f3e8c6 --- /dev/null +++ b/frontend/src/components/context/ExtendableContextMenu.tsx @@ -0,0 +1,162 @@ +import { useRef, useState } from 'react' + +type ContextMenuField = + | { type: 'text'; label: string; onClick?: () => void } + | { + type: 'input' + label: string + onSubmit: (value: string) => void | Promise + placeholder?: string + validate?: (value: string) => Promise + validationError?: string + } + +type Props = { + x: number + y: number + title: string + fields: ContextMenuField[] +} + +/** + * Generic context menu component. + * Supports text labels and input fields. + * Submit button is shared across all fields and sits at the bottom. + */ +export default function ContextMenu({ x, y, title, fields }: Props) { + const [values, setValues] = useState>({}) + const [validating, setValidating] = useState>({}) + const [valid, setValid] = useState>({}) + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + + // track latest value per field to discard stale async validation responses + const latestValues = useRef>({}) + + const handleChange = async ( + i: number, + field: Extract, + val: string + ) => { + latestValues.current[i] = val + setValues(prev => ({ ...prev, [i]: val })) + setErrors(prev => ({ ...prev, [i]: '' })) + setValid(prev => ({ ...prev, [i]: null })) + + if (field.validate && val.length > 0) { + setValidating(prev => ({ ...prev, [i]: true })) + try { + const isValid = await field.validate(val) + // discard if a newer value has been typed since this validation started + if (latestValues.current[i] !== val) return + setValid(prev => ({ ...prev, [i]: isValid })) + if (!isValid) + setErrors(prev => ({ ...prev, [i]: field.validationError ?? 'Invalid value' })) + } catch { + // treat validator errors as invalid to be safe + if (latestValues.current[i] === val) { + setValid(prev => ({ ...prev, [i]: false })) + setErrors(prev => ({ ...prev, [i]: 'Validation failed' })) + } + } finally { + if (latestValues.current[i] === val) { + setValidating(prev => ({ ...prev, [i]: false })) + } + } + } else { + setValidating(prev => ({ ...prev, [i]: false })) + } + } + + const handleSubmit = async () => { + if (submitting) return + + setSubmitting(true) + try { + await Promise.all( + fields.map((field, i) => + field.type === 'input' && values[i] !== undefined + ? Promise.resolve(field.onSubmit(values[i])) + : Promise.resolve() + ) + ) + } catch (error) { + console.error(error) + } finally { + setSubmitting(false) + } + } + + const isAnyValidating = Object.values(validating).some(v => v) + const isAnyInvalid = Object.values(valid).some(v => v === false) + const isAnyPending = fields.some( + (field, i) => + field.type === 'input' && field.validate && values[i]?.length > 0 && valid[i] === null + ) + + return ( +
e.stopPropagation()} + style={{ + position: 'fixed', + top: y, + left: x, + background: 'var(--color-bg-card)', + border: '1px solid var(--color-border)', + padding: '8px', + zIndex: 9999, + }} + > + {title} +
+ {fields.map((field, i) => { + if (field.type === 'text') { + return ( +
+ +
+ ) + } + + if (field.type === 'input') { + const inputId = `context-menu-field-${i}-${field.label.replace(/\s+/g, '-').toLowerCase()}` + const errorId = `${inputId}-error` + + return ( +
+ + handleChange(i, field, e.target.value)} + aria-invalid={!!errors[i]} + aria-describedby={errors[i] ? errorId : undefined} + /> + {errors[i] && ( + + {errors[i]} + + )} +
+ ) + } + })} +
+ + {/* shared submit button at the bottom */} + {fields.some(f => f.type === 'input') && ( + + )} +
+ ) +} diff --git a/frontend/src/components/error/ErrorBanner.tsx b/frontend/src/components/error/ErrorBanner.tsx new file mode 100644 index 0000000..46b4e67 --- /dev/null +++ b/frontend/src/components/error/ErrorBanner.tsx @@ -0,0 +1,36 @@ +import { clearError } from '../../helpers/exports/exportError.ts' + +export default function ErrorBanner({ message }: { message: string }) { + return ( +
+ {message} + +
+ ) +} diff --git a/frontend/src/components/leagues/LeagueContextMenu.tsx b/frontend/src/components/leagues/LeagueContextMenu.tsx index 97b7010..0c7dca8 100644 --- a/frontend/src/components/leagues/LeagueContextMenu.tsx +++ b/frontend/src/components/leagues/LeagueContextMenu.tsx @@ -1,38 +1,28 @@ -/** - * Right-click context menu for leagues - * - * @param x horiz pos - * @param y vert pos - */ -export default function LeagueContextMenu({ x, y }: { x: number; y: number }) { +import ContextMenu from '../context/ExtendableContextMenu.tsx' +import { isNameUnique } from '../../helpers/validation/checkUniqueness.ts' + +type Props = { + x: number + y: number + onRename: (name: string) => void | Promise +} + +export default function LeagueContextMenu({ x, y, onRename }: Props) { return ( -
-
    -
  • -

    hard coded for now:

    -
  • -
  • - -
  • -
  • - -
  • -
-
+ isNameUnique('categories', value), + validationError: 'Name already taken', + }, + ]} + /> ) } diff --git a/frontend/src/components/load/GlobalLoader.tsx b/frontend/src/components/load/GlobalLoader.tsx new file mode 100644 index 0000000..c3ec367 --- /dev/null +++ b/frontend/src/components/load/GlobalLoader.tsx @@ -0,0 +1,26 @@ +import Pokeball from '../Pokeball.tsx' + +type Props = { + opaque?: boolean +} + +export default function GlobalLoader({ opaque = false }: Props) { + return ( +
+ +
+ ) +} diff --git a/frontend/src/components/load/LoadResourceIndex.tsx b/frontend/src/components/load/LoadResourceIndex.tsx deleted file mode 100644 index 699e220..0000000 --- a/frontend/src/components/load/LoadResourceIndex.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useCallback } from 'react' -import { startLoading, stopLoading } from '../../helpers/exports/exportLoading.ts' -import fetchLeaguesIndex from '../../helpers/leagues/fetchLeaguesIndex.ts' -import { Link } from 'react-router-dom' - -/** - * Abstract component to load all resources for a given category todo: fully abstract and extend in LoadLeague.tsx - */ -export default async function LoadResourceIndex({ pageNum }: any) { - startLoading() - const response = await fetchLeaguesIndex(pageNum) - - // // get total pages from Yii2 response _meta - // if (response._meta) { - // setTotalPages(response._meta.pageCount) - // } - - const league = response.items.map((item: any) => { - return { - button: ( - - ), - } - }) - stopLoading() -} diff --git a/frontend/src/components/load/ShowLoader.tsx b/frontend/src/components/load/ShowLoader.tsx index 878d1be..6563037 100644 --- a/frontend/src/components/load/ShowLoader.tsx +++ b/frontend/src/components/load/ShowLoader.tsx @@ -1,28 +1,12 @@ import React from 'react' +import GlobalLoader from './GlobalLoader.tsx' // decides whether a loader should be shown export default function ShowLoader(isLoading: boolean, loader?: React.ReactNode) { // fallback to default global loader if (!loader) { - loader = ( -
-
Loading...
-
- ) + loader = } - if (isLoading) return
{loader}
+ if (isLoading) return <>{loader} return null // Return null when not loading, so parent can continue } diff --git a/frontend/src/components/modals/CreationModal.tsx b/frontend/src/components/modals/CreationModal.tsx index 9e21aba..4d6e05d 100644 --- a/frontend/src/components/modals/CreationModal.tsx +++ b/frontend/src/components/modals/CreationModal.tsx @@ -17,7 +17,7 @@ export interface CreationModalProps { title: string fields: FormField[] onSubmit: ({ data, onReload }: ResourceCreation) => Promise - onReload?: () => Promise + onReload?: () => Promise | void onCancel: () => void isVisible: boolean } @@ -49,34 +49,57 @@ export default function CreationModal({ }) return initial } + const [formData, setFormData] = useState>(setFormsBlank()) - const [error, setError] = useState('') + const [errors, setErrors] = useState>({}) + const [submitError, setSubmitError] = useState('') + const [isValidating, setIsValidating] = useState(false) const portalRoot = document.getElementById('portal-root') - const handleChange = (fieldName: string, value: string) => { + const handleChange = async (fieldName: string, value: string) => { setFormData(prev => ({ ...prev, [fieldName]: value })) + + const field = fields.find(f => f.name === fieldName) + if (!field) return + + // clear error on change + setErrors(prev => ({ ...prev, [fieldName]: '' })) + + // validate unique mid-typing + if (field.unique && field.validateUnique && value.length > 0) { + setIsValidating(true) + const unique = await field.validateUnique(tableName, value) + if (!unique) { + setErrors(prev => ({ ...prev, [fieldName]: `${field.placeholder} must be unique` })) + } + setIsValidating(false) + } } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() for (const field of fields) { - // Validate required fields + // validate required fields if (field.required && formData[field.name].trim() === '') { - setError(`${field.placeholder} cannot be empty`) + setSubmitError(`${field.placeholder} cannot be empty`) return } - // validate unique fields + // validate unique fields on submit as fallback if (field.unique && field.validateUnique) { if (!(await field.validateUnique(tableName, formData[field.name]))) { - setError(`${field.placeholder} must be unique`) + setErrors(prev => ({ ...prev, [field.name]: `${field.placeholder} must be unique` })) return } } } - await onSubmit({ tableName, data: formData, onReload }) + // block submit if any field errors exist + if (Object.values(errors).some(e => e !== '')) return + + await onSubmit({ tableName, data: formData }) + await onReload?.() pipeCancel() // close modal after successful submission (must be changed if onCancel is ever not just `closing window`) setFormData(setFormsBlank()) } @@ -84,7 +107,8 @@ export default function CreationModal({ // clear all fields then run onCancel callback function pipeCancel(): void { setFormData(setFormsBlank()) - setError('') + setErrors({}) + setSubmitError('') onCancel() } @@ -132,9 +156,18 @@ export default function CreationModal({ placeholder={field.placeholder} autoFocus={index === 0} /> + {/* per-field validation error */} + {errors[field.name] && ( + {errors[field.name]} + )} ))} - +

League: '{league?.name}'

-
diff --git a/frontend/src/pages/leagues/Leagues.tsx b/frontend/src/pages/leagues/Leagues.tsx index 4fb6e01..a3163f7 100644 --- a/frontend/src/pages/leagues/Leagues.tsx +++ b/frontend/src/pages/leagues/Leagues.tsx @@ -1,12 +1,13 @@ -import { useCallback, useEffect, useState } from 'react' -import { Link } from 'react-router-dom' +import { useState, type MouseEvent as ReactMouseEvent } from 'react' import LeagueContextMenu from '../../components/leagues/LeagueContextMenu.tsx' import { createPortal } from 'react-dom' import LeagueCreationModal from '../../components/leagues/LeagueCreationModal.tsx' -import fetchLeaguesIndex from '../../helpers/leagues/fetchLeaguesIndex.ts' -import { startLoading, stopLoading } from '../../helpers/exports/exportLoading.ts' -import { isNameUnique } from '../../helpers/checkUniqueness.ts' +import { isNameUnique } from '../../helpers/validation/checkUniqueness.ts' import { createNewResource } from '../../helpers/manageResource/createNewResource.ts' +import DisplayResourceGeneric, { type Item } from '../../components/api/DisplayResourceGeneric.tsx' +import fetchLeaguesIndex from '../../helpers/leagues/fetchLeaguesIndex.ts' +import { useSearchParams } from 'react-router-dom' +import renameLeagueApi from '../../helpers/leagues/renameLeague.ts' /** * Home page for leagues. @@ -14,47 +15,31 @@ import { createNewResource } from '../../helpers/manageResource/createNewResourc * Displays a list of leagues the user has access to, and options to create, edit, etc. */ export default function Leagues() { - const [leagues, setLeague] = useState<{ button: any }[]>([]) const [isLeagueContextMenuVisible, setIsLeagueContextMenuVisible] = useState(false) const [isLeagueCreationMenuVisible, setIsLeagueCreationMenuVisible] = useState(false) + const [contextMenuItem, setContextMenuItem] = useState(null) const [leagueContextMenuPosition, setLeagueContextMenuPosition] = useState<{ x: number y: number - }>({ - x: 0, - y: 0, - }) + }>({ x: 0, y: 0 }) + const [searchParams] = useSearchParams() + const pageNum = parseInt(searchParams.get('page') ?? '1') + const [reloadKey, setReloadKey] = useState(1) const portalRoot = document.getElementById('portal-root') - const [pageNum, setPageNum] = useState(1) - const [totalPages, setTotalPages] = useState(1) - - const loadLeagues = useCallback(async () => { - startLoading() - const leaguesJson = await fetchLeaguesIndex(pageNum) - - // get total pages from Yii2 response _meta - if (leaguesJson._meta) { - setTotalPages(leaguesJson._meta.pageCount) - } - - const league = leaguesJson.items.map((item: any) => { - return { - button: ( - - ), - } - }) - setLeague(league) - stopLoading() - }, [pageNum]) // right-click context menu for editing leagues - const showLeagueContextMenu = (c: any) => { + const showLeagueContextMenu = (c: ReactMouseEvent, item: Item) => { c.preventDefault() setIsLeagueContextMenuVisible(true) setLeagueContextMenuPosition({ x: c.pageX, y: c.pageY }) + setContextMenuItem(item) + } + + // rename the league via api + const renameLeague = async (name: string) => { + if (!contextMenuItem) return + const result = await renameLeagueApi(contextMenuItem.id, name) + if (result) setReloadKey(k => k * -1) } const hideLeagueContextMenu = (c: any) => { @@ -62,29 +47,27 @@ export default function Leagues() { setIsLeagueContextMenuVisible(false) } - useEffect(() => { - loadLeagues().catch(err => { - console.error(err) - // todo: display error message - }) - }, [loadLeagues]) - return ( -
- +
+ {/*right-click menu for editing leagues - todo*/} {isLeagueContextMenuVisible && createPortal( , portalRoot ?? document.body )} + {/*popup when creating a new league*/} setReloadKey(k => k * -1)} onCancel={() => setIsLeagueCreationMenuVisible(false)} fields={[ { @@ -103,19 +86,14 @@ export default function Leagues() { ]} /> -
- {leagues.map(league => league.button)} {/* todo: replace with abstract component */} - {pageNum === totalPages && ( - - )} -
- {pageNum > 1 && } - - Page {pageNum} of {totalPages} - - {pageNum < totalPages && } -
-
+ {/*display all loaded leagues*/} + `/League/${id}`} + onAdd={() => setIsLeagueCreationMenuVisible(true)} + onContextMenu={(e, item) => showLeagueContextMenu(e, item)} + />
) } diff --git a/frontend/src/pages/test/Test.tsx b/frontend/src/pages/test/Test.tsx index cb8fc08..f95c2c8 100644 --- a/frontend/src/pages/test/Test.tsx +++ b/frontend/src/pages/test/Test.tsx @@ -1,8 +1,10 @@ import { useState } from 'react' +import { useNavigate } from 'react-router-dom' import { BASE_URL } from '../../helpers/exports/exportEnv.ts' export default function Test() { const [health, setHealth] = useState('') + const navigate = useNavigate() async function healthCheck() { try { @@ -20,6 +22,8 @@ export default function Test() {
{health}
+ +
) } diff --git a/frontend/src/pages/test/ThemeTest.tsx b/frontend/src/pages/test/ThemeTest.tsx new file mode 100644 index 0000000..662eb32 --- /dev/null +++ b/frontend/src/pages/test/ThemeTest.tsx @@ -0,0 +1,105 @@ +import { useState } from 'react' +import Pokeball from '../../components/Pokeball.tsx' + +/** + * Theme test page - visual reference for all CSS variables in theme.css + * Visit /Test to see this page + */ +export default function ThemeTest() { + const [dark, setDark] = useState(!document.documentElement.classList.contains('light')) + + // toggle dark/light class on + const toggleTheme = () => { + setDark(d => !d) + document.documentElement.classList.toggle('light') + } + + return ( +
+ {/* ── Toggle ── */} +
+ +
+ + {/* ── League cards ── */} +

League cards

+
+ {['Kanto League', 'Johto League', 'Hoenn League'].map((name, i) => ( +
+ +
+
{name}
+
{i + 3} trainers
+
+
+ + Active + + Page {i + 1} +
+
+ ))} + + {/* add card */} +
+ + +
+
+ + {/* ── Trainer card ── */} +

Trainer card

+
+
+
+ A +
+
+
Ash Ketchum
+
Pallet Town
+
+ +
+
+ {[ + ['Badges', '8'], + ['Wins', '42'], + ['Losses', '7'], + ].map(([label, val]) => ( +
+ {label} + {val} +
+ ))} +
+ +
+ + {/* ── Error / success states ── */} +

States

+
+
+ Something went wrong — please try again +
+
+ League created successfully +
+
+
+ ) +} diff --git a/frontend/src/providers/ErrorProvider.tsx b/frontend/src/providers/ErrorProvider.tsx new file mode 100644 index 0000000..12c3803 --- /dev/null +++ b/frontend/src/providers/ErrorProvider.tsx @@ -0,0 +1,15 @@ +import { useSelector } from 'react-redux' +import { selectError } from '../redux/errorSlice.ts' +import ErrorBanner from '../components/error/ErrorBanner.tsx' +import React from 'react' + +export function ErrorProvider({ children }: { children: React.ReactNode }) { + const { error, errorMsg } = useSelector(selectError) + + return ( + <> + {error && } + {children} + + ) +} diff --git a/frontend/src/providers/LoadingProvider.tsx b/frontend/src/providers/LoadingProvider.tsx index c419b7e..94bb838 100644 --- a/frontend/src/providers/LoadingProvider.tsx +++ b/frontend/src/providers/LoadingProvider.tsx @@ -1,36 +1,28 @@ import { useSelector } from 'react-redux' -import { loading } from '../redux/loadingSlice.ts' +import { loading, opaque } from '../redux/loadingSlice.ts' import ShowLoader from '../components/load/ShowLoader.tsx' +import GlobalLoader from '../components/load/GlobalLoader.tsx' +import { useEffect, useState } from 'react' import React from 'react' export function LoadingProvider({ children }: { children: React.ReactNode }) { const isLoading = useSelector(loading) + const isOpaque = useSelector(opaque) + const [showLoader, setShowLoader] = useState(false) - // overlay semi-opaque whitewash plus loader - const globalLoader = ( -
-
Loading...
{' '} - {/* todo: replace with a nicer loader */} -
- ) + // delay loader appearance to avoid flash on fast loads // todo: tweak or even remove this delay if it feels unresponsive. we wont know until its in prod mode and debugging isn't slowing loading time + useEffect(() => { + if (isLoading) { + const timer = setTimeout(() => setShowLoader(true), 0) + return () => clearTimeout(timer) + } else { + setShowLoader(false) + } + }, [isLoading]) - // return loader with children under to maintain state return ( <> - {ShowLoader(isLoading, globalLoader)} + {ShowLoader(showLoader, )} {children} ) diff --git a/frontend/src/redux/errorSlice.ts b/frontend/src/redux/errorSlice.ts new file mode 100644 index 0000000..f0a36c7 --- /dev/null +++ b/frontend/src/redux/errorSlice.ts @@ -0,0 +1,24 @@ +import { createSlice } from '@reduxjs/toolkit' +import type { PayloadAction } from '@reduxjs/toolkit' +import type { RootState } from './store.ts' + +export const errorSlice = createSlice({ + name: 'error', + initialState: { error: false, errorMsg: '' }, + + reducers: { + setError(state, action: PayloadAction) { + state.error = true + state.errorMsg = action.payload ?? 'Something went wrong' + }, + resetError(state) { + state.error = false + state.errorMsg = '' + }, + }, +}) + +export const { setError, resetError } = errorSlice.actions +export const selectError = (state: RootState) => state.error +export const selectErrorMsg = (state: RootState) => state.error.errorMsg +export default errorSlice.reducer diff --git a/frontend/src/redux/loadingSlice.ts b/frontend/src/redux/loadingSlice.ts index b7aea4f..17c35c5 100644 --- a/frontend/src/redux/loadingSlice.ts +++ b/frontend/src/redux/loadingSlice.ts @@ -1,20 +1,24 @@ import { createSlice } from '@reduxjs/toolkit' +import type { PayloadAction } from '@reduxjs/toolkit' import type { RootState } from './store.ts' export const loadingSlice = createSlice({ name: 'loading', - initialState: { loading: false }, + initialState: { loading: false, opaque: false }, reducers: { - startLoad(state) { + startLoad(state, action: PayloadAction<{ opaque?: boolean }> | undefined) { state.loading = true + state.opaque = action?.payload?.opaque ?? false }, stopLoad(state) { state.loading = false + state.opaque = false }, }, }) export const { startLoad, stopLoad } = loadingSlice.actions export const loading = (state: RootState) => state.loading.loading +export const opaque = (state: RootState) => state.loading.opaque export default loadingSlice.reducer diff --git a/frontend/src/redux/store.ts b/frontend/src/redux/store.ts index f9bd0e9..73fb843 100644 --- a/frontend/src/redux/store.ts +++ b/frontend/src/redux/store.ts @@ -1,11 +1,13 @@ import { configureStore } from '@reduxjs/toolkit' import userSlice from './userSlice.ts' import loadingSlice from './loadingSlice.ts' +import errorReducer from './errorSlice.ts' export const store = configureStore({ reducer: { user: userSlice, loading: loadingSlice, + error: errorReducer, }, }) diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css new file mode 100644 index 0000000..ad6ef98 --- /dev/null +++ b/frontend/src/styles/theme.css @@ -0,0 +1,61 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ─── Dark Mode (default) ────────────────────────────────── */ +:root { + --color-bg: #121212; + --color-bg-secondary: #1e1e1e; + --color-bg-card: #1e1e1e; + --color-bg-overlay: rgba(0, 0, 0, 0.7); + + --color-accent: #e74c3c; + --color-accent-hover: #c0392b; + --color-accent-muted: rgba(231, 76, 60, 0.15); + + --color-text: #f0f0f0; + --color-text-muted: #999999; + --color-text-inverse: #ffffff; + + --color-border: #2e2e2e; + --color-border-strong:#444444; + + --color-shadow: rgba(0, 0, 0, 0.3); + --color-shadow-strong:rgba(0, 0, 0, 0.6); + + --color-error: #e74c3c; + --color-success: #2ecc71; + --color-disabled: #555555; +} + +/* ─── Light Mode (opt-in via .light class on ) ─────── */ +:root.light { + --color-bg: #ffffff; + --color-bg-secondary: #f8f8f8; + --color-bg-card: #f8f8f8; + --color-bg-overlay: rgba(0, 0, 0, 0.5); + + --color-accent: #c0392b; + --color-accent-hover: #a93226; + --color-accent-muted: rgba(192, 57, 43, 0.15); + + --color-text: #1a1a1a; + --color-text-muted: #666666; + --color-text-inverse: #ffffff; + + --color-border: #dddddd; + --color-border-strong:#bbbbbb; + + --color-shadow: rgba(0, 0, 0, 0.08); + --color-shadow-strong:rgba(0, 0, 0, 0.2); + + --color-error: #c0392b; + --color-success: #27ae60; + --color-disabled: #aaaaaa; +} diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..3e04556 --- /dev/null +++ b/frontend/tailwind.config.ts @@ -0,0 +1,39 @@ +import type { Config } from 'tailwindcss' + +export default { + content: ['./index.html', './src/**/*.{ts,tsx}'], + darkMode: 'class', + corePlugins: { + preflight: false, + }, + theme: { + extend: { + colors: { + bg: { + DEFAULT: 'var(--color-bg)', + secondary: 'var(--color-bg-secondary)', + card: 'var(--color-bg-card)', + overlay: 'var(--color-bg-overlay)', + }, + accent: { + DEFAULT: 'var(--color-accent)', + hover: 'var(--color-accent-hover)', + muted: 'var(--color-accent-muted)', + }, + text: { + DEFAULT: 'var(--color-text)', + muted: 'var(--color-text-muted)', + inverse: 'var(--color-text-inverse)', + }, + border: { + DEFAULT: 'var(--color-border)', + strong: 'var(--color-border-strong)', + }, + error: 'var(--color-error)', + success: 'var(--color-success)', + disabled: 'var(--color-disabled)', + }, + }, + }, + plugins: [], +} satisfies Config