Private
Public Access
1
0

Compare commits

...

50 Commits

Author SHA1 Message Date
13adf908bf chg: dev: small changes on docs - and improve text on homepage #8
All checks were successful
Deploy to Production / deploy (push) Successful in 3m14s
2026-04-21 11:47:21 +02:00
3bbfb8740f chg: dev: massive refactor on front-end for unification and readiness #8 2026-04-21 11:30:07 +02:00
0d04ec91e7 fix: usr: do not hide the end-game overlay ever #8 2026-04-21 08:48:44 +02:00
20a969705d chg: dev: update all doc blocks on back-end #8 2026-04-20 21:24:39 +02:00
4944d2aa21 chg: dev: small refactors on back-end #8 2026-04-20 21:11:17 +02:00
2ec37a802b chg: dev: add RecentBattle entity that is a Materialized View to speed up the view - and further refactor on ProfileController #8 2026-04-20 21:08:15 +02:00
6a5ba84b5e chg: dev: create the UserStats entity what is a Materialized View to store Profile stats for every user - & massive ProfileController refactor #8 2026-04-20 20:44:33 +02:00
6be0d52fb7 chg: dev: refactor the SecurityController #7 2026-04-20 12:13:08 +02:00
f493f94368 chg: dev: refactor the code - there was unnecessary codes and wrongly formatted or designed code that are related to Repositories #7 2026-04-20 11:10:00 +02:00
cd93a26c2c fix: usr: the username was not recognized properly #7 2026-04-20 10:50:58 +02:00
175581cdd5 chg: pkg: upgrade the doctrine related back-end pkgs to the latest available version #7 2026-04-20 09:05:36 +02:00
5f856e4d70 chg: usr: add filter to the Profile page's recent plays and an infite list too #7 2026-04-19 22:11:58 +02:00
e0495d182e chg: pkg: upgrade to the latest doctrine pkg on back-end #7 2026-04-19 22:09:03 +02:00
0b7c1406cf chg: pkg: new version release !skipChangelog 2026-04-19 21:41:33 +02:00
30edc5782b fix: usr: the PostgreSQL logo was horrible #7
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-04-19 21:41:04 +02:00
d92a7f3aa0 chg: pkg: new version release !skipChangelog 2026-04-19 21:31:44 +02:00
f72cd45afd chg: pkg: new version release !skipChangelog
All checks were successful
Deploy to Production / deploy (push) Successful in 15s
2026-04-19 21:31:22 +02:00
51bd909879 chg: usr: add ReCaptcha overlay again to protect the game #7 2026-04-19 21:31:08 +02:00
db37ab45b2 chg: pkg: upgrade all front-end packages to the latest available version - and fix all eslint warnings & errors #7 2026-04-19 21:04:15 +02:00
9256db7f8c chg: pkg: upgrade fe packages #7 2026-04-19 20:57:00 +02:00
d9059acb78 chg: dev: massive refactor on fetches - create centralized dataProvider #7 2026-04-19 20:56:51 +02:00
5da8a04c18 chg: pkg: new version release !skipChangelog 2026-04-19 18:33:18 +02:00
ba8a0befb0 new: pkg: add Firebase deps to back-end #7
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-04-19 18:32:45 +02:00
5ac291de81 new: usr: add missing buttons for overlays #7 2026-04-19 18:22:28 +02:00
991b114a3c new: usr: a new feature came up - the abandoned plays can be restored, if both users are registered users #7 2026-04-19 18:04:01 +02:00
c79584c7d2 chg: usr: fix the '0' in Battle reports #6 2026-04-19 09:25:58 +02:00
e77c8a8f7c chg: usr: fix missing icons on "Battle report" #6 2026-04-19 09:10:17 +02:00
c2308ba408 fix: usr: the bomb using was not recorded correctly - the old data will be corrupted #6 2026-04-19 09:05:53 +02:00
e5a22cdfe3 chg: pkg: upgrade fe deps #5
All checks were successful
Deploy to Production / deploy (push) Successful in 17s
2026-04-18 22:12:20 +02:00
09b0d21621 new: usr: add new profile charts and stats - & add new logo to the tech stack #5 2026-04-18 22:12:07 +02:00
9aef27a0eb chg: usr: improve the Battle reports to change unnecessary data with interesting data #5 2026-04-18 17:56:50 +02:00
c00ed57240 fix: dev: the react is crashing on some cases #5 2026-04-18 17:53:02 +02:00
ef4cf6ef69 chg: pkg: new version release !skipChangelog 2026-04-18 13:45:10 +02:00
dc9c5f6545 chg: usr: add extended data to battle reports and sharing image to make viewable bonus points #5
All checks were successful
Deploy to Production / deploy (push) Successful in 12s
2026-04-18 13:44:15 +02:00
25f2aaab8c new: usr: add initialization bonus points' system to the gameplay #5 2026-04-18 12:57:20 +02:00
0cc9cdaf07 chg: pkg: new version release !skipChangelog 2026-04-18 11:44:18 +02:00
247f437445 fix: pkg: the font-awesome simplifying to work on bare-metal - & fix all warnings at build time #4
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-04-18 11:42:46 +02:00
0e94367223 new: usr: add rules page #4 2026-04-18 11:11:52 +02:00
a9ee28b395 fix: usr: the css problem had been solved on reponsive gfx on homepage #4 2026-04-18 10:34:46 +02:00
bd074c5c9d chg: pkg: new version release !skipChangelog 2026-04-18 08:49:59 +02:00
42c552c528 fix: usr: quickfix for https-only login - & add user data when the user is not logged in #4
All checks were successful
Deploy to Production / deploy (push) Successful in 1m55s
2026-04-18 08:49:10 +02:00
3b376e5386 chg: pkg: new version release !skipChangelog 2026-04-16 11:56:30 +02:00
45a8e6b4a1 chg: dev: add consent checkbox to user's registration - and fix the sharing pics #4
All checks were successful
Deploy to Production / deploy (push) Successful in 15s
2026-04-16 11:56:10 +02:00
1f8e9c3c56 chg: pkg: add correct version numbering and CHANGELOG - and add the LICENSE #4 2026-04-16 11:35:53 +02:00
a511b86db8 chg: usr: update all texts on all pages - extend them with the game specific things #4
All checks were successful
Deploy to Production / deploy (push) Successful in 11s
2026-04-16 11:25:08 +02:00
1c0ad054bb chg: pkg: new version release !skipChangelog 2026-04-16 10:41:25 +02:00
5a8799bb7f fix: usr: the meta tags does not have https scheme - nothing worked in configuration #4
All checks were successful
Deploy to Production / deploy (push) Successful in 2m26s
2026-04-16 10:40:56 +02:00
6c443d8e86 chg: pkg: new version release !skipChangelog 2026-04-15 20:24:28 +02:00
8795fedda9 chg: usr: add notification on activation too #4
All checks were successful
Deploy to Production / deploy (push) Successful in 11s
2026-04-15 20:23:41 +02:00
588fb57299 new: usr: add notification email when a user is registered #4 2026-04-15 20:19:29 +02:00
163 changed files with 7896 additions and 3179 deletions

32
.gitchangelog.rc Normal file
View File

@@ -0,0 +1,32 @@
ignore_regexps = [
r'@minor', r'!minor',
r'@cosmetic', r'!cosmetic',
r'@refactor', r'!refactor',
r'@wip', r'!wip',
r'^(.{3,3}\s*:)?\s*[fF]irst commit.?\s*$',
r'^$', ## ignore commits with empty messages
r'@skipChangelog', r'!skipChangelog', r'skipChangeLog', r'!skipChangeLog',
r'Merge branch', r'Merge remote-tracking branch', r'!deploy',
]
section_regexps = [
('New', [
r'^[nN]ew\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$',
]),
('Changes', [
r'^[cC]hg\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$',
]),
('Fix', [
r'^[fF]ix\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$',
]),
('Other', None ## Match all lines
),
]
body_process = ReSub(r'((^|\n)[A-Z]\w+(-\w+)*: .*(\n\s+.*)*)+$', r'') | strip
subject_process = (strip |
ReSub(r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n@]*)(@[a-z]+\s+)*$', r'\4') |
SetIfEmpty("No commit message.") | ucfirst | final_dot)
tag_filter_regexp = r'^(v)?[0-9]+\.[0-9]+(\.[0-9]+)?(\-[0-9]+)?$'
unreleased_version_label = "(unreleased)"
output_engine = mustache("markdown")
include_merge = True
revs = []

View File

@@ -1,194 +1,472 @@
Changelog # Changelog
=========
v2026.2.1 (2026-04-15) ## v2026.2.6-1 (2026-04-19)
----------------------
New ### Fix
~~~
- Add Contact page with email sending behaviour #4. [Lang]
- Add timer for the acceptance of the challenge #4. [Lang]
- Registered users have avatars next to the timer #4. [Lang]
- Add opportunity to use profile picture. #4. [Lang]
- Add more stats and a dialog for the recent battle that can be
shareable #4. [Lang]
- Implement the 2FA authentication (TOTP and backup codes) #4. [Lang]
- Add beta logo to the corner #3. [Lang]
- Add mineseeker game to the symfony 4 project #3. [Lang]
- Upgrade to the latest symfony v4 #3. [Lang]
Changes * The PostgreSQL logo was horrible #7. [Lang]
~~~~~~~
- Change the shareable battle - add avatars to it - even on the og tags
#4. [Lang]
- Change text #4. [Lang]
- Add donation button #4. [Lang]
- Protect the gameplay with recaptcha #4. [Lang]
- The waiting dialog is uncloseable until the time is up #4. [Lang]
- Add share button to the overlay when the game ends #4. [Lang]
- Make fancy og tags - and create a special one for battle sharing #4.
[Lang]
- The user's avatar will be saved as a uuid.extension #4. [Lang]
- Fix missing favicon #4. [Lang]
- Add modern Webauthn authentication #4. [Lang]
- Refactor all forms to have Symfony Form Types & Validation
Constrainsts - & implement Google ReCapthca v3 #4. [Lang]
- Add forgot password functionality #4. [Lang]
- Increase the minimum PHP version to the latest major - and massive
refactor on back-end, like Controllers and Repositories #4. [Lang]
- Redesign the resign dialog #4. [Lang]
- Re-implement the waiting for opponent dialog - refactor its gfx - &
add online user selection dialog #4. [Lang]
- Improve the gfx on homepage - implement login/register and activation
for authentication - and add the first version of profile page #4.
[Lang]
- Refactor and redesign the gfx on front-end #4. [Lang]
- Add timers to each player - renew the whole migration #4. [Lang]
- Use namespaces for front-end #4. [Lang]
- Replace webpack w/ vite & remove old, legacy jQuery from the code #4.
[Lang]
- More, massive refactor for front-end #4. [Lang]
- Massive refactor on front-end - and remove unnecessary deps #4. [Lang]
- Change the code style to fit the current standard #4. [Lang]
- Refactor to use Attributes instead of yaml markdown #4. [Lang]
- Outsource the Grid generation and interactions to the backend #4.
[Lang]
- Remove unnecessary variables and prune the Facebook registration
method #4. [Lang]
- Replace the legacy gos/web-socket-bundle & replace it with Mercure
protocol #4. [Lang]
- Created the first working solution since 7 yrs #4. [Lang]
- Make the first working version - the stepping is broken due to the
algorythm structure #4. [Lang]
- Change the composer default php minimum environment #3. [Lang]
- Change the default url to wss on frontend #3. [Lang]
- Refactor Rpc and Topic classes #3. [Lang]
- Refactor classes and reformat some layout #3. [Lang]
- Remove deprecated files #3. [Lang]
- Doc in README.md #3. [Lang]
- Gitignore a js.map file #2. [Lang]
Fix
~~~
- Missing font-awesome icons on bare-metal environment #4. [Lang]
- Quickfix for email sending #4. [Lang]
Other
~~~~~
- Hg: pkg: new version release !skipChangelog. [Lang]
- Pkg: usr: solve the not-working mailing on dev env under docker #4.
[Lang]
- Deploy version 1.1.0 !deploy #11. [Lang]
1.1.0 (2019-10-26) ## v2026.2.6-0 (2026-04-19)
------------------
Changes ### Changes
~~~~~~~
- Reinit project - disable redis module and make the project compatible * Add ReCaptcha overlay again to protect the game #7. [Lang]
w/ PHP7.3 #2. [Lang]
* Upgrade all front-end packages to the latest available version - and fix all eslint warnings & errors #7. [Lang]
* Upgrade fe packages #7. [Lang]
* Massive refactor on fetches - create centralized dataProvider #7. [Lang]
0.4.0 (2019-10-26) ## v2026.2.5-0 (2026-04-19)
------------------
- Change session driver to REDIS. [Lang] ### New
- Add created, updated field to db && improve graph design. [Lang]
- Cache setup && optimalize for google pagespeed && optimalize all * Add Firebase deps to back-end #7. [Lang]
images. [Lang]
- Improve graph design on homepage && add footer and techs && add * Add missing buttons for overlays #7. [Lang]
official pages. [Lang]
- Bugfix mine websocket periodic mysql calling. [Lang] * A new feature came up - the abandoned plays can be restored, if both users are registered users #7. [Lang]
- Bugfix hwioauth remember me && centralize hwioauth and facebook
settings. [Lang] ### Changes
- Centralize jquery && bugfix mysql auto-termination problem w/ user
auth. [Lang] * Fix the '0' in Battle reports #6. [Lang]
- Release beta4. [Lang]
- Gitignore npm debug log. [Lang] * Fix missing icons on "Battle report" #6. [Lang]
- Add english lang everywhere && add snowfall && add centralized version
nbr && improve stylesheet && slack integration. [Lang] ### Fix
- Bugfix #30 && random bg in game. [Lang]
- Add google analytics and facebook scripts && improve url share method * The bomb using was not recorded correctly - the old data will be corrupted #6. [Lang]
w/ fb && enforce https in prod. [Lang]
- Reg and login buttons on index && remove list method && facebook
centralize. [Lang] ## v2026.2.4-0 (2026-04-18)
- Redesign user frontend. [Lang]
- Mods for performance; one js.min file on prod. [Lang] ### New
- Improve webpack config for prod compile #23. [Lang]
- Ssl handling #22 && reconnection issues #20, #21. [Lang] * Add new profile charts and stats - & add new logo to the tech stack #5. [Lang]
- Facebook prod settings w/ app; hwi/HWIOAuthBundle. [Lang]
- Refact && game reconnection and restore w/o refresh #3 && bugfix bomb ### Changes
explosion on opponent mines #19. [Lang]
- Typo in rpc. [Lang] * Upgrade fe deps #5. [Lang]
- Handle prod mysql timeout && graphics improve. [Lang]
- Gitignore webpacked index.js. [Lang] * Improve the Battle reports to change unnecessary data with interesting data #5. [Lang]
- Add production mods. [Lang]
- Bugfix points saving and exploded bombs to db && you can resign #6. ### Fix
[Lang]
- Bugfix resign button existence #11. [Lang] * The react is crashing on some cases #5. [Lang]
- Bugfix opponent bomb btn buzz on hover #10. [Lang]
- Bugfix points problem in the end #16. [Lang]
- Add desc to every user #9. [Lang] ## v2026.2.3-0 (2026-04-18)
- Clipboard - not working #8. [Lang]
- Random player on start #5. [Lang] ### New
- Show left mines after end #2 && reduce network traffic && better
active field checking method. [Lang] * Add initialization bonus points' system to the gameplay #5. [Lang]
- Some refactor #13. [Lang]
- Bugfix grid field render #12. [Lang] ### Changes
- Game ends after x mines. [Lang]
- Add new sounds && refactor && new bg images && form redesigns. [Lang] * Add extended data to battle reports and sharing image to make viewable bonus points #5. [Lang]
- Bugfix entities gridrow, grid && improve graph design on homepage.
[Lang]
- Some refactor && prod settings. [Lang] ## v2026.2.2-9 (2026-04-18)
- Improve graphics design in game. [Lang]
- Bugfix grid row in entity. [Lang] ### New
- Bugfix changePlayer after bomb explosion. [Lang]
- Improve game graph design. [Lang] * Add rules page #4. [Lang]
- Login and register form more design. [Lang]
- Add basic design to userbundle && refactor. [Lang] ### Fix
- Add font-awesome. [Lang]
- Working user authentication w/ fb and plain login. [Lang] * The font-awesome simplifying to work on bare-metal - & fix all warnings at build time #4. [Lang]
- Add facebook login module, hwi/HWIOAuthBundle. [Lang]
- Login && register form overrided. [Lang] * The css problem had been solved on reponsive gfx on homepage #4. [Lang]
- Js and config refactor. [Lang]
- Replace gridcol object to json array in db. [Lang]
- Refactor. [Lang] ## v2026.2.1-8 (2026-04-18)
- Save steps and point info to db. [Lang]
- Save the step data to db. [Lang] ### Fix
- Renamed the acme to mineseeker && handle when the user connection has
been lost. [Lang] * Quickfix for https-only login - & add user data when the user is not logged in #4. [Lang]
- Add player names to UI. [Lang]
- Add overlay && game do not start until the opponent came. [Lang]
- Add base64 encryption to grid when it has been sended to server. ## v2026.2.1-7 (2026-04-16)
[Lang]
- On click opponents bomb, you cannot target && refactor. [Lang] ### Changes
- Warning when player has been found more than 20 mines. [Lang]
- Bugfix center mine counter animation. [Lang] * Add consent checkbox to user's registration - and fix the sharing pics #4. [Lang]
- The opponent is the next when bomb is exploded. [Lang]
- Current username checked && refactor && remove players in channel when * Add correct version numbering and CHANGELOG - and add the LICENSE #4. [Lang]
they are more than 2. [Lang]
- Send bomb info and use it on opponent. [Lang]
- Add sounds w/ howler. [Lang] ## v2026.2.1-6 (2026-04-16)
- Bugfix multiple empty fields w/ one click on opponent view. [Lang]
- Refact && remove sound and logging && bugfix BIGBUG - handleGridField ### Changes
and showAppropriateFields sort order... [Lang]
- Create first working communication. [Lang] * Update all texts on all pages - extend them with the game specific things #4. [Lang]
- Create entities and repositories. [Lang]
- Changed websocket default port && debug RPC. [Lang]
- Created working session and client handler w/ websocket. [Lang] ## v2026.2.1-5 (2026-04-16)
- Working websocket client and server w/o session handling and storage.
[Lang] ### Fix
- Composer update. [Lang]
- Improve game && start sound creating. [Lang] * The meta tags does not have https scheme - nothing worked in configuration #4. [Lang]
- Refactor grid control and grid field. [Lang]
- Created basic game w/ table and animations. [Lang]
- Websocket basic setup FE & BE && working basic game w/ react && ## v2026.2.1-4 (2026-04-15)
webpack & babel config. [Lang]
- Gitignore node_modules && add symlink to node_modules (just for ### New
install) && basic react. [Lang]
- Add react hello world. [Lang] * Add notification email when a user is registered #4. [Lang]
- Rename project in config. [Lang]
- Initial commit && create project in symfony3. [Lang] ### Changes
* Add notification on activation too #4. [Lang]
## v2026.2.1-2 (2026-04-15)
### Fix
* Another attempt to fix the email assets #4. [Lang]
## v2026.2.1-1 (2026-04-15)
### Fix
* The images does not shows in emails #4. [Lang]
## v2026.2.1-0 (2026-04-15)
### New
* Add Contact page with email sending behaviour #4. [Lang]
### Changes
* Add missing .env variable and increase the version number and add missing data from front-end and back-end deps descriptor #4. [Lang]
* Change the shareable battle - add avatars to it - even on the og tags #4. [Lang]
* Change text #4. [Lang]
### Fix
* The mailhog is crashed on development env #4. [Lang]
* The og tags did not have proper http schema - they should have https #4. [Lang]
## v2026.2.0-5 (2026-04-14)
### Changes
* Add donation button #4. [Lang]
## v2026.2.0-4 (2026-04-14)
### New
* Add timer for the acceptance of the challenge #4. [Lang]
### Changes
* Protect the gameplay with recaptcha #4. [Lang]
* The waiting dialog is uncloseable until the time is up #4. [Lang]
* Add share button to the overlay when the game ends #4. [Lang]
* Make fancy og tags - and create a special one for battle sharing #4. [Lang]
### Fix
* Missing font-awesome icons on bare-metal environment #4. [Lang]
## v2026.2.0-3 (2026-04-14)
### Changes
* The user's avatar will be saved as a uuid.extension #4. [Lang]
## v2026.2.0-1 (2026-04-14)
### Fix
* Quickfix for email sending #4. [Lang]
## v2026.2.0-0 (2026-04-14)
### New
* Registered users have avatars next to the timer #4. [Lang]
* Add opportunity to use profile picture. #4. [Lang]
* Add more stats and a dialog for the recent battle that can be shareable #4. [Lang]
* Implement the 2FA authentication (TOTP and backup codes) #4. [Lang]
* Add beta logo to the corner #3. [Lang]
* Add mineseeker game to the symfony 4 project #3. [Lang]
* Upgrade to the latest symfony v4 #3. [Lang]
### Changes
* Implement CD script to Gitea and add docs to the process #4. [Lang]
* Remove unnecessary cdn based fonts #4. [Lang]
* Update docs #4. [Lang]
* Add JWT generation script to make Mercure safe #4. [Lang]
* Fix missing favicon #4. [Lang]
* Make compatible the whole project with bare metal AND with docker #4. [Lang]
* Add modern Webauthn authentication #4. [Lang]
* Refactor all forms to have Symfony Form Types & Validation Constrainsts - & implement Google ReCapthca v3 #4. [Lang]
* Add forgot password functionality #4. [Lang]
* Increase the minimum PHP version to the latest major - and massive refactor on back-end, like Controllers and Repositories #4. [Lang]
* Redesign the resign dialog #4. [Lang]
* Re-implement the waiting for opponent dialog - refactor its gfx - & add online user selection dialog #4. [Lang]
* Improve the gfx on homepage - implement login/register and activation for authentication - and add the first version of profile page #4. [Lang]
* Refactor and redesign the gfx on front-end #4. [Lang]
* Upgrade to the latest LTS Symfony package and backend #4. [Lang]
* Add timers to each player - renew the whole migration #4. [Lang]
* Update the vite related stuff because CORS and React errors - reinit the miration #4. [Lang]
* Use namespaces for front-end #4. [Lang]
* Replace webpack w/ vite & remove old, legacy jQuery from the code #4. [Lang]
* More, massive refactor for front-end #4. [Lang]
* Massive refactor on front-end - and remove unnecessary deps #4. [Lang]
* Change the code style to fit the current standard #4. [Lang]
* Refactor to use Attributes instead of yaml markdown #4. [Lang]
* Outsource the Grid generation and interactions to the backend #4. [Lang]
* Remove unnecessary variables and prune the Facebook registration method #4. [Lang]
* Replace the legacy gos/web-socket-bundle & replace it with Mercure protocol #4. [Lang]
* Make a massive refactor to the backend and remove all unnecessary deps - and make small refactors for the frontend too #4. [Lang]
* Created the first working solution since 7 yrs #4. [Lang]
* Add some changes on BE - add eslint and editorconfig - and add some deps #4. [Lang]
* Make the first working version - the stepping is broken due to the algorythm structure #4. [Lang]
* Change the composer default php minimum environment #3. [Lang]
* Change the default url to wss on frontend #3. [Lang]
* Refactor Rpc and Topic classes #3. [Lang]
* Refactor classes and reformat some layout #3. [Lang]
* Remove deprecated files #3. [Lang]
* Doc in README.md #3. [Lang]
* Gitignore a js.map file #2. [Lang]
### Other
* Pkg: usr: solve the not-working mailing on dev env under docker #4. [Lang]
## 1.1.0 (2019-10-26)
### Changes
* Reinit project - disable redis module and make the project compatible w/ PHP7.3 #2. [Lang]
## 0.4.0 (2019-10-26)
### Other
* Change session driver to REDIS. [Lang]
* Add created, updated field to db && improve graph design. [Lang]
* Cache setup && optimalize for google pagespeed && optimalize all images. [Lang]
* Improve graph design on homepage && add footer and techs && add official pages. [Lang]
* Bugfix mine websocket periodic mysql calling. [Lang]
* Bugfix hwioauth remember me && centralize hwioauth and facebook settings. [Lang]
* Centralize jquery && bugfix mysql auto-termination problem w/ user auth. [Lang]
* Release beta4. [Lang]
* Gitignore npm debug log. [Lang]
* Add english lang everywhere && add snowfall && add centralized version nbr && improve stylesheet && slack integration. [Lang]
* Bugfix #30 && random bg in game. [Lang]
* Add google analytics and facebook scripts && improve url share method w/ fb && enforce https in prod. [Lang]
* Reg and login buttons on index && remove list method && facebook centralize. [Lang]
* Redesign user frontend. [Lang]
* Mods for performance; one js.min file on prod. [Lang]
* Improve webpack config for prod compile #23. [Lang]
* Ssl handling #22 && reconnection issues #20, #21. [Lang]
* Facebook prod settings w/ app; hwi/HWIOAuthBundle. [Lang]
* Refact && game reconnection and restore w/o refresh #3 && bugfix bomb explosion on opponent mines #19. [Lang]
* Typo in rpc. [Lang]
* Handle prod mysql timeout && graphics improve. [Lang]
* Gitignore webpacked index.js. [Lang]
* Add production mods. [Lang]
* Bugfix points saving and exploded bombs to db && you can resign #6. [Lang]
* Bugfix resign button existence #11. [Lang]
* Bugfix opponent bomb btn buzz on hover #10. [Lang]
* Bugfix points problem in the end #16. [Lang]
* Add desc to every user #9. [Lang]
* Clipboard - not working #8. [Lang]
* Random player on start #5. [Lang]
* Show left mines after end #2 && reduce network traffic && better active field checking method. [Lang]
* Some refactor #13. [Lang]
* Bugfix grid field render #12. [Lang]
* Game ends after x mines. [Lang]
* Add new sounds && refactor && new bg images && form redesigns. [Lang]
* Bugfix entities gridrow, grid && improve graph design on homepage. [Lang]
* Some refactor && prod settings. [Lang]
* Improve graphics design in game. [Lang]
* Bugfix grid row in entity. [Lang]
* Bugfix changePlayer after bomb explosion. [Lang]
* Improve game graph design. [Lang]
* Login and register form more design. [Lang]
* Add basic design to userbundle && refactor. [Lang]
* Add font-awesome. [Lang]
* Working user authentication w/ fb and plain login. [Lang]
* Add facebook login module, hwi/HWIOAuthBundle. [Lang]
* Login && register form overrided. [Lang]
* Js and config refactor. [Lang]
* Replace gridcol object to json array in db. [Lang]
* Refactor. [Lang]
* Save steps and point info to db. [Lang]
* Save the step data to db. [Lang]
* Renamed the acme to mineseeker && handle when the user connection has been lost. [Lang]
* Add player names to UI. [Lang]
* Add overlay && game do not start until the opponent came. [Lang]
* Add base64 encryption to grid when it has been sended to server. [Lang]
* On click opponents bomb, you cannot target && refactor. [Lang]
* Warning when player has been found more than 20 mines. [Lang]
* Bugfix center mine counter animation. [Lang]
* The opponent is the next when bomb is exploded. [Lang]
* Current username checked && refactor && remove players in channel when they are more than 2. [Lang]
* Send bomb info and use it on opponent. [Lang]
* Add sounds w/ howler. [Lang]
* Bugfix multiple empty fields w/ one click on opponent view. [Lang]
* Refact && remove sound and logging && bugfix BIGBUG - handleGridField and showAppropriateFields sort order... [Lang]
* Create first working communication. [Lang]
* Create entities and repositories. [Lang]
* Changed websocket default port && debug RPC. [Lang]
* Created working session and client handler w/ websocket. [Lang]
* Working websocket client and server w/o session handling and storage. [Lang]
* Composer update. [Lang]
* Improve game && start sound creating. [Lang]
* Refactor grid control and grid field. [Lang]
* Created basic game w/ table and animations. [Lang]
* Websocket basic setup FE & BE && working basic game w/ react && webpack & babel config. [Lang]
* Gitignore node_modules && add symlink to node_modules (just for install) && basic react. [Lang]
* Add react hello world. [Lang]
* Rename project in config. [Lang]
* Initial commit && create project in symfony3. [Lang]

124
LICENSE Normal file
View File

@@ -0,0 +1,124 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2026 SplendidBear (https://www.splendidbear.org)
MineSeeker is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
MineSeeker is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
---
TERMS AND CONDITIONS:
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of works.
"The Program" refers to any copyrightable work licensed under this License.
"You" refers to each licensee.
"Legal entities" means the union of the acting entity and all other entities
that control, are controlled by, or are under common control with that entity.
"Modify" means to copy from or adapt all or part of the work in a fashion
requiring copyright permission, other than the making of an exact copy.
1. Source Code.
The "source code" for a work means the preferred form of the work for making
modifications to it. "Object code" means any non-source form of a work.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright
on the Program. You are granted all permissions necessary to run, modify and
propagate covered works by this License.
3. Copyleft - Derivative Works.
If you modify the Program, your modified version must:
- Carry prominent notices stating that you have modified it
- License the entire work under this License or a compatible license
- Make the source code available to recipients
- Preserve all notices of previous licensing
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it,
provided that you:
- Keep intact all notices of authorship and licensing
- Give recipients access to the source code along with this License
- Do not modify anything except the License itself
5. Conveying Modified Source Versions.
You may convey a work based on the Program under this License provided that:
- The work must be licensed as a whole under this License
- You must give prominent notice of any modifications
- You must provide access to the Corresponding Source code
- You preserve all licensing notices
6. Conveying Non-Source Forms.
If you convey object code or compiled versions, you must also provide:
- The Corresponding Source code (in machine-readable form)
- A notice of the terms under which it is licensed
7. Additional Terms.
No additional restrictions may be placed on the exercise of the rights
granted or affirmed under this License.
8. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
9. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING
ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF
THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS
OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR
THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
---
For the complete GPL-3.0-or-later license text, visit:
https://www.gnu.org/licenses/gpl-3.0.html
For more information about GNU GPL, visit:
https://www.gnu.org/licenses/
MineSeeker is a multiplayer minesweeper game inspired by MSN Messenger's game.
Project: https://www.mineseeker.hu
Author: SplendidBear (https://www.splendidbear.org)

View File

@@ -1,4 +1,4 @@
.PHONY: help start start-build stop build down ps logs prune-everything db-reset mercure-jwt .PHONY: help start start-build stop build down ps logs prune-everything db-reset mercure-jwt cache-clear og-cache-clear
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
@@ -11,6 +11,9 @@ help:
@echo " make down - Stop and remove containers/networks" @echo " make down - Stop and remove containers/networks"
@echo " make prune-everything - Prune volumes, networks and images (DANGEROUS!)" @echo " make prune-everything - Prune volumes, networks and images (DANGEROUS!)"
@echo " make db-reset - Reset the database (drop, create, migrate) (DANGEROUS!)" @echo " make db-reset - Reset the database (drop, create, migrate) (DANGEROUS!)"
@echo " make ccp - Clear the production cache"
@echo " make cache-clear - Clear all caches (Vite, Symfony, node_modules)"
@echo " make og-cache-clear - Clear Open Graph cache only"
start: start:
docker compose up -d docker compose up -d
@@ -51,3 +54,31 @@ db-reset:
bin/console doctrine:database:drop --force --if-exists --no-interaction bin/console doctrine:database:drop --force --if-exists --no-interaction
bin/console doctrine:database:create --if-not-exists --no-interaction bin/console doctrine:database:create --if-not-exists --no-interaction
bin/console doctrine:migrations:migrate --no-interaction bin/console doctrine:migrations:migrate --no-interaction
ccp:
bin/console cache:clear --no-warmup --env=prod
cache-clear:
@echo "Clearing all caches..."
@rm -rf node_modules/.vite
@rm -rf .vite
@rm -rf var/og-cache
@php bin/console cache:clear --no-warmup
@echo "✓ Vite cache cleared"
@echo "✓ OG cache cleared"
@echo "✓ Symfony cache cleared"
@echo ""
@echo "Rebuilding assets..."
@bun run build
@echo ""
@echo "✓ All caches cleared and assets rebuilt!"
@echo " Next step: Refresh browser with Ctrl+Shift+R"
og-cache-clear:
@echo "Clearing Open Graph cache..."
@rm -rf var/og-cache
@echo "✓ OG cache cleared!"
@echo " Battle card images will be regenerated on next access"

View File

@@ -287,6 +287,14 @@ git push origin v2026.01
--- ---
## Game Documentation
For detailed information about game mechanics, bonus systems, and scoring rules, see the [docs](./docs/) directory:
- **[Bonus Points System](./docs/game-mechanics/BONUS_POINTS_SYSTEM.md)** — Complete reference for all bonus point types, calculation rules, and implementation details
---
## License ## License
LGPL-3.0 — see [LICENSE](LICENSE) for details. LGPL-3.0 — see [LICENSE](LICENSE) for details.

View File

@@ -7,9 +7,4 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
$font-path: "/build/webfonts"; @import '@fortawesome/fontawesome-free/css/all.min.css';
@import '@fortawesome/fontawesome-free/scss/fontawesome';
@import '@fortawesome/fontawesome-free/scss/brands';
@import '@fortawesome/fontawesome-free/scss/solid';
@import '@fortawesome/fontawesome-free/scss/regular';

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
@keyframes appear { @keyframes appear {
from { opacity: 0; transform: scale(0.94); } from { opacity: 0; transform: scale(0.94); }
to { opacity: 1; transform: scale(1); } to { opacity: 1; transform: scale(1); }

View File

@@ -1,9 +1,19 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
#hero-auth {
padding: 20px;
.hero-auth { .hero-auth {
position: absolute;
top: 28px;
right: 36px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end;
gap: 10px; gap: 10px;
z-index: 10; z-index: 10;
} }
@@ -19,6 +29,13 @@
i { font-size: 15px; } i { font-size: 15px; }
} }
@media screen and (max-width: 1100px) {
.hero-auth {
justify-content: center;
}
}
}
.hero-auth-btn { .hero-auth-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
.auth-page { .auth-page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -0,0 +1,200 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
// ── Avatar ───────────────────────────────────────────────────────────────────
.bd-avatar-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
position: relative;
}
.bd-avatar-ring-wrap {
position: relative;
}
.bd-avatar-ring {
width: 72px;
height: 72px;
border-radius: 50%;
background: var(--bd-avatar-gradient);
border: 2px solid var(--bd-avatar-border);
box-shadow: var(--bd-avatar-glow);
display: flex;
align-items: center;
justify-content: center;
font: 800 24px 'Rajdhani', sans-serif;
color: var(--bd-avatar-color);
letter-spacing: 2px;
overflow: hidden;
}
.bd-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.bd-avatar-bonus {
position: absolute;
bottom: -6px;
right: -6px;
background: #ffd700;
border-radius: 50%;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 12px rgba(255, 215, 0, 0.6), 0 0 0 2px rgba(7, 9, 13, 1);
border: 2px solid rgba(0, 0, 0, 0.5);
z-index: 10;
i {
color: #000;
font-size: 14px;
}
}
.bd-avatar-name {
font: 700 15px 'Rajdhani', sans-serif;
color: var(--bd-avatar-color);
letter-spacing: 1px;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
.bd-avatar-side {
font: 600 10px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 2px;
color: rgba(255, 255, 255, 0.3);
}
// ── StatRow ──────────────────────────────────────────────────────────────────
.bd-stat-row {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
&__icon {
width: 16px;
color: rgba(149, 207, 245, 0.4);
font-size: 13px;
}
&__label {
font: 500 13px 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.45);
flex: 1;
letter-spacing: 0.5px;
}
&__value {
font: 700 13px 'Rajdhani', sans-serif;
color: var(--bd-stat-value-color, rgba(255, 255, 255, 0.75));
letter-spacing: 0.5px;
}
}
// ── BonusPoints ──────────────────────────────────────────────────────────────
.bd-bonus {
padding: 16px 20px 0;
border-top: 1px solid rgba(255, 255, 255, 0.08);
margin: 16px 0;
&__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
&__column {
padding: 16px;
border-radius: 6px;
&--red {
border: 1px solid rgba(173, 10, 5, 0.2);
background: rgba(173, 10, 5, 0.05);
}
&--blue {
border: 1px solid rgba(149, 207, 245, 0.2);
background: rgba(149, 207, 245, 0.05);
}
}
&__heading {
font: 700 12px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 2px;
color: #ffd700;
display: block;
margin-bottom: 12px;
i {
margin-right: 8px;
}
}
&__rows {
display: flex;
flex-direction: column;
}
}
// ── BattleDialog header actions & bonus score row ────────────────────────────
.bd-header-actions {
display: flex;
gap: 8px;
}
.bd-bonus-score {
margin-bottom: 8px;
&__red {
font: 700 13px 'Rajdhani', sans-serif;
color: #f67d52;
display: flex;
align-items: center;
gap: 4px;
i {
font-size: 11px;
}
}
&__blue {
font: 700 13px 'Rajdhani', sans-serif;
color: #95cff5;
display: flex;
align-items: center;
gap: 4px;
i {
font-size: 11px;
}
}
}
.bd-result-badge {
background: var(--bd-result-bg);
border: 1px solid var(--bd-result-border);
color: var(--bd-result-color);
}

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
main div.txt { main div.txt {
color: rgba(255, 255, 255, 0.85); color: rgba(255, 255, 255, 0.85);
max-width: 900px; max-width: 900px;
@@ -33,3 +42,11 @@ main div.txt a {
&:hover { color: #c5e8ff; } &:hover { color: #c5e8ff; }
} }
main div.txt img {
border-radius: 10px;
}
main div.txt .img-container {
text-align: center;
}

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
.hero-cta { .hero-cta {
position: relative; position: relative;
display: inline-block; display: inline-block;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
.feature-block { .feature-block {
width: 100%; width: 100%;
padding: 80px 40px; padding: 80px 40px;
@@ -45,33 +54,33 @@
} }
// Bar chart — large, centre // Bar chart — large, centre
i.fa-bar-chart { i.fa-chart-bar {
font-size: 140px; font-size: 160px;
color: rgba(35, 111, 135, 0.35); color: rgba(35, 111, 135, 0.5);
filter: drop-shadow(0 0 30px rgba(35, 111, 135, 0.3)); filter: drop-shadow(0 0 40px rgba(35, 111, 135, 0.5));
} }
// Trophy — top right // Trophy — top right
i.fa-trophy { i.fa-trophy {
font-size: 64px; font-size: 80px;
top: 12px; top: 0px;
right: 30px; right: 20px;
color: rgba(246, 125, 82, 0.5); color: rgba(246, 125, 82, 0.7);
filter: drop-shadow(0 0 16px rgba(246, 125, 82, 0.25)); filter: drop-shadow(0 0 25px rgba(246, 125, 82, 0.4));
} }
// History — bottom left // Clock history — bottom left
i.fa-history { i.fa-clock-rotate-left {
font-size: 52px; font-size: 68px;
bottom: 18px; bottom: 0px;
left: 30px; left: 20px;
color: rgba(149, 207, 245, 0.4); color: rgba(149, 207, 245, 0.65);
filter: drop-shadow(0 0 12px rgba(149, 207, 245, 0.2)); filter: drop-shadow(0 0 20px rgba(149, 207, 245, 0.35));
} }
&:hover i.fa-bar-chart { color: rgba(35, 111, 135, 0.6); } &:hover i.fa-chart-bar { color: rgba(35, 111, 135, 0.8); filter: drop-shadow(0 0 50px rgba(35, 111, 135, 0.7)); }
&:hover i.fa-trophy { color: rgba(246, 125, 82, 0.75); } &:hover i.fa-trophy { color: rgba(246, 125, 82, 0.9); filter: drop-shadow(0 0 35px rgba(246, 125, 82, 0.6)); }
&:hover i.fa-history { color: rgba(149, 207, 245, 0.65); } &:hover i.fa-clock-rotate-left { color: rgba(149, 207, 245, 0.9); filter: drop-shadow(0 0 30px rgba(149, 207, 245, 0.5)); }
} }
// MSN visual // MSN visual
@@ -107,6 +116,98 @@
} }
} }
// Privacy visual
.feature-block__visual--privacy {
height: 260px;
gap: 0;
i {
position: absolute;
color: rgba(35, 111, 135, 0.5);
transition: color 300ms ease;
}
// Shield — centre, large
i.fa-shield {
font-size: 140px;
color: rgba(34, 197, 94, 0.3);
filter: drop-shadow(0 0 30px rgba(34, 197, 94, 0.25));
}
// Lock — top right
i.fa-lock {
font-size: 56px;
top: 20px;
right: 35px;
color: rgba(168, 85, 247, 0.5);
filter: drop-shadow(0 0 16px rgba(168, 85, 247, 0.2));
}
// Eye slash — bottom left
i.fa-eye-slash {
font-size: 48px;
bottom: 28px;
left: 40px;
color: rgba(59, 130, 246, 0.5);
filter: drop-shadow(0 0 12px rgba(59, 130, 246, 0.15));
}
&:hover i.fa-shield { color: rgba(34, 197, 94, 0.6); }
&:hover i.fa-lock { color: rgba(168, 85, 247, 0.75); }
&:hover i.fa-eye-slash { color: rgba(59, 130, 246, 0.7); }
}
// Practice visual
.feature-block__visual--practice {
height: 260px;
gap: 0;
i {
position: absolute;
color: rgba(35, 111, 135, 0.5);
transition: color 300ms ease;
}
// Laptop — centre, large
i.fa-laptop {
font-size: 140px;
color: rgba(251, 146, 60, 0.3);
filter: drop-shadow(0 0 30px rgba(251, 146, 60, 0.25));
}
// Linux — top left
i.fa-linux {
font-size: 48px;
top: 20px;
left: 35px;
color: rgba(245, 158, 11, 0.5);
filter: drop-shadow(0 0 16px rgba(245, 158, 11, 0.2));
}
// Apple — top right
i.fa-apple {
font-size: 56px;
top: 20px;
right: 35px;
color: rgba(156, 163, 175, 0.5);
filter: drop-shadow(0 0 16px rgba(156, 163, 175, 0.2));
}
// Windows — bottom left
i.fa-windows {
font-size: 48px;
bottom: 28px;
left: 40px;
color: rgba(59, 130, 246, 0.5);
filter: drop-shadow(0 0 12px rgba(59, 130, 246, 0.15));
}
&:hover i.fa-laptop { color: rgba(251, 146, 60, 0.6); }
&:hover i.fa-linux { color: rgba(245, 158, 11, 0.75); }
&:hover i.fa-apple { color: rgba(156, 163, 175, 0.75); }
&:hover i.fa-windows { color: rgba(59, 130, 246, 0.7); }
}
// Text side // Text side
.feature-block__text { .feature-block__text {
flex: 1; flex: 1;
@@ -161,6 +262,56 @@
} }
} }
.practice-links {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 24px;
}
.practice-link {
display: inline-flex;
align-items: center;
gap: 12px;
font: 700 13px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 1px;
text-decoration: none;
color: rgba(149, 207, 245, 0.85);
border: 1px solid rgba(59, 130, 246, 0.3);
background: rgba(59, 130, 246, 0.08);
padding: 10px 18px;
border-radius: 4px;
transition: all 200ms ease;
width: fit-content;
&:hover {
background: rgba(59, 130, 246, 0.2);
border-color: rgba(59, 130, 246, 0.6);
color: #fff;
transform: translateX(4px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
}
}
.practice-link-icon {
width: 40px;
height: 40px;
border-radius: 8px;
object-fit: contain;
background: rgba(255, 255, 255, 0.08);
padding: 4px;
border: 1px solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
transition: all 200ms ease;
}
.practice-link:hover .practice-link-icon {
background: rgba(59, 130, 246, 0.15);
border-color: rgba(59, 130, 246, 0.4);
transform: scale(1.05);
}
@media screen and (max-width: 900px) { @media screen and (max-width: 900px) {
.feature-block__inner, .feature-block__inner,
.feature-block--reverse .feature-block__inner { .feature-block--reverse .feature-block__inner {
@@ -199,4 +350,14 @@
.feature-block { .feature-block {
padding: 60px 24px; padding: 60px 24px;
} }
.practice-links {
justify-content: center;
align-items: center;
}
.practice-link {
width: 100%;
justify-content: center;
}
} }

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
footer { footer {
background: #040608; background: #040608;
border-top: 1px solid rgba(35, 111, 135, 0.12); border-top: 1px solid rgba(35, 111, 135, 0.12);
@@ -14,7 +23,6 @@ footer {
gap: 40px; gap: 40px;
} }
// Left: brand block
.footer-brand { .footer-brand {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -55,7 +63,6 @@ footer {
line-height: 1.5; line-height: 1.5;
} }
// Right: navigation
.footer-nav-label { .footer-nav-label {
font: 700 11px 'Rajdhani', sans-serif; font: 700 11px 'Rajdhani', sans-serif;
text-transform: uppercase; text-transform: uppercase;
@@ -91,7 +98,6 @@ footer {
} }
} }
// Bottom copyright bar
.footer-copy { .footer-copy {
border-top: 1px solid rgba(255, 255, 255, 0.05); border-top: 1px solid rgba(255, 255, 255, 0.05);
padding: 16px 60px; padding: 16px 60px;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
header { header {
position: relative; position: relative;
width: 100%; width: 100%;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
.hero--compact { .hero--compact {
min-height: unset; min-height: unset;
padding: 36px 60px 48px; padding: 36px 60px 48px;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
.hero { .hero {
position: relative; position: relative;
z-index: 2; z-index: 2;

View File

@@ -210,11 +210,43 @@
} }
} }
&--best { &--bonus {
border-color: rgba(255, 215, 0, 0.15); border-color: rgba(255, 215, 0, 0.18);
&:hover { &:hover {
border-color: rgba(255, 215, 0, 0.4); border-color: rgba(255, 215, 0, 0.45);
}
}
&--avg-bonus {
border-color: rgba(230, 184, 60, 0.18);
&:hover {
border-color: rgba(230, 184, 60, 0.45);
}
}
&--chain {
border-color: rgba(94, 232, 154, 0.15);
&:hover {
border-color: rgba(94, 232, 154, 0.4);
}
}
&--blind {
border-color: rgba(255, 140, 90, 0.15);
&:hover {
border-color: rgba(255, 140, 90, 0.4);
}
}
&--edge {
border-color: rgba(168, 210, 255, 0.15);
&:hover {
border-color: rgba(168, 210, 255, 0.4);
} }
} }
} }
@@ -248,8 +280,24 @@
color: rgba(80, 200, 220, 0.35); color: rgba(80, 200, 220, 0.35);
} }
.profile-stat--best & { .profile-stat--bonus & {
color: rgba(255, 215, 0, 0.3); color: rgba(255, 215, 0, 0.35);
}
.profile-stat--avg-bonus & {
color: rgba(230, 184, 60, 0.3);
}
.profile-stat--chain & {
color: rgba(94, 232, 154, 0.3);
}
.profile-stat--blind & {
color: rgba(255, 140, 90, 0.3);
}
.profile-stat--edge & {
color: rgba(168, 210, 255, 0.3);
} }
} }
@@ -289,9 +337,25 @@
color: #50c8dc; color: #50c8dc;
} }
.profile-stat--best & { .profile-stat--bonus & {
color: #ffd700; color: #ffd700;
} }
.profile-stat--avg-bonus & {
color: #e6b83c;
}
.profile-stat--chain & {
color: #5ee89a;
}
.profile-stat--blind & {
color: #ff8c5a;
}
.profile-stat--edge & {
color: #a8d2ff;
}
} }
.profile-stat__label { .profile-stat__label {
@@ -363,15 +427,93 @@
} }
} }
.profile-games__filter-wrap {
position: relative;
display: flex;
align-items: center;
margin-bottom: 4px;
}
.profile-games__filter-icon {
position: absolute;
left: 14px;
font-size: 12px;
color: rgba(149, 207, 245, 0.4);
pointer-events: none;
}
.profile-games__filter {
width: 100%;
background: rgba(255, 255, 255, 0.025);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 6px;
padding: 9px 14px 9px 36px;
font: 500 13px 'Rajdhani', sans-serif;
color: #fff;
letter-spacing: 0.5px;
transition: border-color 200ms ease, background 200ms ease;
&::placeholder {
color: rgba(255, 255, 255, 0.3);
}
&:focus {
outline: none;
background: rgba(255, 255, 255, 0.045);
border-color: rgba(35, 111, 135, 0.55);
}
}
.profile-games { .profile-games {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
&.is-filtering + .profile-games__load-more {
display: none;
}
&.is-filtering .profile-game--hidden:not(.profile-game--filtered-out) {
display: grid;
}
.profile-game--filtered-out {
display: none;
}
&__load-more {
align-self: center;
margin-top: 14px;
background: rgba(35, 111, 135, 0.12);
color: rgba(149, 207, 245, 0.75);
border: 1px solid rgba(35, 111, 135, 0.3);
border-radius: 6px;
padding: 9px 20px;
font: 600 12px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 1.5px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: background 200ms ease, border-color 200ms ease, color 200ms ease;
i {
font-size: 11px;
opacity: 0.8;
}
&:hover {
background: rgba(35, 111, 135, 0.22);
border-color: rgba(35, 111, 135, 0.55);
color: rgba(149, 207, 245, 1);
}
}
} }
.profile-game { .profile-game {
display: grid; display: grid;
grid-template-columns: 26px 76px 22px 1fr 18px auto; grid-template-columns: 60px 76px 22px 1fr 18px auto;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 11px 16px; padding: 11px 16px;
@@ -400,17 +542,31 @@
&--draw { &--draw {
border-left-color: rgba(149, 207, 245, 0.25); border-left-color: rgba(149, 207, 245, 0.25);
} }
&--ongoing {
border-left-color: rgba(255, 193, 7, 0.4);
opacity: 0.85;
}
&--hidden {
display: none;
}
} }
.profile-game__badge { .profile-game__badge {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 20px; width: 100%;
min-width: 0;
height: 20px; height: 20px;
border-radius: 4px; border-radius: 4px;
font: 800 10px 'Rajdhani', sans-serif; font: 800 10px 'Rajdhani', sans-serif;
letter-spacing: 0; letter-spacing: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
gap: 4px;
.profile-game--win & { .profile-game--win & {
background: rgba(42, 158, 96, 0.18); background: rgba(42, 158, 96, 0.18);
@@ -426,12 +582,49 @@
background: rgba(149, 207, 245, 0.1); background: rgba(149, 207, 245, 0.1);
color: rgba(149, 207, 245, 0.65); color: rgba(149, 207, 245, 0.65);
} }
.profile-game--ongoing & {
background: rgba(255, 193, 7, 0.12);
color: #ffc107;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
&::before {
content: '';
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid transparent;
border-top-color: #ffc107;
border-right-color: #ffc107;
border-radius: 50%;
animation: spin 1s linear infinite;
flex-shrink: 0;
}
}
.profile-game--abandoned & {
background: rgba(107, 114, 126, 0.18);
color: #6b727e;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
} }
.profile-game__score { .profile-game__score {
font: 700 14px 'Rajdhani', sans-serif; font: 700 14px 'Rajdhani', sans-serif;
color: #fff; color: #fff;
letter-spacing: 1px; letter-spacing: 1px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
} }
.profile-game__vs { .profile-game__vs {
@@ -461,21 +654,23 @@
letter-spacing: 0.5px; letter-spacing: 0.5px;
text-align: right; text-align: right;
white-space: nowrap; white-space: nowrap;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
} }
.profile-charts { .profile-charts {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: 1fr 1fr;
gap: 20px; gap: 20px;
} }
.profile-chart-block { .profile-chart-block {
flex: 1 1 300px; min-width: 0;
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(35, 111, 135, 0.2); border: 1px solid rgba(35, 111, 135, 0.2);
border-radius: 10px; border-radius: 10px;
padding: 24px 20px 16px; padding: 24px 20px 16px;
backdrop-filter: blur(4px);
box-shadow: 0 8px 48px rgba(0, 0, 0, 0.4); box-shadow: 0 8px 48px rgba(0, 0, 0, 0.4);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -484,12 +679,25 @@
.profile-section__title { .profile-section__title {
margin: 0; margin: 0;
} }
&--wide {
grid-column: 1 / -1;
.profile-chart-inner {
justify-content: stretch;
overflow: hidden;
> * {
width: 100% !important;
}
}
}
} }
.profile-chart-inner { .profile-chart-inner {
display: flex; display: flex;
justify-content: center; justify-content: center;
overflow: auto; overflow: hidden;
svg text { svg text {
font-family: 'Rajdhani', sans-serif !important; font-family: 'Rajdhani', sans-serif !important;
@@ -564,6 +772,32 @@
} }
} }
.bd-continue {
background: linear-gradient(135deg, rgba(42, 158, 96, 0.35) 0%, rgba(94, 232, 154, 0.35) 100%);
border: 1px solid rgba(94, 232, 154, 0.6);
border-radius: 6px;
color: #5ee89a;
height: 32px;
padding: 0 14px;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font: 700 11px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 1.5px;
text-decoration: none;
transition: all 180ms ease;
white-space: nowrap;
box-shadow: 0 0 14px rgba(94, 232, 154, 0.25);
&:hover {
background: linear-gradient(135deg, rgba(42, 158, 96, 0.55) 0%, rgba(94, 232, 154, 0.55) 100%);
color: #fff;
box-shadow: 0 0 20px rgba(94, 232, 154, 0.45);
}
}
.bd-close { .bd-close {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
@@ -859,6 +1093,104 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.bshare-bonus {
padding: 28px 28px 0;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.bshare-bonus__title {
font: 700 13px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 2px;
color: #ffd700;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 8px;
i { font-size: 14px; }
}
.bshare-bonus__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-bottom: 28px;
}
.bshare-bonus__player {
padding: 16px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.02);
&--red {
border-color: rgba(246, 125, 82, 0.15);
background: rgba(246, 125, 82, 0.04);
}
&--blue {
border-color: rgba(149, 207, 245, 0.15);
background: rgba(149, 207, 245, 0.04);
}
}
.bshare-bonus__header {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.bshare-bonus__points {
font: 700 24px 'Rajdhani', sans-serif;
background: linear-gradient(135deg, #ffd700, #ffed4e);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.bshare-bonus__label {
font: 700 11px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 1px;
color: rgba(255, 215, 0, 0.7);
}
.bshare-bonus__stats {
display: flex;
flex-direction: column;
gap: 10px;
}
.bshare-bonus__stat {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
gap: 8px;
}
.bshare-bonus__stat-label {
color: rgba(255, 255, 255, 0.6);
font: 500 11px 'Rajdhani', sans-serif;
text-transform: capitalize;
}
.bshare-bonus__stat-value {
font: 700 13px 'Rajdhani', sans-serif;
color: rgba(255, 215, 0, 0.9);
min-width: 24px;
text-align: right;
}
.bshare-bonus__stat--empty {
color: rgba(255, 255, 255, 0.4);
font-size: 11px;
}
.bshare-btn { .bshare-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
* { * {
outline: none; outline: none;
padding: 0; padding: 0;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
@media screen and (max-width: 900px) { @media screen and (max-width: 900px) {
.hero h1 { .hero h1 {
font-size: 44px; font-size: 44px;
@@ -24,6 +33,10 @@
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.profile-charts {
grid-template-columns: 1fr;
}
.profile-header { .profile-header {
flex-direction: column; flex-direction: column;
text-align: center; text-align: center;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
main { main {
background: #07090d; background: #07090d;
} }

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
.back-from-game { .back-from-game {
display: inline-block; display: inline-block;
position: fixed; position: fixed;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
html { html {
height: 100%; height: 100%;
padding: 0; padding: 0;
@@ -17,7 +26,6 @@ main {
} }
.mine-container { .mine-container {
background: url("/images/bg-mineseeker-0-outbg.jpg") no-repeat;
background-size: cover; background-size: cover;
display: flex; display: flex;
justify-content: center; justify-content: center;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
#mine-wrapper .game-wrapper .users .user-container .user-control { #mine-wrapper .game-wrapper .users .user-container .user-control {
background: -moz-linear-gradient(-45deg, rgba(255, 255, 255, 0.5) 0%, rgba(125, 185, 232, 0) 100%); background: -moz-linear-gradient(-45deg, rgba(255, 255, 255, 0.5) 0%, rgba(125, 185, 232, 0) 100%);
background: -webkit-linear-gradient(-45deg, rgba(255, 255, 255, 0.5) 0%, rgba(125, 185, 232, 0) 100%); background: -webkit-linear-gradient(-45deg, rgba(255, 255, 255, 0.5) 0%, rgba(125, 185, 232, 0) 100%);

View File

@@ -0,0 +1,250 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
#mine-wrapper .bonus-box {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 58px;
padding: 6px 12px;
border-radius: 8px;
border: 2px solid transparent;
background: #07090d;
font-family: 'Rajdhani', sans-serif;
font-weight: bold;
cursor: pointer;
transition: all 0.25s ease;
align-self: stretch;
&:hover {
transform: translateY(-1px);
filter: brightness(1.15);
}
&:active {
transform: translateY(0);
}
}
#mine-wrapper .bonus-box.red-bonus {
background: linear-gradient(to bottom, #2a0502 0%, #4a1510 100%);
border-color: rgba(246, 125, 82, 0.4);
color: rgba(246, 125, 82, 0.85);
&:hover {
border-color: rgba(246, 125, 82, 0.85);
box-shadow: 0 0 12px rgba(173, 10, 5, 0.6);
}
}
#mine-wrapper .bonus-box.blue-bonus {
background: linear-gradient(to bottom, #050f18 0%, #0f2838 100%);
border-color: rgba(149, 207, 245, 0.4);
color: rgba(149, 207, 245, 0.85);
&:hover {
border-color: rgba(149, 207, 245, 0.85);
box-shadow: 0 0 12px rgba(35, 111, 135, 0.6);
}
}
#mine-wrapper .bonus-box__icon {
font-size: 13px;
opacity: 0.9;
}
#mine-wrapper .bonus-box__value {
font-family: 'Courier New', monospace;
font-size: 16px;
letter-spacing: 1px;
}
.bsd {
display: flex;
flex-direction: column;
font-family: 'Rajdhani', sans-serif;
}
.bsd-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 22px 14px;
border-bottom: 1px solid rgba(35, 111, 135, 0.3);
}
.bsd-header-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.bsd-label {
font-size: 11px;
letter-spacing: 2px;
color: rgba(149, 207, 245, 0.7);
text-transform: uppercase;
}
.bsd-title {
margin: 0;
font-size: 20px;
font-weight: 700;
display: flex;
align-items: center;
gap: 10px;
color: #fff;
.fa {
color: #f6d572;
}
}
.bsd-close {
background: transparent;
border: 1px solid rgba(35, 111, 135, 0.4);
border-radius: 6px;
color: rgba(255, 255, 255, 0.7);
width: 32px;
height: 32px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: #fff;
border-color: rgba(149, 207, 245, 0.8);
}
}
.bsd-body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
padding: 18px 22px;
}
.bsd-column {
border-radius: 10px;
padding: 14px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.02);
}
.bsd-column--red {
border-color: rgba(246, 125, 82, 0.35);
background: linear-gradient(to bottom, rgba(74, 6, 3, 0.35), rgba(107, 37, 21, 0.15));
}
.bsd-column--blue {
border-color: rgba(149, 207, 245, 0.35);
background: linear-gradient(to bottom, rgba(11, 37, 48, 0.35), rgba(22, 61, 85, 0.15));
}
.bsd-column-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.bsd-column-name {
font-weight: 700;
font-size: 16px;
color: #fff;
}
.bsd-column-total {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: 'Courier New', monospace;
font-size: 18px;
font-weight: 700;
color: #f6d572;
.fa {
font-size: 14px;
}
}
.bsd-stats {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.bsd-stat {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 6px 2px;
border-bottom: 1px dashed rgba(255, 255, 255, 0.06);
&:last-child {
border-bottom: none;
}
}
.bsd-stat-text {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.bsd-stat-label {
font-size: 13px;
font-weight: 600;
color: rgba(255, 255, 255, 0.92);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bsd-stat-desc {
font-size: 11px;
color: rgba(255, 255, 255, 0.48);
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.bsd-stat-value {
font-family: 'Courier New', monospace;
font-size: 16px;
font-weight: 700;
color: #fff;
min-width: 24px;
text-align: right;
}
.bsd-note {
margin: 0;
padding: 12px 22px 18px;
font-size: 12px;
color: rgba(255, 255, 255, 0.45);
text-align: center;
font-style: italic;
}
@media (max-width: 520px) {
.bsd-body {
grid-template-columns: 1fr;
}
}

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
#mine-wrapper .grid { #mine-wrapper .grid {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@@ -78,8 +87,6 @@
} }
#mine-wrapper .grid .field-wrapper .field .field-corner { #mine-wrapper .grid .field-wrapper .field .field-corner {
background: url('/images/bg-corner-outbg.png') no-repeat top left;
background-size: 100%;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
#mine-wrapper .game-wrapper .users .active-mines-container { #mine-wrapper .game-wrapper .users .active-mines-container {
background: -moz-radial-gradient(center, ellipse cover, rgba(255, 252, 252, 1) 0%, rgba(255, 252, 252, 0.99) 1%, rgba(106, 106, 106, 0.39) 61%, rgba(106, 106, 106, 0) 100%); background: -moz-radial-gradient(center, ellipse cover, rgba(255, 252, 252, 1) 0%, rgba(255, 252, 252, 0.99) 1%, rgba(106, 106, 106, 0.39) 61%, rgba(106, 106, 106, 0) 100%);
background: -webkit-radial-gradient(center, ellipse cover, rgba(255, 252, 252, 1) 0%, rgba(255, 252, 252, 0.99) 1%, rgba(106, 106, 106, 0.39) 61%, rgba(106, 106, 106, 0) 100%); background: -webkit-radial-gradient(center, ellipse cover, rgba(255, 252, 252, 1) 0%, rgba(255, 252, 252, 0.99) 1%, rgba(106, 106, 106, 0.39) 61%, rgba(106, 106, 106, 0) 100%);

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
#mine-wrapper .game-wrapper .game-overlay { #mine-wrapper .game-wrapper .game-overlay {
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.2);
display: flex; display: flex;
@@ -35,6 +44,8 @@
flex-direction: column; flex-direction: column;
gap: 0; gap: 0;
animation: slideUp 0.5s cubic-bezier(0.34, 1.56, 0.64, 1); animation: slideUp 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
overflow: hidden;
max-height: 90vh;
} }
@keyframes slideUp { @keyframes slideUp {
@@ -54,6 +65,11 @@
color: #fff; color: #fff;
margin: 0 0 50px 0; margin: 0 0 50px 0;
letter-spacing: 1px; letter-spacing: 1px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
} }
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window h2 { #mine-wrapper .game-wrapper .game-overlay .game-overlay-window h2 {
@@ -183,6 +199,10 @@
width: 100%; width: 100%;
animation: fadeInUp 0.6s ease-out 0.2s both; animation: fadeInUp 0.6s ease-out 0.2s both;
&.waiting-options--invite-only {
grid-template-columns: 1fr;
}
@media (max-width: 600px) { @media (max-width: 600px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 20px; gap: 20px;
@@ -264,6 +284,11 @@
margin: 0; margin: 0;
letter-spacing: 0.4px; letter-spacing: 0.4px;
line-height: 1.4; line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
} }
.waiting-divider { .waiting-divider {
@@ -527,6 +552,19 @@
} }
} }
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .game-overlay-actions {
display: flex;
align-items: stretch;
gap: 12px;
margin-top: 20px;
width: 100%;
> * {
flex: 1 1 0;
margin-top: 0 !important;
}
}
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .game-overlay-share { #mine-wrapper .game-wrapper .game-overlay .game-overlay-window .game-overlay-share {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -590,3 +628,153 @@
} }
} }
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .game-overlay-profile {
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
background: linear-gradient(to bottom, #1a6844 0%, #135233 100%);
border: 2px solid #2a9e60;
color: #d0ffe0;
font-family: 'Rajdhani', sans-serif;
font-size: 13px;
font-weight: 800;
letter-spacing: 1px;
text-transform: uppercase;
padding: 12px 24px;
border-radius: 8px;
cursor: pointer;
transition: all 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
width: 100%;
margin-top: 20px;
position: relative;
overflow: hidden;
box-shadow: 0 4px 12px rgba(42, 158, 96, 0.25);
text-decoration: none;
z-index: 10;
&::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.4s ease;
}
&:hover {
background: linear-gradient(to bottom, #238f5c 0%, #1a6844 100%);
border-color: #5ee89a;
color: #fff;
box-shadow: 0 8px 24px rgba(42, 158, 96, 0.4);
transform: translateY(-2px);
&::before {
left: 100%;
}
}
&:active {
transform: translateY(0);
}
i {
font-size: 15px;
}
}
// CaptchaOverlay Styles
.captcha-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(7, 9, 13, 0.95);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.captcha-content {
text-align: center;
color: #fff;
max-width: 400px;
padding: 40px;
}
.captcha-icon {
font-size: 64px;
color: #236f87;
margin-bottom: 24px;
}
.captcha-title {
font: 800 32px 'Rajdhani', sans-serif;
margin: 0 0 16px;
letter-spacing: 1px;
}
.captcha-description {
color: rgba(149, 207, 245, 0.7);
font: 400 16px 'Rajdhani', sans-serif;
margin: 0 0 32px;
letter-spacing: 0.5px;
}
.captcha-button {
background: linear-gradient(#236f87 0%, #1a5068 100%);
border: 2px solid #2e7a9a;
border-radius: 8px;
color: #e0f4ff;
cursor: pointer;
font: 800 18px 'Rajdhani', sans-serif;
letter-spacing: 2px;
padding: 16px 40px;
text-transform: uppercase;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 12px;
opacity: 1;
i {
font-size: 16px;
}
&:disabled {
opacity: 0.7;
cursor: wait;
}
&:hover:not(:disabled) {
background: linear-gradient(#2d8aa8 0%, #236f87 100%);
border-color: #5ba4d4;
color: #fff;
box-shadow: 0 8px 24px rgba(35, 111, 135, 0.4);
transform: translateY(-2px);
}
&:active:not(:disabled) {
transform: translateY(0);
}
&.captcha-button--error {
background: linear-gradient(#8a2323 0%, #681a1a 100%);
border-color: #9a2e2e;
&:hover {
background: linear-gradient(#a82d2d 0%, #872323 100%);
border-color: #d45b5b;
box-shadow: 0 8px 24px rgba(135, 35, 35, 0.4);
}
}
&.captcha-button--loading {
opacity: 0.7;
}
}

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
@media screen and (max-width: 900px) { @media screen and (max-width: 900px) {
#mine-wrapper .game-wrapper .users { #mine-wrapper .game-wrapper .users {
visibility: hidden; visibility: hidden;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
#mine-wrapper .game-timer-container { #mine-wrapper .game-timer-container {
display: flex; display: flex;
gap: 12px; gap: 12px;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
#mine-wrapper .game-wrapper .users { #mine-wrapper .game-wrapper .users {
width: 180px; width: 180px;
padding: 0 10px 0 0; padding: 0 10px 0 0;
@@ -105,10 +114,12 @@
text-align: center; text-align: center;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
padding: 3px 0; padding: 3px 5px;
margin: 0 5px; margin: 0;
overflow: hidden; overflow: hidden;
word-break: break-word;
max-width: 100%;
} }
#mine-wrapper .game-wrapper .users .user-container.user-blue .user-name { #mine-wrapper .game-wrapper .users .user-container.user-blue .user-name {
@@ -142,6 +153,13 @@
height: 65px; height: 65px;
font-size: 14px; font-size: 14px;
text-align: center; text-align: center;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
word-break: break-word;
padding: 0 5px;
} }
#mine-wrapper .game-wrapper .users .user-container.user-blue .user-desc { #mine-wrapper .game-wrapper .users .user-container.user-blue .user-desc {

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
.opd-paper { .opd-paper {
background: #07090d !important; background: #07090d !important;
background-image: linear-gradient(rgba(35, 111, 135, 0.08) 1px, transparent 1px), background-image: linear-gradient(rgba(35, 111, 135, 0.08) 1px, transparent 1px),

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
@use "sass:color"; @use "sass:color";
.twofa-status { .twofa-status {

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2019 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
@use 'homepage/reset'; @use 'homepage/reset';
@use 'homepage/animations'; @use 'homepage/animations';
@use 'homepage/header'; @use 'homepage/header';
@@ -12,4 +21,5 @@
@use 'homepage/tech'; @use 'homepage/tech';
@use 'homepage/footer'; @use 'homepage/footer';
@use 'homepage/profile'; @use 'homepage/profile';
@use 'homepage/battle-dialog';
@use 'homepage/responsive'; @use 'homepage/responsive';

View File

@@ -1,7 +1,7 @@
/*!* /*!*
* This file is part of the SplendidBear Websites' projects. * This file is part of the SplendidBear Websites' projects.
* *
* Copyright (c) 2026 @ www.splendidbear.org * Copyright (c) 2019 @ www.splendidbear.org
* *
* For the full copyright and license information, please view the LICENSE * For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code. * file that was distributed with this source code.
@@ -320,7 +320,6 @@ footer nav ul li {
} }
footer nav ul li:nth-child(even) { footer nav ul li:nth-child(even) {
width: 50px;
text-align: center; text-align: center;
} }
@@ -401,8 +400,4 @@ footer nav ul li a:hover {
footer nav ul li { footer nav ul li {
display: block; display: block;
} }
footer nav ul li:nth-child(even) {
display: none;
}
} }

View File

@@ -1,7 +1,7 @@
/*!* /*!*
* This file is part of the SplendidBear Websites' projects. * This file is part of the SplendidBear Websites' projects.
* *
* Copyright (c) 2026 @ www.splendidbear.org * Copyright (c) 2019 @ www.splendidbear.org
* *
* For the full copyright and license information, please view the LICENSE * For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code. * file that was distributed with this source code.
@@ -19,5 +19,6 @@
@import 'mineseeker/grid'; @import 'mineseeker/grid';
@import 'mineseeker/back-button'; @import 'mineseeker/back-button';
@import 'mineseeker/timer'; @import 'mineseeker/timer';
@import 'mineseeker/bonus-box';
@import 'mineseeker/responsive'; @import 'mineseeker/responsive';
@import 'mineseeker/waiting-dialog'; @import 'mineseeker/waiting-dialog';

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2019 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
.mine-beta { .mine-beta {
position: fixed; position: fixed;
top: 0; top: 0;

View File

@@ -17,5 +17,6 @@ createRoot(wrapper).render(
<MineSeeker <MineSeeker
env={wrapper.dataset.env} env={wrapper.dataset.env}
gameId={wrapper.dataset.gameId} gameId={wrapper.dataset.gameId}
opponentName={wrapper.dataset.opponentName || ''}
/>, />,
); );

View File

@@ -1,36 +1,32 @@
import React, { useRef, useState } from 'react'; /**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
export default function AvatarUpload({ uploadUrl, initialThumbUrl, initials }) { import React, { useMemo, useRef } from 'react';
const [thumbUrl, setThumbUrl] = useState(initialThumbUrl || null); import { string } from 'prop-types';
const [loading, setLoading] = useState(false); import { useProfileDataProvider } from '@mine-hooks/useGameDataProvider';
const [error, setError] = useState(null);
export const AvatarUpload = ({ uploadUrl, initialThumbUrl, initials }) => {
const inputRef = useRef(null); const inputRef = useRef(null);
const [thumbUrl, setThumbUrl] = React.useState(initialThumbUrl || null);
const { uploadAvatarMutation: { isPending, error, mutate } } = useProfileDataProvider();
function handleClick() { const handleChange = e => {
inputRef.current?.click();
}
function handleChange(e) {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
const fd = new FormData(); mutate({ uploadUrl, file }, {
fd.append('avatar', file); onSuccess: data => {
setLoading(true);
setError(null);
fetch(uploadUrl, { method: 'POST', body: fd })
.then(r => r.json())
.then(data => {
if (data.error) {
setError(data.error);
return;
}
setThumbUrl(data.thumbUrl); setThumbUrl(data.thumbUrl);
const navImg = document.querySelector('.hero-auth-avatar:not(.hero-auth-avatar--initials)'); const navImg = document.querySelector('.hero-auth-avatar:not(.hero-auth-avatar--initials)');
const navInitials = document.querySelector('.hero-auth-avatar.hero-auth-avatar--initials'); const navInitials = document.querySelector('.hero-auth-avatar.hero-auth-avatar--initials');
if (navImg) { if (navImg) {
navImg.src = data.thumbUrl; navImg.src = data.thumbUrl;
} else if (navInitials) { } else if (navInitials) {
@@ -40,16 +36,17 @@ export default function AvatarUpload({ uploadUrl, initialThumbUrl, initials }) {
img.className = 'hero-auth-avatar'; img.className = 'hero-auth-avatar';
navInitials.replaceWith(img); navInitials.replaceWith(img);
} }
}) },
.catch(() => setError('Upload failed. Please try again.')) });
.finally(() => setLoading(false)); };
}
const errorMessage = useMemo(() => error?.message ?? null, [error]);
return ( return (
<div <div
className={`profile-avatar${loading ? ' profile-avatar--loading' : ''}`} className={`profile-avatar${isPending ? ' profile-avatar--loading' : ''}`}
title="Click to change profile picture" title="Click to change profile picture"
onClick={handleClick} onClick={() => inputRef.current?.click()}
> >
{thumbUrl {thumbUrl
? <img src={thumbUrl} alt={initials} className="profile-avatar__img" /> ? <img src={thumbUrl} alt={initials} className="profile-avatar__img" />
@@ -65,7 +62,13 @@ export default function AvatarUpload({ uploadUrl, initialThumbUrl, initials }) {
style={{ display: 'none' }} style={{ display: 'none' }}
onChange={handleChange} onChange={handleChange}
/> />
{error && <div className="profile-avatar__error">{error}</div>} {errorMessage && <div className="profile-avatar__error">{errorMessage}</div>}
</div> </div>
); );
} }
AvatarUpload.propTypes = {
uploadUrl: string.isRequired,
initialThumbUrl: string,
initials: string.isRequired,
};

View File

@@ -1,31 +1,21 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { array } from 'prop-types';
import { formatDuration } from '@global-utils/format';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import { createTheme, ThemeProvider } from '@mui/material/styles'; import { createTheme, styled, ThemeProvider } from '@mui/material/styles';
import { Avatar, BonusPoints, StatRow } from '@global-components';
const darkTheme = createTheme({ palette: { mode: 'dark' } }); const darkTheme = createTheme({ palette: { mode: 'dark' } });
const DIALOG_SX = {
'& .MuiDialog-paper': {
background: '#07090d',
backgroundImage: `
linear-gradient(rgba(35, 111, 135, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(35, 111, 135, 0.08) 1px, transparent 1px)
`,
backgroundSize: '46px 46px',
border: '1px solid rgba(35, 111, 135, 0.4)',
borderRadius: '12px',
boxShadow: '0 0 80px rgba(35, 111, 135, 0.15), 0 32px 80px rgba(0,0,0,0.9)',
width: '580px',
maxWidth: '94vw',
overflow: 'hidden',
color: '#fff',
},
'& .MuiBackdrop-root': {
background: 'rgba(2, 4, 8, 0.88)',
backdropFilter: 'blur(4px)',
},
};
const RESULT_META = { const RESULT_META = {
win: { win: {
label: 'Victory', label: 'Victory',
@@ -50,103 +40,7 @@ const RESULT_META = {
}, },
}; };
function Avatar({ name, color, avatarUrl }) { export const BattleDialog = ({ games }) => {
const isRed = 'red' === color;
const initials = (name || '?').slice(0, 2).toUpperCase();
const gradient = isRed
? 'linear-gradient(135deg, rgba(173,10,5,0.6) 0%, rgba(246,125,82,0.4) 100%)'
: 'linear-gradient(135deg, rgba(35,111,135,0.6) 0%, rgba(41,128,185,0.4) 100%)';
const glow = isRed
? '0 0 0 3px rgba(173,10,5,0.2), 0 0 28px rgba(173,10,5,0.35)'
: '0 0 0 3px rgba(35,111,135,0.2), 0 0 28px rgba(35,111,135,0.35)';
const border = isRed
? 'rgba(173,10,5,0.5)'
: 'rgba(35,111,135,0.5)';
const textColor = isRed ? '#f67d52' : '#95cff5';
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10 }}>
<div style={{
width: 72, height: 72, borderRadius: '50%',
background: avatarUrl ? 'transparent' : gradient,
border: `2px solid ${border}`,
boxShadow: glow,
display: 'flex', alignItems: 'center', justifyContent: 'center',
font: '800 24px \'Rajdhani\', sans-serif',
color: textColor,
letterSpacing: 2,
overflow: 'hidden',
}}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt={name}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
initials
)}
</div>
<span style={{
font: '700 15px \'Rajdhani\', sans-serif',
color: textColor,
letterSpacing: 1,
maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
textAlign: 'center',
}}
>
{name}
</span>
<span style={{
font: '600 10px \'Rajdhani\', sans-serif',
textTransform: 'uppercase',
letterSpacing: 2,
color: 'rgba(255,255,255,0.3)',
}}
>
{isRed ? 'Red' : 'Blue'}
</span>
</div>
);
}
function StatRow({ icon, label, value, valueColor }) {
return (
<div style={{
display: 'flex', alignItems: 'center',
gap: 10, padding: '9px 0',
borderBottom: '1px solid rgba(255,255,255,0.05)',
}}
>
<i className={`fa ${icon}`} style={{ width: 16, color: 'rgba(149,207,245,0.4)', fontSize: 13 }} />
<span style={{
font: '500 13px \'Rajdhani\', sans-serif',
color: 'rgba(255,255,255,0.45)',
flex: 1,
letterSpacing: 0.5,
}}
>
{label}
</span>
<span style={{
font: '700 13px \'Rajdhani\', sans-serif',
color: valueColor || 'rgba(255,255,255,0.75)',
letterSpacing: 0.5,
}}
>
{value}
</span>
</div>
);
}
export default function BattleDialog({ games }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [game, setGame] = useState(null); const [game, setGame] = useState(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -166,15 +60,24 @@ export default function BattleDialog({ games }) {
}, [games]); }, [games]);
if (!game) { if (!game) {
return <ThemeProvider theme={darkTheme}><Dialog open={false} sx={DIALOG_SX} /></ThemeProvider>; return <ThemeProvider theme={darkTheme}><StyledDialog open={false} /></ThemeProvider>;
} }
const meta = RESULT_META[game.result] ?? RESULT_META.draw; const meta = RESULT_META[game.result] ?? RESULT_META.draw;
const resign = game.resign; const resign = game.resign;
const maxPoints = Math.max(game.redPoints ?? 0, game.bluePoints ?? 0);
const endReason = resign const endReason = resign
? `${resign.charAt(0).toUpperCase() + resign.slice(1)} resigned` ? `${resign.charAt(0).toUpperCase() + resign.slice(1)} resigned`
: 'Points'; : 26 <= maxPoints ? 'Points' : 'Abandoned';
const shareUrl = `${window.location.origin}/battle/${game.uuid}`; const shareUrl = `${window.location.origin}/battle/${game.uuid}`;
const canContinue = !resign && 26 > maxPoints;
const playUrl = `${window.location.origin}/play/${game.uuid}`;
const duration = formatDuration(game.created, game.date);
const pointDiff = Math.abs((game.redPoints ?? 0) - (game.bluePoints ?? 0));
const winnerColor = (game.redPoints ?? 0) > (game.bluePoints ?? 0) ? '#f67d52'
: (game.bluePoints ?? 0) > (game.redPoints ?? 0) ? '#95cff5'
: 'rgba(255,255,255,0.45)';
const handleShare = () => { const handleShare = () => {
navigator.clipboard.writeText(shareUrl).then(() => { navigator.clipboard.writeText(shareUrl).then(() => {
@@ -185,7 +88,7 @@ export default function BattleDialog({ games }) {
return ( return (
<ThemeProvider theme={darkTheme}> <ThemeProvider theme={darkTheme}>
<Dialog open={open} onClose={() => setOpen(false)} sx={DIALOG_SX}> <StyledDialog open={open} onClose={() => setOpen(false)}>
<div className="bd"> <div className="bd">
<div className="bd-header"> <div className="bd-header">
<div className="bd-header-left"> <div className="bd-header-left">
@@ -194,7 +97,18 @@ export default function BattleDialog({ games }) {
<i className="fa fa-crosshairs" /> Match Details <i className="fa fa-crosshairs" /> Match Details
</h2> </h2>
</div> </div>
<div style={{ display: 'flex', gap: 8 }}> <div className="bd-header-actions">
{canContinue ? (
<a
className="bd-continue"
href={playUrl}
aria-label="Continue the game"
title="Continue the game"
>
<i className="fa fa-play" />
Continue
</a>
) : (
<button <button
className={`bd-share${copied ? ' bd-share--copied' : ''}`} className={`bd-share${copied ? ' bd-share--copied' : ''}`}
onClick={handleShare} onClick={handleShare}
@@ -204,48 +118,102 @@ export default function BattleDialog({ games }) {
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} /> <i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} />
{copied ? 'Copied!' : 'Share'} {copied ? 'Copied!' : 'Share'}
</button> </button>
)}
<button className="bd-close" onClick={() => setOpen(false)} aria-label="Close"> <button className="bd-close" onClick={() => setOpen(false)} aria-label="Close">
<i className="fa fa-times" /> <i className="fa fa-times" />
</button> </button>
</div> </div>
</div> </div>
<div className="bd-vs-panel"> <div className="bd-vs-panel">
<Avatar name={game.redName} color="red" avatarUrl={game.redAvatar} /> <Avatar
name={game.redName} color="red" avatarUrl={game.redAvatar}
bonusPoints={game.redBonusPoints > game.blueBonusPoints ? game.redBonusPoints : 0}
/>
<div className="bd-vs-center"> <div className="bd-vs-center">
<div className="bd-vs-score"> <div className="bd-vs-score">
<span className="bd-vs-score__red">{game.redPoints ?? '—'}</span> <span className="bd-vs-score__red">{game.redPoints ?? '—'}</span>
<span className="bd-vs-score__sep">:</span> <span className="bd-vs-score__sep">:</span>
<span className="bd-vs-score__blue">{game.bluePoints ?? '—'}</span> <span className="bd-vs-score__blue">{game.bluePoints ?? '—'}</span>
</div> </div>
<div className="bd-vs-score bd-bonus-score">
<span className="bd-bonus-score__red">
<i className="fa fa-star" /> {(game.redBonusPoints ?? 0).toFixed(1)}
</span>
<span className="bd-vs-score__sep">:</span>
<span className="bd-bonus-score__blue">
{(game.blueBonusPoints ?? 0).toFixed(1)} <i className="fa fa-star" />
</span>
</div>
<div className="bd-vs-label">VS</div> <div className="bd-vs-label">VS</div>
<div <div
className="bd-result-badge" className="bd-result-badge"
style={{ background: meta.bg, border: `1px solid ${meta.border}`, color: meta.color }} style={{ '--bd-result-bg': meta.bg, '--bd-result-border': meta.border, '--bd-result-color': meta.color }}
> >
<i className={`fa ${meta.icon}`} /> {meta.label} <i className={`fa ${meta.icon}`} /> {meta.label}
</div> </div>
</div> </div>
<Avatar name={game.blueName} color="blue" avatarUrl={game.blueAvatar} /> <Avatar
name={game.blueName} color="blue" avatarUrl={game.blueAvatar}
bonusPoints={game.blueBonusPoints > game.redBonusPoints ? game.blueBonusPoints : 0}
/>
</div> </div>
<div className="bd-stats"> <div className="bd-stats">
<StatRow icon="fa-calendar" label="Date" value={game.date ?? '—'} /> <StatRow icon="fa-calendar" label="Date" value={game.date ?? '—'} />
{game.created && game.date && game.created !== game.date && (
<StatRow icon="fa-clock" label="Started" value={game.created} />
)}
{duration && (
<StatRow icon="fa-hourglass-half" label="Match duration" value={duration} />
)}
<StatRow icon="fa-flag-checkered" label="End reason" value={endReason} /> <StatRow icon="fa-flag-checkered" label="End reason" value={endReason} />
{0 < pointDiff && (
<StatRow <StatRow
icon="fa-bomb" label="Red hit a mine" icon="fa-balance-scale" label="Winning margin"
value={`${pointDiff} mine${1 === pointDiff ? '' : 's'}`} valueColor={winnerColor}
/>
)}
<StatRow
icon="fa-bomb" label="Red used bomb"
value={game.redExplodedBomb ? 'Yes' : 'No'} value={game.redExplodedBomb ? 'Yes' : 'No'}
valueColor={game.redExplodedBomb ? '#f67d52' : 'rgba(255,255,255,0.45)'} valueColor={game.redExplodedBomb ? '#f67d52' : 'rgba(255,255,255,0.45)'}
/> />
<StatRow <StatRow
icon="fa-bomb" label="Blue hit a mine" icon="fa-bomb" label="Blue used bomb"
value={game.blueExplodedBomb ? 'Yes' : 'No'} value={game.blueExplodedBomb ? 'Yes' : 'No'}
valueColor={game.blueExplodedBomb ? '#f67d52' : 'rgba(255,255,255,0.45)'} valueColor={game.blueExplodedBomb ? '#95cff5' : 'rgba(255,255,255,0.45)'}
/> />
{game.created && game.date && game.created !== game.date && (
<StatRow icon="fa-clock-o" label="Started" value={game.created} />
)}
</div> </div>
<BonusPoints
game={game}
/>
</div> </div>
</Dialog> </StyledDialog>
</ThemeProvider> </ThemeProvider>
); );
} };
BattleDialog.propTypes = {
games: array.isRequired,
};
const StyledDialog = styled(Dialog)({
'& .MuiDialog-paper': {
background: '#07090d',
backgroundImage: `
linear-gradient(rgba(35, 111, 135, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(35, 111, 135, 0.08) 1px, transparent 1px)
`,
backgroundSize: '46px 46px',
border: '1px solid rgba(35, 111, 135, 0.4)',
borderRadius: '12px',
boxShadow: '0 0 80px rgba(35, 111, 135, 0.15), 0 32px 80px rgba(0,0,0,0.9)',
width: '580px',
maxWidth: '94vw',
overflow: 'hidden',
color: '#fff',
},
'& .MuiBackdrop-root': {
background: 'rgba(2, 4, 8, 0.88)',
backdropFilter: 'blur(4px)',
},
});

View File

@@ -8,6 +8,7 @@
*/ */
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { string } from 'prop-types';
/** /**
* ContactForm Component * ContactForm Component
@@ -80,4 +81,9 @@ const ContactForm = ({ siteKey, recaptchaFieldId }) => {
return null; return null;
}; };
ContactForm.propTypes = {
siteKey: string.isRequired,
recaptchaFieldId: string.isRequired,
};
export default ContactForm; export default ContactForm;

View File

@@ -8,6 +8,7 @@
*/ */
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { shape, string } from 'prop-types';
const base64ToArrayBuffer = base64 => { const base64ToArrayBuffer = base64 => {
const binary = atob(base64.replace(/([-_])/g, m => ('-' === m ? '+' : '/'))); const binary = atob(base64.replace(/([-_])/g, m => ('-' === m ? '+' : '/')));
@@ -108,3 +109,10 @@ const PasskeyLogin = ({ apiRoutes }) => {
}; };
export default PasskeyLogin; export default PasskeyLogin;
PasskeyLogin.propTypes = {
apiRoutes: shape({
authenticationBegin: string.isRequired,
authenticationComplete: string.isRequired,
}).isRequired,
};

View File

@@ -9,13 +9,15 @@
import React, { Fragment, useCallback, useEffect, useState } from 'react'; import React, { Fragment, useCallback, useEffect, useState } from 'react';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import { styled } from '@mui/material/styles';
import DialogTitle from '@mui/material/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent'; import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions'; import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import { arrayOf, shape, string, bool } from 'prop-types';
const DIALOG_SX = { const StyledDialog = styled(Dialog)({
'& .MuiDialog-paper': { '& .MuiDialog-paper': {
background: '#0a0e14', background: '#0a0e14',
color: '#e0e0e0', color: '#e0e0e0',
@@ -47,7 +49,7 @@ const DIALOG_SX = {
background: 'rgba(2, 4, 8, 0.88)', background: 'rgba(2, 4, 8, 0.88)',
backdropFilter: 'blur(4px)', backdropFilter: 'blur(4px)',
}, },
}; });
const base64ToArrayBuffer = base64 => { const base64ToArrayBuffer = base64 => {
const binary = atob(base64.replace(/([-_])/g, m => ('-' === m ? '+' : '/'))); const binary = atob(base64.replace(/([-_])/g, m => ('-' === m ? '+' : '/')));
@@ -314,7 +316,7 @@ const PasskeyManager = ({ credentials, apiRoutes }) => {
)} )}
</div> </div>
<Dialog open={addModalOpen} onClose={closeAddModal} sx={DIALOG_SX}> <StyledDialog open={addModalOpen} onClose={closeAddModal}>
<DialogTitle>Add New Passkey</DialogTitle> <DialogTitle>Add New Passkey</DialogTitle>
<DialogContent> <DialogContent>
<TextField <TextField
@@ -344,9 +346,9 @@ const PasskeyManager = ({ credentials, apiRoutes }) => {
Continue Continue
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </StyledDialog>
<Dialog open={renameModalOpen} onClose={closeRenameModal} sx={DIALOG_SX}> <StyledDialog open={renameModalOpen} onClose={closeRenameModal}>
<DialogTitle>Rename Passkey</DialogTitle> <DialogTitle>Rename Passkey</DialogTitle>
<DialogContent> <DialogContent>
<TextField <TextField
@@ -374,9 +376,9 @@ const PasskeyManager = ({ credentials, apiRoutes }) => {
Rename Rename
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </StyledDialog>
<Dialog open={deleteModalOpen} onClose={closeDeleteModal} sx={DIALOG_SX}> <StyledDialog open={deleteModalOpen} onClose={closeDeleteModal}>
<DialogTitle>Delete Passkey</DialogTitle> <DialogTitle>Delete Passkey</DialogTitle>
<DialogContent> <DialogContent>
<p> <p>
@@ -402,9 +404,25 @@ const PasskeyManager = ({ credentials, apiRoutes }) => {
Delete Delete
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </StyledDialog>
</div> </div>
); );
}; };
export default PasskeyManager; export default PasskeyManager;
PasskeyManager.propTypes = {
credentials: arrayOf(shape({
id: string.isRequired,
credentialName: string.isRequired,
createdAt: string,
lastUsedAt: string,
isBackupEligible: bool,
isBackupAuthenticated: bool,
})).isRequired,
apiRoutes: shape({
credentials: string.isRequired,
registrationBegin: string.isRequired,
registrationComplete: string.isRequired,
}).isRequired,
};

View File

@@ -1,7 +1,18 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React from 'react'; import React from 'react';
import { BarChart } from '@mui/x-charts/BarChart'; import { BarChart } from '@mui/x-charts/BarChart';
import { LineChart } from '@mui/x-charts/LineChart';
import { PieChart } from '@mui/x-charts/PieChart'; import { PieChart } from '@mui/x-charts/PieChart';
import { createTheme, ThemeProvider } from '@mui/material/styles'; import { createTheme, ThemeProvider } from '@mui/material/styles';
import { shape, arrayOf, number, string } from 'prop-types';
const darkTheme = createTheme({ const darkTheme = createTheme({
palette: { palette: {
@@ -16,6 +27,8 @@ const darkTheme = createTheme({
const WIN_COLOR = '#5ee89a'; const WIN_COLOR = '#5ee89a';
const LOSS_COLOR = '#f67d52'; const LOSS_COLOR = '#f67d52';
const DRAW_COLOR = '#95cff5'; const DRAW_COLOR = '#95cff5';
const MINES_COLOR = '#f67d52';
const BONUS_COLOR = '#ffd700';
const axisStyle = { const axisStyle = {
tickLabelStyle: { fill: 'rgba(255,255,255,0.4)', fontSize: 11, fontFamily: '\'Rajdhani\', sans-serif' }, tickLabelStyle: { fill: 'rgba(255,255,255,0.4)', fontSize: 11, fontFamily: '\'Rajdhani\', sans-serif' },
@@ -23,10 +36,12 @@ const axisStyle = {
}; };
export default function ProfileCharts({ chartData }) { export default function ProfileCharts({ chartData }) {
const { months, wins, losses, draws, pieWins, pieLosses, pieDraws } = chartData; const { months, wins, losses, draws, pieWins, pieLosses, pieDraws, recentGames } = chartData;
const total = pieWins + pieLosses + pieDraws; const total = pieWins + pieLosses + pieDraws;
const hasBars = wins.some(v => 0 < v) || losses.some(v => 0 < v) || draws.some(v => 0 < v); const hasBars = wins.some(v => 0 < v) || losses.some(v => 0 < v) || draws.some(v => 0 < v);
const hasRecent = recentGames
&& (recentGames.mines?.some(v => 0 < v) || recentGames.bonus?.some(v => 0 < v));
return ( return (
<ThemeProvider theme={darkTheme}> <ThemeProvider theme={darkTheme}>
@@ -97,7 +112,54 @@ export default function ProfileCharts({ chartData }) {
</div> </div>
</div> </div>
)} )}
{hasRecent && (
<div className="profile-chart-block profile-chart-block--wide">
<h2 className="profile-section__title">
<i className="fa fa-line-chart" /> Last {recentGames.labels.length} games mines & bonus
</h2>
<div className="profile-chart-inner">
<LineChart
xAxis={[{ scaleType: 'band', data: recentGames.labels, ...axisStyle }]}
yAxis={[{ ...axisStyle }]}
series={[
{ data: recentGames.mines, label: 'Mines hit', color: MINES_COLOR },
{ data: recentGames.bonus, label: 'Bonus points', color: BONUS_COLOR },
]}
slotProps={{
legend: {
labelStyle: {
fill: 'rgba(255,255,255,0.55)',
fontSize: 13,
fontFamily: '\'Rajdhani\', sans-serif',
},
},
}}
borderRadius={3}
height={220}
margin={{ top: 10, bottom: 30, left: 40, right: 140 }}
/>
</div>
</div>
)}
</div> </div>
</ThemeProvider> </ThemeProvider>
); );
} }
ProfileCharts.propTypes = {
chartData: shape({
months: arrayOf(string).isRequired,
wins: arrayOf(number).isRequired,
losses: arrayOf(number).isRequired,
draws: arrayOf(number).isRequired,
pieWins: number.isRequired,
pieLosses: number.isRequired,
pieDraws: number.isRequired,
recentGames: shape({
labels: arrayOf(string).isRequired,
mines: arrayOf(number).isRequired,
bonus: arrayOf(number).isRequired,
}).isRequired,
}).isRequired,
};

View File

@@ -0,0 +1,54 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React, { useMemo } from 'react';
import { string } from 'prop-types';
export const Avatar = ({ name, color, avatarUrl, bonusPoints = 0 }) => {
const isRed = 'red' === color;
const initials = useMemo(() => (name || '?').slice(0, 2).toUpperCase(), [name]);
const cssVars = isRed ? {
'--bd-avatar-gradient': 'linear-gradient(135deg, rgba(173,10,5,0.6) 0%, rgba(246,125,82,0.4) 100%)',
'--bd-avatar-glow': '0 0 0 3px rgba(173,10,5,0.2), 0 0 28px rgba(173,10,5,0.35)',
'--bd-avatar-border': 'rgba(173,10,5,0.5)',
'--bd-avatar-color': '#f67d52',
} : {
'--bd-avatar-gradient': 'linear-gradient(135deg, rgba(35,111,135,0.6) 0%, rgba(41,128,185,0.4) 100%)',
'--bd-avatar-glow': '0 0 0 3px rgba(35,111,135,0.2), 0 0 28px rgba(35,111,135,0.35)',
'--bd-avatar-border': 'rgba(35,111,135,0.5)',
'--bd-avatar-color': '#95cff5',
};
return (
<div className="bd-avatar-wrap" style={cssVars}>
<div className="bd-avatar-ring-wrap">
<div className="bd-avatar-ring">
{avatarUrl
? <img src={avatarUrl} alt={name} className="bd-avatar-img" />
: initials}
</div>
{0 < bonusPoints && (
<div className="bd-avatar-bonus">
<i className="fa fa-star" />
</div>
)}
</div>
<span className="bd-avatar-name">{name}</span>
<span className="bd-avatar-side">{isRed ? 'Red' : 'Blue'}</span>
</div>
);
};
Avatar.propTypes = {
name: string,
color: string,
avatarUrl: string,
bonusPoints: string,
};

View File

@@ -0,0 +1,122 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import { useMemo } from 'react';
import { StatRow } from './StatRow';
import { object } from 'prop-types';
export const BonusPoints = ({ game }) => {
const hasBonuspoints = useMemo(
() => 0 < game?.redBonusPoints
|| 0 < game?.blueBonusPoints
|| game?.redBonusStats?.blindHits
|| game?.blueBonusStats?.blindHits,
[
game?.blueBonusPoints,
game?.blueBonusStats?.blindHits,
game?.redBonusPoints,
game?.redBonusStats?.blindHits,
],
);
const hasRedNoBonuses = useMemo(
() => !game.redBonusStats?.blindHits
&& !game.redBonusStats?.chainBest
&& !game.redBonusStats?.edgeMines
&& !game.redBonusStats?.lastMineHits
&& !game.redBonusStats?.biggestReveal,
[
game.redBonusStats?.biggestReveal,
game.redBonusStats?.blindHits,
game.redBonusStats?.chainBest,
game.redBonusStats?.edgeMines,
game.redBonusStats?.lastMineHits,
],
);
const hasBlueNoBonuses = useMemo(
() => !game.blueBonusStats?.blindHits
&& !game.blueBonusStats?.chainBest
&& !game.blueBonusStats?.edgeMines
&& !game.blueBonusStats?.lastMineHits
&& !game.blueBonusStats?.biggestReveal,
[
game.blueBonusStats?.biggestReveal,
game.blueBonusStats?.blindHits,
game.blueBonusStats?.chainBest,
game.blueBonusStats?.edgeMines,
game.blueBonusStats?.lastMineHits,
],
);
if (!hasBonuspoints) return '';
return (
<div className="bd-bonus">
<div className="bd-bonus__grid">
<div className="bd-bonus__column bd-bonus__column--red">
<span className="bd-bonus__heading">
<i className="fa fa-star" /> Red Bonus Statistics
</span>
<div className="bd-bonus__rows">
<StatRow icon="fa-star" label="Total Bonus Points" value={(game.redBonusPoints ?? 0).toFixed(1)} valueColor="#ffd700" />
{0 < game.redBonusStats?.blindHits && (
<StatRow icon="fa-bullseye" label="Blind hits" value={game.redBonusStats.blindHits} />
)}
{0 < game.redBonusStats?.chainBest && (
<StatRow icon="fa-link" label="Best chain" value={game.redBonusStats.chainBest} />
)}
{0 < game.redBonusStats?.edgeMines && (
<StatRow icon="fa-border-all" label="Edge mines" value={game.redBonusStats.edgeMines} />
)}
{0 < game.redBonusStats?.lastMineHits && (
<StatRow icon="fa-hourglass-end" label="Endgame mines" value={game.redBonusStats.lastMineHits} />
)}
{0 < game.redBonusStats?.biggestReveal && (
<StatRow icon="fa-expand" label="Biggest reveal" value={game.redBonusStats.biggestReveal} />
)}
{hasRedNoBonuses && (
<StatRow icon="fa-minus-circle" label="Status" value="No bonuses" valueColor="rgba(255,255,255,0.3)" />
)}
</div>
</div>
<div className="bd-bonus__column bd-bonus__column--blue">
<span className="bd-bonus__heading">
<i className="fa fa-star" /> Blue Bonus Statistics
</span>
<div className="bd-bonus__rows">
<StatRow icon="fa-star" label="Total Bonus Points" value={(game.blueBonusPoints ?? 0).toFixed(1)} valueColor="#ffd700" />
{0 < game.blueBonusStats?.blindHits && (
<StatRow icon="fa-bullseye" label="Blind hits" value={game.blueBonusStats.blindHits} />
)}
{0 < game.blueBonusStats?.chainBest && (
<StatRow icon="fa-link" label="Best chain" value={game.blueBonusStats.chainBest} />
)}
{0 < game.blueBonusStats?.edgeMines && (
<StatRow icon="fa-border-all" label="Edge mines" value={game.blueBonusStats.edgeMines} />
)}
{0 < game.blueBonusStats?.lastMineHits && (
<StatRow icon="fa-hourglass-end" label="Endgame mines" value={game.blueBonusStats.lastMineHits} />
)}
{0 < game.blueBonusStats?.biggestReveal && (
<StatRow icon="fa-expand" label="Biggest reveal" value={game.blueBonusStats.biggestReveal} />
)}
{hasBlueNoBonuses && (
<StatRow icon="fa-minus-circle" label="Status" value="No bonuses" valueColor="rgba(255,255,255,0.3)" />
)}
</div>
</div>
</div>
</div>
);
};
BonusPoints.propTypes = {
game: object.isRequired,
};

View File

@@ -0,0 +1,31 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React from 'react';
import { node, string } from 'prop-types';
export const StatRow = ({ icon, label, value, valueColor }) => (
<div className="bd-stat-row">
<i className={`fa ${icon} bd-stat-row__icon`} />
<span className="bd-stat-row__label">{label}</span>
<span
className="bd-stat-row__value"
style={valueColor ? { '--bd-stat-value-color': valueColor } : undefined}
>
{value}
</span>
</div>
);
StatRow.propTypes = {
icon: string.isRequired,
label: string.isRequired,
value: node.isRequired,
valueColor: string,
};

View File

@@ -0,0 +1,18 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
export { AvatarUpload } from './AvatarUpload';
export { BattleDialog } from './BattleDialog';
export { default as ContactForm } from './ContactForm';
export { default as PasskeyLogin } from './PasskeyLogin';
export { default as PasskeyManager } from './PasskeyManager';
export { default as ProfileCharts } from './ProfileCharts';
export { BonusPoints } from './battle-dialog/BonusPoints';
export { Avatar } from './battle-dialog/Avatar';
export { StatRow } from './battle-dialog/StatRow';

View File

@@ -9,7 +9,7 @@
import React from 'react'; import React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import ContactForm from './components/ContactForm'; import { ContactForm } from '@global-components';
const wrapper = document.getElementById('contact-form-wrapper'); const wrapper = document.getElementById('contact-form-wrapper');
@@ -28,4 +28,3 @@ if (wrapper) {
console.error('ContactForm: Missing siteKey or recaptchaFieldId in data attributes'); console.error('ContactForm: Missing siteKey or recaptchaFieldId in data attributes');
} }
} }

View File

@@ -11,10 +11,11 @@ import React, { useRef } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { GameProvider } from '@mine-contexts'; import { GameProvider } from '@mine-contexts';
import { GameBoard } from '@mine-components'; import { GameBoard } from '@mine-components';
import { string } from 'prop-types';
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const MineSeeker = ({ env, gameId }) => { const MineSeeker = ({ env, gameId, opponentName = '' }) => {
const isEnvDev = 'dev' === env; const isEnvDev = 'dev' === env;
const gameAssoc = useRef('' !== gameId ? gameId : crypto.randomUUID()).current; const gameAssoc = useRef('' !== gameId ? gameId : crypto.randomUUID()).current;
const gameInherited = '' !== gameId; const gameInherited = '' !== gameId;
@@ -25,6 +26,7 @@ const MineSeeker = ({ env, gameId }) => {
<GameBoard <GameBoard
gameAssoc={gameAssoc} gameAssoc={gameAssoc}
gameInherited={gameInherited} gameInherited={gameInherited}
opponentName={opponentName}
isEnvDev={isEnvDev} isEnvDev={isEnvDev}
/> />
</GameProvider> </GameProvider>
@@ -33,3 +35,9 @@ const MineSeeker = ({ env, gameId }) => {
}; };
export default MineSeeker; export default MineSeeker;
MineSeeker.propTypes = {
env: string.isRequired,
gameId: string,
opponentName: string,
};

View File

@@ -0,0 +1,33 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React from 'react';
import { func, number, string } from 'prop-types';
const BonusBox = ({ color, points, onClick, title }) => (
<button
type="button"
className={`bonus-box ${color}-bonus`}
onClick={onClick}
title={title || 'View bonus statistics'}
aria-label={`${color} bonus points: ${points}`}
>
<i className="fa fa-star bonus-box__icon" />
<span className="bonus-box__value">{points}</span>
</button>
);
export default BonusBox;
BonusBox.propTypes = {
color: string.isRequired,
points: number.isRequired,
onClick: func.isRequired,
title: string,
};

View File

@@ -0,0 +1,79 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React from 'react';
import Dialog from '@mui/material/Dialog';
import { styled } from '@mui/material/styles';
import { PlayerColumn } from '@mine-components';
import { bool, func, shape, string, number, object } from 'prop-types';
const BonusStatsDialog = ({ open, onClose, red, blue }) => (
<StyledDialog open={open} onClose={onClose}>
<div className="bsd">
<div className="bsd-header">
<div className="bsd-header-text">
<span className="bsd-label">Scoring</span>
<h2 className="bsd-title">
<i className="fa fa-star" />
Bonus Statistics
</h2>
</div>
<button className="bsd-close" onClick={onClose} aria-label="Close">
<i className="fa fa-times" />
</button>
</div>
<div className="bsd-body">
<PlayerColumn color="red" player={red} />
<PlayerColumn color="blue" player={blue} />
</div>
<p className="bsd-note">
Bonus points are awarded alongside the main score for skillful play.
</p>
</div>
</StyledDialog>
);
const StyledDialog = styled(Dialog)({
'& .MuiDialog-paper': {
background: '#07090d',
backgroundImage: `
linear-gradient(rgba(35, 111, 135, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(35, 111, 135, 0.08) 1px, transparent 1px)
`,
backgroundSize: '46px 46px',
border: '1px solid rgba(35, 111, 135, 0.4)',
borderRadius: '12px',
boxShadow: '0 0 80px rgba(35, 111, 135, 0.15), 0 32px 80px rgba(0, 0, 0, 0.9)',
width: '560px',
maxWidth: '94vw',
overflow: 'hidden',
color: '#fff',
},
'& .MuiBackdrop-root': {
background: 'rgba(2, 4, 8, 0.88)',
backdropFilter: 'blur(4px)',
},
});
export default BonusStatsDialog;
BonusStatsDialog.propTypes = {
open: bool.isRequired,
onClose: func.isRequired,
red: shape({
name: string,
bonusPoints: number,
bonusStats: object,
}).isRequired,
blue: shape({
name: string,
bonusPoints: number,
bonusStats: object,
}).isRequired,
};

View File

@@ -6,7 +6,9 @@
* For the full copyright and license information, please view the LICENSE * For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
import React, { useEffect, useState } from 'react';
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import { func, node, string } from 'prop-types';
const CAPTCHA_STORAGE_KEY = 'mineseeker_captcha_verified'; const CAPTCHA_STORAGE_KEY = 'mineseeker_captcha_verified';
const CAPTCHA_TOKEN_KEY = 'mineseeker_captcha_token'; const CAPTCHA_TOKEN_KEY = 'mineseeker_captcha_token';
@@ -17,6 +19,23 @@ const CaptchaOverlay = ({ siteKey, onVerified, children }) => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const handleToken = useCallback(token => {
const wrapper = document.getElementById('mine-wrapper');
if (wrapper) {
wrapper.dataset.captchaToken = token;
}
sessionStorage.setItem(CAPTCHA_TOKEN_KEY, token);
sessionStorage.setItem(CAPTCHA_STORAGE_KEY, Date.now().toString());
setVerified(true);
onVerified?.();
}, [onVerified]);
const buttonClasses = useMemo(() => [
'captcha-button',
error && 'captcha-button--error',
loading && 'captcha-button--loading',
].filter(Boolean).join(' '), [error, loading]);
useEffect(() => { useEffect(() => {
const storedToken = sessionStorage.getItem(CAPTCHA_TOKEN_KEY); const storedToken = sessionStorage.getItem(CAPTCHA_TOKEN_KEY);
const storedTime = sessionStorage.getItem(CAPTCHA_STORAGE_KEY); const storedTime = sessionStorage.getItem(CAPTCHA_STORAGE_KEY);
@@ -46,18 +65,8 @@ const CaptchaOverlay = ({ siteKey, onVerified, children }) => {
}); });
}); });
} }
}, [siteKey, onVerified]); }, [siteKey, onVerified, handleToken]);
const handleToken = token => {
const wrapper = document.getElementById('mine-wrapper');
if (wrapper) {
wrapper.dataset.captchaToken = token;
}
sessionStorage.setItem(CAPTCHA_TOKEN_KEY, token);
sessionStorage.setItem(CAPTCHA_STORAGE_KEY, Date.now().toString());
setVerified(true);
onVerified?.();
};
const handleClick = () => { const handleClick = () => {
setLoading(true); setLoading(true);
@@ -79,82 +88,21 @@ const CaptchaOverlay = ({ siteKey, onVerified, children }) => {
}; };
if (verified) { if (verified) {
return <>{children}</>; return <Fragment>{children}</Fragment>;
} }
const overlayStyles = {
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'rgba(7, 9, 13, 0.95)',
backdropFilter: 'blur(8px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
};
const contentStyles = {
textAlign: 'center',
color: '#fff',
maxWidth: '400px',
padding: '40px',
};
const iconStyles = {
fontSize: '64px',
color: '#236f87',
marginBottom: '24px',
};
const h1Styles = {
font: '800 32px Rajdhani, sans-serif',
margin: '0 0 16px',
letterSpacing: '1px',
};
const pStyles = {
color: 'rgba(149, 207, 245, 0.7)',
font: '400 16px Rajdhani, sans-serif',
margin: '0 0 32px',
letterSpacing: '0.5px',
};
const buttonStyles = {
background: error
? 'linear-gradient(#8a2323 0%, #681a1a 100%)'
: loading
? 'linear-gradient(#236f87 0%, #1a5068 100%)'
: 'linear-gradient(#236f87 0%, #1a5068 100%)',
border: `2px solid ${error ? '#9a2e2e' : loading ? '#2e7a9a' : '#2e7a9a'}`,
borderRadius: '8px',
color: '#e0f4ff',
cursor: loading ? 'wait' : 'pointer',
font: '800 18px Rajdhani, sans-serif',
letterSpacing: '2px',
padding: '16px 40px',
textTransform: 'uppercase',
transition: 'all 0.3s ease',
display: 'inline-flex',
alignItems: 'center',
gap: '12px',
opacity: loading ? 0.7 : 1,
};
return ( return (
<div style={overlayStyles}> <div className="captcha-overlay">
<div style={contentStyles}> <div className="captcha-content">
<div style={iconStyles}> <div className="captcha-icon">
<i className="fa fa-shield-halved" /> <i className="fa fa-shield-halved" />
</div> </div>
<h1 style={h1Styles}>Ready to Play?</h1> <h1 className="captcha-title">Ready to Play?</h1>
<p style={pStyles}> <p className="captcha-description">
Click below to verify you&apos;re human and start playing. Click below to verify you&apos;re human and start playing.
</p> </p>
<button <button
style={buttonStyles} className={buttonClasses}
onClick={handleClick} onClick={handleClick}
disabled={loading} disabled={loading}
> >
@@ -167,3 +115,9 @@ const CaptchaOverlay = ({ siteKey, onVerified, children }) => {
}; };
export default CaptchaOverlay; export default CaptchaOverlay;
CaptchaOverlay.propTypes = {
siteKey: string.isRequired,
onVerified: func,
children: node,
};

View File

@@ -7,6 +7,7 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
import { Fragment, useEffect, useState } from 'react'; import { Fragment, useEffect, useState } from 'react';
import { func, number } from 'prop-types';
const ChallengeCountdown = ({ onAccept, onDecline, seconds = 30 }) => { const ChallengeCountdown = ({ onAccept, onDecline, seconds = 30 }) => {
const [countdown, setCountdown] = useState(seconds); const [countdown, setCountdown] = useState(seconds);
@@ -39,3 +40,9 @@ const ChallengeCountdown = ({ onAccept, onDecline, seconds = 30 }) => {
}; };
export default ChallengeCountdown; export default ChallengeCountdown;
ChallengeCountdown.propTypes = {
onAccept: func.isRequired,
onDecline: func.isRequired,
seconds: number,
};

View File

@@ -7,14 +7,19 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
import React from 'react'; import React, { useState } from 'react';
import { useGame } from '@mine-contexts'; import { useGame } from '@mine-contexts';
import { useServerCommunication } from '@mine-hooks'; import { useServerCommunication } from '@mine-hooks';
import CaptchaOverlay from './CaptchaOverlay';
import GridControl from './grid/GridControl'; import GridControl from './grid/GridControl';
import { bool, string } from 'prop-types';
export const GameBoard = ({ gameAssoc, gameInherited, isEnvDev }) => { export const GameBoard = ({ gameAssoc, gameInherited, opponentName = '', isEnvDev }) => {
const { gridReady } = useGame(); const { gridReady } = useGame();
const { onClick, resign } = useServerCommunication(gameAssoc, gameInherited, isEnvDev); const { onClick, resign } = useServerCommunication(gameAssoc, gameInherited, opponentName, isEnvDev);
const [captchaVerified, setCaptchaVerified] = useState(false);
const siteKey = document.getElementById('mine-wrapper')?.dataset.recaptchaSiteKey;
if (!gridReady) { if (!gridReady) {
return ( return (
@@ -24,6 +29,12 @@ export const GameBoard = ({ gameAssoc, gameInherited, isEnvDev }) => {
); );
} }
if (!captchaVerified && siteKey) {
return (
<CaptchaOverlay siteKey={siteKey} onVerified={() => setCaptchaVerified(true)} />
);
}
return ( return (
<GridControl <GridControl
gameAssoc={gameAssoc} gameAssoc={gameAssoc}
@@ -32,3 +43,10 @@ export const GameBoard = ({ gameAssoc, gameInherited, isEnvDev }) => {
/> />
); );
}; };
GameBoard.propTypes = {
gameAssoc: string.isRequired,
gameInherited: bool.isRequired,
opponentName: string,
isEnvDev: bool,
};

View File

@@ -7,37 +7,26 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
import React, { useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { BonusBox, BonusStatsDialog, Avatar } from '@mine-components';
import { formatTime } from '@global-utils/format';
import { useGame } from '@mine-contexts'; import { useGame } from '@mine-contexts';
const renderAvatar = player => {
if (!player.registered) return null;
return (
<div className="timer-avatar">
{player.avatar
? <img src={player.avatar} alt={player.name} className="timer-avatar__img" />
: <span className="timer-avatar__initials">{player.name.slice(0, 2).toUpperCase()}</span>
}
</div>
);
};
const GameTimer = () => { const GameTimer = () => {
const { overlay, connectionLost, endRef, activePlayer, red, blue } = useGame(); const { overlay, connectionLost, endRef, activePlayer, red, blue } = useGame();
const [redTime, setRedTime] = useState(0); const [redTime, setRedTime] = useState(0);
const [blueTime, setBlueTime] = useState(0); const [blueTime, setBlueTime] = useState(0);
const [isRunning, setIsRunning] = useState(false); const [isRunning, setIsRunning] = useState(false);
const [bonusDialogOpen, setBonusDialogOpen] = useState(false);
const timerIntervalRef = useRef(null); const timerIntervalRef = useRef(null);
const gameStartedRef = useRef(false); const gameStartedRef = useRef(false);
// Use timestamps instead of counters for more reliable background tracking
const redStartTimeRef = useRef(null); const redStartTimeRef = useRef(null);
const blueStartTimeRef = useRef(null); const blueStartTimeRef = useRef(null);
const lastActivePlayerRef = useRef(null); const lastActivePlayerRef = useRef(null);
const pausedRedTimeRef = useRef(0); const pausedRedTimeRef = useRef(0);
const pausedBlueTimeRef = useRef(0); const pausedBlueTimeRef = useRef(0);
// Start timer when overlay is hidden (both players connected and game started)
useEffect(() => { useEffect(() => {
if (!overlay && !gameStartedRef.current) { if (!overlay && !gameStartedRef.current) {
gameStartedRef.current = true; gameStartedRef.current = true;
@@ -50,28 +39,20 @@ const GameTimer = () => {
pausedBlueTimeRef.current = 0; pausedBlueTimeRef.current = 0;
lastActivePlayerRef.current = activePlayer; lastActivePlayerRef.current = activePlayer;
} }
}, [overlay]); }, [activePlayer, overlay]);
// Stop timer on game end (resign/win)
useEffect(() => { useEffect(() => {
if (endRef.current) { if (endRef.current) setIsRunning(false);
setIsRunning(false); }, [endRef]);
}
}, [endRef.current]);
// Stop timer on connection loss
useEffect(() => { useEffect(() => {
if (connectionLost) { if (connectionLost) setIsRunning(false);
setIsRunning(false);
}
}, [connectionLost]); }, [connectionLost]);
// Handle player switch - pause one timer, resume the other
useEffect(() => { useEffect(() => {
if (!isRunning) return; if (!isRunning) return;
if (lastActivePlayerRef.current !== activePlayer) { if (lastActivePlayerRef.current !== activePlayer) {
// Player switched, save current accumulated time for whoever was active
const startRef = lastActivePlayerRef.current ? blueStartTimeRef.current : redStartTimeRef.current; const startRef = lastActivePlayerRef.current ? blueStartTimeRef.current : redStartTimeRef.current;
if (startRef) { if (startRef) {
const elapsed = Math.floor((Date.now() - startRef) / 1000); const elapsed = Math.floor((Date.now() - startRef) / 1000);
@@ -82,7 +63,6 @@ const GameTimer = () => {
} }
} }
// Start the new active player's timer
if (activePlayer) { if (activePlayer) {
blueStartTimeRef.current = Date.now(); blueStartTimeRef.current = Date.now();
} else { } else {
@@ -93,85 +73,61 @@ const GameTimer = () => {
} }
}, [activePlayer, isRunning]); }, [activePlayer, isRunning]);
// Main timer effect - update display every 100ms const syncTimes = useCallback(() => {
let currentRedTime = pausedRedTimeRef.current;
let currentBlueTime = pausedBlueTimeRef.current;
if (!activePlayer && redStartTimeRef.current) {
currentRedTime += Math.floor((Date.now() - redStartTimeRef.current) / 1000);
} else if (activePlayer && blueStartTimeRef.current) {
currentBlueTime += Math.floor((Date.now() - blueStartTimeRef.current) / 1000);
}
setRedTime(currentRedTime);
setBlueTime(currentBlueTime);
}, [activePlayer]);
useEffect(() => { useEffect(() => {
if (!isRunning) { if (!isRunning) {
if (timerIntervalRef.current) { if (timerIntervalRef.current) clearInterval(timerIntervalRef.current);
clearInterval(timerIntervalRef.current);
}
return; return;
} }
timerIntervalRef.current = setInterval(() => { timerIntervalRef.current = setInterval(syncTimes, 100);
let currentRedTime = pausedRedTimeRef.current;
let currentBlueTime = pausedBlueTimeRef.current;
// Add elapsed time for the active player
if (!activePlayer && redStartTimeRef.current) {
currentRedTime += Math.floor((Date.now() - redStartTimeRef.current) / 1000);
} else if (activePlayer && blueStartTimeRef.current) {
currentBlueTime += Math.floor((Date.now() - blueStartTimeRef.current) / 1000);
}
setRedTime(currentRedTime);
setBlueTime(currentBlueTime);
}, 100);
return () => { return () => {
if (timerIntervalRef.current) { if (timerIntervalRef.current) clearInterval(timerIntervalRef.current);
clearInterval(timerIntervalRef.current);
}
}; };
}, [isRunning, activePlayer]); }, [isRunning, activePlayer, syncTimes]);
// Handle focus/blur to synchronize timer when tab regains focus
useEffect(() => { useEffect(() => {
const handleFocus = () => { const handleFocus = () => {
// Force update when tab regains focus to sync any background drift if (isRunning) syncTimes();
if (isRunning) {
let currentRedTime = pausedRedTimeRef.current;
let currentBlueTime = pausedBlueTimeRef.current;
if (!activePlayer && redStartTimeRef.current) {
currentRedTime += Math.floor((Date.now() - redStartTimeRef.current) / 1000);
} else if (activePlayer && blueStartTimeRef.current) {
currentBlueTime += Math.floor((Date.now() - blueStartTimeRef.current) / 1000);
}
setRedTime(currentRedTime);
setBlueTime(currentBlueTime);
}
}; };
window.addEventListener('focus', handleFocus); window.addEventListener('focus', handleFocus);
return () => window.removeEventListener('focus', handleFocus); return () => window.removeEventListener('focus', handleFocus);
}, [isRunning, activePlayer]); }, [isRunning, activePlayer, syncTimes]);
// Cleanup on unmount
useEffect(() => () => { useEffect(() => () => {
if (timerIntervalRef.current) { if (timerIntervalRef.current) clearInterval(timerIntervalRef.current);
clearInterval(timerIntervalRef.current);
}
}, []); }, []);
const formatTime = seconds => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
};
return ( return (
<div className="game-timer-container"> <div className="game-timer-container">
<BonusBox color="red" points={red.bonusPoints ?? 0} onClick={() => setBonusDialogOpen(true)} />
<div className={`game-timer red-timer ${!activePlayer ? 'active' : ''}`}> <div className={`game-timer red-timer ${!activePlayer ? 'active' : ''}`}>
{renderAvatar(red)} <Avatar player={red} />
<i className={`fa ${!activePlayer ? 'fa-hourglass-half' : 'fa-hourglass-start'} timer-icon`} /> <i className={`fa ${!activePlayer ? 'fa-hourglass-half' : 'fa-hourglass-start'} timer-icon`} />
<span className="timer-display">{formatTime(redTime)}</span> <span className="timer-display">{formatTime(redTime)}</span>
</div> </div>
<div className={`game-timer blue-timer ${activePlayer ? 'active' : ''}`}> <div className={`game-timer blue-timer ${activePlayer ? 'active' : ''}`}>
{renderAvatar(blue)} <Avatar player={blue} />
<i className={`fa ${activePlayer ? 'fa-hourglass-half' : 'fa-hourglass-start'} timer-icon`} /> <i className={`fa ${activePlayer ? 'fa-hourglass-half' : 'fa-hourglass-start'} timer-icon`} />
<span className="timer-display">{formatTime(blueTime)}</span> <span className="timer-display">{formatTime(blueTime)}</span>
</div> </div>
<BonusBox color="blue" points={blue.bonusPoints ?? 0} onClick={() => setBonusDialogOpen(true)} />
<BonusStatsDialog open={bonusDialogOpen} onClose={() => setBonusDialogOpen(false)} red={red} blue={blue} />
</div> </div>
); );
}; };

View File

@@ -8,38 +8,13 @@
*/ */
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { formatSince } from '@global-utils/format';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import { styled } from '@mui/material/styles';
import { useLobbyDataProvider } from '@mine-hooks';
import { bool, func, string } from 'prop-types';
const DIALOG_SX = { const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc, isEnvDev = false }) => {
'& .MuiDialog-paper': {
background: '#07090d',
backgroundImage: `
linear-gradient(rgba(35, 111, 135, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(35, 111, 135, 0.08) 1px, transparent 1px)
`,
backgroundSize: '46px 46px',
border: '1px solid rgba(35, 111, 135, 0.4)',
borderRadius: '12px',
boxShadow: '0 0 80px rgba(35, 111, 135, 0.15), 0 32px 80px rgba(0, 0, 0, 0.9)',
width: '500px',
maxWidth: '94vw',
overflow: 'hidden',
color: '#fff',
},
'& .MuiBackdrop-root': {
background: 'rgba(2, 4, 8, 0.88)',
backdropFilter: 'blur(4px)',
},
};
const formatSince = isoStr => {
const diff = Math.floor((Date.now() - new Date(isoStr)) / 60000);
if (1 > diff) return 'just now';
if (1 === diff) return '1 min ago';
return `${diff} min ago`;
};
const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
const [players, setPlayers] = useState([]); const [players, setPlayers] = useState([]);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -49,6 +24,7 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
const [declinedMsg, setDeclinedMsg] = useState(''); const [declinedMsg, setDeclinedMsg] = useState('');
const [waitingCountdown, setWaitingCountdown] = useState(0); const [waitingCountdown, setWaitingCountdown] = useState(0);
const declinedTimerRef = useRef(null); const declinedTimerRef = useRef(null);
const { waitingPlayersQuery, challengeMutation } = useLobbyDataProvider();
const addPlayer = useCallback(entry => { const addPlayer = useCallback(entry => {
setPlayers(prev => setPlayers(prev =>
@@ -66,20 +42,21 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
if (!open) return; if (!open) return;
setLoading(true); setLoading(true);
setSnapshotLoaded(false); setSnapshotLoaded(false);
fetch('/api/game/waiting')
.then(r => r.json()) waitingPlayersQuery.refetch().then(result => {
.then(data => { if (result.data) {
// Filter out current user's game from the snapshot // Filter out current user's game from the snapshot
const filtered = data.filter(p => p.gameAssoc !== currentGameAssoc); const filtered = result.data.filter(p => p.gameAssoc !== currentGameAssoc);
setPlayers(filtered); setPlayers(filtered);
}
setSnapshotLoaded(true); setSnapshotLoaded(true);
setLoading(false); setLoading(false);
}) }).catch(() => {
.catch(() => {
setPlayers([]); setPlayers([]);
setSnapshotLoaded(true); setSnapshotLoaded(true);
setLoading(false); setLoading(false);
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, refreshKey, currentGameAssoc]); }, [open, refreshKey, currentGameAssoc]);
useEffect(() => { useEffect(() => {
@@ -107,6 +84,13 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
return () => es.close(); return () => es.close();
}, [open, snapshotLoaded, addPlayer, removePlayer, currentGameAssoc]); }, [open, snapshotLoaded, addPlayer, removePlayer, currentGameAssoc]);
useEffect(() => {
if (challengeMutation.isError) {
setChallengingGameAssoc(null);
setWaitingCountdown(0);
}
}, [challengeMutation.isError]);
useEffect(() => { useEffect(() => {
const handler = () => { const handler = () => {
setChallengingGameAssoc(null); setChallengingGameAssoc(null);
@@ -138,14 +122,10 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
setChallengingGameAssoc(player.gameAssoc); setChallengingGameAssoc(player.gameAssoc);
setDeclinedMsg(''); setDeclinedMsg('');
setWaitingCountdown(30); setWaitingCountdown(30);
fetch('/api/game/challenge/' + player.gameAssoc, {
method: 'POST', challengeMutation.mutate(
headers: { 'Content-Type': 'application/json' }, { targetGameAssoc: player.gameAssoc, challengerGameAssoc: currentGameAssoc }
body: JSON.stringify({ challengerGameAssoc: currentGameAssoc }), );
}).catch(() => {
setChallengingGameAssoc(null);
setWaitingCountdown(0);
});
}; };
const visible = players const visible = players
@@ -156,19 +136,18 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
const hasMore = 5 < visible.length; const hasMore = 5 < visible.length;
// Debug: log if currentGameAssoc is undefined or if current user appears // Debug: log if currentGameAssoc is undefined or if current user appears
if ('development' === process.env.NODE_ENV && 0 < players.length) { if (isEnvDev && 0 < players.length) {
const userInList = players.find(p => p.gameAssoc === currentGameAssoc); const userInList = players.find(p => p.gameAssoc === currentGameAssoc);
if (userInList) { if (userInList) {
console.warn('[OnlinePlayersDialog] Current user appears in players list:', { currentGameAssoc, userInList }); console.warn('[OnlinePlayersDialog] Current user appears in players list:', { currentGameAssoc, userInList });
} }
} }
return ( return (
<Dialog <StyledDialog
open={open} open={open}
onClose={0 < waitingCountdown ? undefined : onClose} onClose={0 < waitingCountdown ? undefined : onClose}
disableEscapeKeyDown={0 < waitingCountdown}
sx={DIALOG_SX}
> >
<div className="opd"> <div className="opd">
<div className="opd-header"> <div className="opd-header">
@@ -256,7 +235,7 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
<div className="opd-info"> <div className="opd-info">
<span className="opd-name">{player.name}</span> <span className="opd-name">{player.name}</span>
<span className="opd-since"> <span className="opd-since">
<i className="fa fa-clock-o" /> <i className="fa fa-clock" />
{' '}Waiting {formatSince(player.since)} {' '}Waiting {formatSince(player.since)}
</span> </span>
</div> </div>
@@ -279,8 +258,37 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
</p> </p>
)} )}
</div> </div>
</Dialog> </StyledDialog>
); );
}; };
const StyledDialog = styled(Dialog)({
'& .MuiDialog-paper': {
background: '#07090d',
backgroundImage: `
linear-gradient(rgba(35, 111, 135, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(35, 111, 135, 0.08) 1px, transparent 1px)
`,
backgroundSize: '46px 46px',
border: '1px solid rgba(35, 111, 135, 0.4)',
borderRadius: '12px',
boxShadow: '0 0 80px rgba(35, 111, 135, 0.15), 0 32px 80px rgba(0, 0, 0, 0.9)',
width: '500px',
maxWidth: '94vw',
overflow: 'hidden',
color: '#fff',
},
'& .MuiBackdrop-root': {
background: 'rgba(2, 4, 8, 0.88)',
backdropFilter: 'blur(4px)',
},
});
export default OnlinePlayersDialog; export default OnlinePlayersDialog;
OnlinePlayersDialog.propTypes = {
open: bool.isRequired,
onClose: func.isRequired,
currentGameAssoc: string,
isEnvDev: bool,
};

View File

@@ -8,23 +8,29 @@
*/ */
import { Fragment, useState } from 'react'; import { Fragment, useState } from 'react';
import { OnlinePlayersDialog } from '@mine-components'; import { OnlinePlayersDialog } from '@mine-components';
import { bool, string } from 'prop-types';
const WaitingOverlayContent = ({ shareUrl, currentGameAssoc }) => { const WaitingOverlayContent = ({ shareUrl, currentGameAssoc, opponentName = '', inviteOnly = false }) => {
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const inviteHeader = inviteOnly && opponentName
? `Invite ${opponentName}`
: 'Invite a Friend';
return ( return (
<Fragment> <Fragment>
<div className="waiting-options"> <div className={`waiting-options${inviteOnly ? ' waiting-options--invite-only' : ''}`}>
<div className="waiting-option"> <div className="waiting-option">
<div className="waiting-option-header"> <div className="waiting-option-header">
<i className="fa fa-link" /> <i className="fa fa-link" />
<span>Invite a Friend</span> <span>{inviteHeader}</span>
</div> </div>
<p className="waiting-option-desc">Share this link with your opponent</p> <p className="waiting-option-desc">Share this link with your opponent</p>
<ShareLinkBox <ShareLinkBox
url={shareUrl} url={shareUrl}
/> />
</div> </div>
{!inviteOnly && (
<Fragment>
<div className="waiting-divider"> <div className="waiting-divider">
<span>OR</span> <span>OR</span>
</div> </div>
@@ -42,13 +48,17 @@ const WaitingOverlayContent = ({ shareUrl, currentGameAssoc }) => {
Browse Players Browse Players
</button> </button>
</div> </div>
</Fragment>
)}
</div> </div>
{!inviteOnly && (
<OnlinePlayersDialog <OnlinePlayersDialog
open={dialogOpen} open={dialogOpen}
onClose={() => setDialogOpen(false)} onClose={() => setDialogOpen(false)}
currentGameAssoc={currentGameAssoc} currentGameAssoc={currentGameAssoc}
/> />
)}
</Fragment> </Fragment>
); );
}; };
@@ -57,10 +67,12 @@ const ShareLinkBox = ({ url }) => {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const handleCopy = () => { const handleCopy = () => {
navigator.clipboard.writeText(url).then(() => { navigator.clipboard.writeText(url)
.then(() => {
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2500); setTimeout(() => setCopied(false), 2500);
}).catch(() => {}); })
.catch(() => null);
}; };
return ( return (
@@ -83,3 +95,10 @@ const ShareLinkBox = ({ url }) => {
}; };
export default WaitingOverlayContent; export default WaitingOverlayContent;
WaitingOverlayContent.propTypes = {
shareUrl: string.isRequired,
currentGameAssoc: string,
opponentName: string,
inviteOnly: bool,
};

View File

@@ -13,6 +13,7 @@ import GridField from './GridField';
import UserControl from '../user/UserControl'; import UserControl from '../user/UserControl';
import GameTimer from '../GameTimer'; import GameTimer from '../GameTimer';
import { BOMB_SYMBOLS, bombRadius } from '@mine-utils'; import { BOMB_SYMBOLS, bombRadius } from '@mine-utils';
import { func, string } from 'prop-types';
const GridControl = ({ gameAssoc, onClick, resign }) => { const GridControl = ({ gameAssoc, onClick, resign }) => {
const { const {
@@ -22,11 +23,14 @@ const GridControl = ({ gameAssoc, onClick, resign }) => {
} = useGame(); } = useGame();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const shareUrl = gameAssoc ? `${window.location.origin}/battle/${gameAssoc}` : null; const shareUrl = gameAssoc ? `${window.location.origin}/play/${gameAssoc}` : null;
const endShareUrl = gameAssoc ? `${window.location.origin}/battle/${gameAssoc}` : null;
const isAuthenticated = '1' === document.getElementById('mine-wrapper')?.dataset.isAuthenticated;
const handleShare = () => { const handleShare = () => {
if (!shareUrl) return; const url = endRef.current ? endShareUrl : shareUrl;
navigator.clipboard.writeText(shareUrl).then(() => { if (!url) return;
navigator.clipboard.writeText(url).then(() => {
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2200); setTimeout(() => setCopied(false), 2200);
}); });
@@ -58,12 +62,14 @@ const GridControl = ({ gameAssoc, onClick, resign }) => {
<div className={`game-overlay${overlay ? '' : ' hide'}`}> <div className={`game-overlay${overlay ? '' : ' hide'}`}>
<div className="game-overlay-window"> <div className="game-overlay-window">
<h1>{overlayTitle}</h1> <h1>{overlayTitle}</h1>
{'string' === typeof overlaySubTitle ? ( {'string' === typeof overlaySubTitle && (
<h2>{overlaySubTitle}</h2> <h2>{overlaySubTitle}</h2>
) : ( )}
overlaySubTitle {'string' !== typeof overlaySubTitle && (
<Fragment>{overlaySubTitle}</Fragment>
)} )}
{gameAssoc && endRef.current && ( {gameAssoc && endRef.current && (
<div className="game-overlay-actions">
<button <button
className={`game-overlay-share${copied ? ' copied' : ''}`} className={`game-overlay-share${copied ? ' copied' : ''}`}
onClick={handleShare} onClick={handleShare}
@@ -73,6 +79,16 @@ const GridControl = ({ gameAssoc, onClick, resign }) => {
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} /> <i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} />
{copied ? 'Copied!' : 'Share Battle'} {copied ? 'Copied!' : 'Share Battle'}
</button> </button>
<a
className="game-overlay-profile"
href={isAuthenticated ? '/profile' : '/'}
title={isAuthenticated ? 'Go to your profile' : 'Go to homepage'}
aria-label={isAuthenticated ? 'Go to your profile' : 'Go to homepage'}
>
<i className={`fa ${isAuthenticated ? 'fa-user' : 'fa-house'}`} />
{isAuthenticated ? 'My Profile' : 'Homepage'}
</a>
</div>
)} )}
</div> </div>
</div> </div>
@@ -99,3 +115,9 @@ const GridControl = ({ gameAssoc, onClick, resign }) => {
}; };
export default GridControl; export default GridControl;
GridControl.propTypes = {
gameAssoc: string,
onClick: func.isRequired,
resign: func.isRequired,
};

View File

@@ -9,6 +9,7 @@
import React, { memo, useMemo } from 'react'; import React, { memo, useMemo } from 'react';
import { IMAGES } from '@mine-utils'; import { IMAGES } from '@mine-utils';
import { func, shape, bool, number, string } from 'prop-types';
const bombSrc = area => { const bombSrc = area => {
if (null === area) return null; if (null === area) return null;
@@ -53,7 +54,10 @@ const GridField = memo(function GridField({ cell, onClick, onMouseEnter }) {
/> />
)} )}
<div className={fieldClass}> <div className={fieldClass}>
<div className="field-corner"> <div
style={{ background: "url('/images/bg-corner-outbg.png') no-repeat top left / 100% 100%" }}
className="field-corner"
>
{isNaN(currentImage) && ( {isNaN(currentImage) && (
<div className="flag-mine"> <div className="flag-mine">
<img src={currentImage} alt="" /> <img src={currentImage} alt="" />
@@ -72,3 +76,16 @@ const GridField = memo(function GridField({ cell, onClick, onMouseEnter }) {
}); });
export default GridField; export default GridField;
GridField.propTypes = {
cell: shape({
currentImage: string,
currentObj: string,
active: bool,
lastClickedRed: bool,
lastClickedBlue: bool,
bombTargetArea: number,
}).isRequired,
onClick: func.isRequired,
onMouseEnter: func.isRequired,
};

View File

@@ -16,3 +16,7 @@ export { default as GridControl } from './grid/GridControl';
export { default as GridField } from './grid/GridField'; export { default as GridField } from './grid/GridField';
export { default as User } from './user/User'; export { default as User } from './user/User';
export { default as UserControl } from './user/UserControl'; export { default as UserControl } from './user/UserControl';
export { default as BonusBox } from './BonusBox';
export { default as BonusStatsDialog } from './BonusStatsDialog';
export { Avatar } from './timer/Avatar';
export { PlayerColumn } from './profile/PlayerColumn';

View File

@@ -0,0 +1,52 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React from 'react';
import { object, string } from 'prop-types';
import { BONUS_LABELS } from '@mine-utils';
const formatPlayerName = name => {
if (name && name.startsWith('anon_')) {
return 'Anonymous';
}
if (name && 10 < name.length) {
return name.substring(0, 7) + '...';
}
return name || 'Unknown';
};
export const PlayerColumn = ({ color, player }) => (
<div className={`bsd-column bsd-column--${color}`}>
<div className="bsd-column-header">
<span className="bsd-column-name">{formatPlayerName(player.name)}</span>
<span className="bsd-column-total">
<i className="fa fa-star" />
{player.bonusPoints}
</span>
</div>
<ul className="bsd-stats">
{Object.entries(BONUS_LABELS).map(([key, { label, desc }]) => (
<li key={key} className="bsd-stat">
<div className="bsd-stat-text">
<span className="bsd-stat-label">{label}</span>
<span className="bsd-stat-desc">{desc}</span>
</div>
<span className="bsd-stat-value">{player.bonusStats?.[key] ?? 0}</span>
</li>
))}
</ul>
</div>
);
PlayerColumn.propTypes = {
color: string.isRequired,
player: object.isRequired,
};

View File

@@ -0,0 +1,28 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React from 'react';
import { object } from 'prop-types';
export const Avatar = ({ player }) => {
if (!player.registered) return '';
return (
<div className="timer-avatar">
{player.avatar
? <img src={player.avatar} alt={player.name} className="timer-avatar__img" />
: <span className="timer-avatar__initials">{player.name.slice(0, 2).toUpperCase()}</span>
}
</div>
);
};
Avatar.propTypes = {
player: object.isRequired,
};

View File

@@ -9,6 +9,7 @@
import React, { memo } from 'react'; import React, { memo } from 'react';
import { IMAGES } from '@mine-utils'; import { IMAGES } from '@mine-utils';
import { bool, func, number, string } from 'prop-types';
const User = memo(function User( const User = memo(function User(
{ {
@@ -52,3 +53,15 @@ const User = memo(function User(
}); });
export default User; export default User;
User.propTypes = {
color: string.isRequired,
webPlayer: string,
name: string,
desc: string,
active: bool,
mines: number,
haveBomb: bool,
enabledBomb: bool,
onClickBombSelector: func.isRequired,
};

View File

@@ -7,15 +7,19 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
import React from 'react'; import React, { Fragment, useState } from 'react';
import { useGame } from '@mine-contexts'; import { useGame } from '@mine-contexts';
import User from './User'; import User from './User';
import BonusStatsDialog from '../BonusStatsDialog';
import { func } from 'prop-types';
const UserControl = ({ resign }) => { const UserControl = ({ resign }) => {
const { webPlayer, activePlayer, mines, foundMines, red, blue, onBombToggle } = useGame(); const { webPlayer, activePlayer, foundMines, red, blue, onBombToggle } = useGame();
const [bonusDialogOpen, setBonusDialogOpen] = useState(false);
const activeColor = activePlayer ? 'blue' : 'red'; const activeColor = activePlayer ? 'blue' : 'red';
const resignClass = 'resign' + (activeColor !== webPlayer ? ' disabled' : ''); const resignClass = 'resign' + (activeColor !== webPlayer ? ' disabled' : '');
const minesClass = 'active-mines' + (foundMines ? ' found-mine' : ''); const minesClass = 'active-mines' + (foundMines ? ' found-mine' : '');
const remainingMines = 51 - red.mines - blue.mines;
const handleBombClick = (color, player) => { const handleBombClick = (color, player) => {
const p = 'red' === color ? red : blue; const p = 'red' === color ? red : blue;
@@ -24,16 +28,22 @@ const UserControl = ({ resign }) => {
} }
}; };
const handleBonusClick = () => {
setBonusDialogOpen(true);
};
return ( return (
<Fragment>
<div className="users"> <div className="users">
<User <User
color="blue" webPlayer={webPlayer} {...blue} color="blue" webPlayer={webPlayer} {...blue}
onClickBombSelector={() => handleBombClick('blue', 1)} onClickBombSelector={() => handleBombClick('blue', 1)}
onBonusClick={handleBonusClick}
/> />
<div className="active-mines-container"> <div className="active-mines-container">
<i className="fa fa-star" /> <i className="fa fa-star" />
<div className={minesClass}> <div className={minesClass}>
<div className="active-mines-nbr">{mines}</div> <div className="active-mines-nbr">{remainingMines}</div>
<div className="active-mines-shine" /> <div className="active-mines-shine" />
</div> </div>
<i className="fa fa-star" /> <i className="fa fa-star" />
@@ -42,13 +52,25 @@ const UserControl = ({ resign }) => {
<User <User
color="red" webPlayer={webPlayer} {...red} color="red" webPlayer={webPlayer} {...red}
onClickBombSelector={() => handleBombClick('red', 0)} onClickBombSelector={() => handleBombClick('red', 0)}
onBonusClick={handleBonusClick}
/> />
<button className={resignClass} onClick={resign}> <button className={resignClass} onClick={resign}>
<div className="resign-shine" /> <div className="resign-shine" />
Resign Resign
</button> </button>
</div> </div>
<BonusStatsDialog
open={bonusDialogOpen}
onClose={() => setBonusDialogOpen(false)}
red={red}
blue={blue}
/>
</Fragment>
); );
} }
export default UserControl; export default UserControl;
UserControl.propTypes = {
resign: func.isRequired,
};

View File

@@ -132,7 +132,18 @@ export const GameProvider = ({ children }) => {
}; };
const applyStep = stepData => { const applyStep = stepData => {
const { player, bomb: isBomb, minesFound = 0, revealedCells = [], redPoints: rp, bluePoints: bp } = stepData; const {
player,
bomb: isBomb,
minesFound = 0,
revealedCells = [],
redPoints: rp,
bluePoints: bp,
redBonusPoints = 0,
blueBonusPoints = 0,
redBonusStats = {},
blueBonusStats = {},
} = stepData;
if (isBomb) { if (isBomb) {
sounds.current.bomb.play(); sounds.current.bomb.play();
@@ -176,6 +187,18 @@ export const GameProvider = ({ children }) => {
syncBlue(p => ({ ...p, mines: 'blue' === player ? bp : p.mines })); syncBlue(p => ({ ...p, mines: 'blue' === player ? bp : p.mines }));
} }
/** Update bonus points and stats */
syncRed(p => ({
...p,
bonusPoints: 'red' === player ? redBonusPoints : p.bonusPoints,
bonusStats: 'red' === player ? redBonusStats : p.bonusStats,
}));
syncBlue(p => ({
...p,
bonusPoints: 'blue' === player ? blueBonusPoints : p.bonusPoints,
bonusStats: 'blue' === player ? blueBonusStats : p.bonusStats,
}));
syncRed(p => ({ ...p, enabledBomb: rp <= bp })); syncRed(p => ({ ...p, enabledBomb: rp <= bp }));
syncBlue(p => ({ ...p, enabledBomb: bp <= rp })); syncBlue(p => ({ ...p, enabledBomb: bp <= rp }));
@@ -195,7 +218,10 @@ export const GameProvider = ({ children }) => {
if (redWins || blueWins || resign) { if (redWins || blueWins || resign) {
sounds.current.won.play(); sounds.current.won.play();
if (!resign) showOverlay((redWins ? 'Red' : 'Blue') + ' wins the game!', 'Play again!'); if (!resign) {
endRef.current = true;
showOverlay((redWins ? 'Red' : 'Blue') + ' wins the game!', null);
}
showLeftMines(leftMines); showLeftMines(leftMines);
syncActivePlayer(false); syncActivePlayer(false);
@@ -228,20 +254,20 @@ export const GameProvider = ({ children }) => {
return ( return (
<GameContext.Provider <GameContext.Provider
value={{ value={{
// State (for rendering) /** State (for rendering) */
webPlayer, activePlayer, overlay, overlayTitle, overlaySubTitle, webPlayer, activePlayer, overlay, overlayTitle, overlaySubTitle,
mines, bombSelected, foundMines, red, blue, cells, gridReady, connectionLost, gameUuid, mines, bombSelected, foundMines, red, blue, cells, gridReady, connectionLost, gameUuid,
// Setters needed by useServerComm /** Setters needed by useServerComm */
setCells, setGridReady, setGameUuid, setCells, setGridReady, setGameUuid,
// Refs (needed by useServerComm for async-safe reads) /** Refs (needed by useServerComm for async-safe reads) */
webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef, webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, lastClickedRef, endRef,
// Sync helpers /** Sync helpers */
syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue, syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue,
// Game logic called by useServerComm /** Game logic called by useServerComm */
showOverlay, hideOverlay, showOverlay, hideOverlay,
applyRevealedCell, applyStep, applyRevealedCell, applyStep,
makeGameEndIfItEnds, resignProcess, makeGameEndIfItEnds, resignProcess,
// UI action /** UI action */
onBombToggle, onBombToggle,
}} }}
> >

View File

@@ -11,4 +11,4 @@ export { default as useGameRefs } from './useGameRefs';
export { default as useGameState } from './useGameState'; export { default as useGameState } from './useGameState';
export { default as useServerCommunication } from './useServerCommunication'; export { default as useServerCommunication } from './useServerCommunication';
export { default as useStepTimer } from './useStepTimer'; export { default as useStepTimer } from './useStepTimer';
export { default as useGameDataProvider, useLobbyDataProvider } from './useGameDataProvider';

View File

@@ -0,0 +1,132 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import { useQuery, useMutation } from '@tanstack/react-query';
/**
* Game Data Provider Hook
* Centralized API communication layer for game-related queries and mutations
*/
const useGameDataProvider = gameAssoc => {
// Queries
const connectQuery = useQuery({
queryKey: ['game-connect', gameAssoc],
queryFn: () => fetch(`/api/game/connect/${gameAssoc}`)
.then(r => r.text())
.then(b64 => JSON.parse(window.atob(b64))),
enabled: false,
retry: false,
});
// Mutations
const startMutation = useMutation({
mutationFn: () => fetch('/api/game/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gameAssoc }),
}).then(r => r.json()),
});
const joinMutation = useMutation({
mutationFn: () => fetch(`/api/game/join/${gameAssoc}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
});
const stepMutation = useMutation({
mutationFn: dataPack => fetch(`/api/game/step/${gameAssoc}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dataPack),
}).then(r => r.json()),
});
const heartbeatMutation = useMutation({
mutationFn: color => fetch(`/api/game/heartbeat/${gameAssoc}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ color }),
}).then(r => r.json()),
});
const challengeRespondMutation = useMutation({
mutationFn: ({ challengerGameAssoc, accepted, targetGameAssoc }) => fetch('/api/game/challenge/respond/' + challengerGameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accepted, targetGameAssoc }),
}).then(r => r.json()),
});
const leaveMutation = useMutation({
mutationFn: () => fetch(`/api/game/leave/${gameAssoc}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}).then(r => r.json()),
});
return {
// Queries
connectQuery,
// Mutations
startMutation,
joinMutation,
stepMutation,
heartbeatMutation,
challengeRespondMutation,
leaveMutation,
};
};
/**
* Lobby Data Provider Hook
* Centralized API communication layer for lobby-related queries and mutations
*/
export const useLobbyDataProvider = () => {
const waitingPlayersQuery = useQuery({
queryKey: ['game-waiting'],
queryFn: () => fetch('/api/game/waiting')
.then(r => r.json()),
});
const challengeMutation = useMutation({
mutationFn: ({ targetGameAssoc, challengerGameAssoc }) => fetch(`/api/game/challenge/${targetGameAssoc}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challengerGameAssoc }),
}).then(r => r.json()),
});
return {
// Queries
waitingPlayersQuery,
// Mutations
challengeMutation,
};
};
export default useGameDataProvider;
/**
* Profile Data Provider Hook
* Centralized API communication layer for profile-related mutations
*/
export const useProfileDataProvider = () => {
const uploadAvatarMutation = useMutation({
mutationFn: ({ uploadUrl, file }) => {
const fd = new FormData();
fd.append('avatar', file);
return fetch(uploadUrl, { method: 'POST', body: fd })
.then(r => r.json())
.then(data => { if (data.error) throw new Error(data.error); return data; });
},
});
return { uploadAvatarMutation };
};

View File

@@ -8,105 +8,230 @@
*/ */
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useGame } from '@mine-contexts'; import { useGame } from '@mine-contexts';
import { DESC } from '@mine-utils'; import { DESC, IMAGES } from '@mine-utils';
import useStepTimer from './useStepTimer'; import { ChallengeCountdown, WaitingOverlayContent } from '@mine-components';
import { WaitingOverlayContent } from '@mine-components'; import { useGameDataProvider, useStepTimer } from '@mine-hooks';
import { ChallengeCountdown } from '@mine-components'; const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev) => {
const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
const { const {
/** Async-safe refs */ /** Async-safe refs */
webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef, webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, lastClickedRef, endRef,
/** State setters */ /** State setters */
setGridReady, setGameUuid, setCells, setGridReady, setGameUuid,
/** Sync helpers */ /** Sync helpers */
syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue, syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue,
/** Game logic */ /** Game logic */
showOverlay, hideOverlay, showOverlay, hideOverlay, applyStep, makeGameEndIfItEnds, resignProcess,
applyRevealedCell, applyStep,
makeGameEndIfItEnds, resignProcess,
/** Current cells snapshot (for active-check in onClick) */ /** Current cells snapshot (for active-check in onClick) */
cells, cells,
} = useGame(); } = useGame();
/** Get all API queries and mutations from data provider */
const {
connectQuery,
startMutation,
joinMutation,
stepMutation,
heartbeatMutation,
challengeRespondMutation,
leaveMutation,
} = useGameDataProvider(gameAssoc);
const eventSourceRef = useRef(null); const eventSourceRef = useRef(null);
const rpcUsersRef = useRef(null); const rpcUsersRef = useRef(null);
const stepCacheRef = useRef([]); const stepCacheRef = useRef([]);
const lastStepRef = useRef(null);
const isGameFinishedRef = useRef(false);
const { getStepElapsed, resetStepTimer, startNewTurn } = useStepTimer(); const { getStepElapsed, resetStepTimer, startNewTurn } = useStepTimer();
const isGameRunningRef = useRef(false); const isGameRunningRef = useRef(false);
const lastActivePlayerRef = useRef(null); const lastActivePlayerRef = useRef(null);
const heartbeatPubIntervalRef = useRef(null);
const opponentLastSeenRef = useRef(0);
const isTrueRestoredRef = useRef(false);
/** REST mutations / queries */ const HEARTBEAT_INTERVAL_MS = 1500;
const connectQuery = useQuery({
queryKey: ['game-connect', gameAssoc],
queryFn: () => fetch('/api/game/connect/' + gameAssoc)
.then(r => r.text())
.then(b64 => JSON.parse(window.atob(b64))),
enabled: false,
retry: false,
});
const startMutation = useMutation({
mutationFn: () => fetch('/api/game/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gameAssoc }),
}),
});
const joinMutation = useMutation({
mutationFn: () => fetch('/api/game/join/' + gameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}).catch(e => isEnvDev && console.error('Join error', e)),
});
const stepMutation = useMutation({
mutationFn: dataPack => fetch('/api/game/step/' + gameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dataPack),
}).then(r => r.json()),
});
/** Game-start helpers (triggered by server events) */ /** Game-start helpers (triggered by server events) */
const wInit = (revealedCells = []) => { const wInit = (revealedCells = [], lastStep = {}, gameState = {}, isGameFinished = false) => {
setGridReady(true); /** Detect if this is a restored game */
showOverlay('Choose an opponent!', gameAssoc ? ( const isRestoredGame = 0 < revealedCells.length;
isTrueRestoredRef.current = isRestoredGame;
/** Store game finished status */
isGameFinishedRef.current = isGameFinished;
/** Apply game state (points, bonus) immediately for restored games */
if (0 < Object.keys(gameState).length) {
const {
redPoints = 0,
bluePoints = 0,
redBonusPoints = 0,
blueBonusPoints = 0,
redBonusStats = {},
blueBonusStats = {},
} = gameState;
syncRed(p => ({
...p,
mines: redPoints,
bonusPoints: redBonusPoints,
bonusStats: redBonusStats,
}));
syncBlue(p => ({
...p,
mines: bluePoints,
bonusPoints: blueBonusPoints,
bonusStats: blueBonusStats,
}));
}
/** Apply revealed cells immediately (not in setTimeout) */
if (0 < revealedCells.length) {
setCells(prev => {
let next = prev.map(r => [...r]);
revealedCells.forEach(({ row, col, value, player }) => {
if (next[row][col].active) return;
/** Check if this cell is the last step for either player */
const isRedLastStep = lastStep.red && lastStep.red.player === player && lastStep.red.row === row && lastStep.red.col === col;
const isBlueLastStep = lastStep.blue && lastStep.blue.player === player && lastStep.blue.row === row && lastStep.blue.col === col;
const patch = 'm' === value
? { currentImage: IMAGES.flag(player), currentObj: 'm', active: true }
: { currentImage: value, currentObj: value, active: true };
if (isRedLastStep || isBlueLastStep) {
patch.lastClickedRed = 'red' === player;
patch.lastClickedBlue = 'blue' === player;
}
next[row][col] = { ...next[row][col], ...patch };
});
return next;
});
}
/** Update the lastClickedRef so applyStep knows about it */
if (lastStep.red) {
lastClickedRef.current = {
...lastClickedRef.current,
red: [lastStep.red.row, lastStep.red.col],
};
}
if (lastStep.blue) {
lastClickedRef.current = {
...lastClickedRef.current,
blue: [lastStep.blue.row, lastStep.blue.col],
};
}
/** Determine overlay message */
let overlayTitle, overlaySubtitle;
if (isGameFinished) {
/** Game is finished - show game over message */
const redPoints = gameState.redPoints ?? 0;
const bluePoints = gameState.bluePoints ?? 0;
const winner = redPoints > bluePoints ? 'Red' : 'Blue';
overlayTitle = `${winner} wins the game!`;
overlaySubtitle = 'Play again!';
/** Mark the game as ended */
endRef.current = true;
} else if (isRestoredGame) {
overlayTitle = 'Waiting for opponent to reconnect...';
overlaySubtitle = gameAssoc ? (
<WaitingOverlayContent <WaitingOverlayContent
shareUrl={`${window.location.href}/${gameAssoc}`} shareUrl={`${window.location.origin}/play/${gameAssoc}`}
currentGameAssoc={gameAssoc}
opponentName={opponentName}
inviteOnly
/>
) : (
<div style={{ textAlign: 'center', padding: '20px' }}>
<p>Waiting for opponent to join...</p>
</div>
);
} else {
overlayTitle = 'Choose an opponent!';
overlaySubtitle = gameAssoc ? (
<WaitingOverlayContent
shareUrl={`${window.location.origin}/play/${gameAssoc}`}
currentGameAssoc={gameAssoc} currentGameAssoc={gameAssoc}
/> />
) : ''); ) : '';
setTimeout(() => revealedCells.forEach(cell => applyRevealedCell(cell, cell.player)), 0); }
showOverlay(overlayTitle, overlaySubtitle);
/** Use Promise.resolve to defer setGridReady slightly to ensure overlay is rendered first */
Promise.resolve().then(() => setGridReady(true));
}; };
const makeGameStart = payload => { const makeGameStart = payload => {
syncActivePlayer(1); /** Don't start a finished game */
if (isGameFinishedRef.current) {
return;
}
/** If game is being restored and has a most recent step, determine starter based on that */
let starterIsBlue;
/** lastStepRef contains the single most recent step from the server */
if (lastStepRef.current && lastStepRef.current.player) {
/** The NEXT player is opposite of who made the last step */
starterIsBlue = 'red' === lastStepRef.current.player; // If red played last, blue plays next
} else {
/** New game: blue always starts */
starterIsBlue = true;
}
const starterColor = starterIsBlue ? 'blue' : 'red';
const starterVal = starterIsBlue ? 1 : 0;
const starterDesc = starterColor === webPlayerRef.current ? DESC.you : DESC.buddy;
syncActivePlayer(starterVal);
syncRed(p => ({ syncRed(p => ({
...p, ...p,
name: payload.users.red || payload.users.redAnon || p.name, name: payload.users.red || payload.users.redAnon || p.name,
registered: !!payload.users.red, registered: !!payload.users.red,
avatar: payload.users.redAvatar ?? null, avatar: payload.users.redAvatar ?? null,
desc: 'red' === starterColor ? starterDesc : '',
active: 'red' === starterColor,
})); }));
syncBlue(p => ({ syncBlue(p => ({
...p, ...p,
name: payload.users.blue || payload.users.blueAnon || p.name, name: payload.users.blue || payload.users.blueAnon || p.name,
registered: !!payload.users.blue, registered: !!payload.users.blue,
avatar: payload.users.blueAvatar ?? null, avatar: payload.users.blueAvatar ?? null,
desc: 'blue' === webPlayerRef.current ? DESC.you : DESC.buddy, desc: 'blue' === starterColor ? starterDesc : '',
active: true, active: 'blue' === starterColor,
})); }));
isGameRunningRef.current = true; isGameRunningRef.current = true;
lastActivePlayerRef.current = 1; // Blue starts lastActivePlayerRef.current = starterVal;
startNewTurn(); startNewTurn();
resetStepTimer(); resetStepTimer();
/**
* For a truly restored game, keep the "Waiting for opponent..." overlay
* up until we actually see a heartbeat from the other player.
*/
if (!endRef.current && (!isTrueRestoredRef.current || 0 !== opponentLastSeenRef.current)) {
hideOverlay(); hideOverlay();
}
};
const publishHeartbeat = () => {
const me = webPlayerRef.current;
if (!me || endRef.current) return;
heartbeatMutation.mutate(me);
};
const startHeartbeat = () => {
if (heartbeatPubIntervalRef.current) return;
publishHeartbeat();
heartbeatPubIntervalRef.current = setInterval(publishHeartbeat, HEARTBEAT_INTERVAL_MS);
};
const stopHeartbeat = () => {
if (heartbeatPubIntervalRef.current) {
clearInterval(heartbeatPubIntervalRef.current);
heartbeatPubIntervalRef.current = null;
}
}; };
/** Mercure / SSE message handlers */ /** Mercure / SSE message handlers */
@@ -132,7 +257,28 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
const wUnsubscribe = payload => { const wUnsubscribe = payload => {
isEnvDev && console.info(payload.msg); isEnvDev && console.info(payload.msg);
showOverlay('The connection has been lost w/ your friend...', 'Please, restart the game!'); const isAuthenticated = '1' === document.getElementById('mine-wrapper')?.dataset.isAuthenticated;
const redirectPath = isAuthenticated ? '/profile' : '/';
const buttonText = isAuthenticated ? 'My Profile' : 'Homepage';
const buttonIcon = isAuthenticated ? 'fa-user' : 'fa-house';
showOverlay(
'The connection has been lost w/ your friend...',
(
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px', width: '100%' }}>
<p style={{ margin: 0 }}>Please, restart the game!</p>
<a
className="game-overlay-profile"
href={redirectPath}
title={isAuthenticated ? 'Go to your profile' : 'Go to homepage'}
aria-label={isAuthenticated ? 'Go to your profile' : 'Go to homepage'}
>
<i className={`fa ${buttonIcon}`} />
{buttonText}
</a>
</div>
),
);
}; };
const wChallenge = payload => { const wChallenge = payload => {
@@ -141,29 +287,31 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
const handleAccept = () => { const handleAccept = () => {
clearTimeout(declineTimeout); clearTimeout(declineTimeout);
fetch('/api/game/challenge/respond/' + challengerGameAssoc, { challengeRespondMutation.mutate(
method: 'POST', { challengerGameAssoc, accepted: true, targetGameAssoc: gameAssoc },
headers: { 'Content-Type': 'application/json' }, {
body: JSON.stringify({ accepted: true, targetGameAssoc: gameAssoc }), onSuccess: () => {
}).then(() => {
showOverlay('Challenge accepted!', 'Waiting for the challenger to join...'); showOverlay('Challenge accepted!', 'Waiting for the challenger to join...');
}).catch(() => {}); },
},
);
}; };
const handleDecline = () => { const handleDecline = () => {
clearTimeout(declineTimeout); clearTimeout(declineTimeout);
fetch('/api/game/challenge/respond/' + challengerGameAssoc, { challengeRespondMutation.mutate(
method: 'POST', { challengerGameAssoc, accepted: false, targetGameAssoc: gameAssoc },
headers: { 'Content-Type': 'application/json' }, {
body: JSON.stringify({ accepted: false, targetGameAssoc: gameAssoc }), onSuccess: () => {
}).then(() => {
showOverlay('We are waiting for your opponent...', gameAssoc ? ( showOverlay('We are waiting for your opponent...', gameAssoc ? (
<WaitingOverlayContent <WaitingOverlayContent
shareUrl={window.location.origin + '/play/' + gameAssoc} shareUrl={`${window.location.origin}/play/${gameAssoc}`}
currentGameAssoc={gameAssoc} currentGameAssoc={gameAssoc}
/> />
) : ''); ) : '');
}).catch(() => {}); },
},
);
}; };
declineTimeout = setTimeout(handleDecline, 30000); declineTimeout = setTimeout(handleDecline, 30000);
@@ -188,8 +336,10 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
isEnvDev && console.warn(payload.user + ' stepped to ' + payload.data.coords.join(',')); isEnvDev && console.warn(payload.user + ' stepped to ' + payload.data.coords.join(','));
syncBombSelected(payload.data.bomb); syncBombSelected(payload.data.bomb);
// Detect if turn switched (other player made a move) /**
// After their move, it's now our turn (or the opposite player's turn) * Detect if turn switched (other player made a move)
* After their move, it's now our turn (or the opposite player's turn)
*/
if (lastActivePlayerRef.current !== activePlayerRef.current) { if (lastActivePlayerRef.current !== activePlayerRef.current) {
startNewTurn(); startNewTurn();
lastActivePlayerRef.current = activePlayerRef.current; lastActivePlayerRef.current = activePlayerRef.current;
@@ -210,6 +360,16 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
if (undefined !== payload.type) { if (undefined !== payload.type) {
if ('challenge' === payload.type) wChallenge(payload); if ('challenge' === payload.type) wChallenge(payload);
else if ('challenge-response' === payload.type) wChallengeResponse(payload); else if ('challenge-response' === payload.type) wChallengeResponse(payload);
else if ('heartbeat' === payload.type) {
const me = webPlayerRef.current;
if (me && payload.color && payload.color !== me) {
const wasFirst = 0 === opponentLastSeenRef.current;
opponentLastSeenRef.current = Date.now();
if (wasFirst && isTrueRestoredRef.current && !endRef.current) {
hideOverlay();
}
}
}
return; return;
} }
@@ -235,9 +395,9 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
const subscriberJwt = wrapper.dataset.mercureSubscriberJwt; const subscriberJwt = wrapper.dataset.mercureSubscriberJwt;
const url = new URL(hubUrl, window.location.origin); const url = new URL(hubUrl, window.location.origin);
url.searchParams.append('topic', 'mineseeker/channel/' + gameAssoc); url.searchParams.append('topic', `mineseeker/channel/${gameAssoc}`);
if (subscriberJwt) url.searchParams.append('authorization', subscriberJwt);
if (subscriberJwt) url.searchParams.append('authorization', subscriberJwt);
if (eventSourceRef.current) eventSourceRef.current.close(); if (eventSourceRef.current) eventSourceRef.current.close();
const es = new EventSource(url.toString()); const es = new EventSource(url.toString());
@@ -264,6 +424,7 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
openEventSource(); openEventSource();
return; return;
} }
try { try {
if (gameInherited) { if (gameInherited) {
const serverData = await connectQuery.refetch().then(r => { const serverData = await connectQuery.refetch().then(r => {
@@ -278,23 +439,50 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
} }
rpcUsersRef.current = serverData.users; rpcUsersRef.current = serverData.users;
lastStepRef.current = serverData.mostRecentStep || null;
/** Pass game state (points, bonus) to wInit */
const gameState = {
redPoints: serverData.redPoints ?? 0,
bluePoints: serverData.bluePoints ?? 0,
redBonusPoints: serverData.redBonusPoints ?? 0,
blueBonusPoints: serverData.blueBonusPoints ?? 0,
redBonusStats: serverData.redBonusStats ?? {},
blueBonusStats: serverData.blueBonusStats ?? {},
};
const isGameFinished = serverData.gameFinished ?? false;
wInit(serverData.revealedCells || [], serverData.lastStep || {}, gameState, isGameFinished);
/** Open event source after showing overlay */
openEventSource(); openEventSource();
wInit(serverData.revealedCells || []);
} else { } else {
await startMutation.mutateAsync(); const startResponse = await startMutation.mutateAsync();
if (!startResponse?.success) {
showOverlay('Error', 'Failed to start game. Please try again.');
isEnvDev && console.error('Start game failed:', startResponse);
return;
}
openEventSource(); openEventSource();
wInit(); wInit();
} }
isEnvDev && console.info('Connection initialised — joining channel'); isEnvDev && console.info('Connection initialised — joining channel');
await joinMutation.mutateAsync(); await joinMutation.mutateAsync();
startHeartbeat();
} catch (e) { } catch (e) {
isEnvDev && console.error('Connection error', e); isEnvDev && console.error('Connection error', e);
showOverlay('Error', 'Connection failed. Please try again.');
setTimeout(() => window.location.reload(), 500); setTimeout(() => window.location.reload(), 500);
} }
})(); })();
window.addEventListener('pagehide', () => navigator.sendBeacon('/api/game/leave/' + gameAssoc)); window.addEventListener('pagehide', () => {
leaveMutation.mutate();
});
return () => {
stopHeartbeat();
};
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -318,9 +506,11 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
try { try {
const result = await stepMutation.mutateAsync(dataPack); const result = await stepMutation.mutateAsync(dataPack);
applyStep(result); applyStep(result);
if (result.uuid && !endRef.current) { if (result.uuid && !endRef.current) {
setGameUuid(result.uuid); setGameUuid(result.uuid);
} }
makeGameEndIfItEnds(result.bluePoints, result.redPoints, false, result.leftMines); makeGameEndIfItEnds(result.bluePoints, result.redPoints, false, result.leftMines);
} catch (e) { } catch (e) {
isEnvDev && console.error('Step error', e); isEnvDev && console.error('Step error', e);
@@ -330,6 +520,7 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
const clickResign = () => { const clickResign = () => {
const color = activePlayerRef.current ? 'blue' : 'red'; const color = activePlayerRef.current ? 'blue' : 'red';
const stepElapsed = getStepElapsed(activePlayerRef.current, isGameRunningRef.current); const stepElapsed = getStepElapsed(activePlayerRef.current, isGameRunningRef.current);
stepMutation.mutate( stepMutation.mutate(
{ resign: color, stepElapsed }, { resign: color, stepElapsed },
{ {
@@ -338,13 +529,15 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
resignProcess(webPlayerRef.current, result.uuid); resignProcess(webPlayerRef.current, result.uuid);
} }
}, },
} },
); );
}; };
const resign = () => { const resign = () => {
const activeColor = activePlayerRef.current ? 'blue' : 'red'; const activeColor = activePlayerRef.current ? 'blue' : 'red';
if (webPlayerRef.current !== activeColor) return; if (webPlayerRef.current !== activeColor) return;
showOverlay('Are u sure u want to resign?!', ( showOverlay('Are u sure u want to resign?!', (
<div className="resign"> <div className="resign">
<a onClick={clickResign}>Yes</a> <a onClick={clickResign}>Yes</a>

View File

@@ -10,24 +10,18 @@
import { useRef } from 'react'; import { useRef } from 'react';
const useStepTimer = () => { const useStepTimer = () => {
// Record when the current turn started (timestamp)
const turnStartTimeRef = useRef(null); const turnStartTimeRef = useRef(null);
// Flag to track if we've already recorded a turn start
const turnStartedRef = useRef(false); const turnStartedRef = useRef(false);
const getStepElapsed = (currentActivePlayer, isGameRunning) => { const getStepElapsed = (currentActivePlayer, isGameRunning) => {
// If game not running, return 0
if (!isGameRunning) return 0; if (!isGameRunning) return 0;
// Only initialize the turn timer ONCE per call to getStepElapsed
// This prevents resetting on multiple calls
if (!turnStartedRef.current) { if (!turnStartedRef.current) {
turnStartTimeRef.current = Date.now(); turnStartTimeRef.current = Date.now();
turnStartedRef.current = true; turnStartedRef.current = true;
return 0; return 0;
} }
// After initialization, just calculate elapsed time
if (turnStartTimeRef.current) { if (turnStartTimeRef.current) {
return Math.floor((Date.now() - turnStartTimeRef.current) / 1000); return Math.floor((Date.now() - turnStartTimeRef.current) / 1000);
} }
@@ -40,7 +34,6 @@ const useStepTimer = () => {
turnStartedRef.current = false; turnStartedRef.current = false;
}; };
// Call this when we know a turn has actually changed (from server response)
const startNewTurn = () => { const startNewTurn = () => {
turnStartTimeRef.current = Date.now(); turnStartTimeRef.current = Date.now();
turnStartedRef.current = true; turnStartedRef.current = true;

View File

@@ -34,9 +34,23 @@ export const IMAGES = {
bombPos: (hor, vert) => `${IMG}bg-bomb-${hor}-${vert}-outbg.png`, bombPos: (hor, vert) => `${IMG}bg-bomb-${hor}-${vert}-outbg.png`,
}; };
export const BONUS_STATS_DEF = {
blindHits: 0, chainBest: 0, chainCurrent: 0, lastMineHits: 0, edgeMines: 0, biggestReveal: 0,
};
export const BONUS_LABELS = {
blindHits: { label: 'Blind hits', desc: 'Mines clicked with no revealed number nearby' },
chainBest: { label: 'Best chain', desc: 'Longest streak of consecutive mine-clicks' },
chainCurrent: { label: 'Current chain', desc: 'Active consecutive mine-click streak' },
lastMineHits: { label: 'Endgame mines', desc: 'Mines clicked while few remain on the board' },
edgeMines: { label: 'Edge mines', desc: 'Mines clicked on the board boundary' },
biggestReveal: { label: 'Biggest reveal', desc: 'Largest number of safe cells revealed in one click' },
};
export const PLAYER_DEF = { export const PLAYER_DEF = {
name: '...', desc: '', active: false, mines: 0, haveBomb: true, enabledBomb: true, name: '...', desc: '', active: false, mines: 0, haveBomb: true, enabledBomb: true,
registered: false, avatar: null, registered: false, avatar: null,
bonusPoints: 0, bonusStats: { ...BONUS_STATS_DEF },
}; };
export const DESC = { export const DESC = {

View File

@@ -7,4 +7,4 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
export { DESC, IMAGES, BOMB_SYMBOLS, PLAYER_DEF, bombRadius, initCells, patchCells } from './constants'; export { DESC, IMAGES, BOMB_SYMBOLS, PLAYER_DEF, BONUS_STATS_DEF, BONUS_LABELS, bombRadius, initCells, patchCells } from './constants';

View File

@@ -9,8 +9,7 @@
import React from 'react'; import React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import PasskeyManager from './components/PasskeyManager'; import { PasskeyLogin, PasskeyManager } from '@global-components';
import PasskeyLogin from './components/PasskeyLogin';
const passkeyManagerRoot = document.getElementById('passkey-manager-root'); const passkeyManagerRoot = document.getElementById('passkey-manager-root');

View File

@@ -1,18 +1,30 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React from 'react'; import React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import ProfileCharts from './components/ProfileCharts'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import BattleDialog from './components/BattleDialog'; import { AvatarUpload, BattleDialog, ProfileCharts } from '@global-components';
import AvatarUpload from './components/AvatarUpload';
const queryClient = new QueryClient();
const avatarRoot = document.getElementById('profile-avatar-root'); const avatarRoot = document.getElementById('profile-avatar-root');
if (avatarRoot) { if (avatarRoot) {
const { uploadUrl, thumbUrl, initials } = avatarRoot.dataset; const { uploadUrl, thumbUrl, initials } = avatarRoot.dataset;
createRoot(avatarRoot).render( createRoot(avatarRoot).render(
<QueryClientProvider client={queryClient}>
<AvatarUpload <AvatarUpload
uploadUrl={uploadUrl} uploadUrl={uploadUrl}
initialThumbUrl={thumbUrl || null} initialThumbUrl={thumbUrl || null}
initials={initials} initials={initials}
/>, />
</QueryClientProvider>,
); );
} }
@@ -29,3 +41,28 @@ if (battleRoot) {
<BattleDialog games={JSON.parse(battleRoot.dataset.games)} />, <BattleDialog games={JSON.parse(battleRoot.dataset.games)} />,
); );
} }
const list = document.querySelector('.profile-games');
const loadMoreBtn = document.querySelector('[data-load-more]');
if (list && loadMoreBtn) {
const batchSize = parseInt(list.dataset.batchSize, 10) || 5;
loadMoreBtn.addEventListener('click', () => {
const hidden = list.querySelectorAll('.profile-game--hidden');
Array.from(hidden).slice(0, batchSize).forEach(el => el.classList.remove('profile-game--hidden'));
if (0 === list.querySelectorAll('.profile-game--hidden').length) {
loadMoreBtn.remove();
}
});
}
const filterInput = document.querySelector('[data-filter]');
if (list && filterInput) {
filterInput.addEventListener('input', () => {
const term = filterInput.value.trim().toLowerCase();
list.classList.toggle('is-filtering', 0 < term.length);
list.querySelectorAll('.profile-game').forEach(card => {
const opp = card.querySelector('.profile-game__opponent')?.textContent.trim().toLowerCase() ?? '';
card.classList.toggle('profile-game--filtered-out', 0 < term.length && !opp.includes(term));
});
});
}

44
assets/js/utils/format.js Normal file
View File

@@ -0,0 +1,44 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
/** Formats a duration in seconds as MM:SS. */
export const formatTime = seconds => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
};
/**
* Formats the difference between two 'YYYY-MM-DD HH:mm' strings as a
* human-readable duration (e.g. "1h 4m 23s", "4m 23s", "23s").
* Returns null when the inputs are missing or the diff is not positive.
*/
export const formatDuration = (from, to) => {
if (!from || !to) return null;
const diffMs = new Date(to.replace(' ', 'T')) - new Date(from.replace(' ', 'T'));
if (isNaN(diffMs) || 0 >= diffMs) return null;
const totalSec = Math.floor(diffMs / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
if (0 < h) return `${h}h ${m}m ${s}s`;
if (0 < m) return `${m}m ${s}s`;
return `${s}s`;
};
/**
* Formats an ISO timestamp as a "X min ago" string (minute resolution).
* Returns 'just now' for differences under one minute.
*/
export const formatSince = isoStr => {
const diff = Math.floor((Date.now() - new Date(isoStr)) / 60000);
if (1 > diff) return 'just now';
if (1 === diff) return '1 min ago';
return `${diff} min ago`;
};

190
bun.lock
View File

@@ -12,24 +12,24 @@
"@fontsource/rajdhani": "^5.2.7", "@fontsource/rajdhani": "^5.2.7",
"@fortawesome/fontawesome-free": "^7.2.0", "@fortawesome/fontawesome-free": "^7.2.0",
"@mui/material": "^9.0.0", "@mui/material": "^9.0.0",
"@mui/x-charts": "^9.0.1", "@mui/x-charts": "^9.0.2",
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.99.2",
"howler": "^2.1.2", "howler": "^2.2.4",
"lodash": "^4.18.1", "lodash": "^4.18.1",
"prop-types": "^15.7.2", "prop-types": "^15.8.1",
"react": "^19.0.0", "react": "^19.2.5",
"react-dom": "^19.0.0", "react-dom": "^19.2.5",
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.5", "@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^9.0.0", "@eslint/js": "10.0.1",
"@stylistic/eslint-plugin": "^4.0.0", "@stylistic/eslint-plugin": "5.10.0",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.0.0", "eslint": "10.2.1",
"eslint-plugin-react": "^7.0.0", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "7.1.1",
"globals": "^15.0.0", "globals": "17.5.0",
"sass": "^1.77.0", "sass": "^1.99.0",
"vite": "^8.0.8", "vite": "^8.0.8",
"vite-plugin-symfony": "^8.2.4", "vite-plugin-symfony": "^8.2.4",
}, },
@@ -38,16 +38,28 @@
"packages": { "packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.0", "http://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/code-frame": ["@babel/code-frame@7.29.0", "http://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
"@babel/generator": ["@babel/generator@7.29.1", "http://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], "@babel/generator": ["@babel/generator@7.29.1", "http://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "http://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "http://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "http://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "http://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "http://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "http://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "http://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "http://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
"@babel/parser": ["@babel/parser@7.29.2", "http://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], "@babel/parser": ["@babel/parser@7.29.2", "http://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
"@babel/runtime": ["@babel/runtime@7.29.2", "http://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], "@babel/runtime": ["@babel/runtime@7.29.2", "http://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
@@ -94,23 +106,23 @@
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "http://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "http://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.21.2", "http://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="], "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "http://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], "@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="],
"@eslint/core": ["@eslint/core@0.17.0", "http://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "http://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="], "@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "http://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="],
"@eslint/js": ["@eslint/js@9.39.4", "http://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="], "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="],
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "http://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "http://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
"@fontsource/changa-one": ["@fontsource/changa-one@5.2.8", "http://registry.npmjs.org/@fontsource/changa-one/-/changa-one-5.2.8.tgz", {}, "sha512-gagiU8sMWLs9ejh41NmrYlGdgasSYWFegz5/+22WqYdJVS9HZbaUEXj/6jj3ZKgi+dQZT0kYI+Nha2UQGbO/mA=="], "@fontsource/changa-one": ["@fontsource/changa-one@5.2.8", "http://registry.npmjs.org/@fontsource/changa-one/-/changa-one-5.2.8.tgz", {}, "sha512-gagiU8sMWLs9ejh41NmrYlGdgasSYWFegz5/+22WqYdJVS9HZbaUEXj/6jj3ZKgi+dQZT0kYI+Nha2UQGbO/mA=="],
"@fontsource/open-sans": ["@fontsource/open-sans@5.2.7", "", {}, "sha512-8yfgDYjE5O0vmTPdrcjV35y4yMnctsokmi9gN49Gcsr0sjzkMkR97AnKDe6OqZh2SFkYlR28fxOvi21bYEgMSw=="], "@fontsource/open-sans": ["@fontsource/open-sans@5.2.7", "http://registry.npmjs.org/@fontsource/open-sans/-/open-sans-5.2.7.tgz", {}, "sha512-8yfgDYjE5O0vmTPdrcjV35y4yMnctsokmi9gN49Gcsr0sjzkMkR97AnKDe6OqZh2SFkYlR28fxOvi21bYEgMSw=="],
"@fontsource/rajdhani": ["@fontsource/rajdhani@5.2.7", "http://registry.npmjs.org/@fontsource/rajdhani/-/rajdhani-5.2.7.tgz", {}, "sha512-7Gy10U688fCdeFfYKebUF2TZotdgH/ghKyMsseXPmB60lpaUHC8aoCSJl5/OpZ+KHKSU2TqBfKfteVkcIXxTAQ=="], "@fontsource/rajdhani": ["@fontsource/rajdhani@5.2.7", "http://registry.npmjs.org/@fontsource/rajdhani/-/rajdhani-5.2.7.tgz", {}, "sha512-7Gy10U688fCdeFfYKebUF2TZotdgH/ghKyMsseXPmB60lpaUHC8aoCSJl5/OpZ+KHKSU2TqBfKfteVkcIXxTAQ=="],
@@ -126,6 +138,8 @@
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "http://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "http://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "http://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "http://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "http://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "http://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
@@ -146,11 +160,11 @@
"@mui/utils": ["@mui/utils@9.0.0", "http://registry.npmjs.org/@mui/utils/-/utils-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@mui/types": "^9.0.0", "@types/prop-types": "^15.7.15", "clsx": "^2.1.1", "prop-types": "^15.8.1", "react-is": "^19.2.4" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-bQcqyg/gjULUqTuyUjSAFr6LQGLvtkNtDbJerAtoUn9kGZ0hg5QJiN1PLHMLbeFpe3te1831uq7GFl2ITokGdg=="], "@mui/utils": ["@mui/utils@9.0.0", "http://registry.npmjs.org/@mui/utils/-/utils-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@mui/types": "^9.0.0", "@types/prop-types": "^15.7.15", "clsx": "^2.1.1", "prop-types": "^15.8.1", "react-is": "^19.2.4" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-bQcqyg/gjULUqTuyUjSAFr6LQGLvtkNtDbJerAtoUn9kGZ0hg5QJiN1PLHMLbeFpe3te1831uq7GFl2ITokGdg=="],
"@mui/x-charts": ["@mui/x-charts@9.0.1", "http://registry.npmjs.org/@mui/x-charts/-/x-charts-9.0.1.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@mui/utils": "9.0.0", "@mui/x-charts-vendor": "^9.0.0", "@mui/x-internal-gestures": "^9.0.0", "@mui/x-internals": "^9.0.0", "bezier-easing": "^2.1.0", "clsx": "^2.1.1", "prop-types": "^15.8.1", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", "@mui/material": "^7.3.0 || ^9.0.0", "@mui/system": "^7.3.0 || ^9.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled"] }, "sha512-0LyhlGhUm07wGJY0d0U+hSljGS1EHKWgPBsTJ/lBNGDrNc4DI9zSbp4h802LN/eLwMUVXJSI7DH2W3Ef3WsqnQ=="], "@mui/x-charts": ["@mui/x-charts@9.0.2", "http://registry.npmjs.org/@mui/x-charts/-/x-charts-9.0.2.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@mui/utils": "9.0.0", "@mui/x-charts-vendor": "^9.0.0", "@mui/x-internal-gestures": "^9.0.2", "@mui/x-internals": "^9.0.0", "bezier-easing": "^2.1.0", "clsx": "^2.1.1", "prop-types": "^15.8.1", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", "@mui/material": "^7.3.0 || ^9.0.0", "@mui/system": "^7.3.0 || ^9.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled"] }, "sha512-bKgjGD+uJbDN/g7tMjVmlNdm+iM4UkCJoYruQmgpQ0l+cip8Kn4kmn1iD//rZ35an+LdWaUZ4MHvMzV76D6EJw=="],
"@mui/x-charts-vendor": ["@mui/x-charts-vendor@9.0.0", "http://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@types/d3-array": "^3.2.2", "@types/d3-color": "^3.1.3", "@types/d3-format": "^3.0.4", "@types/d3-interpolate": "^3.0.4", "@types/d3-path": "^3.1.1", "@types/d3-scale": "^4.0.9", "@types/d3-shape": "^3.1.8", "@types/d3-time": "^3.0.4", "@types/d3-time-format": "^4.0.3", "@types/d3-timer": "^3.0.2", "d3-array": "^3.2.4", "d3-color": "^3.1.0", "d3-format": "^3.1.2", "d3-interpolate": "^3.0.1", "d3-path": "^3.1.0", "d3-scale": "^4.0.2", "d3-shape": "^3.2.0", "d3-time": "^3.1.0", "d3-time-format": "^4.1.0", "d3-timer": "^3.0.1", "flatqueue": "^3.0.0", "internmap": "^2.0.3" } }, "sha512-Do91i+fZiNj/4LN5oaGpJoutolzDBDwdfw6tHrx2LKXDMCRlaImCfreLbdbkk7dFsi9fuIP7hWiMV4vDJKPJTA=="], "@mui/x-charts-vendor": ["@mui/x-charts-vendor@9.0.0", "http://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@types/d3-array": "^3.2.2", "@types/d3-color": "^3.1.3", "@types/d3-format": "^3.0.4", "@types/d3-interpolate": "^3.0.4", "@types/d3-path": "^3.1.1", "@types/d3-scale": "^4.0.9", "@types/d3-shape": "^3.1.8", "@types/d3-time": "^3.0.4", "@types/d3-time-format": "^4.0.3", "@types/d3-timer": "^3.0.2", "d3-array": "^3.2.4", "d3-color": "^3.1.0", "d3-format": "^3.1.2", "d3-interpolate": "^3.0.1", "d3-path": "^3.1.0", "d3-scale": "^4.0.2", "d3-shape": "^3.2.0", "d3-time": "^3.1.0", "d3-time-format": "^4.1.0", "d3-timer": "^3.0.1", "flatqueue": "^3.0.0", "internmap": "^2.0.3" } }, "sha512-Do91i+fZiNj/4LN5oaGpJoutolzDBDwdfw6tHrx2LKXDMCRlaImCfreLbdbkk7dFsi9fuIP7hWiMV4vDJKPJTA=="],
"@mui/x-internal-gestures": ["@mui/x-internal-gestures@9.0.0", "http://registry.npmjs.org/@mui/x-internal-gestures/-/x-internal-gestures-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.6" } }, "sha512-+fW1EUai25GJbivGRsi3GX4GYsSvzFPvUEjmMgB4POkRBDjrEZNaLdVWfapT6DlWv/Vfbi08bYSuyvhPXGMZjw=="], "@mui/x-internal-gestures": ["@mui/x-internal-gestures@9.0.2", "http://registry.npmjs.org/@mui/x-internal-gestures/-/x-internal-gestures-9.0.2.tgz", { "dependencies": { "@babel/runtime": "^7.28.6" } }, "sha512-xCp99a7cSb7iH1bj4G524ooMOFe92H8m/rONCUiKyj7LvV1YUGzTfHgJysQgDCZJqHYaW7YAGLvwMUyEMZVzqQ=="],
"@mui/x-internals": ["@mui/x-internals@9.0.0", "http://registry.npmjs.org/@mui/x-internals/-/x-internals-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@mui/utils": "9.0.0", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-E/4rdg69JjhyybpPGypCjAKSKLLnSdCFM+O6P/nkUg47+qt3uftxQEhjQO53rcn6ahHl6du/uNZ9BLgeY6kYxQ=="], "@mui/x-internals": ["@mui/x-internals@9.0.0", "http://registry.npmjs.org/@mui/x-internals/-/x-internals-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@mui/utils": "9.0.0", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-E/4rdg69JjhyybpPGypCjAKSKLLnSdCFM+O6P/nkUg47+qt3uftxQEhjQO53rcn6ahHl6du/uNZ9BLgeY6kYxQ=="],
@@ -228,11 +242,11 @@
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "http://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "http://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@4.4.1", "http://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-4.4.1.tgz", { "dependencies": { "@typescript-eslint/utils": "^8.32.1", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "estraverse": "^5.3.0", "picomatch": "^4.0.2" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-CEigAk7eOLyHvdgmpZsKFwtiqS2wFwI1fn4j09IU9GmD4euFM4jEBAViWeCqaNLlbX2k2+A/Fq9cje4HQBXuJQ=="], "@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.10.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.56.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": "^9.0.0 || ^10.0.0" } }, "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ=="],
"@tanstack/query-core": ["@tanstack/query-core@5.97.0", "http://registry.npmjs.org/@tanstack/query-core/-/query-core-5.97.0.tgz", {}, "sha512-QdpLP5VzVMgo4VtaPppRA2W04UFjIqX+bxke/ZJhE5cfd5UPkRzqIAJQt9uXkQJjqE8LBOMbKv7f8HCsZltXlg=="], "@tanstack/query-core": ["@tanstack/query-core@5.99.2", "http://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.2.tgz", {}, "sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA=="],
"@tanstack/react-query": ["@tanstack/react-query@5.97.0", "http://registry.npmjs.org/@tanstack/react-query/-/react-query-5.97.0.tgz", { "dependencies": { "@tanstack/query-core": "5.97.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-y4So4eGcQoK2WVMAcDNZE9ofB/p5v1OlKvtc1F3uqHwrtifobT7q+ZnXk2mRkc8E84HKYSlAE9z6HXl2V0+ySQ=="], "@tanstack/react-query": ["@tanstack/react-query@5.99.2", "http://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.2.tgz", { "dependencies": { "@tanstack/query-core": "5.99.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vM91UEe45QUS9ED6OklsVL15i8qKcRqNwpWzPTVWvRPRSEgDudDgHpvyTjcdlwHcrKNa80T+xXYcchT2noPnZA=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "http://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "http://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
@@ -256,6 +270,8 @@
"@types/d3-timer": ["@types/d3-timer@3.0.2", "http://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], "@types/d3-timer": ["@types/d3-timer@3.0.2", "http://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
"@types/estree": ["@types/estree@1.0.8", "http://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "http://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "http://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/json-schema": ["@types/json-schema@7.0.15", "http://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
@@ -268,20 +284,8 @@
"@types/react-transition-group": ["@types/react-transition-group@4.4.12", "http://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", { "peerDependencies": { "@types/react": "*" } }, "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w=="], "@types/react-transition-group": ["@types/react-transition-group@4.4.12", "http://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", { "peerDependencies": { "@types/react": "*" } }, "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.1", "http://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.1", "@typescript-eslint/types": "^8.58.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.58.1", "http://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz", { "dependencies": { "@typescript-eslint/types": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1" } }, "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.1", "http://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.58.1", "http://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", {}, "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw=="], "@typescript-eslint/types": ["@typescript-eslint/types@8.58.1", "http://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", {}, "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.1", "http://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz", { "dependencies": { "@typescript-eslint/project-service": "8.58.1", "@typescript-eslint/tsconfig-utils": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.58.1", "http://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.1", "http://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz", { "dependencies": { "@typescript-eslint/types": "8.58.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "http://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "http://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
"acorn": ["acorn@8.16.0", "http://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn": ["acorn@8.16.0", "http://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
@@ -290,8 +294,6 @@
"ajv": ["ajv@6.14.0", "http://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], "ajv": ["ajv@6.14.0", "http://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"ansi-styles": ["ansi-styles@4.3.0", "http://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"argparse": ["argparse@2.0.1", "http://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "argparse": ["argparse@2.0.1", "http://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "http://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "http://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
@@ -316,12 +318,16 @@
"balanced-match": ["balanced-match@1.0.0", "http://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", {}, "sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg=="], "balanced-match": ["balanced-match@1.0.0", "http://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", {}, "sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.20", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ=="],
"bezier-easing": ["bezier-easing@2.1.0", "http://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz", {}, "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig=="], "bezier-easing": ["bezier-easing@2.1.0", "http://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz", {}, "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig=="],
"brace-expansion": ["brace-expansion@1.1.11", "http://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], "brace-expansion": ["brace-expansion@1.1.11", "http://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"braces": ["braces@3.0.3", "http://registry.npmjs.org/braces/-/braces-3.0.3.tgz", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "braces": ["braces@3.0.3", "http://registry.npmjs.org/braces/-/braces-3.0.3.tgz", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.28.2", "", { "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" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"call-bind": ["call-bind@1.0.9", "http://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], "call-bind": ["call-bind@1.0.9", "http://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "http://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "http://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
@@ -330,19 +336,15 @@
"callsites": ["callsites@3.1.0", "http://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "callsites": ["callsites@3.1.0", "http://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"chalk": ["chalk@4.1.2", "http://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "caniuse-lite": ["caniuse-lite@1.0.30001788", "", {}, "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ=="],
"chokidar": ["chokidar@4.0.3", "http://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "chokidar": ["chokidar@4.0.3", "http://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"clsx": ["clsx@2.1.1", "http://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "clsx": ["clsx@2.1.1", "http://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["color-convert@2.0.1", "http://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "http://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"concat-map": ["concat-map@0.0.1", "http://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "concat-map": ["concat-map@0.0.1", "http://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"convert-source-map": ["convert-source-map@1.9.0", "http://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cosmiconfig": ["cosmiconfig@7.1.0", "http://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="], "cosmiconfig": ["cosmiconfig@7.1.0", "http://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="],
@@ -392,6 +394,8 @@
"dunder-proto": ["dunder-proto@1.0.1", "http://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "dunder-proto": ["dunder-proto@1.0.1", "http://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.340", "", {}, "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA=="],
"error-ex": ["error-ex@1.3.4", "http://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], "error-ex": ["error-ex@1.3.4", "http://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
"es-abstract": ["es-abstract@1.24.2", "http://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="], "es-abstract": ["es-abstract@1.24.2", "http://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="],
@@ -410,15 +414,17 @@
"es-to-primitive": ["es-to-primitive@1.3.0", "http://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], "es-to-primitive": ["es-to-primitive@1.3.0", "http://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "http://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "http://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.39.4", "http://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], "eslint": ["eslint@10.2.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q=="],
"eslint-plugin-react": ["eslint-plugin-react@7.37.5", "http://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "http://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "http://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="],
"eslint-scope": ["eslint-scope@8.4.0", "http://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "http://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "http://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
@@ -470,6 +476,8 @@
"generator-function": ["generator-function@2.0.1", "http://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], "generator-function": ["generator-function@2.0.1", "http://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "http://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-intrinsic": ["get-intrinsic@1.3.0", "http://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "http://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], "get-proto": ["get-proto@1.0.1", "http://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
@@ -478,7 +486,7 @@
"glob-parent": ["glob-parent@6.0.2", "http://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "glob-parent": ["glob-parent@6.0.2", "http://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@15.15.0", "http://registry.npmjs.org/globals/-/globals-15.15.0.tgz", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], "globals": ["globals@17.5.0", "", {}, "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g=="],
"globalthis": ["globalthis@1.0.4", "http://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], "globalthis": ["globalthis@1.0.4", "http://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
@@ -486,8 +494,6 @@
"has-bigints": ["has-bigints@1.1.0", "http://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], "has-bigints": ["has-bigints@1.1.0", "http://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
"has-flag": ["has-flag@4.0.0", "http://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"has-property-descriptors": ["has-property-descriptors@1.0.2", "http://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], "has-property-descriptors": ["has-property-descriptors@1.0.2", "http://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
"has-proto": ["has-proto@1.2.0", "http://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], "has-proto": ["has-proto@1.2.0", "http://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="],
@@ -498,9 +504,13 @@
"hasown": ["hasown@2.0.2", "http://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "hasown": ["hasown@2.0.2", "http://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "http://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "http://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
"howler": ["howler@2.1.2", "http://registry.npmjs.org/howler/-/howler-2.1.2.tgz", {}, "sha512-oKrTFaVXsDRoB/jik7cEpWKTj7VieoiuzMYJ7E/EU5ayvmpRhumCv3YQ3823zi9VTJkSWAhbryHnlZAionGAJg=="], "howler": ["howler@2.2.4", "http://registry.npmjs.org/howler/-/howler-2.2.4.tgz", {}, "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="],
"ignore": ["ignore@5.3.2", "http://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "ignore": ["ignore@5.3.2", "http://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -586,6 +596,8 @@
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "http://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "http://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "http://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "http://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
"keyv": ["keyv@4.5.4", "http://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "keyv": ["keyv@4.5.4", "http://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
@@ -622,10 +634,10 @@
"lodash": ["lodash@4.18.1", "http://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], "lodash": ["lodash@4.18.1", "http://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"lodash.merge": ["lodash.merge@4.6.2", "http://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"loose-envify": ["loose-envify@1.4.0", "http://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "loose-envify": ["loose-envify@1.4.0", "http://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "http://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "math-intrinsics": ["math-intrinsics@1.1.0", "http://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"merge2": ["merge2@1.4.1", "http://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "merge2": ["merge2@1.4.1", "http://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
@@ -646,6 +658,8 @@
"node-exports-info": ["node-exports-info@1.6.0", "http://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="], "node-exports-info": ["node-exports-info@1.6.0", "http://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="],
"node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
"object-assign": ["object-assign@4.1.1", "http://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-assign": ["object-assign@4.1.1", "http://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "http://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], "object-inspect": ["object-inspect@1.13.4", "http://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
@@ -690,7 +704,7 @@
"prelude-ls": ["prelude-ls@1.2.1", "http://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prelude-ls": ["prelude-ls@1.2.1", "http://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prop-types": ["prop-types@15.7.2", "http://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.8.1" } }, "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ=="], "prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"punycode": ["punycode@2.3.1", "http://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "punycode": ["punycode@2.3.1", "http://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
@@ -774,8 +788,6 @@
"stylis": ["stylis@4.2.0", "http://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="], "stylis": ["stylis@4.2.0", "http://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="],
"supports-color": ["supports-color@7.2.0", "http://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "http://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "http://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tinyglobby": ["tinyglobby@0.2.16", "http://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], "tinyglobby": ["tinyglobby@0.2.16", "http://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
@@ -784,8 +796,6 @@
"totalist": ["totalist@3.0.1", "http://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], "totalist": ["totalist@3.0.1", "http://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
"ts-api-utils": ["ts-api-utils@2.5.0", "http://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
"tslib": ["tslib@2.8.1", "http://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["tslib@2.8.1", "http://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-check": ["type-check@0.4.0", "http://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "type-check": ["type-check@0.4.0", "http://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
@@ -798,10 +808,10 @@
"typed-array-length": ["typed-array-length@1.0.7", "http://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], "typed-array-length": ["typed-array-length@1.0.7", "http://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="],
"typescript": ["typescript@6.0.2", "http://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
"unbox-primitive": ["unbox-primitive@1.1.0", "http://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], "unbox-primitive": ["unbox-primitive@1.1.0", "http://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"uri-js": ["uri-js@4.4.1", "http://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], "uri-js": ["uri-js@4.4.1", "http://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "http://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "use-sync-external-store": ["use-sync-external-store@1.6.0", "http://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
@@ -822,35 +832,31 @@
"word-wrap": ["word-wrap@1.2.5", "http://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "word-wrap": ["word-wrap@1.2.5", "http://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@1.10.3", "http://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], "yaml": ["yaml@1.10.3", "http://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
"yocto-queue": ["yocto-queue@0.1.0", "http://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "yocto-queue": ["yocto-queue@0.1.0", "http://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "http://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "http://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "http://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/config-array/minimatch": ["minimatch@10.2.5", "http://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "http://registry.npmjs.org/globals/-/globals-14.0.0.tgz", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "http://registry.npmjs.org/globals/-/globals-14.0.0.tgz", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@mui/material/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"@mui/private-theming/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"@mui/styled-engine/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"@mui/system/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"@mui/utils/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"@mui/x-charts/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "http://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "http://registry.npmjs.org/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "http://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"babel-plugin-macros/resolve": ["resolve@1.22.12", "http://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], "babel-plugin-macros/resolve": ["resolve@1.22.12", "http://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
"eslint-plugin-react/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "eslint/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "http://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"eslint/espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
"eslint/minimatch": ["minimatch@10.2.5", "http://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "http://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "http://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -858,30 +864,16 @@
"micromatch/picomatch": ["picomatch@2.3.2", "http://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "micromatch/picomatch": ["picomatch@2.3.2", "http://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"prop-types/react-is": ["react-is@16.11.0", "http://registry.npmjs.org/react-is/-/react-is-16.11.0.tgz", {}, "sha512-gbBVYR2p8mnriqAwWx9LbuUrShnAuSCNnuPGyc7GJrMVQtPDAh8iLpv7FRuMPFb56KkaVZIYSz1PrjI9q0QPCw=="], "prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-transition-group/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="], "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="],
"@mui/material/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@5.0.5", "http://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"@mui/private-theming/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "eslint/minimatch/brace-expansion": ["brace-expansion@5.0.5", "http://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"@mui/styled-engine/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "@eslint/config-array/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "http://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"@mui/system/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "eslint/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "http://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"@mui/utils/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"@mui/x-charts/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "http://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"eslint-plugin-react/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-transition-group/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "http://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
} }
} }

View File

@@ -33,7 +33,11 @@ services:
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
MINIO_ENDPOINT: http://minio:9000 MINIO_ENDPOINT: http://minio:9000
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9000} MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9000}
TRUSTED_PROXIES: ${TRUSTED_PROXIES} # IMPORTANT: Set TRUSTED_PROXIES to your reverse proxy IP in production.
# For Docker on same host, use: 172.17.0.1 (default bridge) or 172.16.0.0/12 (overlay network)
# For Kubernetes or external proxy, use the proxy's IP address.
# WARNING: Using 0.0.0.0/0 is insecure in production environments!
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1}
volumes: volumes:
- app_var:/app/var - app_var:/app/var
- caddy_data:/data - caddy_data:/data

View File

@@ -11,14 +11,15 @@
"private": true, "private": true,
"require": { "require": {
"php": ">=8.5", "php": ">=8.5",
"ext-gd": "*",
"ext-iconv": "*", "ext-iconv": "*",
"ext-json": "*", "ext-json": "*",
"ext-gd": "*", "doctrine/dbal": "^4.3",
"doctrine/dbal": "^3.7", "doctrine/doctrine-bundle": "^3.2",
"doctrine/doctrine-bundle": ">=2.11 <2.14", "doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/doctrine-migrations-bundle": "^3.0", "doctrine/orm": "^3.5",
"doctrine/orm": "^2.6",
"endroid/qr-code": "^6.1", "endroid/qr-code": "^6.1",
"firebase/php-jwt": "^7.0",
"league/flysystem-aws-s3-v3": "^3.0", "league/flysystem-aws-s3-v3": "^3.0",
"league/flysystem-bundle": "^3.6", "league/flysystem-bundle": "^3.6",
"liip/imagine-bundle": "^2.13", "liip/imagine-bundle": "^2.13",
@@ -43,7 +44,6 @@
"web-auth/webauthn-framework": "^5.2" "web-auth/webauthn-framework": "^5.2"
}, },
"require-dev": { "require-dev": {
"firebase/php-jwt": "^7.0",
"roave/security-advisories": "dev-master", "roave/security-advisories": "dev-master",
"symfony/dotenv": "7.4.*", "symfony/dotenv": "7.4.*",
"symfony/maker-bundle": "^1.5", "symfony/maker-bundle": "^1.5",

673
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,9 +11,12 @@ doctrine:
url: '%env(resolve:DATABASE_URL)%' url: '%env(resolve:DATABASE_URL)%'
orm: orm:
auto_generate_proxy_classes: '%kernel.debug%' enable_native_lazy_objects: true
naming_strategy: doctrine.orm.naming_strategy.underscore naming_strategy: doctrine.orm.naming_strategy.underscore
auto_mapping: true auto_mapping: true
schema_ignore_classes:
- App\Entity\UserStats
- App\Entity\RecentBattle
mappings: mappings:
App: App:
is_bundle: false is_bundle: false

View File

@@ -23,12 +23,14 @@ security:
auth_code_parameter_name: _auth_code auth_code_parameter_name: _auth_code
post_only: true post_only: true
default_target_path: MineSeekerBundle_homepage default_target_path: MineSeekerBundle_homepage
always_use_default_target_path: false
prepare_on_login: true prepare_on_login: true
prepare_on_access_denied: true prepare_on_access_denied: true
form_login: form_login:
login_path: MineSeekerBundle_login login_path: MineSeekerBundle_login
check_path: MineSeekerBundle_login check_path: MineSeekerBundle_login
default_target_path: MineSeekerBundle_homepage default_target_path: MineSeekerBundle_homepage
always_use_default_target_path: false
username_parameter: _username username_parameter: _username
password_parameter: _password password_parameter: _password
enable_csrf: true enable_csrf: true

47
docs/README.md Normal file
View File

@@ -0,0 +1,47 @@
# Mine-Seeker Game Documentation
This directory contains comprehensive documentation about the Mine-Seeker game mechanics and implementation.
## Game Mechanics
### [Bonus Points System](./game-mechanics/BONUS_POINTS_SYSTEM.md)
Complete reference for the bonus points system including:
- All 6 bonus point types (Blind Hit, Chain Combo, Edge Mine, Endgame Mine, Safe Cell Bonus, Biggest Reveal)
- Calculation rules and examples
- Bonus statistics tracking
- Player name formatting in dialogs
- Database schema
- Implementation notes
- Testing checklist
**Recommended for**: Developers working on bonus system, AI assistants implementing or debugging bonus features, understanding game scoring mechanics.
---
## Quick Reference
### Bonus Points at a Glance
| Bonus Type | Points | Condition |
|-----------|--------|-----------|
| Blind Hit | +2 | Mine with no revealed numbered neighbors |
| Edge Mine | +1 | Mine on board boundary (row/col 0 or 15) |
| Endgame Mine | +3 | Mine clicked when ≤10 mines remain |
| Safe Cell | +0.5 each | ≥2 safe cells revealed (min requirement) |
| Chain Combo | Tracked | Consecutive mine clicks (no safe clicks) |
| Biggest Reveal | Tracked | Largest number of safe cells revealed |
### Key Rules
- Safe cell bonus only awarded for ≥2 cells minimum
- Chain counter resets on any safe cell click
- Endgame threshold: 51 - (redPoints + bluePoints) ≤ 10
- Bonus stats are per-player and persist in database
---
## Files Using This Information
- Backend: `/src/Util/TopicManager.php`
- Frontend: `/assets/js/mine-seeker/contexts/GameProvider.jsx`
- UI: `/assets/js/mine-seeker/components/BonusStatsDialog.jsx`
- Constants: `/assets/js/mine-seeker/utils/constants.jsx`

View File

@@ -0,0 +1,168 @@
# Mine-Seeker Bonus Points System
## Overview
The Mine-Seeker game includes a bonus points system that rewards skilled play. Bonus points are tracked separately from the main score and displayed in the "Bonus Statistics" dialog.
## Bonus Point Types
### 1. Blind Hit (+2 points)
**When**: Click a mine with no revealed numbered neighbors around it.
**Example**: Mine surrounded by unrevealed cells = +2 points
---
### 2. Chain Combo
**When**: Click consecutive mines without clicking any safe cell in between.
**Tracked as**:
- `chainCurrent`: Current streak (resets when you click a safe cell)
- `chainBest`: Longest streak achieved
**Example**: Mine → Mine → Mine = chainBest becomes 3
---
### 3. Edge Mine (+1 point)
**When**: Click a mine on the board boundary (row 0, row 15, col 0, or col 15).
**Example**: Click a mine on the edge = +1 point
---
### 4. Endgame Mine (+3 points)
**When**: Click a mine when 10 or fewer mines remain on the board.
**Calculation**: `51 total mines - (red_points + blue_points) = mines_remaining`
**Example**: When 8 mines remain, click one = +3 points
---
### 5. Safe Cell Bonus (+0.5 points per cell)
**When**: Click a safe cell and reveal 2 or more cells.
**Important**: Minimum 2 cells required. Single cell reveals = 0 points.
**Examples**:
- Reveal 1 safe cell = 0 points
- Reveal 2 safe cells = 1.0 points
- Reveal 11 safe cells = 5.5 points
---
### 6. Biggest Reveal (Tracking stat)
**What**: Tracks the largest number of safe cells revealed in one click.
**Example**: Largest reveal in a game = 15 cells shown in stats
---
## Bonus Statistics Display
### Dialog Shows
- Both players' bonus statistics side-by-side
- Each stat with label, description, and value
- Total bonus points per player
### Player Name Rules
- `anon_*` usernames → displays as "Anonymous"
- Names longer than 10 chars → truncated to 7 chars + "..." (example: `VeryLongName``VeryLon...`)
---
## Tracked Statistics
```javascript
{
blindHits: 0, // Blind hit mines clicked
chainBest: 0, // Longest mine streak
chainCurrent: 0, // Current active streak
lastMineHits: 0, // Endgame mines clicked
edgeMines: 0, // Edge mine clicks
biggestReveal: 0 // Largest safe cell reveal
}
```
---
## Database
- `red_bonus_points` (FLOAT) — Red player's total bonus points
- `blue_bonus_points` (FLOAT) — Blue player's total bonus points
- `red_bonus_stats` (JSON) — Red player's tracked stats
- `blue_bonus_stats` (JSON) — Blue player's tracked stats
---
## 📋 Documentation Maintenance
**IMPORTANT**: This documentation must be updated whenever:
- New bonus types are added
- Point values change
- Bonus calculation logic changes
- New stats are tracked
- Bonus display rules change
**Update these files**:
1. This file (`BONUS_POINTS_SYSTEM.md`) — Update descriptions and examples
2. Code comments in `/src/Util/TopicManager.php` — Explain calculation logic
3. `/docs/README.md` — Update Quick Reference table if values change
**Keep documentation**:
- ✅ Simple and clear
- ✅ With real code examples
- ✅ Synchronized with actual code behavior
- ✅ Updated before/after feature changes
---
## Implementation Files
- Backend: `/src/Util/TopicManager.php` — Bonus calculation logic
- Frontend: `/assets/js/mine-seeker/contexts/GameProvider.jsx` — State sync
- UI: `/assets/js/mine-seeker/components/BonusStatsDialog.jsx` — Display dialog
- Constants: `/assets/js/mine-seeker/utils/constants.jsx` — Labels and defaults
---
## Battle Report Display Components
**IMPORTANT**: The Bonus Statistics display appears in **two places** that must be kept in sync:
### 1. Public Battle Share Page
**File**: `/templates/Game/battle_share.html.twig`
- Displays via `bshare-bonus` CSS classes
- Backend data passed from `ProfileController::battleShare()`
- Shows bonus stats as HTML table format
### 2. Profile Dialog (BattleDialog component)
**File**: `/assets/js/components/BattleDialog.jsx`
- React component using Material-UI Dialog
- Displays inside the match details modal on profile page
- Shows bonus stats using `StatRow` components in side-by-side boxes
### Synchronization Requirements
When making changes to the bonus statistics display, update **BOTH** files:
1. **Update logic/data** → Edit both template and component
2. **Change stat names** → Update both BONUS_LABELS and both display files
3. **Modify formatting** → Keep visual consistency between both displays
4. **Add new stats** → Add to both the `.twig` template AND the `.jsx` component
**Checklist for changes**:
- [ ] Update `/src/Util/TopicManager.php` if bonus calculation changes
- [ ] Update `/templates/Game/battle_share.html.twig` for public display
- [ ] Update `/assets/js/components/BattleDialog.jsx` for profile dialog
- [ ] Update `/assets/js/mine-seeker/utils/constants.jsx` if adding new stats
- [ ] Test both displays show identical data
---
## Quick Checklist for Changes
- [ ] Code changes implemented
- [ ] This documentation updated
- [ ] `/docs/README.md` Quick Reference table updated
- [ ] Code comments added/updated
- [ ] Examples updated to match new behavior
- [ ] Both battle report displays tested

View File

@@ -21,31 +21,31 @@
"@fontsource/rajdhani": "^5.2.7", "@fontsource/rajdhani": "^5.2.7",
"@fortawesome/fontawesome-free": "^7.2.0", "@fortawesome/fontawesome-free": "^7.2.0",
"@mui/material": "^9.0.0", "@mui/material": "^9.0.0",
"@mui/x-charts": "^9.0.1", "@mui/x-charts": "^9.0.2",
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.99.2",
"howler": "^2.1.2", "howler": "^2.2.4",
"lodash": "^4.18.1", "lodash": "^4.18.1",
"prop-types": "^15.7.2", "prop-types": "^15.8.1",
"react": "^19.0.0", "react": "^19.2.5",
"react-dom": "^19.0.0" "react-dom": "^19.2.5"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.5", "@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^9.0.0", "@eslint/js": "10.0.1",
"@stylistic/eslint-plugin": "^4.0.0", "@stylistic/eslint-plugin": "5.10.0",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.0.0", "eslint": "10.2.1",
"eslint-plugin-react": "^7.0.0", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "7.1.1",
"globals": "^15.0.0", "globals": "17.5.0",
"sass": "^1.77.0", "sass": "^1.99.0",
"vite": "^8.0.8", "vite": "^8.0.8",
"vite-plugin-symfony": "^8.2.4" "vite-plugin-symfony": "^8.2.4"
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"watch": "vite build --watch", "watch": "vite build --watch",
"build": "vite build && cp -r node_modules/@fortawesome/fontawesome-free/webfonts public/build/webfonts", "build": "vite build",
"lint": "eslint assets/js/" "lint": "eslint assets/js/"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@@ -12,16 +12,15 @@ namespace App\Controller;
use App\Entity\ContactMessage; use App\Entity\ContactMessage;
use App\Form\ContactFormType; use App\Form\ContactFormType;
use App\Service\Email\SendContactMailService;
use App\Service\MercureJwtService;
use App\Service\ResolveUserNamesService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -33,7 +32,7 @@ use Symfony\Component\Routing\Attribute\Route;
* @category Class * @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org * @link www.splendidbear.org
* @since 2026. 04. 09. * @since 2019. 10. 27.
*/ */
#[AsController] #[AsController]
class GameController extends AbstractController class GameController extends AbstractController
@@ -43,11 +42,9 @@ class GameController extends AbstractController
private readonly string $env, private readonly string $env,
#[Autowire(env: 'MERCURE_PUBLIC_URL')] #[Autowire(env: 'MERCURE_PUBLIC_URL')]
private readonly string $mercurePublicUrl, private readonly string $mercurePublicUrl,
#[Autowire(env: 'MERCURE_SUBSCRIBER_JWT')] private readonly MercureJwtService $mercureJwtService,
private readonly string $mercureSubscriberJwt, private readonly ResolveUserNamesService $opponentNameService,
#[Autowire(env: 'APP_CONTACT_MAIL_ADDRESS')] private readonly SendContactMailService $contactMailService,
private readonly string $appContactMailAddress,
private readonly LoggerInterface $logger,
) { ) {
} }
@@ -59,12 +56,15 @@ class GameController extends AbstractController
#[Route('/play', name: 'MineSeekerBundle_gamePlay')] #[Route('/play', name: 'MineSeekerBundle_gamePlay')]
#[Route('/play/{gameAssoc}', name: 'MineSeekerBundle_gamePlayWId')] #[Route('/play/{gameAssoc}', name: 'MineSeekerBundle_gamePlayWId')]
public function play(): Response public function play(?string $gameAssoc = null): Response
{ {
return $this->render('Game/play.html.twig', [ return $this->render('Game/play.html.twig', [
'env' => $this->env, 'env' => $this->env,
'mercure_hub_url' => $this->mercurePublicUrl, 'mercure_hub_url' => $this->mercurePublicUrl,
'mercure_subscriber_jwt' => $this->mercureSubscriberJwt, 'mercure_subscriber_jwt' => $this->mercureJwtService->mintSubscriberToken(
$gameAssoc ?? '', $this->opponentNameService->resolveUserName(),
),
'opponent_name' => $this->opponentNameService->opponentName($gameAssoc),
]); ]);
} }
@@ -91,10 +91,12 @@ class GameController extends AbstractController
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$contactMessage->setIpAddress($request->getClientIp()); $contactMessage->ipAddress = $request->getClientIp();
$em->persist($contactMessage); $em->persist($contactMessage);
$em->flush(); $em->flush();
$this->sendMail($mailer, $contactMessage);
$this->contactMailService->send($contactMessage);
$this->addFlash('contact_success', 'Thank you for your message! We will get back to you soon.'); $this->addFlash('contact_success', 'Thank you for your message! We will get back to you soon.');
return $this->redirectToRoute('MineSeekerBundle_contact'); return $this->redirectToRoute('MineSeekerBundle_contact');
@@ -111,30 +113,9 @@ class GameController extends AbstractController
return $this->render('Official/landing.html.twig'); return $this->render('Official/landing.html.twig');
} }
public function sendMail(MailerInterface $mailer, ContactMessage $contactMessage): void #[Route('/rules', name: 'MineSeekerBundle_rules')]
public function rules(): Response
{ {
try { return $this->render('Official/rules.html.twig');
$mailer->send(
new TemplatedEmail()
->from('noreply@mineseeker.hu')
->to($this->appContactMailAddress)
->replyTo($contactMessage->getEmail())
->subject('New Contact Message from ' . $contactMessage->getName())
->htmlTemplate('emails/contact_notification.html.twig')
->context(['message' => $contactMessage])
);
} catch (\Exception $e) {
$this->logger->error('Failed to send contact notification email: ' . $e->getMessage(), [
'exception' => $e,
'message' => $contactMessage,
]);
throw new RuntimeException('Failed to send contact notification email: ' . $e->getMessage());
} catch (TransportExceptionInterface $e) {
$this->logger->error('Failed to send contact notification email: ' . $e->getMessage(), [
'exception' => $e,
'message' => $contactMessage,
]);
throw new RuntimeException('Failed to send contact notification email: ' . $e->getMessage());
}
} }
} }

View File

@@ -2,7 +2,7 @@
/** /**
* This file is part of the SplendidBear Websites' projects. * This file is part of the SplendidBear Websites' projects.
* *
* Copyright (c) 2019 @ www.splendidbear.org * Copyright (c) 2026 @ www.splendidbear.org
* *
* For the full copyright and license information, please view the LICENSE * For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code. * file that was distributed with this source code.
@@ -12,8 +12,11 @@ namespace App\Controller;
use App\Entity\PlayedGame; use App\Entity\PlayedGame;
use App\Repository\PlayedGameRepository; use App\Repository\PlayedGameRepository;
use App\Service\ResolveUserNamesService;
use App\Util\RpcManager; use App\Util\RpcManager;
use App\Util\TopicManager; use App\Util\TopicManager;
use DateTimeInterface;
use Exception;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -41,30 +44,41 @@ class MercureController extends AbstractController
public function __construct( public function __construct(
private readonly TopicManager $topicManager, private readonly TopicManager $topicManager,
private readonly RpcManager $rpcManager, private readonly RpcManager $rpcManager,
private readonly ResolveUserNamesService $userNamesService,
) { ) {
} }
#[Route('/api/game/start', name: 'MineSeekerBundle_api_game_start', methods: ['POST'])] #[Route('/api/game/start', name: 'MineSeekerBundle_api_game_start', methods: ['POST'])]
public function start(Request $request): JsonResponse public function start(Request $request): JsonResponse
{ {
try {
$data = $request->toArray(); $data = $request->toArray();
$result = $this->rpcManager->saveGrid($data['gameAssoc']); $result = $this->rpcManager->saveGrid($data['gameAssoc']);
return $this->json(['success' => $result]); return $this->json(['success' => $result]);
} catch (Exception $e) {
return $this->json(
['success' => false, 'error' => 'Failed to start game: ' . $e->getMessage()],
Response::HTTP_INTERNAL_SERVER_ERROR
);
}
} }
#[Route('/api/game/connect/{gameAssoc}', name: 'MineSeekerBundle_api_game_connect', methods: ['GET'])] #[Route('/api/game/connect/{gameAssoc}', name: 'MineSeekerBundle_api_game_connect', methods: ['GET'])]
public function connect(string $gameAssoc): Response public function connect(string $gameAssoc): Response
{ {
try {
$payload = $this->rpcManager->getConnectInformation($gameAssoc); $payload = $this->rpcManager->getConnectInformation($gameAssoc);
return new Response($payload, Response::HTTP_OK, ['Content-Type' => 'text/plain']); return new Response($payload, Response::HTTP_OK, ['Content-Type' => 'text/plain']);
} catch (Exception $e) {
return new Response('', Response::HTTP_INTERNAL_SERVER_ERROR);
}
} }
#[Route('/api/game/join/{gameAssoc}', name: 'MineSeekerBundle_api_game_join', methods: ['POST'])] #[Route('/api/game/join/{gameAssoc}', name: 'MineSeekerBundle_api_game_join', methods: ['POST'])]
public function join(string $gameAssoc, Request $request): JsonResponse public function join(string $gameAssoc): JsonResponse
{ {
$this->topicManager->subscribe($gameAssoc, $this->resolveUserName($request), $this->getUser()); $this->topicManager->subscribe($gameAssoc, $this->userNamesService->resolveUserName());
return $this->json(['success' => true]); return $this->json(['success' => true]);
} }
@@ -72,15 +86,15 @@ class MercureController extends AbstractController
#[Route('/api/game/step/{gameAssoc}', name: 'MineSeekerBundle_api_game_step', methods: ['POST'])] #[Route('/api/game/step/{gameAssoc}', name: 'MineSeekerBundle_api_game_step', methods: ['POST'])]
public function step(string $gameAssoc, Request $request): JsonResponse public function step(string $gameAssoc, Request $request): JsonResponse
{ {
$result = $this->topicManager->publish($gameAssoc, $this->resolveUserName($request), $request->toArray()); $result = $this->topicManager->publish($gameAssoc, $this->userNamesService->resolveUserName(), $request->toArray());
return $this->json($result); return $this->json($result);
} }
#[Route('/api/game/leave/{gameAssoc}', name: 'MineSeekerBundle_api_game_leave', methods: ['POST'])] #[Route('/api/game/leave/{gameAssoc}', name: 'MineSeekerBundle_api_game_leave', methods: ['POST'])]
public function leave(string $gameAssoc, Request $request): JsonResponse public function leave(string $gameAssoc): JsonResponse
{ {
$this->topicManager->unSubscribe($gameAssoc, $this->resolveUserName($request)); $this->topicManager->unSubscribe($gameAssoc, $this->userNamesService->resolveUserName());
return $this->json(['success' => true]); return $this->json(['success' => true]);
} }
@@ -95,7 +109,11 @@ class MercureController extends AbstractController
return $this->json(['success' => true]); return $this->json(['success' => true]);
} }
#[Route('/api/game/challenge/respond/{challengerGameAssoc}', name: 'MineSeekerBundle_api_game_challenge_respond', methods: ['POST'])] #[Route(
'/api/game/challenge/respond/{challengerGameAssoc}',
name: 'MineSeekerBundle_api_game_challenge_respond',
methods: ['POST'],
)]
public function challengeRespond(string $challengerGameAssoc, Request $request): JsonResponse public function challengeRespond(string $challengerGameAssoc, Request $request): JsonResponse
{ {
$data = $request->toArray(); $data = $request->toArray();
@@ -106,6 +124,21 @@ class MercureController extends AbstractController
return $this->json(['success' => true]); return $this->json(['success' => true]);
} }
#[Route('/api/game/heartbeat/{gameAssoc}', name: 'MineSeekerBundle_api_game_heartbeat', methods: ['POST'])]
public function heartbeat(string $gameAssoc, Request $request): JsonResponse
{
$data = $request->toArray();
$color = $data['color'] ?? '';
if ('red' !== $color && 'blue' !== $color) {
return $this->json(['success' => false], Response::HTTP_BAD_REQUEST);
}
$this->topicManager->publishHeartbeat($gameAssoc, $color);
return $this->json(['success' => true]);
}
#[Route('/api/game/waiting', name: 'MineSeekerBundle_api_game_waiting', methods: ['GET'])] #[Route('/api/game/waiting', name: 'MineSeekerBundle_api_game_waiting', methods: ['GET'])]
public function waiting(PlayedGameRepository $repo): JsonResponse public function waiting(PlayedGameRepository $repo): JsonResponse
{ {
@@ -113,35 +146,19 @@ class MercureController extends AbstractController
$result = array_map(static function (PlayedGame $g): array { $result = array_map(static function (PlayedGame $g): array {
$name = match (true) { $name = match (true) {
null !== $g->getRed() => $g->getRed()->getUsername(), null !== $g->red => $g->red->getUsername(),
null !== $g->getRedAnon() => $g->getRedAnon()->getUserName(), null !== $g->redAnon => $g->redAnon->userName,
null !== $g->getBlue() => $g->getBlue()->getUsername(), null !== $g->blue => $g->blue->getUsername(),
default => $g->getBlueAnon()?->getUserName() ?? 'Unknown', default => $g->blueAnon?->userName ?? 'Unknown',
}; };
return [ return [
'gameAssoc' => $g->getGameAssoc(), 'gameAssoc' => $g->gameAssoc,
'name' => $name, 'name' => $name,
'since' => $g->getCreated()?->format(\DateTimeInterface::ATOM) ?? '', 'since' => $g->created?->format(DateTimeInterface::ATOM) ?? '',
]; ];
}, $games); }, $games);
return $this->json($result); return $this->json($result);
} }
private function resolveUserName(Request $request): string
{
$user = $this->getUser();
if (null !== $user) {
return $user->getUserIdentifier();
}
$sessionId = $request->getSession()->getId();
if (empty($sessionId)) {
$sessionId = bin2hex(random_bytes(16));
}
return 'anon_' . $sessionId;
}
} }

View File

@@ -10,12 +10,19 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\PlayedGame; use App\Dto\BattleShareDto;
use App\Dto\ProfileChartDataFactory;
use App\Dto\ProfileGameDto;
use App\Dto\ProfileGameDtoFactory;
use App\Dto\ProfileStatsDto;
use App\Dto\ProfileViewDto;
use App\Entity\User; use App\Entity\User;
use App\Entity\RecentBattle;
use App\Repository\PlayedGameRepository; use App\Repository\PlayedGameRepository;
use App\Repository\RecentBattleRepository;
use App\Repository\UserStatsRepository;
use App\Service\BattleCardGenerator; use App\Service\BattleCardGenerator;
use App\Service\WebAuthnService; use App\Service\WebAuthnService;
use DateTime;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use League\Flysystem\FilesystemException; use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator; use League\Flysystem\FilesystemOperator;
@@ -50,131 +57,42 @@ use function count;
class ProfileController extends AbstractController class ProfileController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly PlayedGameRepository $repo,
private readonly WebAuthnService $webAuthnService,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
private readonly PlayedGameRepository $repo,
private readonly UserStatsRepository $userStatsRepo,
private readonly RecentBattleRepository $recentBattleRepo,
private readonly WebAuthnService $webAuthnService,
private readonly ProfileGameDtoFactory $profileGameDtoFactory,
private readonly ProfileChartDataFactory $profileChartDataFactory,
) { ) {
} }
#[Route('/profile', name: 'MineSeekerBundle_profile')] #[Route('/profile', name: 'MineSeekerBundle_profile')]
public function index(CacheManager $cacheManager): Response public function index(): Response
{ {
/** @var User $user */ /** @var User $user */
$user = $this->getUser(); $user = $this->getUser();
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
$total = $this->repo->countFinishedForUser($user); $userId = $user->id;
$wins = $this->repo->countWinsForUser($user); $stats = ProfileStatsDto::fromUserStats($this->userStatsRepo->findByUserId($userId));
$losses = $this->repo->countLossesForUser($user); $recent = $this->recentBattleRepo->findRecentForUser($userId, 30);
$draws = $this->repo->countDrawsForUser($user);
/** Build monthly buckets for the last 6 months */ $gamesData = array_map(
$monthlyData = []; fn(RecentBattle $battle): ProfileGameDto => $this->profileGameDtoFactory->createFromRecentBattle($battle),
for ($i = 5; $i >= 0; $i--) { $recent,
$dt = new DateTime("first day of -$i months midnight"); );
$key = $dt->format('Y-m');
$monthlyData[$key] = ['label' => $dt->format('M'), 'wins' => 0, 'losses' => 0, 'draws' => 0];
}
$since = new DateTime('first day of -5 months midnight'); $chartData = $this->profileChartDataFactory->buildChartData($user, $userId, $stats);
$recentGames = $this->repo->findFinishedForUserSince($user, $since);
$userId = $user->getId();
foreach ($recentGames as $game) { $view = new ProfileViewDto(
if (!$game->getUpdated()) { stats: $stats,
continue; recent: $recent,
} gamesData: $gamesData,
chartData: $chartData,
);
$month = $game->getUpdated()->format('Y-m'); return $this->render('Security/profile.html.twig', $view->toTemplateContext());
if (!isset($monthlyData[$month])) {
continue;
}
$isRed = $game->getRed()?->getId() === $userId;
$myPts = $isRed ? $game->getRedPoints() : $game->getBluePoints();
$oppPts = $isRed ? $game->getBluePoints() : $game->getRedPoints();
$resign = $game->getResign();
$myColor = $isRed ? 'red' : 'blue';
$oppColor = $isRed ? 'blue' : 'red';
$result = 'draws';
if ($resign === $myColor) {
$result = 'losses';
} elseif ($resign === $oppColor) {
$result = 'wins';
} elseif ($myPts !== null && $oppPts !== null) {
if ($myPts > $oppPts) $result = 'wins';
elseif ($myPts < $oppPts) $result = 'losses';
}
$monthlyData[$month][$result]++;
}
$months = array_column(array_values($monthlyData), 'label');
return $this->render('Security/profile.html.twig', [
'stats' => [
'total' => $total,
'wins' => $wins,
'losses' => $losses,
'draws' => $draws,
'bombs' => $this->repo->countBombsForUser($user),
'winRate' => $total > 0 ? (int)round($wins / $total * 100) : 0,
'avgScore' => $this->repo->findAvgScoreForUser($user),
'bestScore' => $this->repo->findBestScoreForUser($user),
],
'recent' => ($recent = $this->repo->findRecentFinishedForUser($user)),
'gamesData' => array_map(function (PlayedGame $game) use ($userId, $cacheManager): array {
$isRed = $game->getRed()?->getId() === $userId;
$resign = $game->getResign();
$myColor = $isRed ? 'red' : 'blue';
$oppColor = $isRed ? 'blue' : 'red';
$myPts = $isRed ? $game->getRedPoints() : $game->getBluePoints();
$oppPts = $isRed ? $game->getBluePoints() : $game->getRedPoints();
$result = 'draw';
if ($resign === $myColor) $result = 'loss';
elseif ($resign === $oppColor) $result = 'win';
elseif ($myPts !== null && $oppPts !== null) {
if ($myPts > $oppPts) $result = 'win';
elseif ($myPts < $oppPts) $result = 'loss';
}
$redAvatarPath = $game->getRed()?->getAvatarPath();
$blueAvatarPath = $game->getBlue()?->getAvatarPath();
return [
'id' => $game->getId(),
'uuid' => $game->getUuid()?->toRfc4122(),
'redName' =>
$game->getRed()?->getUsername() ?? $game->getRedAnon()?->getUserName() ?? 'Guest',
'blueName' =>
$game->getBlue()?->getUsername() ?? $game->getBlueAnon()?->getUserName() ?? 'Guest',
'redAvatar' => $redAvatarPath ? $cacheManager->generateUrl($redAvatarPath, 'avatar_thumb') : null,
'blueAvatar' => $blueAvatarPath ? $cacheManager->generateUrl($blueAvatarPath, 'avatar_thumb') : null,
'redPoints' => $game->getRedPoints(),
'bluePoints' => $game->getBluePoints(),
'redExplodedBomb' => $game->getRedExplodedBomb(),
'blueExplodedBomb' => $game->getBlueExplodedBomb(),
'resign' => $resign,
'created' => $game->getCreated()?->format('Y-m-d H:i'),
'date' => $game->getUpdated()?->format('Y-m-d H:i'),
'isRed' => $isRed,
'result' => $result,
'myPoints' => $myPts,
'oppPoints' => $oppPts,
];
}, $recent),
'chartData' => [
'months' => $months,
'wins' => array_column(array_values($monthlyData), 'wins'),
'losses' => array_column(array_values($monthlyData), 'losses'),
'draws' => array_column(array_values($monthlyData), 'draws'),
'pieWins' => $wins,
'pieLosses' => $losses,
'pieDraws' => $draws,
],
]);
} }
#[Route( #[Route(
@@ -185,47 +103,13 @@ class ProfileController extends AbstractController
)] )]
public function battleShare(Uuid $uuid): Response public function battleShare(Uuid $uuid): Response
{ {
$game = $this->repo->findOneBy(['uuid' => $uuid]); $game = $this->repo->findOneByUuid($uuid);
if (!$game) { if (!$game) {
throw $this->createNotFoundException('Battle not found.'); throw $this->createNotFoundException('Battle not found.');
} }
$redName = $game->getRed()?->getUsername() ?? ($game->getRedAnon() !== null ? 'Anonymous' : 'Guest'); return $this->render('Game/battle_share.html.twig', BattleShareDto::fromPlayedGame($game)->toTemplateContext());
$blueName = $game->getBlue()?->getUsername() ?? ($game->getBlueAnon() !== null ? 'Anonymous' : 'Guest');
$redPts = $game->getRedPoints();
$bluePts = $game->getBluePoints();
$resign = $game->getResign();
$redAvatar = $game->getRed()?->getAvatarPath();
$blueAvatar = $game->getBlue()?->getAvatarPath();
if ($resign === 'red') {
$summary = "$redName resigned — $blueName wins";
} elseif ($resign === 'blue') {
$summary = "$blueName resigned — $redName wins";
} elseif ($redPts !== null && $bluePts !== null) {
if ($redPts > $bluePts) {
$summary = "$redName defeated $blueName ($redPts $bluePts)";
} elseif ($bluePts > $redPts) {
$summary = "$blueName defeated $redName ($bluePts $redPts)";
} else {
$summary = "$redName and $blueName drew ($redPts $bluePts)";
}
} else {
$summary = "$redName vs $blueName";
}
return $this->render('Game/battle_share.html.twig', [
'game' => $game,
'redName' => $redName,
'blueName' => $blueName,
'redPts' => $redPts,
'bluePts' => $bluePts,
'resign' => $resign,
'redAvatar' => $redAvatar,
'blueAvatar' => $blueAvatar,
'ogTitle' => "MineSeeker · $summary",
'ogDesc' => "Watch the battle replay: $summary — played on MineSeeker, the multiplayer minesweeper.",
]);
} }
#[Route( #[Route(
@@ -264,6 +148,7 @@ class ProfileController extends AbstractController
$user = $this->getUser(); $user = $this->getUser();
$file = $request->files->get('avatar'); $file = $request->files->get('avatar');
if (!$file instanceof UploadedFile) { if (!$file instanceof UploadedFile) {
return $this->json(['error' => 'No file uploaded.'], 400); return $this->json(['error' => 'No file uploaded.'], 400);
} }
@@ -279,7 +164,7 @@ class ProfileController extends AbstractController
$ext = $file->guessExtension() ?? 'jpg'; $ext = $file->guessExtension() ?? 'jpg';
$newPath = sprintf('avatar/%s.%s', Uuid::v4()->toRfc4122(), $ext); $newPath = sprintf('avatar/%s.%s', Uuid::v4()->toRfc4122(), $ext);
$oldPath = $user->getAvatarPath(); $oldPath = $user->avatarPath;
/** Remove old file and any cached thumbnails */ /** Remove old file and any cached thumbnails */
if ($oldPath) { if ($oldPath) {
@@ -301,7 +186,7 @@ class ProfileController extends AbstractController
} }
fclose($stream); fclose($stream);
$user->setAvatarPath($newPath); $user->avatarPath = $newPath;
$em->flush(); $em->flush();
return $this->json([ return $this->json([
@@ -318,18 +203,18 @@ class ProfileController extends AbstractController
$credentials = $this->webAuthnService->getCredentialsForUser($user); $credentials = $this->webAuthnService->getCredentialsForUser($user);
$credentialsData = array_map(fn($cred) => [ $credentialsData = array_map(fn($cred) => [
'id' => $cred->getId(), 'id' => $cred->id,
'credentialName' => $cred->getCredentialName(), 'credentialName' => $cred->credentialName,
'createdAt' => $cred->getCreatedAt()?->format('Y-m-d H:i:s'), 'createdAt' => $cred->createdAt?->format('Y-m-d H:i:s'),
'lastUsedAt' => $cred->getLastUsedAt()?->format('Y-m-d H:i:s'), 'lastUsedAt' => $cred->lastUsedAt?->format('Y-m-d H:i:s'),
'isBackupEligible' => $cred->isBackupEligible(), 'isBackupEligible' => $cred->isBackupEligible,
'isBackupAuthenticated' => $cred->isBackupAuthenticated(), 'isBackupAuthenticated' => $cred->isBackupAuthenticated,
], $credentials); ], $credentials);
return $this->render('Security/profile_security.html.twig', [ return $this->render('Security/profile_security.html.twig', [
'credentials' => $credentialsData, 'credentials' => $credentialsData,
'isTotpEnabled' => $user->isTotpAuthenticationEnabled(), 'isTotpEnabled' => $user->isTotpAuthenticationEnabled(),
'backupCodesCount' => count($user->getBackupCodes()), 'backupCodesCount' => count($user->backupCodes),
]); ]);
} }
} }

View File

@@ -15,14 +15,17 @@ use App\Form\ForgotPasswordFormType;
use App\Form\RegistrationFormType; use App\Form\RegistrationFormType;
use App\Form\ResetPasswordFormType; use App\Form\ResetPasswordFormType;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use App\Service\Email\SendActivationEmailService;
use App\Service\Email\SendPasswordResetEmailService;
use App\Service\Email\SendUserActivationNotificationService;
use App\Service\Email\SendUserRegistrationNotificationService;
use DateTime; use DateTime;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail; use LogicException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
@@ -41,50 +44,58 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
#[AsController] #[AsController]
class SecurityController extends AbstractController class SecurityController extends AbstractController
{ {
public function __construct(
private readonly EntityManagerInterface $em,
private readonly RequestStack $requestStack,
private readonly UserRepository $userRepository,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly AuthenticationUtils $authenticationUtils,
private readonly SendActivationEmailService $activationEmail,
private readonly SendPasswordResetEmailService $passwordResetEmail,
private readonly SendUserActivationNotificationService $activationNotificationEmail,
private readonly SendUserRegistrationNotificationService $registrationNotificationEmail,
) {
}
#[Route('/login', name: 'MineSeekerBundle_login')] #[Route('/login', name: 'MineSeekerBundle_login')]
public function login(AuthenticationUtils $authenticationUtils): Response public function login(): Response
{ {
if ($this->getUser()) { if ($this->getUser()) {
return $this->redirectToRoute('MineSeekerBundle_homepage'); return $this->redirectToRoute('MineSeekerBundle_homepage');
} }
return $this->render('Security/login.html.twig', [ return $this->render('Security/login.html.twig', [
'last_username' => $authenticationUtils->getLastUsername(), 'last_username' => $this->authenticationUtils->getLastUsername(),
'error' => $authenticationUtils->getLastAuthenticationError(), 'error' => $this->authenticationUtils->getLastAuthenticationError(),
]); ]);
} }
#[Route('/logout', name: 'MineSeekerBundle_logout', methods: ['POST'])] #[Route('/logout', name: 'MineSeekerBundle_logout', methods: ['POST'])]
public function logout(): never public function logout(): never
{ {
throw new \LogicException('This action is intercepted by the security firewall.'); throw new LogicException('This action is intercepted by the security firewall.');
} }
#[Route('/register', name: 'MineSeekerBundle_register')] #[Route('/register', name: 'MineSeekerBundle_register')]
public function register( public function register(): Response
Request $request, {
UserPasswordHasherInterface $hasher,
EntityManagerInterface $em,
MailerInterface $mailer,
): Response {
if ($this->getUser()) { if ($this->getUser()) {
return $this->redirectToRoute('MineSeekerBundle_homepage'); return $this->redirectToRoute('MineSeekerBundle_homepage');
} }
$user = new User(); $user = new User();
$form = $this->createForm(RegistrationFormType::class, $user); $form = $this->createForm(RegistrationFormType::class, $user);
$form->handleRequest($request); $form->handleRequest($this->requestStack->getCurrentRequest());
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$token = bin2hex(random_bytes(32)); $token = bin2hex(random_bytes(32));
$user $user->isVerified = false;
->setIsVerified(false) $user->verificationToken = $token;
->setVerificationToken($token) $user->password = $this->passwordHasher->hashPassword($user, $form->get('plainPassword')->getData());
->setPassword($hasher->hashPassword($user, $form->get('plainPassword')->getData()));
$em->persist($user); $this->em->persist($user);
$em->flush(); $this->em->flush();
$activationUrl = $this->generateUrl( $activationUrl = $this->generateUrl(
'MineSeekerBundle_activate', 'MineSeekerBundle_activate',
@@ -92,24 +103,15 @@ class SecurityController extends AbstractController
UrlGeneratorInterface::ABSOLUTE_URL, UrlGeneratorInterface::ABSOLUTE_URL,
); );
// Ensure HTTPS scheme in production /** Ensure HTTPS scheme in production */
if ($this->getParameter('kernel.environment') === 'prod') { if ($this->getParameter('kernel.environment') === 'prod') {
$activationUrl = str_replace('http://', 'https://', $activationUrl); $activationUrl = str_replace('http://', 'https://', $activationUrl);
} }
$mailer->send( $this->activationEmail->send($user, $activationUrl);
new TemplatedEmail() $this->registrationNotificationEmail->send($user, new DateTime());
->from('noreply@mineseeker.hu')
->to($user->getEmail())
->subject('Activate your MineSeeker account')
->htmlTemplate('emails/activation.html.twig')
->context([
'username' => $user->getUsername(),
'activation_url' => $activationUrl,
])
);
$this->addFlash('verify_email', $user->getEmail()); $this->addFlash('verify_email', $user->email);
return $this->redirectToRoute('MineSeekerBundle_register'); return $this->redirectToRoute('MineSeekerBundle_register');
} }
@@ -118,29 +120,24 @@ class SecurityController extends AbstractController
} }
#[Route('/forgot-password', name: 'MineSeekerBundle_forgot_password')] #[Route('/forgot-password', name: 'MineSeekerBundle_forgot_password')]
public function forgotPassword( public function forgotPassword(): Response
Request $request, {
UserRepository $userRepository,
EntityManagerInterface $em,
MailerInterface $mailer,
): Response {
if ($this->getUser()) { if ($this->getUser()) {
return $this->redirectToRoute('MineSeekerBundle_homepage'); return $this->redirectToRoute('MineSeekerBundle_homepage');
} }
$form = $this->createForm(ForgotPasswordFormType::class); $form = $this->createForm(ForgotPasswordFormType::class);
$form->handleRequest($request); $form->handleRequest($this->requestStack->getCurrentRequest());
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$email = $form->get('email')->getData(); $email = $form->get('email')->getData();
$user = $userRepository->findOneByEmail($email); $user = $this->userRepository->findOneByEmail($email);
if ($user && $user->isVerified()) { if ($user && $user->isVerified) {
$token = bin2hex(random_bytes(32)); $token = bin2hex(random_bytes(32));
$user $user->resetToken = $token;
->setResetToken($token) $user->resetTokenExpiresAt = new DateTime('+1 hour');
->setResetTokenExpiresAt(new DateTime('+1 hour')); $this->em->flush();
$em->flush();
$resetUrl = $this->generateUrl( $resetUrl = $this->generateUrl(
'MineSeekerBundle_reset_password', 'MineSeekerBundle_reset_password',
@@ -148,25 +145,14 @@ class SecurityController extends AbstractController
UrlGeneratorInterface::ABSOLUTE_URL, UrlGeneratorInterface::ABSOLUTE_URL,
); );
// Ensure HTTPS scheme in production /** Ensure HTTPS scheme in production */
if ($this->getParameter('kernel.environment') === 'prod') { if ($this->getParameter('kernel.environment') === 'prod') {
$resetUrl = str_replace('http://', 'https://', $resetUrl); $resetUrl = str_replace('http://', 'https://', $resetUrl);
} }
$mailer->send( $this->passwordResetEmail->send($email, $user->getUsername(), $resetUrl);
new TemplatedEmail()
->from('noreply@mineseeker.hu')
->to($email)
->subject('Reset your MineSeeker password')
->htmlTemplate('emails/reset_password.html.twig')
->context([
'username' => $user->getUsername(),
'reset_url' => $resetUrl,
])
);
} }
// Always show the same flash to prevent email enumeration
$this->addFlash('reset_sent', $email); $this->addFlash('reset_sent', $email);
return $this->redirectToRoute('MineSeekerBundle_forgot_password'); return $this->redirectToRoute('MineSeekerBundle_forgot_password');
@@ -176,29 +162,24 @@ class SecurityController extends AbstractController
} }
#[Route('/reset-password/{token}', name: 'MineSeekerBundle_reset_password')] #[Route('/reset-password/{token}', name: 'MineSeekerBundle_reset_password')]
public function resetPassword( public function resetPassword(string $token): Response
string $token, {
Request $request, $user = $this->userRepository->findOneByResetToken($token);
UserRepository $userRepository,
EntityManagerInterface $em,
UserPasswordHasherInterface $hasher,
): Response {
$user = $userRepository->findOneByResetToken($token);
if (!$user || $user->getResetTokenExpiresAt() < new DateTime()) { if (!$user || $user->resetTokenExpiresAt < new DateTime()) {
$this->addFlash('error', 'This password reset link is invalid or has expired.'); $this->addFlash('error', 'This password reset link is invalid or has expired.');
return $this->redirectToRoute('MineSeekerBundle_forgot_password'); return $this->redirectToRoute('MineSeekerBundle_forgot_password');
} }
$form = $this->createForm(ResetPasswordFormType::class); $form = $this->createForm(ResetPasswordFormType::class);
$form->handleRequest($request); $form->handleRequest($this->requestStack->getCurrentRequest());
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$user $user->password = $this->passwordHasher->hashPassword($user, $form->get('plainPassword')->getData());
->setPassword($hasher->hashPassword($user, $form->get('plainPassword')->getData())) $user->resetToken = null;
->setResetToken(null) $user->resetTokenExpiresAt = null;
->setResetTokenExpiresAt(null);
$em->flush(); $this->em->flush();
$this->addFlash('success', 'Your password has been reset. You can now sign in.'); $this->addFlash('success', 'Your password has been reset. You can now sign in.');
@@ -209,17 +190,20 @@ class SecurityController extends AbstractController
} }
#[Route('/activate/{token}', name: 'MineSeekerBundle_activate')] #[Route('/activate/{token}', name: 'MineSeekerBundle_activate')]
public function activate(string $token, EntityManagerInterface $em): Response public function activate(string $token): Response
{ {
$user = $em->getRepository(User::class)->findOneBy(['verificationToken' => $token]); $user = $this->em->getRepository(User::class)->findOneBy(['verificationToken' => $token]);
if (!$user) { if (!$user) {
$this->addFlash('error', 'This activation link is invalid or has already been used.'); $this->addFlash('error', 'This activation link is invalid or has already been used.');
return $this->redirectToRoute('MineSeekerBundle_login'); return $this->redirectToRoute('MineSeekerBundle_login');
} }
$user->setIsVerified(true)->setVerificationToken(null); $user->isVerified = true;
$em->flush(); $user->verificationToken = null;
$this->em->flush();
$this->activationNotificationEmail->send($user, new DateTime());
$this->addFlash('success', 'Your account is now active. Welcome, ' . $user->getUsername() . '!'); $this->addFlash('success', 'Your account is now active. Welcome, ' . $user->getUsername() . '!');

View File

@@ -156,16 +156,16 @@ class TwoFactorController extends AbstractController
$code = $request->request->getString('_auth_code'); $code = $request->request->getString('_auth_code');
// Temporarily set the pending secret to verify the code // Temporarily set the pending secret to verify the code
$user->setTotpSecret($pendingSecret); $user->totpSecret = $pendingSecret;
if (!$this->totpAuthenticator->checkCode($user, $code)) { if (!$this->totpAuthenticator->checkCode($user, $code)) {
$user->setTotpSecret(null); $user->totpSecret = null;
$this->addFlash('error', 'Invalid verification code. Please try again.'); $this->addFlash('error', 'Invalid verification code. Please try again.');
return $this->redirectToRoute('MineSeekerBundle_2fa_setup'); return $this->redirectToRoute('MineSeekerBundle_2fa_setup');
} }
$backupCodes = $this->generateBackupCodes(); $backupCodes = $this->generateBackupCodes();
$user->setBackupCodes($backupCodes); $user->backupCodes = $backupCodes;
$this->em->flush(); $this->em->flush();
$request->getSession()->remove('totp_pending_secret'); $request->getSession()->remove('totp_pending_secret');
@@ -187,8 +187,8 @@ class TwoFactorController extends AbstractController
/** @var User $user */ /** @var User $user */
$user = $this->getUser(); $user = $this->getUser();
$user->setTotpSecret(null); $user->totpSecret = null;
$user->setBackupCodes([]); $user->backupCodes = [];
$this->em->flush(); $this->em->flush();
$this->addFlash('success', 'Two-factor authentication has been disabled.'); $this->addFlash('success', 'Two-factor authentication has been disabled.');
@@ -196,7 +196,11 @@ class TwoFactorController extends AbstractController
} }
/** Regenerate backup codes for the current user. */ /** Regenerate backup codes for the current user. */
#[Route('/profile/security/2fa/backup-codes/regenerate', name: 'MineSeekerBundle_2fa_backup_regenerate', methods: ['POST'])] #[Route(
'/profile/security/2fa/backup-codes/regenerate',
name: 'MineSeekerBundle_2fa_backup_regenerate',
methods: ['POST'],
)]
public function regenerateBackupCodes(Request $request): Response public function regenerateBackupCodes(Request $request): Response
{ {
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
@@ -213,7 +217,7 @@ class TwoFactorController extends AbstractController
} }
$backupCodes = $this->generateBackupCodes(); $backupCodes = $this->generateBackupCodes();
$user->setBackupCodes($backupCodes); $user->backupCodes = $backupCodes;
$this->em->flush(); $this->em->flush();
$this->addFlash('2fa_backup_codes', $backupCodes); $this->addFlash('2fa_backup_codes', $backupCodes);

View File

@@ -13,6 +13,7 @@ namespace App\Controller;
use App\Entity\User; use App\Entity\User;
use App\Security\PasskeyToken; use App\Security\PasskeyToken;
use App\Service\WebAuthnService; use App\Service\WebAuthnService;
use Exception;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -25,6 +26,7 @@ use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialParameters; use Webauthn\PublicKeyCredentialParameters;
use Webauthn\PublicKeyCredentialRpEntity; use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\PublicKeyCredentialUserEntity; use Webauthn\PublicKeyCredentialUserEntity;
use function random_bytes;
/** /**
* Class WebAuthnController * Class WebAuthnController
@@ -64,7 +66,7 @@ class WebAuthnController extends AbstractController
$userEntity = new PublicKeyCredentialUserEntity( $userEntity = new PublicKeyCredentialUserEntity(
$user->getUserIdentifier(), $user->getUserIdentifier(),
(string)$user->getId(), (string)$user->id,
$user->getUsername(), $user->getUsername(),
); );
@@ -78,7 +80,7 @@ class WebAuthnController extends AbstractController
$creationOptions = PublicKeyCredentialCreationOptions::create( $creationOptions = PublicKeyCredentialCreationOptions::create(
$rpEntity, $rpEntity,
$userEntity, $userEntity,
\random_bytes(32), random_bytes(32),
$credentialParameters, $credentialParameters,
$authenticatorSelectionCriteria, $authenticatorSelectionCriteria,
PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_DIRECT PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_DIRECT
@@ -113,7 +115,7 @@ class WebAuthnController extends AbstractController
]; ];
return new JsonResponse($response); return new JsonResponse($response);
} catch (\Exception $e) { } catch (Exception $e) {
return new JsonResponse( return new JsonResponse(
['error' => $e->getMessage()], ['error' => $e->getMessage()],
Response::HTTP_BAD_REQUEST Response::HTTP_BAD_REQUEST
@@ -141,7 +143,7 @@ class WebAuthnController extends AbstractController
} }
/** Store the credential with user ID for later retrieval during authentication */ /** Store the credential with user ID for later retrieval during authentication */
$credentialJson['userId'] = $user->getId(); $credentialJson['userId'] = $user->id;
$credentialJson['username'] = $user->getUsername(); $credentialJson['username'] = $user->getUsername();
/** Save the credential data directly */ /** Save the credential data directly */
@@ -155,7 +157,7 @@ class WebAuthnController extends AbstractController
$request->getSession()->remove('webauthn_credential_name'); $request->getSession()->remove('webauthn_credential_name');
return new JsonResponse(['success' => true]); return new JsonResponse(['success' => true]);
} catch (\Exception $e) { } catch (Exception $e) {
return new JsonResponse( return new JsonResponse(
['error' => 'Registration failed: ' . $e->getMessage()], ['error' => 'Registration failed: ' . $e->getMessage()],
Response::HTTP_BAD_REQUEST Response::HTTP_BAD_REQUEST
@@ -173,12 +175,12 @@ class WebAuthnController extends AbstractController
$credentials = $this->webAuthnService->getCredentialsForUser($user); $credentials = $this->webAuthnService->getCredentialsForUser($user);
return new JsonResponse(array_map(fn($credential) => [ return new JsonResponse(array_map(fn($credential) => [
'id' => $credential->getId(), 'id' => $credential->id,
'name' => $credential->getCredentialName(), 'name' => $credential->credentialName,
'createdAt' => $credential->getCreatedAt()?->format('Y-m-d H:i:s'), 'createdAt' => $credential->createdAt?->format('Y-m-d H:i:s'),
'lastUsedAt' => $credential->getLastUsedAt()?->format('Y-m-d H:i:s'), 'lastUsedAt' => $credential->lastUsedAt?->format('Y-m-d H:i:s'),
'isBackupEligible' => $credential->isBackupEligible(), 'isBackupEligible' => $credential->isBackupEligible,
'isBackupAuthenticated' => $credential->isBackupAuthenticated(), 'isBackupAuthenticated' => $credential->isBackupAuthenticated,
], $credentials)); ], $credentials));
} }
@@ -219,7 +221,7 @@ class WebAuthnController extends AbstractController
} }
return new JsonResponse(['error' => 'Credential not found'], Response::HTTP_NOT_FOUND); return new JsonResponse(['error' => 'Credential not found'], Response::HTTP_NOT_FOUND);
} catch (\Exception $e) { } catch (Exception $e) {
return new JsonResponse( return new JsonResponse(
['error' => $e->getMessage()], ['error' => $e->getMessage()],
Response::HTTP_BAD_REQUEST Response::HTTP_BAD_REQUEST
@@ -232,7 +234,7 @@ class WebAuthnController extends AbstractController
{ {
try { try {
/** Generate challenge */ /** Generate challenge */
$challenge = \random_bytes(32); $challenge = random_bytes(32);
/** Store in session for verification later */ /** Store in session for verification later */
$request->getSession()->set('webauthn_request_challenge', $challenge); $request->getSession()->set('webauthn_request_challenge', $challenge);
@@ -250,7 +252,7 @@ class WebAuthnController extends AbstractController
]; ];
return new JsonResponse($response); return new JsonResponse($response);
} catch (\Exception $e) { } catch (Exception $e) {
return new JsonResponse( return new JsonResponse(
['error' => $e->getMessage()], ['error' => $e->getMessage()],
Response::HTTP_BAD_REQUEST Response::HTTP_BAD_REQUEST
@@ -304,7 +306,7 @@ class WebAuthnController extends AbstractController
'redirect' => '/', 'redirect' => '/',
'message' => 'Successfully authenticated with passkey', 'message' => 'Successfully authenticated with passkey',
]); ]);
} catch (\Exception $e) { } catch (Exception $e) {
return new JsonResponse( return new JsonResponse(
['error' => $e->getMessage()], ['error' => $e->getMessage()],
Response::HTTP_BAD_REQUEST Response::HTTP_BAD_REQUEST

View File

@@ -1,63 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2024 @ www.splendidbear.org
*
* For the full copyright and licence information, please view the LICENCE
* file that was distributed with this source code.
*/
namespace App\Doctrine;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Schema\PostgreSQLSchemaManager;
use Doctrine\DBAL\Schema\SchemaException;
use Doctrine\ORM\Tools\Event\GenerateSchemaEventArgs;
use Doctrine\ORM\Tools\ToolEvents;
use RuntimeException;
/**
* Class FixPostgreMigrationDefaultSchemaListener
*
* @package App\Doctrine
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2023. 02. 28.
*
* @see https://github.com/doctrine/dbal/issues/1110
* There is a recent bug when you create new migration, it creates a new schema even if there is no any
* changes.
*/
#[AsDoctrineListener(event: ToolEvents::postGenerateSchema, priority: 500, connection: 'default')]
final class FixPostgreMigrationDefaultSchemaListener
{
public function postGenerateSchema(GenerateSchemaEventArgs $args): void
{
try {
$schemaManager = $args
->getEntityManager()
->getConnection()
->createSchemaManager();
if (!$schemaManager instanceof PostgreSqlSchemaManager) {
return;
}
$schema = $args->getSchema();
foreach ($schemaManager->getExistingSchemaSearchPaths() as $namespace) {
if ($schema->hasNamespace($namespace)) {
continue;
}
$schema->createNamespace($namespace);
}
} catch (SchemaException|Exception $e) {
throw new RuntimeException($e->getMessage());
}
}
}

Some files were not shown because too many files have changed in this diff Show More