Private
Public Access
1
0

Compare commits

..

72 Commits

Author SHA1 Message Date
1d8efa4e61 chg: usr: fine-tune the recent battle list #8
All checks were successful
Deploy to Production / deploy (push) Successful in 27s
2026-04-21 13:57:44 +02:00
69fce52bed chg: pkg: new version release !skipChangelog 2026-04-21 11:47:58 +02:00
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
eb345e17ca chg: pkg: new version release !skipChangelog
All checks were successful
Deploy to Production / deploy (push) Successful in 19s
2026-04-15 20:13:38 +02:00
c2693c4648 fix: usr: another attempt to fix the email assets #4
All checks were successful
Deploy to Production / deploy (push) Successful in 11s
2026-04-15 20:03:48 +02:00
43efc16562 fix: usr: the images does not shows in emails #4
All checks were successful
Deploy to Production / deploy (push) Successful in 15s
2026-04-15 19:50:14 +02:00
80d6440ece chg: pkg: new version release !skipChangelog
All checks were successful
Deploy to Production / deploy (push) Successful in 16s
2026-04-15 19:00:43 +02:00
5ee972f003 chg: pkg: add missing .env variable and increase the version number and add missing data from front-end and back-end deps descriptor #4
Some checks failed
Deploy to Production / deploy (push) Failing after 0s
2026-04-15 18:59:52 +02:00
6f3edb41ea new: usr: add Contact page with email sending behaviour #4
All checks were successful
Deploy to Production / deploy (push) Successful in 39s
2026-04-15 18:35:05 +02:00
c52939a7a3 chg: usr: change the shareable battle - add avatars to it - even on the og tags #4 2026-04-15 16:44:57 +02:00
573d409606 fix: pkg: the mailhog is crashed on development env #4 2026-04-15 14:45:44 +02:00
9a58bc9a5e chg: usr: change text #4 2026-04-15 14:38:25 +02:00
8780800dff fix: pkg: the og tags did not have proper http schema - they should have https #4 2026-04-15 14:33:53 +02:00
f442942faf chg: pkg: new version release !skipChangelog
All checks were successful
Deploy to Production / deploy (push) Successful in 25s
2026-04-14 21:51:17 +02:00
a61d881a4e chg: usr: add donation button #4 2026-04-14 21:50:58 +02:00
926b614136 chg: pkg: new version release !skipChangelog
All checks were successful
Deploy to Production / deploy (push) Successful in 23s
2026-04-14 21:08:27 +02:00
c0c84f4651 chg: dev: protect the gameplay with recaptcha #4 2026-04-14 21:07:54 +02:00
176e255037 chg: usr: the waiting dialog is uncloseable until the time is up #4 2026-04-14 21:04:05 +02:00
b134358e9e new: usr: add timer for the acceptance of the challenge #4 2026-04-14 20:30:18 +02:00
3525aaeeb7 fix: usr: missing font-awesome icons on bare-metal environment #4 2026-04-14 19:44:01 +02:00
af67ec3931 chg: usr: add share button to the overlay when the game ends #4 2026-04-14 19:37:42 +02:00
d515f42cfd chg: usr: make fancy og tags - and create a special one for battle sharing #4 2026-04-14 18:54:44 +02:00
5d6aff8d90 chg: dev: the user's avatar will be saved as a uuid.extension #4
All checks were successful
Deploy to Production / deploy (push) Successful in 10s
2026-04-14 16:53:16 +02:00
182 changed files with 9948 additions and 3271 deletions

View File

@@ -6,6 +6,12 @@
APP_ENV=dev
APP_SECRET=changethis
APP_NAME=mineseeker
# APP_PUBLIC_HOSTNAME: The public hostname for your application (used for generating absolute URLs in emails)
# For production, set this to your domain (e.g., mineseeker.com)
APP_PUBLIC_HOSTNAME=localhost
# TRUSTED_PROXIES: Only needed for bare-metal dev behind a reverse proxy
# For Docker development, this is set in compose.override.yaml
# For production, set in PROD_ENV_FILE Gitea secret (use 172.18.0.0/16 initially)
#TRUSTED_PROXIES=127.0.0.1,127.0.0.2
#TRUSTED_HOSTS=localhost,example.com
###< symfony/framework-bundle ###

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,177 +1,505 @@
Changelog
=========
# Changelog
v2026.01 (2026-04-14)
---------------------
## v2026.2.7-0 (2026-04-21)
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
Changes
~~~~~~~
- 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]
* Small changes on docs - and improve text on homepage #8. [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]
* Massive refactor on front-end for unification and readiness #8. [Lang]
* Update all doc blocks on back-end #8. [Lang]
* Small refactors on back-end #8. [Lang]
* Add RecentBattle entity that is a Materialized View to speed up the view - and further refactor on ProfileController #8. [Lang]
* Create the UserStats entity what is a Materialized View to store Profile stats for every user - & massive ProfileController refactor #8. [Lang]
* Refactor the SecurityController #7. [Lang]
* Refactor the code - there was unnecessary codes and wrongly formatted or designed code that are related to Repositories #7. [Lang]
* Upgrade the doctrine related back-end pkgs to the latest available version #7. [Lang]
* Add filter to the Profile page's recent plays and an infite list too #7. [Lang]
* Upgrade to the latest doctrine pkg on back-end #7. [Lang]
### Fix
* Do not hide the end-game overlay ever #8. [Lang]
* The username was not recognized properly #7. [Lang]
1.1.0 (2019-10-26)
------------------
## v2026.2.6-1 (2026-04-19)
Changes
~~~~~~~
- Reinit project - disable redis module and make the project compatible
w/ PHP7.3 #2. [Lang]
### Fix
* The PostgreSQL logo was horrible #7. [Lang]
0.4.0 (2019-10-26)
------------------
- 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]
## v2026.2.6-0 (2026-04-19)
### Changes
* Add ReCaptcha overlay again to protect the game #7. [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]
## v2026.2.5-0 (2026-04-19)
### New
* Add Firebase deps to back-end #7. [Lang]
* Add missing buttons for overlays #7. [Lang]
* A new feature came up - the abandoned plays can be restored, if both users are registered users #7. [Lang]
### Changes
* Fix the '0' in Battle reports #6. [Lang]
* Fix missing icons on "Battle report" #6. [Lang]
### Fix
* The bomb using was not recorded correctly - the old data will be corrupted #6. [Lang]
## v2026.2.4-0 (2026-04-18)
### New
* Add new profile charts and stats - & add new logo to the tech stack #5. [Lang]
### Changes
* Upgrade fe deps #5. [Lang]
* Improve the Battle reports to change unnecessary data with interesting data #5. [Lang]
### Fix
* The react is crashing on some cases #5. [Lang]
## v2026.2.3-0 (2026-04-18)
### New
* Add initialization bonus points' system to the gameplay #5. [Lang]
### Changes
* Add extended data to battle reports and sharing image to make viewable bonus points #5. [Lang]
## v2026.2.2-9 (2026-04-18)
### New
* Add rules page #4. [Lang]
### Fix
* The font-awesome simplifying to work on bare-metal - & fix all warnings at build time #4. [Lang]
* The css problem had been solved on reponsive gfx on homepage #4. [Lang]
## v2026.2.1-8 (2026-04-18)
### Fix
* Quickfix for https-only login - & add user data when the user is not logged in #4. [Lang]
## v2026.2.1-7 (2026-04-16)
### Changes
* Add consent checkbox to user's registration - and fix the sharing pics #4. [Lang]
* Add correct version numbering and CHANGELOG - and add the LICENSE #4. [Lang]
## v2026.2.1-6 (2026-04-16)
### Changes
* Update all texts on all pages - extend them with the game specific things #4. [Lang]
## v2026.2.1-5 (2026-04-16)
### Fix
* The meta tags does not have https scheme - nothing worked in configuration #4. [Lang]
## v2026.2.1-4 (2026-04-15)
### New
* Add notification email when a user is registered #4. [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]

View File

@@ -13,6 +13,10 @@
encode zstd br gzip
# Forward scheme information to the PHP application
header X-Forwarded-Proto {scheme}
header X-Forwarded-Host {host}
mercure {
transport_url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db}
publisher_jwt {$MERCURE_JWT_SECRET} HS256

View File

@@ -22,6 +22,12 @@ RUN install-php-extensions \
apcu \
sodium
RUN apt-get update && apt-get install -y --no-install-recommends \
fonts-dejavu-core \
fontconfig \
&& fc-cache -f -v \
&& rm -rf /var/lib/apt/lists/*
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
RUN printf '[opcache]\nopcache.enable=1\nopcache.memory_consumption=256\nopcache.max_accelerated_files=20000\nopcache.validate_timestamps=0\n' \
> "$PHP_INI_DIR/conf.d/opcache.ini"

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
@@ -11,6 +11,9 @@ help:
@echo " make down - Stop and remove containers/networks"
@echo " make prune-everything - Prune volumes, networks and images (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:
docker compose up -d
@@ -51,3 +54,31 @@ db-reset:
bin/console doctrine:database:drop --force --if-exists --no-interaction
bin/console doctrine:database:create --if-not-exists --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

@@ -151,6 +151,7 @@ services:
app:
environment:
MAILER_DSN: smtp://mail:1025?verify_peer=0
TRUSTED_PROXIES: "0.0.0.0/0"
mail:
image: mailhog/mailhog:latest
ports:
@@ -233,8 +234,13 @@ MERCURE_SUBSCRIBER_JWT="<generated by make mercure-jwt>"
APP_PUBLIC_HOSTNAME=mineseeker.hu
WEBAUTHN_RP_ID=mineseeker.hu
WEBAUTHN_ORIGIN=https://mineseeker.hu
```
# OG Tags & Social Media Sharing (IMPORTANT for Docker deployments)
# TRUSTED_PROXIES: IP address (or range) of your reverse proxy (Caddy/Nginx)
# This ensures OG image tags use HTTPS URLs instead of HTTP
TRUSTED_PROXIES="172.18.0.0/16"
TRUSTED_HOSTS="mineseeker.hu,www.mineseeker.hu"
```
### Production server: one-time setup
The server needs Docker, Git, and a self-hosted `act_runner` registered against the Gitea repository. Bun and Composer run inside the multi-stage Dockerfile, so they are not needed on the server.
@@ -254,7 +260,7 @@ make mercure-jwt
Copy the three printed values into the `PROD_ENV_FILE` secret.
#### 5. First deploy
#### 3. First deploy
Trigger it by pushing the first tag:
@@ -265,7 +271,7 @@ git push origin v2026.01
This writes `.env`, builds the Docker image, starts all services, runs migrations, and initialises the MinIO buckets automatically via `minio_init`.
#### 6. Verify
#### 4. Verify
```bash
docker compose ps # all services should be healthy/running
@@ -281,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
LGPL-3.0 — see [LICENSE](LICENSE) for details.

View File

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

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 {
from { opacity: 0; transform: scale(0.94); }
to { opacity: 1; transform: scale(1); }

View File

@@ -1,14 +1,24 @@
.hero-auth {
position: absolute;
top: 28px;
right: 36px;
/*!*
* 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 {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
z-index: 10;
}
}
.hero-auth-user {
.hero-auth-user {
font: 600 13px 'Rajdhani', sans-serif;
color: rgba(149, 207, 245, 0.75);
letter-spacing: 0.5px;
@@ -17,6 +27,13 @@
gap: 6px;
i { font-size: 15px; }
}
@media screen and (max-width: 1100px) {
.hero-auth {
justify-content: center;
}
}
}
.hero-auth-btn {

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 {
display: flex;
flex-direction: column;
@@ -180,6 +189,41 @@
input[type="checkbox"] { accent-color: #236f87; }
}
.auth-checkbox {
accent-color: #236f87;
cursor: pointer;
width: 18px;
height: 18px;
flex-shrink: 0;
}
.auth-checkbox-label {
display: flex;
align-items: flex-start;
cursor: pointer;
user-select: none;
font: 400 14px 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.6);
line-height: 1.5;
a {
color: #95cff5;
text-decoration: none;
font-weight: 600;
transition: color 180ms;
&:hover { color: #c5e8ff; }
}
}
textarea.auth-input {
padding: 11px 14px;
min-height: 120px;
resize: vertical;
font-family: 'Rajdhani', sans-serif;
line-height: 1.5;
}
.auth-submit {
display: flex;
align-items: center;

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 {
color: rgba(255, 255, 255, 0.85);
max-width: 900px;
@@ -32,4 +41,12 @@ main div.txt a {
transition: color 180ms;
&: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 {
position: relative;
display: inline-block;

View File

@@ -0,0 +1,72 @@
/*!*
* 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-donate-text {
position: relative;
z-index: 1;
font: 400 12px 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.5);
letter-spacing: 0.5px;
margin-top: 42px;
margin-bottom: 8px;
animation: rise 0.8s 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.hero-donate {
position: relative;
display: inline-block;
font: 500 12px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 1px;
text-decoration: none;
color: rgba(255, 184, 82, 0.75);
padding: 6px 14px;
border-radius: 2px;
border: 1px solid rgba(255, 184, 82, 0.25);
background: rgba(255, 140, 30, 0.05);
box-shadow:
0 0 0 1px rgba(255, 140, 30, 0.1),
0 0 8px rgba(255, 140, 30, 0.08);
transition: transform 200ms ease, box-shadow 200ms ease, color 200ms ease, background 200ms ease;
animation: rise 0.8s 0.58s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.hero-donate::before {
content: '';
position: absolute;
inset: -2px;
border-radius: 3px;
background: rgba(255, 140, 30, 0.15);
filter: blur(8px);
opacity: 0.1;
z-index: -1;
transition: opacity 200ms ease;
}
.hero-donate:hover {
transform: translateY(-1px);
color: rgba(255, 200, 100, 0.9);
background: rgba(255, 140, 30, 0.1);
box-shadow:
0 0 0 1px rgba(255, 140, 30, 0.2),
0 0 12px rgba(255, 140, 30, 0.15);
}
.hero-donate:hover::before {
opacity: 0.2;
}
.hero-donate:active {
transform: translateY(0px);
}

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 {
width: 100%;
padding: 80px 40px;
@@ -45,33 +54,33 @@
}
// Bar chart — large, centre
i.fa-bar-chart {
font-size: 140px;
color: rgba(35, 111, 135, 0.35);
filter: drop-shadow(0 0 30px rgba(35, 111, 135, 0.3));
i.fa-chart-bar {
font-size: 160px;
color: rgba(35, 111, 135, 0.5);
filter: drop-shadow(0 0 40px rgba(35, 111, 135, 0.5));
}
// Trophy — top right
i.fa-trophy {
font-size: 64px;
top: 12px;
right: 30px;
color: rgba(246, 125, 82, 0.5);
filter: drop-shadow(0 0 16px rgba(246, 125, 82, 0.25));
font-size: 80px;
top: 0px;
right: 20px;
color: rgba(246, 125, 82, 0.7);
filter: drop-shadow(0 0 25px rgba(246, 125, 82, 0.4));
}
// History — bottom left
i.fa-history {
font-size: 52px;
bottom: 18px;
left: 30px;
color: rgba(149, 207, 245, 0.4);
filter: drop-shadow(0 0 12px rgba(149, 207, 245, 0.2));
// Clock history — bottom left
i.fa-clock-rotate-left {
font-size: 68px;
bottom: 0px;
left: 20px;
color: rgba(149, 207, 245, 0.65);
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-trophy { color: rgba(246, 125, 82, 0.75); }
&:hover i.fa-history { color: rgba(149, 207, 245, 0.65); }
&: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.9); filter: drop-shadow(0 0 35px rgba(246, 125, 82, 0.6)); }
&: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
@@ -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
.feature-block__text {
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) {
.feature-block__inner,
.feature-block--reverse .feature-block__inner {
@@ -199,4 +350,14 @@
.feature-block {
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 {
background: #040608;
border-top: 1px solid rgba(35, 111, 135, 0.12);
@@ -14,7 +23,6 @@ footer {
gap: 40px;
}
// Left: brand block
.footer-brand {
display: flex;
flex-direction: column;
@@ -55,7 +63,6 @@ footer {
line-height: 1.5;
}
// Right: navigation
.footer-nav-label {
font: 700 11px 'Rajdhani', sans-serif;
text-transform: uppercase;
@@ -91,7 +98,6 @@ footer {
}
}
// Bottom copyright bar
.footer-copy {
border-top: 1px solid rgba(255, 255, 255, 0.05);
padding: 16px 60px;
@@ -112,4 +118,4 @@ footer {
&:hover { color: #95cff5; }
}
}
}

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 {
position: relative;
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 {
min-height: unset;
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 {
position: relative;
z-index: 2;

View File

@@ -210,11 +210,43 @@
}
}
&--best {
border-color: rgba(255, 215, 0, 0.15);
&--bonus {
border-color: rgba(255, 215, 0, 0.18);
&: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);
}
.profile-stat--best & {
color: rgba(255, 215, 0, 0.3);
.profile-stat--bonus & {
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;
}
.profile-stat--best & {
.profile-stat--bonus & {
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 {
@@ -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 {
display: flex;
flex-direction: column;
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 {
display: grid;
grid-template-columns: 26px 76px 22px 1fr 18px auto;
grid-template-columns: 60px 76px 22px 1fr 18px auto;
align-items: center;
gap: 10px;
padding: 11px 16px;
@@ -400,17 +542,31 @@
&--draw {
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 {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
width: 100%;
min-width: 0;
height: 20px;
border-radius: 4px;
font: 800 10px 'Rajdhani', sans-serif;
letter-spacing: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
gap: 4px;
.profile-game--win & {
background: rgba(42, 158, 96, 0.18);
@@ -426,12 +582,49 @@
background: rgba(149, 207, 245, 0.1);
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 {
font: 700 14px 'Rajdhani', sans-serif;
color: #fff;
letter-spacing: 1px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.profile-game__vs {
@@ -461,21 +654,23 @@
letter-spacing: 0.5px;
text-align: right;
white-space: nowrap;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.profile-charts {
display: flex;
flex-wrap: wrap;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.profile-chart-block {
flex: 1 1 300px;
min-width: 0;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(35, 111, 135, 0.2);
border-radius: 10px;
padding: 24px 20px 16px;
backdrop-filter: blur(4px);
box-shadow: 0 8px 48px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
@@ -484,12 +679,25 @@
.profile-section__title {
margin: 0;
}
&--wide {
grid-column: 1 / -1;
.profile-chart-inner {
justify-content: stretch;
overflow: hidden;
> * {
width: 100% !important;
}
}
}
}
.profile-chart-inner {
display: flex;
justify-content: center;
overflow: auto;
overflow: hidden;
svg text {
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 {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
@@ -747,6 +981,13 @@
font: 800 24px 'Rajdhani', sans-serif;
letter-spacing: 2px;
&__img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
&--red {
background: linear-gradient(135deg, rgba(173, 10, 5, 0.6) 0%, rgba(246, 125, 82, 0.4) 100%);
border: 2px solid rgba(173, 10, 5, 0.5);
@@ -852,6 +1093,104 @@
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 {
display: inline-flex;
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;
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) {
.hero h1 {
font-size: 44px;
@@ -24,6 +33,10 @@
grid-template-columns: repeat(2, 1fr);
}
.profile-charts {
grid-template-columns: 1fr;
}
.profile-header {
flex-direction: column;
text-align: center;
@@ -97,4 +110,4 @@
font-size: 20px;
letter-spacing: 4px;
}
}
}

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 {
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 {
display: inline-block;
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 {
height: 100%;
padding: 0;
@@ -17,7 +26,6 @@ main {
}
.mine-container {
background: url("/images/bg-mineseeker-0-outbg.jpg") no-repeat;
background-size: cover;
display: flex;
justify-content: center;
@@ -54,4 +62,4 @@ main {
-webkit-border-radius: 10px;
border-radius: 10px;
}
}

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 {
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%);

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 {
display: flex;
flex-direction: row;
@@ -78,8 +87,6 @@
}
#mine-wrapper .grid .field-wrapper .field .field-corner {
background: url('/images/bg-corner-outbg.png') no-repeat top left;
background-size: 100%;
width: 100%;
height: 100%;
}
@@ -206,4 +213,4 @@
#mine-wrapper .grid .field-wrapper .field img {
width: 80%;
}
}

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 {
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%);

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 {
background: rgba(255, 255, 255, 0.2);
display: flex;
@@ -21,21 +30,23 @@
}
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window {
background: linear-gradient(135deg, rgba(7, 9, 13, 0.98) 0%, rgba(10, 20, 35, 0.98) 100%);
border: 2px solid rgba(35, 111, 135, 0.4);
backdrop-filter: blur(12px);
font-family: 'Rajdhani', sans-serif;
color: #fff;
width: 100%;
max-width: 680px;
padding: 40px;
border-radius: 16px;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.7), 0 0 40px rgba(35, 111, 135, 0.15);
display: flex;
flex-direction: column;
gap: 0;
animation: slideUp 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
background: linear-gradient(135deg, rgba(7, 9, 13, 0.98) 0%, rgba(10, 20, 35, 0.98) 100%);
border: 2px solid rgba(35, 111, 135, 0.4);
backdrop-filter: blur(12px);
font-family: 'Rajdhani', sans-serif;
color: #fff;
width: 100%;
max-width: 680px;
padding: 40px;
border-radius: 16px;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.7), 0 0 40px rgba(35, 111, 135, 0.15);
display: flex;
flex-direction: column;
gap: 0;
animation: slideUp 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
overflow: hidden;
max-height: 90vh;
}
@keyframes slideUp {
from {
@@ -49,12 +60,17 @@
}
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window h1 {
font-weight: 800;
font-size: 32px;
color: #fff;
margin: 0 0 50px 0;
letter-spacing: 1px;
}
font-weight: 800;
font-size: 32px;
color: #fff;
margin: 0 0 50px 0;
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 {
font-size: 14px;
@@ -183,6 +199,10 @@
width: 100%;
animation: fadeInUp 0.6s ease-out 0.2s both;
&.waiting-options--invite-only {
grid-template-columns: 1fr;
}
@media (max-width: 600px) {
grid-template-columns: 1fr;
gap: 20px;
@@ -259,12 +279,17 @@
}
.waiting-option-desc {
font: 600 12px 'Rajdhani', sans-serif;
color: rgba(149, 207, 245, 0.75);
margin: 0;
letter-spacing: 0.4px;
line-height: 1.4;
}
font: 600 12px 'Rajdhani', sans-serif;
color: rgba(149, 207, 245, 0.75);
margin: 0;
letter-spacing: 0.4px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.waiting-divider {
display: flex;
@@ -471,58 +496,285 @@
}
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .share-copy-btn {
display: inline-flex;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
background: linear-gradient(to bottom, #236f87 0%, #1a5068 100%);
border: 2px solid #2e7a9a;
color: #e0f4ff;
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%;
position: relative;
overflow: hidden;
box-shadow: 0 4px 12px rgba(35, 111, 135, 0.25);
&::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, #2d8aa8 0%, #236f87 100%);
border-color: #5ba4d4;
color: #fff;
box-shadow: 0 8px 24px rgba(35, 111, 135, 0.4);
transform: translateY(-2px);
&::before {
left: 100%;
}
}
&:active {
transform: translateY(0);
}
&.copied {
background: linear-gradient(to bottom, #1a6844 0%, #135233 100%);
border-color: #2a9e60;
color: #a0f0c0;
box-shadow: 0 4px 12px rgba(26, 104, 68, 0.4);
}
}
#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 {
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
background: linear-gradient(to bottom, #236f87 0%, #1a5068 100%);
border: 2px solid #2e7a9a;
color: #e0f4ff;
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(35, 111, 135, 0.25);
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, #2d8aa8 0%, #236f87 100%);
border-color: #5ba4d4;
color: #fff;
box-shadow: 0 8px 24px rgba(35, 111, 135, 0.4);
transform: translateY(-2px);
&::before {
left: 100%;
}
}
&:active {
transform: translateY(0);
}
&.copied {
background: linear-gradient(to bottom, #1a6844 0%, #135233 100%);
border-color: #2a9e60;
color: #a0f0c0;
box-shadow: 0 4px 12px rgba(26, 104, 68, 0.4);
}
i {
font-size: 15px;
}
}
#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;
gap: 9px;
background: linear-gradient(to bottom, #236f87 0%, #1a5068 100%);
border: 2px solid #2e7a9a;
color: #e0f4ff;
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%;
position: relative;
overflow: hidden;
box-shadow: 0 4px 12px rgba(35, 111, 135, 0.25);
z-index: 1000;
}
&::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;
.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;
}
&:hover {
background: linear-gradient(to bottom, #2d8aa8 0%, #236f87 100%);
&: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);
&::before {
left: 100%;
}
}
&:active {
&:active:not(:disabled) {
transform: translateY(0);
}
&.copied {
background: linear-gradient(to bottom, #1a6844 0%, #135233 100%);
border-color: #2a9e60;
color: #a0f0c0;
box-shadow: 0 4px 12px rgba(26, 104, 68, 0.4);
&.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) {
#mine-wrapper .game-wrapper .users {
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 {
display: flex;
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 {
width: 180px;
padding: 0 10px 0 0;
@@ -100,16 +109,18 @@
}
#mine-wrapper .game-wrapper .users .user-container .user-name {
min-height: 30px;
font-weight: normal;
text-align: center;
white-space: nowrap;
text-overflow: ellipsis;
padding: 3px 0;
margin: 0 5px;
min-height: 30px;
font-weight: normal;
text-align: center;
white-space: nowrap;
text-overflow: ellipsis;
padding: 3px 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 {
border-top: 1px dashed #0b3776;
@@ -139,10 +150,17 @@
}
#mine-wrapper .game-wrapper .users .user-container .user-desc {
height: 65px;
font-size: 14px;
text-align: center;
}
height: 65px;
font-size: 14px;
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 {
color: #0b3776;
@@ -150,4 +168,4 @@
#mine-wrapper .game-wrapper .users .user-container.user-red .user-desc {
color: #fdf612;
}
}

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 {
background: #07090d !important;
background-image: linear-gradient(rgba(35, 111, 135, 0.08) 1px, transparent 1px),
@@ -305,3 +314,43 @@
padding-top: 14px;
border-top: 1px solid rgba(35, 111, 135, 0.14);
}
.opd-header-actions {
.opd-refresh[disabled] {
opacity: 0.4;
cursor: not-allowed;
}
.opd-close[disabled] {
opacity: 0.4;
cursor: not-allowed;
}
}
.opd-waiting {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
margin-bottom: 16px;
background: rgba(35, 111, 135, 0.07);
border: 1px solid rgba(35, 111, 135, 0.28);
border-radius: 8px;
color: #95cff5;
i {
font-size: 16px;
animation: opd-hourglass 1s ease-in-out infinite;
}
p {
margin: 0;
font: 600 14px 'Rajdhani', sans-serif;
letter-spacing: 0.5px;
}
}
@keyframes opd-hourglass {
0%, 100% { transform: rotate(0deg); }
50% { transform: rotate(180deg); }
}

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";
.twofa-status {

View File

@@ -1,9 +1,19 @@
/*!*
* 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/animations';
@use 'homepage/header';
@use 'homepage/hero';
@use 'homepage/hero-compact';
@use 'homepage/cta';
@use 'homepage/donate';
@use 'homepage/auth-bar';
@use 'homepage/auth';
@use 'homepage/content';
@@ -11,4 +21,5 @@
@use 'homepage/tech';
@use 'homepage/footer';
@use 'homepage/profile';
@use 'homepage/battle-dialog';
@use 'homepage/responsive';

View File

@@ -1,7 +1,7 @@
/*!*
* 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
* file that was distributed with this source code.
@@ -320,7 +320,6 @@ footer nav ul li {
}
footer nav ul li:nth-child(even) {
width: 50px;
text-align: center;
}
@@ -401,8 +400,4 @@ footer nav ul li a:hover {
footer nav ul li {
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.
*
* Copyright (c) 2026 @ www.splendidbear.org
* 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.
@@ -19,5 +19,6 @@
@import 'mineseeker/grid';
@import 'mineseeker/back-button';
@import 'mineseeker/timer';
@import 'mineseeker/bonus-box';
@import 'mineseeker/responsive';
@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 {
position: fixed;
top: 0;

View File

@@ -17,5 +17,6 @@ createRoot(wrapper).render(
<MineSeeker
env={wrapper.dataset.env}
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 }) {
const [thumbUrl, setThumbUrl] = useState(initialThumbUrl || null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
import React, { useMemo, useRef } from 'react';
import { string } from 'prop-types';
import { useProfileDataProvider } from '@mine-hooks/useGameDataProvider';
export const AvatarUpload = ({ uploadUrl, initialThumbUrl, initials }) => {
const inputRef = useRef(null);
const [thumbUrl, setThumbUrl] = React.useState(initialThumbUrl || null);
const { uploadAvatarMutation: { isPending, error, mutate } } = useProfileDataProvider();
function handleClick() {
inputRef.current?.click();
}
function handleChange(e) {
const handleChange = e => {
const file = e.target.files?.[0];
if (!file) return;
const fd = new FormData();
fd.append('avatar', file);
setLoading(true);
setError(null);
fetch(uploadUrl, { method: 'POST', body: fd })
.then(r => r.json())
.then(data => {
if (data.error) {
setError(data.error);
return;
}
mutate({ uploadUrl, file }, {
onSuccess: data => {
setThumbUrl(data.thumbUrl);
const navImg = document.querySelector('.hero-auth-avatar:not(.hero-auth-avatar--initials)');
const navInitials = document.querySelector('.hero-auth-avatar.hero-auth-avatar--initials');
if (navImg) {
navImg.src = data.thumbUrl;
} else if (navInitials) {
@@ -40,16 +36,17 @@ export default function AvatarUpload({ uploadUrl, initialThumbUrl, initials }) {
img.className = 'hero-auth-avatar';
navInitials.replaceWith(img);
}
})
.catch(() => setError('Upload failed. Please try again.'))
.finally(() => setLoading(false));
}
},
});
};
const errorMessage = useMemo(() => error?.message ?? null, [error]);
return (
<div
className={`profile-avatar${loading ? ' profile-avatar--loading' : ''}`}
className={`profile-avatar${isPending ? ' profile-avatar--loading' : ''}`}
title="Click to change profile picture"
onClick={handleClick}
onClick={() => inputRef.current?.click()}
>
{thumbUrl
? <img src={thumbUrl} alt={initials} className="profile-avatar__img" />
@@ -65,7 +62,13 @@ export default function AvatarUpload({ uploadUrl, initialThumbUrl, initials }) {
style={{ display: 'none' }}
onChange={handleChange}
/>
{error && <div className="profile-avatar__error">{error}</div>}
{errorMessage && <div className="profile-avatar__error">{errorMessage}</div>}
</div>
);
}
AvatarUpload.propTypes = {
uploadUrl: string.isRequired,
initialThumbUrl: string,
initials: string.isRequired,
};

View File

@@ -1,10 +1,204 @@
/**
* 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 { array } from 'prop-types';
import { formatDuration } from '@global-utils/format';
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 DIALOG_SX = {
const RESULT_META = {
win: {
label: 'Victory',
color: '#5ee89a',
bg: 'rgba(42,158,96,0.15)',
border: 'rgba(42,158,96,0.4)',
icon: 'fa-trophy',
},
loss: {
label: 'Defeated',
color: '#f67d52',
bg: 'rgba(173,10,5,0.15)',
border: 'rgba(173,10,5,0.4)',
icon: 'fa-flag',
},
draw: {
label: 'Draw',
color: '#95cff5',
bg: 'rgba(149,207,245,0.1)',
border: 'rgba(149,207,245,0.3)',
icon: 'fa-minus',
},
};
export const BattleDialog = ({ games }) => {
const [open, setOpen] = useState(false);
const [game, setGame] = useState(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
const handler = e => {
const row = e.target.closest('[data-game-index]');
if (!row) return;
const idx = parseInt(row.dataset.gameIndex, 10);
if (!isNaN(idx) && games[idx]) {
setGame(games[idx]);
setOpen(true);
}
};
document.addEventListener('click', handler);
return () => document.removeEventListener('click', handler);
}, [games]);
if (!game) {
return <ThemeProvider theme={darkTheme}><StyledDialog open={false} /></ThemeProvider>;
}
const meta = RESULT_META[game.result] ?? RESULT_META.draw;
const resign = game.resign;
const maxPoints = Math.max(game.redPoints ?? 0, game.bluePoints ?? 0);
const endReason = resign
? `${resign.charAt(0).toUpperCase() + resign.slice(1)} resigned`
: 26 <= maxPoints ? 'Points' : 'Abandoned';
const canShare = !canContinue;
const bothRegistered = game.bothRegistered;
const canContinue = bothRegistered && !resign && 26 > maxPoints;
const playUrl = `${window.location.origin}/play/${game.uuid}`;
const shareUrl = `${window.location.origin}/battle/${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 = () => {
navigator.clipboard.writeText(shareUrl).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2200);
});
};
return (
<ThemeProvider theme={darkTheme}>
<StyledDialog open={open} onClose={() => setOpen(false)}>
<div className="bd">
<div className="bd-header">
<div className="bd-header-left">
<span className="bd-label">Battle Report</span>
<h2 className="bd-title">
<i className="fa fa-crosshairs" /> Match Details
</h2>
</div>
<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>
) : canShare ? (
<button
className={`bd-share${copied ? ' bd-share--copied' : ''}`}
onClick={handleShare}
aria-label="Copy share link"
title="Copy share link"
>
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} />
{copied ? 'Copied!' : 'Share'}
</button>
) : null}
<button className="bd-close" onClick={() => setOpen(false)} aria-label="Close">
<i className="fa fa-times" />
</button>
</div>
</div>
<div className="bd-vs-panel">
<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-score">
<span className="bd-vs-score__red">{game.redPoints ?? '—'}</span>
<span className="bd-vs-score__sep">:</span>
<span className="bd-vs-score__blue">{game.bluePoints ?? '—'}</span>
</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-result-badge"
style={{ '--bd-result-bg': meta.bg, '--bd-result-border': meta.border, '--bd-result-color': meta.color }}
>
<i className={`fa ${meta.icon}`} /> {meta.label}
</div>
</div>
<Avatar
name={game.blueName} color="blue" avatarUrl={game.blueAvatar}
bonusPoints={game.blueBonusPoints > game.redBonusPoints ? game.blueBonusPoints : 0}
/>
</div>
<div className="bd-stats">
<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} />
{0 < pointDiff && (
<StatRow
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'}
valueColor={game.redExplodedBomb ? '#f67d52' : 'rgba(255,255,255,0.45)'}
/>
<StatRow
icon="fa-bomb" label="Blue used bomb"
value={game.blueExplodedBomb ? 'Yes' : 'No'}
valueColor={game.blueExplodedBomb ? '#95cff5' : 'rgba(255,255,255,0.45)'}
/>
</div>
<BonusPoints
game={game}
/>
</div>
</StyledDialog>
</ThemeProvider>
);
};
BattleDialog.propTypes = {
games: array.isRequired,
};
const StyledDialog = styled(Dialog)({
'& .MuiDialog-paper': {
background: '#07090d',
backgroundImage: `
@@ -24,215 +218,4 @@ const DIALOG_SX = {
background: 'rgba(2, 4, 8, 0.88)',
backdropFilter: 'blur(4px)',
},
};
const RESULT_META = {
win: {
label: 'Victory',
color: '#5ee89a',
bg: 'rgba(42,158,96,0.15)',
border: 'rgba(42,158,96,0.4)',
icon: 'fa-trophy',
},
loss: {
label: 'Defeat',
color: '#f67d52',
bg: 'rgba(173,10,5,0.15)',
border: 'rgba(173,10,5,0.4)',
icon: 'fa-flag',
},
draw: {
label: 'Draw',
color: '#95cff5',
bg: 'rgba(149,207,245,0.1)',
border: 'rgba(149,207,245,0.3)',
icon: 'fa-minus',
},
};
function Avatar({ name, color }) {
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: gradient,
border: `2px solid ${border}`,
boxShadow: glow,
display: 'flex', alignItems: 'center', justifyContent: 'center',
font: '800 24px \'Rajdhani\', sans-serif',
color: textColor,
letterSpacing: 2,
}}
>
{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 [game, setGame] = useState(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
const handler = e => {
const row = e.target.closest('[data-game-index]');
if (!row) return;
const idx = parseInt(row.dataset.gameIndex, 10);
if (!isNaN(idx) && games[idx]) {
setGame(games[idx]);
setOpen(true);
}
};
document.addEventListener('click', handler);
return () => document.removeEventListener('click', handler);
}, [games]);
if (!game) {
return <ThemeProvider theme={darkTheme}><Dialog open={false} sx={DIALOG_SX} /></ThemeProvider>;
}
const meta = RESULT_META[game.result] ?? RESULT_META.draw;
const resign = game.resign;
const endReason = resign
? `${resign.charAt(0).toUpperCase() + resign.slice(1)} resigned`
: 'Points';
const shareUrl = `${window.location.origin}/battle/${game.id}`;
const handleShare = () => {
navigator.clipboard.writeText(shareUrl).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2200);
});
};
return (
<ThemeProvider theme={darkTheme}>
<Dialog open={open} onClose={() => setOpen(false)} sx={DIALOG_SX}>
<div className="bd">
<div className="bd-header">
<div className="bd-header-left">
<span className="bd-label">Battle Report</span>
<h2 className="bd-title">
<i className="fa fa-crosshairs" /> Match Details
</h2>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
className={`bd-share${copied ? ' bd-share--copied' : ''}`}
onClick={handleShare}
aria-label="Copy share link"
title="Copy share link"
>
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} />
{copied ? 'Copied!' : 'Share'}
</button>
<button className="bd-close" onClick={() => setOpen(false)} aria-label="Close">
<i className="fa fa-times" />
</button>
</div>
</div>
<div className="bd-vs-panel">
<Avatar name={game.redName} color="red" />
<div className="bd-vs-center">
<div className="bd-vs-score">
<span className="bd-vs-score__red">{game.redPoints ?? '—'}</span>
<span className="bd-vs-score__sep">:</span>
<span className="bd-vs-score__blue">{game.bluePoints ?? '—'}</span>
</div>
<div className="bd-vs-label">VS</div>
<div
className="bd-result-badge"
style={{ background: meta.bg, border: `1px solid ${meta.border}`, color: meta.color }}
>
<i className={`fa ${meta.icon}`} /> {meta.label}
</div>
</div>
<Avatar name={game.blueName} color="blue" />
</div>
<div className="bd-stats">
<StatRow icon="fa-calendar" label="Date" value={game.date ?? '—'} />
<StatRow icon="fa-flag-checkered" label="End reason" value={endReason} />
<StatRow
icon="fa-bomb" label="Red hit a mine"
value={game.redExplodedBomb ? 'Yes' : 'No'}
valueColor={game.redExplodedBomb ? '#f67d52' : 'rgba(255,255,255,0.45)'}
/>
<StatRow
icon="fa-bomb" label="Blue hit a mine"
value={game.blueExplodedBomb ? 'Yes' : 'No'}
valueColor={game.blueExplodedBomb ? '#f67d52' : '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>
</Dialog>
</ThemeProvider>
);
}
});

View File

@@ -0,0 +1,89 @@
/**
* 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 { useEffect, useRef } from 'react';
import { string } from 'prop-types';
/**
* ContactForm Component
*
* Handles reCAPTCHA v3 integration for the contact form.
* Intercepts form submission, executes reCAPTCHA, and submits the form with the token.
*
* @param {string} siteKey - Google reCAPTCHA site key
* @param {string} recaptchaFieldId - ID of the hidden recaptcha input field
*/
const ContactForm = ({ siteKey, recaptchaFieldId }) => {
const formRef = useRef(null);
const isSubmittingRef = useRef(false);
useEffect(() => {
const form = document.querySelector('.auth-form');
if (!form) {
console.warn('ContactForm: No .auth-form found');
return;
}
formRef.current = form;
const handleSubmit = e => {
e.preventDefault();
if (isSubmittingRef.current) {
return;
}
isSubmittingRef.current = true;
if ('undefined' !== typeof grecaptcha) {
grecaptcha.ready(() => {
grecaptcha
.execute(siteKey, { action: 'contact' })
.then(token => {
const recaptchaField = document.getElementById(recaptchaFieldId);
if (recaptchaField) {
recaptchaField.value = token;
} else {
console.error(`ContactForm: Recaptcha field with ID "${recaptchaFieldId}" not found`);
}
isSubmittingRef.current = false;
form.submit();
})
.catch(error => {
console.error('ContactForm: reCAPTCHA execution failed', error);
isSubmittingRef.current = false;
});
});
} else {
console.error('ContactForm: grecaptcha is not loaded');
isSubmittingRef.current = false;
}
};
form.addEventListener('submit', handleSubmit);
return () => {
if (formRef.current) {
formRef.current.removeEventListener('submit', handleSubmit);
}
};
}, [siteKey, recaptchaFieldId]);
return null;
};
ContactForm.propTypes = {
siteKey: string.isRequired,
recaptchaFieldId: string.isRequired,
};
export default ContactForm;

View File

@@ -8,6 +8,7 @@
*/
import React, { useState, useCallback } from 'react';
import { shape, string } from 'prop-types';
const base64ToArrayBuffer = base64 => {
const binary = atob(base64.replace(/([-_])/g, m => ('-' === m ? '+' : '/')));
@@ -107,4 +108,11 @@ 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 Dialog from '@mui/material/Dialog';
import { styled } from '@mui/material/styles';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import { arrayOf, shape, string, bool } from 'prop-types';
const DIALOG_SX = {
const StyledDialog = styled(Dialog)({
'& .MuiDialog-paper': {
background: '#0a0e14',
color: '#e0e0e0',
@@ -47,7 +49,7 @@ const DIALOG_SX = {
background: 'rgba(2, 4, 8, 0.88)',
backdropFilter: 'blur(4px)',
},
};
});
const base64ToArrayBuffer = base64 => {
const binary = atob(base64.replace(/([-_])/g, m => ('-' === m ? '+' : '/')));
@@ -314,7 +316,7 @@ const PasskeyManager = ({ credentials, apiRoutes }) => {
)}
</div>
<Dialog open={addModalOpen} onClose={closeAddModal} sx={DIALOG_SX}>
<StyledDialog open={addModalOpen} onClose={closeAddModal}>
<DialogTitle>Add New Passkey</DialogTitle>
<DialogContent>
<TextField
@@ -344,9 +346,9 @@ const PasskeyManager = ({ credentials, apiRoutes }) => {
Continue
</Button>
</DialogActions>
</Dialog>
</StyledDialog>
<Dialog open={renameModalOpen} onClose={closeRenameModal} sx={DIALOG_SX}>
<StyledDialog open={renameModalOpen} onClose={closeRenameModal}>
<DialogTitle>Rename Passkey</DialogTitle>
<DialogContent>
<TextField
@@ -374,9 +376,9 @@ const PasskeyManager = ({ credentials, apiRoutes }) => {
Rename
</Button>
</DialogActions>
</Dialog>
</StyledDialog>
<Dialog open={deleteModalOpen} onClose={closeDeleteModal} sx={DIALOG_SX}>
<StyledDialog open={deleteModalOpen} onClose={closeDeleteModal}>
<DialogTitle>Delete Passkey</DialogTitle>
<DialogContent>
<p>
@@ -402,9 +404,25 @@ const PasskeyManager = ({ credentials, apiRoutes }) => {
Delete
</Button>
</DialogActions>
</Dialog>
</StyledDialog>
</div>
);
};
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 { BarChart } from '@mui/x-charts/BarChart';
import { LineChart } from '@mui/x-charts/LineChart';
import { PieChart } from '@mui/x-charts/PieChart';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import { shape, arrayOf, number, string } from 'prop-types';
const darkTheme = createTheme({
palette: {
@@ -16,6 +27,8 @@ const darkTheme = createTheme({
const WIN_COLOR = '#5ee89a';
const LOSS_COLOR = '#f67d52';
const DRAW_COLOR = '#95cff5';
const MINES_COLOR = '#f67d52';
const BONUS_COLOR = '#ffd700';
const axisStyle = {
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 }) {
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 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 (
<ThemeProvider theme={darkTheme}>
@@ -97,7 +112,54 @@ export default function ProfileCharts({ chartData }) {
</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>
</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';

30
assets/js/contact.jsx Normal file
View File

@@ -0,0 +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 { createRoot } from 'react-dom/client';
import { ContactForm } from '@global-components';
const wrapper = document.getElementById('contact-form-wrapper');
if (wrapper) {
const siteKey = wrapper.dataset.siteKey;
const recaptchaFieldId = wrapper.dataset.recaptchaFieldId;
if (siteKey && recaptchaFieldId) {
createRoot(wrapper).render(
<ContactForm
siteKey={siteKey}
recaptchaFieldId={recaptchaFieldId}
/>
);
} else {
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 { GameProvider } from '@mine-contexts';
import { GameBoard } from '@mine-components';
import { string } from 'prop-types';
const queryClient = new QueryClient();
const MineSeeker = ({ env, gameId }) => {
const MineSeeker = ({ env, gameId, opponentName = '' }) => {
const isEnvDev = 'dev' === env;
const gameAssoc = useRef('' !== gameId ? gameId : crypto.randomUUID()).current;
const gameInherited = '' !== gameId;
@@ -25,6 +26,7 @@ const MineSeeker = ({ env, gameId }) => {
<GameBoard
gameAssoc={gameAssoc}
gameInherited={gameInherited}
opponentName={opponentName}
isEnvDev={isEnvDev}
/>
</GameProvider>
@@ -33,3 +35,9 @@ const MineSeeker = ({ env, gameId }) => {
};
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

@@ -0,0 +1,123 @@
/**
* 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, { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import { func, node, string } from 'prop-types';
const CAPTCHA_STORAGE_KEY = 'mineseeker_captcha_verified';
const CAPTCHA_TOKEN_KEY = 'mineseeker_captcha_token';
const RECAPTCHA_ACTION = 'mineseeker_play';
const CaptchaOverlay = ({ siteKey, onVerified, children }) => {
const [verified, setVerified] = useState(false);
const [loading, setLoading] = 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(() => {
const storedToken = sessionStorage.getItem(CAPTCHA_TOKEN_KEY);
const storedTime = sessionStorage.getItem(CAPTCHA_STORAGE_KEY);
if (storedToken && storedTime) {
const elapsed = (Date.now() - parseInt(storedTime)) / 1000;
if (110 > elapsed) {
const wrapper = document.getElementById('mine-wrapper');
if (wrapper) {
wrapper.dataset.captchaToken = storedToken;
}
setVerified(true);
onVerified?.();
return;
}
}
if (window.grecaptcha) {
window.grecaptcha.ready(() => {
window.grecaptcha
.execute(siteKey, { action: RECAPTCHA_ACTION })
.then(token => {
handleToken(token);
})
.catch(() => {
setError(true);
});
});
}
}, [siteKey, onVerified, handleToken]);
const handleClick = () => {
setLoading(true);
setError(false);
window.grecaptcha.ready(() => {
window.grecaptcha
.execute(siteKey, { action: RECAPTCHA_ACTION })
.then(token => {
handleToken(token);
setLoading(false);
})
.catch(() => {
setLoading(false);
setError(true);
setTimeout(() => setError(false), 2000);
});
});
};
if (verified) {
return <Fragment>{children}</Fragment>;
}
return (
<div className="captcha-overlay">
<div className="captcha-content">
<div className="captcha-icon">
<i className="fa fa-shield-halved" />
</div>
<h1 className="captcha-title">Ready to Play?</h1>
<p className="captcha-description">
Click below to verify you&apos;re human and start playing.
</p>
<button
className={buttonClasses}
onClick={handleClick}
disabled={loading}
>
<i className={`fa ${loading ? 'fa-spinner fa-spin' : error ? 'fa-exclamation-circle' : 'fa-play'}`} />
{loading ? 'Verifying...' : error ? 'Try Again' : 'Start Playing'}
</button>
</div>
</div>
);
};
export default CaptchaOverlay;
CaptchaOverlay.propTypes = {
siteKey: string.isRequired,
onVerified: func,
children: node,
};

View File

@@ -0,0 +1,48 @@
/**
* 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 { Fragment, useEffect, useState } from 'react';
import { func, number } from 'prop-types';
const ChallengeCountdown = ({ onAccept, onDecline, seconds = 30 }) => {
const [countdown, setCountdown] = useState(seconds);
useEffect(() => {
const interval = setInterval(() => {
setCountdown(prev => {
if (1 >= prev) {
clearInterval(interval);
onDecline();
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}, [onDecline]);
return (
<Fragment>
<p style={{ textAlign: 'center', marginBottom: 20, color: '#95cff5' }}>
You have {countdown} second{1 === countdown ? '' : 's'} to answer to the challenge!
</p>
<div className="resign">
<a onClick={onAccept}>Accept</a>
<a onClick={onDecline}>Decline</a>
</div>
</Fragment>
);
};
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.
*/
import React from 'react';
import React, { useState } from 'react';
import { useGame } from '@mine-contexts';
import { useServerCommunication } from '@mine-hooks';
import CaptchaOverlay from './CaptchaOverlay';
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 { 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) {
return (
@@ -24,10 +29,24 @@ export const GameBoard = ({ gameAssoc, gameInherited, isEnvDev }) => {
);
}
if (!captchaVerified && siteKey) {
return (
<CaptchaOverlay siteKey={siteKey} onVerified={() => setCaptchaVerified(true)} />
);
}
return (
<GridControl
gameAssoc={gameAssoc}
onClick={onClick}
resign={resign}
/>
);
};
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.
*/
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';
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 { overlay, connectionLost, endRef, activePlayer, red, blue } = useGame();
const [redTime, setRedTime] = useState(0);
const [blueTime, setBlueTime] = useState(0);
const [isRunning, setIsRunning] = useState(false);
const [bonusDialogOpen, setBonusDialogOpen] = useState(false);
const timerIntervalRef = useRef(null);
const gameStartedRef = useRef(false);
// Use timestamps instead of counters for more reliable background tracking
const redStartTimeRef = useRef(null);
const blueStartTimeRef = useRef(null);
const lastActivePlayerRef = useRef(null);
const pausedRedTimeRef = useRef(0);
const pausedBlueTimeRef = useRef(0);
// Start timer when overlay is hidden (both players connected and game started)
useEffect(() => {
if (!overlay && !gameStartedRef.current) {
gameStartedRef.current = true;
@@ -50,28 +39,20 @@ const GameTimer = () => {
pausedBlueTimeRef.current = 0;
lastActivePlayerRef.current = activePlayer;
}
}, [overlay]);
}, [activePlayer, overlay]);
// Stop timer on game end (resign/win)
useEffect(() => {
if (endRef.current) {
setIsRunning(false);
}
}, [endRef.current]);
if (endRef.current) setIsRunning(false);
}, [endRef]);
// Stop timer on connection loss
useEffect(() => {
if (connectionLost) {
setIsRunning(false);
}
if (connectionLost) setIsRunning(false);
}, [connectionLost]);
// Handle player switch - pause one timer, resume the other
useEffect(() => {
if (!isRunning) return;
if (lastActivePlayerRef.current !== activePlayer) {
// Player switched, save current accumulated time for whoever was active
const startRef = lastActivePlayerRef.current ? blueStartTimeRef.current : redStartTimeRef.current;
if (startRef) {
const elapsed = Math.floor((Date.now() - startRef) / 1000);
@@ -82,7 +63,6 @@ const GameTimer = () => {
}
}
// Start the new active player's timer
if (activePlayer) {
blueStartTimeRef.current = Date.now();
} else {
@@ -93,85 +73,61 @@ const GameTimer = () => {
}
}, [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(() => {
if (!isRunning) {
if (timerIntervalRef.current) {
clearInterval(timerIntervalRef.current);
}
if (timerIntervalRef.current) clearInterval(timerIntervalRef.current);
return;
}
timerIntervalRef.current = setInterval(() => {
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);
timerIntervalRef.current = setInterval(syncTimes, 100);
return () => {
if (timerIntervalRef.current) {
clearInterval(timerIntervalRef.current);
}
if (timerIntervalRef.current) clearInterval(timerIntervalRef.current);
};
}, [isRunning, activePlayer]);
}, [isRunning, activePlayer, syncTimes]);
// Handle focus/blur to synchronize timer when tab regains focus
useEffect(() => {
const handleFocus = () => {
// Force update when tab regains focus to sync any background drift
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);
}
if (isRunning) syncTimes();
};
window.addEventListener('focus', handleFocus);
return () => window.removeEventListener('focus', handleFocus);
}, [isRunning, activePlayer]);
}, [isRunning, activePlayer, syncTimes]);
// Cleanup on unmount
useEffect(() => () => {
if (timerIntervalRef.current) {
clearInterval(timerIntervalRef.current);
}
if (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 (
<div className="game-timer-container">
<BonusBox color="red" points={red.bonusPoints ?? 0} onClick={() => setBonusDialogOpen(true)} />
<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`} />
<span className="timer-display">{formatTime(redTime)}</span>
</div>
<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`} />
<span className="timer-display">{formatTime(blueTime)}</span>
</div>
<BonusBox color="blue" points={blue.bonusPoints ?? 0} onClick={() => setBonusDialogOpen(true)} />
<BonusStatsDialog open={bonusDialogOpen} onClose={() => setBonusDialogOpen(false)} red={red} blue={blue} />
</div>
);
};

View File

@@ -8,38 +8,13 @@
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { formatSince } from '@global-utils/format';
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 = {
'& .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 OnlinePlayersDialog = ({ open, onClose, currentGameAssoc, isEnvDev = false }) => {
const [players, setPlayers] = useState([]);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(false);
@@ -47,7 +22,9 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
const [snapshotLoaded, setSnapshotLoaded] = useState(false);
const [challengingGameAssoc, setChallengingGameAssoc] = useState(null);
const [declinedMsg, setDeclinedMsg] = useState('');
const [waitingCountdown, setWaitingCountdown] = useState(0);
const declinedTimerRef = useRef(null);
const { waitingPlayersQuery, challengeMutation } = useLobbyDataProvider();
const addPlayer = useCallback(entry => {
setPlayers(prev =>
@@ -65,20 +42,21 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
if (!open) return;
setLoading(true);
setSnapshotLoaded(false);
fetch('/api/game/waiting')
.then(r => r.json())
.then(data => {
waitingPlayersQuery.refetch().then(result => {
if (result.data) {
// 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);
setSnapshotLoaded(true);
setLoading(false);
})
.catch(() => {
setPlayers([]);
setSnapshotLoaded(true);
setLoading(false);
});
}
setSnapshotLoaded(true);
setLoading(false);
}).catch(() => {
setPlayers([]);
setSnapshotLoaded(true);
setLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, refreshKey, currentGameAssoc]);
useEffect(() => {
@@ -106,11 +84,19 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
return () => es.close();
}, [open, snapshotLoaded, addPlayer, removePlayer, currentGameAssoc]);
useEffect(() => {
if (challengeMutation.isError) {
setChallengingGameAssoc(null);
setWaitingCountdown(0);
}
}, [challengeMutation.isError]);
useEffect(() => {
const handler = () => {
setChallengingGameAssoc(null);
clearTimeout(declinedTimerRef.current);
setDeclinedMsg('Challenge was not accepted.');
setWaitingCountdown(0);
declinedTimerRef.current = setTimeout(() => setDeclinedMsg(''), 3500);
};
window.addEventListener('challenge-declined', handler);
@@ -120,15 +106,26 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
};
}, []);
useEffect(() => {
if (!waitingCountdown) return;
const interval = setInterval(() => {
setWaitingCountdown(prev => {
if (1 >= prev) return 0;
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}, [waitingCountdown]);
const handleChallenge = player => {
if (challengingGameAssoc) return;
setChallengingGameAssoc(player.gameAssoc);
setDeclinedMsg('');
fetch('/api/game/challenge/' + player.gameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challengerGameAssoc: currentGameAssoc }),
}).catch(() => setChallengingGameAssoc(null));
setWaitingCountdown(30);
challengeMutation.mutate(
{ targetGameAssoc: player.gameAssoc, challengerGameAssoc: currentGameAssoc }
);
};
const visible = players
@@ -139,15 +136,19 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
const hasMore = 5 < visible.length;
// 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);
if (userInList) {
console.warn('[OnlinePlayersDialog] Current user appears in players list:', { currentGameAssoc, userInList });
}
}
return (
<Dialog open={open} onClose={onClose} sx={DIALOG_SX}>
<StyledDialog
open={open}
onClose={0 < waitingCountdown ? undefined : onClose}
>
<div className="opd">
<div className="opd-header">
<div className="opd-header-text">
@@ -160,32 +161,44 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
<div className="opd-header-actions">
<button
className={`opd-refresh${loading ? ' opd-refresh--spin' : ''}`}
onClick={() => setRefreshKey(k => k + 1)}
disabled={loading}
onClick={() => { if (0 === waitingCountdown) setRefreshKey(k => k + 1); }}
disabled={loading || 0 < waitingCountdown}
aria-label="Refresh"
title="Refresh list"
>
<i className="fa fa-refresh" />
</button>
<button className="opd-close" onClick={onClose} aria-label="Close">
<button
className="opd-close"
onClick={() => { if (0 === waitingCountdown) onClose(); }}
disabled={0 < waitingCountdown}
aria-label="Close"
>
<i className="fa fa-times" />
</button>
</div>
</div>
<div className="opd-search-wrap">
<i className="fa fa-search opd-search-icon" />
<input
className="opd-search"
placeholder="Search by username…"
value={search}
onChange={e => setSearch(e.target.value)}
/>
{search && (
<button className="opd-search-clear" onClick={() => setSearch('')}>
<i className="fa fa-times" />
</button>
)}
</div>
{0 < waitingCountdown ? (
<div className="opd-waiting">
<i className="fa fa-hourglass-start" />
<p>Waiting {waitingCountdown} second{1 === waitingCountdown ? '' : 's'} for opponent's answer...</p>
</div>
) : (
<div className="opd-search-wrap">
<i className="fa fa-search opd-search-icon" />
<input
className="opd-search"
placeholder="Search by username…"
value={search}
onChange={e => setSearch(e.target.value)}
/>
{search && (
<button className="opd-search-clear" onClick={() => setSearch('')}>
<i className="fa fa-times" />
</button>
)}
</div>
)}
<div className="opd-list">
{loading && (
<div className="opd-empty">
@@ -222,7 +235,7 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
<div className="opd-info">
<span className="opd-name">{player.name}</span>
<span className="opd-since">
<i className="fa fa-clock-o" />
<i className="fa fa-clock" />
{' '}Waiting {formatSince(player.since)}
</span>
</div>
@@ -245,8 +258,37 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
</p>
)}
</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;
OnlinePlayersDialog.propTypes = {
open: bool.isRequired,
onClose: func.isRequired,
currentGameAssoc: string,
isEnvDev: bool,
};

View File

@@ -8,47 +8,57 @@
*/
import { Fragment, useState } from 'react';
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 inviteHeader = inviteOnly && opponentName
? `Invite ${opponentName}`
: 'Invite a Friend';
return (
<Fragment>
<div className="waiting-options">
<div className={`waiting-options${inviteOnly ? ' waiting-options--invite-only' : ''}`}>
<div className="waiting-option">
<div className="waiting-option-header">
<i className="fa fa-link" />
<span>Invite a Friend</span>
<span>{inviteHeader}</span>
</div>
<p className="waiting-option-desc">Share this link with your opponent</p>
<ShareLinkBox
url={shareUrl}
/>
</div>
<div className="waiting-divider">
<span>OR</span>
</div>
<div className="waiting-option">
<div className="waiting-option-header">
<i className="fa fa-users" />
<span>Challenge a Player</span>
</div>
<p className="waiting-option-desc">Browse online players and challenge them</p>
<button
className="browse-players-btn"
onClick={() => setDialogOpen(true)}
>
<i className="fa fa-search" />
Browse Players
</button>
</div>
{!inviteOnly && (
<Fragment>
<div className="waiting-divider">
<span>OR</span>
</div>
<div className="waiting-option">
<div className="waiting-option-header">
<i className="fa fa-users" />
<span>Challenge a Player</span>
</div>
<p className="waiting-option-desc">Browse online players and challenge them</p>
<button
className="browse-players-btn"
onClick={() => setDialogOpen(true)}
>
<i className="fa fa-search" />
Browse Players
</button>
</div>
</Fragment>
)}
</div>
<OnlinePlayersDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
currentGameAssoc={currentGameAssoc}
/>
{!inviteOnly && (
<OnlinePlayersDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
currentGameAssoc={currentGameAssoc}
/>
)}
</Fragment>
);
};
@@ -57,10 +67,12 @@ const ShareLinkBox = ({ url }) => {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(url).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2500);
}).catch(() => {});
navigator.clipboard.writeText(url)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2500);
})
.catch(() => null);
};
return (
@@ -83,3 +95,10 @@ const ShareLinkBox = ({ url }) => {
};
export default WaitingOverlayContent;
WaitingOverlayContent.propTypes = {
shareUrl: string.isRequired,
currentGameAssoc: string,
opponentName: string,
inviteOnly: bool,
};

View File

@@ -7,20 +7,35 @@
* file that was distributed with this source code.
*/
import React, { Fragment } from 'react';
import React, { Fragment, useState } from 'react';
import { useGame } from '@mine-contexts';
import GridField from './GridField';
import UserControl from '../user/UserControl';
import GameTimer from '../GameTimer';
import { BOMB_SYMBOLS, bombRadius } from '@mine-utils';
import { func, string } from 'prop-types';
const GridControl = ({ onClick, resign }) => {
const GridControl = ({ gameAssoc, onClick, resign }) => {
const {
overlay, overlayTitle, overlaySubTitle,
webPlayer, activePlayer, bombSelected,
cells, setCells,
cells, setCells, endRef,
} = useGame();
const [copied, setCopied] = useState(false);
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 url = endRef.current ? endShareUrl : shareUrl;
if (!url) return;
navigator.clipboard.writeText(url).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2200);
});
};
const handleHover = (row, col) => {
if (!bombSelected) return;
const activeColor = activePlayer ? 'blue' : 'red';
@@ -47,7 +62,34 @@ const GridControl = ({ onClick, resign }) => {
<div className={`game-overlay${overlay ? '' : ' hide'}`}>
<div className="game-overlay-window">
<h1>{overlayTitle}</h1>
<h2>{overlaySubTitle}</h2>
{'string' === typeof overlaySubTitle && (
<h2>{overlaySubTitle}</h2>
)}
{'string' !== typeof overlaySubTitle && (
<Fragment>{overlaySubTitle}</Fragment>
)}
{gameAssoc && endRef.current && (
<div className="game-overlay-actions">
<button
className={`game-overlay-share${copied ? ' copied' : ''}`}
onClick={handleShare}
title="Copy share link"
aria-label="Copy share link"
>
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} />
{copied ? 'Copied!' : 'Share Battle'}
</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>
<UserControl
@@ -73,3 +115,9 @@ const GridControl = ({ onClick, resign }) => {
};
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 { IMAGES } from '@mine-utils';
import { func, shape, bool, number, string } from 'prop-types';
const bombSrc = area => {
if (null === area) return null;
@@ -53,7 +54,10 @@ const GridField = memo(function GridField({ cell, onClick, onMouseEnter }) {
/>
)}
<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) && (
<div className="flag-mine">
<img src={currentImage} alt="" />
@@ -72,3 +76,16 @@ const GridField = memo(function GridField({ cell, onClick, onMouseEnter }) {
});
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

@@ -10,8 +10,13 @@
export { GameBoard } from './GameBoard';
export { default as OnlinePlayersDialog } from './OnlinePlayersDialog';
export { default as WaitingOverlayContent } from './WaitingOverlayContent';
export { default as ChallengeCountdown } from './ChallengeCountdown';
export { default as GameTimer } from './GameTimer';
export { default as GridControl } from './grid/GridControl';
export { default as GridField } from './grid/GridField';
export { default as User } from './user/User';
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 { IMAGES } from '@mine-utils';
import { bool, func, number, string } from 'prop-types';
const User = memo(function User(
{
@@ -52,3 +53,15 @@ const User = memo(function 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.
*/
import React from 'react';
import React, { Fragment, useState } from 'react';
import { useGame } from '@mine-contexts';
import User from './User';
import BonusStatsDialog from '../BonusStatsDialog';
import { func } from 'prop-types';
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 resignClass = 'resign' + (activeColor !== webPlayer ? ' disabled' : '');
const minesClass = 'active-mines' + (foundMines ? ' found-mine' : '');
const remainingMines = 51 - red.mines - blue.mines;
const handleBombClick = (color, player) => {
const p = 'red' === color ? red : blue;
@@ -24,31 +28,49 @@ const UserControl = ({ resign }) => {
}
};
const handleBonusClick = () => {
setBonusDialogOpen(true);
};
return (
<div className="users">
<User
color="blue" webPlayer={webPlayer} {...blue}
onClickBombSelector={() => handleBombClick('blue', 1)}
/>
<div className="active-mines-container">
<i className="fa fa-star" />
<div className={minesClass}>
<div className="active-mines-nbr">{mines}</div>
<div className="active-mines-shine" />
<Fragment>
<div className="users">
<User
color="blue" webPlayer={webPlayer} {...blue}
onClickBombSelector={() => handleBombClick('blue', 1)}
onBonusClick={handleBonusClick}
/>
<div className="active-mines-container">
<i className="fa fa-star" />
<div className={minesClass}>
<div className="active-mines-nbr">{remainingMines}</div>
<div className="active-mines-shine" />
</div>
<i className="fa fa-star" />
</div>
<i className="fa fa-star" />
<div className="clear" />
<User
color="red" webPlayer={webPlayer} {...red}
onClickBombSelector={() => handleBombClick('red', 0)}
onBonusClick={handleBonusClick}
/>
<button className={resignClass} onClick={resign}>
<div className="resign-shine" />
Resign
</button>
</div>
<div className="clear" />
<User
color="red" webPlayer={webPlayer} {...red}
onClickBombSelector={() => handleBombClick('red', 0)}
<BonusStatsDialog
open={bonusDialogOpen}
onClose={() => setBonusDialogOpen(false)}
red={red}
blue={blue}
/>
<button className={resignClass} onClick={resign}>
<div className="resign-shine" />
Resign
</button>
</div>
</Fragment>
);
}
export default UserControl;
UserControl.propTypes = {
resign: func.isRequired,
};

View File

@@ -41,6 +41,8 @@ export const GameProvider = ({ children }) => {
connectionLost, setConnectionLost,
} = useGameState();
const [gameUuid, setGameUuid] = React.useState(null);
const sounds = useRef({
click: new Howl({ src: ['/sound/click.mp3'] }),
bomb: new Howl({ src: ['/sound/bomb.mp3'] }),
@@ -130,7 +132,18 @@ export const GameProvider = ({ children }) => {
};
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) {
sounds.current.bomb.play();
@@ -174,6 +187,18 @@ export const GameProvider = ({ children }) => {
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 }));
syncBlue(p => ({ ...p, enabledBomb: bp <= rp }));
@@ -193,7 +218,10 @@ export const GameProvider = ({ children }) => {
if (redWins || blueWins || resign) {
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);
syncActivePlayer(false);
@@ -202,8 +230,11 @@ export const GameProvider = ({ children }) => {
}
};
const resignProcess = color => {
const resignProcess = (color, uuid = null) => {
const wp = webPlayerRef.current;
if (uuid) {
setGameUuid(uuid);
}
showOverlay(
color === wp ? 'You have been give up' : 'Your opponent has been resigned',
color === wp ? 'You LOSE!' : 'You WIN!',
@@ -223,20 +254,20 @@ export const GameProvider = ({ children }) => {
return (
<GameContext.Provider
value={{
// State (for rendering)
/** State (for rendering) */
webPlayer, activePlayer, overlay, overlayTitle, overlaySubTitle,
mines, bombSelected, foundMines, red, blue, cells, gridReady, connectionLost,
// Setters needed by useServerComm
setCells, setGridReady,
// Refs (needed by useServerComm for async-safe reads)
webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef,
// Sync helpers
mines, bombSelected, foundMines, red, blue, cells, gridReady, connectionLost, gameUuid,
/** Setters needed by useServerComm */
setCells, setGridReady, setGameUuid,
/** Refs (needed by useServerComm for async-safe reads) */
webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, lastClickedRef, endRef,
/** Sync helpers */
syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue,
// Game logic called by useServerComm
/** Game logic called by useServerComm */
showOverlay, hideOverlay,
applyRevealedCell, applyStep,
makeGameEndIfItEnds, resignProcess,
// UI action
/** UI action */
onBombToggle,
}}
>

View File

@@ -11,4 +11,4 @@ export { default as useGameRefs } from './useGameRefs';
export { default as useGameState } from './useGameState';
export { default as useServerCommunication } from './useServerCommunication';
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,104 +8,230 @@
*/
import React, { useEffect, useRef } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useGame } from '@mine-contexts';
import { DESC } from '@mine-utils';
import useStepTimer from './useStepTimer';
import { WaitingOverlayContent } from '@mine-components';
import { DESC, IMAGES } from '@mine-utils';
import { ChallengeCountdown, WaitingOverlayContent } from '@mine-components';
import { useGameDataProvider, useStepTimer } from '@mine-hooks';
/** Handles all server communication: SSE (Mercure), REST calls, and the initialization lifecycle. */
const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev) => {
const {
/** Async-safe refs */
webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef,
webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, lastClickedRef, endRef,
/** State setters */
setGridReady,
setCells, setGridReady, setGameUuid,
/** Sync helpers */
syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue,
/** Game logic */
showOverlay, hideOverlay,
applyRevealedCell, applyStep,
makeGameEndIfItEnds, resignProcess,
showOverlay, hideOverlay, applyStep, makeGameEndIfItEnds, resignProcess,
/** Current cells snapshot (for active-check in onClick) */
cells,
} = 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 rpcUsersRef = useRef(null);
const stepCacheRef = useRef([]);
const lastStepRef = useRef(null);
const isGameFinishedRef = useRef(false);
const { getStepElapsed, resetStepTimer, startNewTurn } = useStepTimer();
const isGameRunningRef = useRef(false);
const lastActivePlayerRef = useRef(null);
const heartbeatPubIntervalRef = useRef(null);
const opponentLastSeenRef = useRef(0);
const isTrueRestoredRef = useRef(false);
/** REST mutations / 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,
});
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()),
});
const HEARTBEAT_INTERVAL_MS = 1500;
/** Game-start helpers (triggered by server events) */
const wInit = (revealedCells = []) => {
setGridReady(true);
showOverlay('Choose an opponent!', gameAssoc ? (
<WaitingOverlayContent
shareUrl={`${window.location.href}/${gameAssoc}`}
currentGameAssoc={gameAssoc}
/>
) : '');
setTimeout(() => revealedCells.forEach(cell => applyRevealedCell(cell, cell.player)), 0);
const wInit = (revealedCells = [], lastStep = {}, gameState = {}, isGameFinished = false) => {
/** Detect if this is a restored game */
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
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}
/>
) : '';
}
showOverlay(overlayTitle, overlaySubtitle);
/** Use Promise.resolve to defer setGridReady slightly to ensure overlay is rendered first */
Promise.resolve().then(() => setGridReady(true));
};
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 => ({
...p,
name: payload.users.red || payload.users.redAnon || p.name,
registered: !!payload.users.red,
avatar: payload.users.redAvatar ?? null,
desc: 'red' === starterColor ? starterDesc : '',
active: 'red' === starterColor,
}));
syncBlue(p => ({
...p,
name: payload.users.blue || payload.users.blueAnon || p.name,
registered: !!payload.users.blue,
avatar: payload.users.blueAvatar ?? null,
desc: 'blue' === webPlayerRef.current ? DESC.you : DESC.buddy,
active: true,
desc: 'blue' === starterColor ? starterDesc : '',
active: 'blue' === starterColor,
}));
isGameRunningRef.current = true;
lastActivePlayerRef.current = 1; // Blue starts
lastActivePlayerRef.current = starterVal;
startNewTurn();
resetStepTimer();
hideOverlay();
/**
* 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();
}
};
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 */
@@ -131,43 +257,68 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
const wUnsubscribe = payload => {
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 { challengerName, challengerGameAssoc } = payload;
let declineTimeout = null;
const handleAccept = () => {
fetch('/api/game/challenge/respond/' + challengerGameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accepted: true, targetGameAssoc: gameAssoc }),
}).then(() => {
showOverlay('Challenge accepted!', 'Waiting for the challenger to join...');
}).catch(() => {});
clearTimeout(declineTimeout);
challengeRespondMutation.mutate(
{ challengerGameAssoc, accepted: true, targetGameAssoc: gameAssoc },
{
onSuccess: () => {
showOverlay('Challenge accepted!', 'Waiting for the challenger to join...');
},
},
);
};
const handleDecline = () => {
fetch('/api/game/challenge/respond/' + challengerGameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accepted: false, targetGameAssoc: gameAssoc }),
}).then(() => {
showOverlay('We are waiting for your opponent...', gameAssoc ? (
<WaitingOverlayContent
shareUrl={window.location.origin + '/play/' + gameAssoc}
currentGameAssoc={gameAssoc}
/>
) : '');
}).catch(() => {});
clearTimeout(declineTimeout);
challengeRespondMutation.mutate(
{ challengerGameAssoc, accepted: false, targetGameAssoc: gameAssoc },
{
onSuccess: () => {
showOverlay('We are waiting for your opponent...', gameAssoc ? (
<WaitingOverlayContent
shareUrl={`${window.location.origin}/play/${gameAssoc}`}
currentGameAssoc={gameAssoc}
/>
) : '');
},
},
);
};
declineTimeout = setTimeout(handleDecline, 30000);
showOverlay(
challengerName + ' wants to challenge you!',
<div className="resign">
<a onClick={handleAccept}>Accept</a>
<a onClick={handleDecline}>Decline</a>
</div>,
<ChallengeCountdown onAccept={handleAccept} onDecline={handleDecline} />,
);
};
@@ -185,17 +336,22 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
isEnvDev && console.warn(payload.user + ' stepped to ' + payload.data.coords.join(','));
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) {
startNewTurn();
lastActivePlayerRef.current = activePlayerRef.current;
}
applyStep(payload.data);
if (payload.data.uuid && !endRef.current) {
setGameUuid(payload.data.uuid);
}
makeGameEndIfItEnds(payload.data.bluePoints, payload.data.redPoints, false, payload.data.leftMines);
} else {
resignProcess(payload.data.resign);
resignProcess(payload.data.resign, payload.data.uuid);
}
}
};
@@ -204,6 +360,16 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
if (undefined !== payload.type) {
if ('challenge' === payload.type) wChallenge(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;
}
@@ -229,9 +395,9 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
const subscriberJwt = wrapper.dataset.mercureSubscriberJwt;
const url = new URL(hubUrl, window.location.origin);
url.searchParams.append('topic', 'mineseeker/channel/' + gameAssoc);
if (subscriberJwt) url.searchParams.append('authorization', subscriberJwt);
url.searchParams.append('topic', `mineseeker/channel/${gameAssoc}`);
if (subscriberJwt) url.searchParams.append('authorization', subscriberJwt);
if (eventSourceRef.current) eventSourceRef.current.close();
const es = new EventSource(url.toString());
@@ -258,6 +424,7 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
openEventSource();
return;
}
try {
if (gameInherited) {
const serverData = await connectQuery.refetch().then(r => {
@@ -272,23 +439,50 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
}
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();
wInit(serverData.revealedCells || []);
} 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();
wInit();
}
isEnvDev && console.info('Connection initialised — joining channel');
await joinMutation.mutateAsync();
startHeartbeat();
} catch (e) {
isEnvDev && console.error('Connection error', e);
showOverlay('Error', 'Connection failed. Please try again.');
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
}, []);
@@ -312,6 +506,11 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
try {
const result = await stepMutation.mutateAsync(dataPack);
applyStep(result);
if (result.uuid && !endRef.current) {
setGameUuid(result.uuid);
}
makeGameEndIfItEnds(result.bluePoints, result.redPoints, false, result.leftMines);
} catch (e) {
isEnvDev && console.error('Step error', e);
@@ -321,13 +520,24 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
const clickResign = () => {
const color = activePlayerRef.current ? 'blue' : 'red';
const stepElapsed = getStepElapsed(activePlayerRef.current, isGameRunningRef.current);
stepMutation.mutate({ resign: color, stepElapsed });
resignProcess(webPlayerRef.current);
stepMutation.mutate(
{ resign: color, stepElapsed },
{
onSuccess: result => {
if (result?.uuid && !endRef.current) {
resignProcess(webPlayerRef.current, result.uuid);
}
},
},
);
};
const resign = () => {
const activeColor = activePlayerRef.current ? 'blue' : 'red';
if (webPlayerRef.current !== activeColor) return;
showOverlay('Are u sure u want to resign?!', (
<div className="resign">
<a onClick={clickResign}>Yes</a>

View File

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

View File

@@ -34,9 +34,23 @@ export const IMAGES = {
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 = {
name: '...', desc: '', active: false, mines: 0, haveBomb: true, enabledBomb: true,
registered: false, avatar: null,
bonusPoints: 0, bonusStats: { ...BONUS_STATS_DEF },
};
export const DESC = {

View File

@@ -7,4 +7,4 @@
* 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 { createRoot } from 'react-dom/client';
import PasskeyManager from './components/PasskeyManager';
import PasskeyLogin from './components/PasskeyLogin';
import { PasskeyLogin, PasskeyManager } from '@global-components';
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 { createRoot } from 'react-dom/client';
import ProfileCharts from './components/ProfileCharts';
import BattleDialog from './components/BattleDialog';
import AvatarUpload from './components/AvatarUpload';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AvatarUpload, BattleDialog, ProfileCharts } from '@global-components';
const queryClient = new QueryClient();
const avatarRoot = document.getElementById('profile-avatar-root');
if (avatarRoot) {
const { uploadUrl, thumbUrl, initials } = avatarRoot.dataset;
createRoot(avatarRoot).render(
<AvatarUpload
uploadUrl={uploadUrl}
initialThumbUrl={thumbUrl || null}
initials={initials}
/>,
<QueryClientProvider client={queryClient}>
<AvatarUpload
uploadUrl={uploadUrl}
initialThumbUrl={thumbUrl || null}
initials={initials}
/>
</QueryClientProvider>,
);
}
@@ -29,3 +41,28 @@ if (battleRoot) {
<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",
"@fortawesome/fontawesome-free": "^7.2.0",
"@mui/material": "^9.0.0",
"@mui/x-charts": "^9.0.1",
"@tanstack/react-query": "^5.0.0",
"howler": "^2.1.2",
"@mui/x-charts": "^9.0.2",
"@tanstack/react-query": "^5.99.2",
"howler": "^2.2.4",
"lodash": "^4.18.1",
"prop-types": "^15.7.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"prop-types": "^15.8.1",
"react": "^19.2.5",
"react-dom": "^19.2.5",
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^9.0.0",
"@stylistic/eslint-plugin": "^4.0.0",
"@eslint/js": "10.0.1",
"@stylistic/eslint-plugin": "5.10.0",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.0.0",
"eslint-plugin-react": "^7.0.0",
"eslint-plugin-react-hooks": "^5.0.0",
"globals": "^15.0.0",
"sass": "^1.77.0",
"eslint": "10.2.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "7.1.1",
"globals": "17.5.0",
"sass": "^1.99.0",
"vite": "^8.0.8",
"vite-plugin-symfony": "^8.2.4",
},
@@ -38,16 +38,28 @@
"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/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/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-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-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/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/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/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/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=="],
@@ -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/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/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/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-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=="],
@@ -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=="],
"@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=="],
@@ -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/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/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=="],
"@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/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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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-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=="],
"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=="],
"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=="],
"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=="],
@@ -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=="],
"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=="],
"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=="],
"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=="],
"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-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=="],
@@ -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=="],
"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-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=="],
"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=="],
@@ -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-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-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=="],
"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=="],
"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=="],
@@ -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=="],
"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=="],
"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.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=="],
"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=="],
"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-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-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=="],
"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=="],
@@ -774,8 +788,6 @@
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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/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=="],
"@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=="],
"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=="],
@@ -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=="],
"prop-types/react-is": ["react-is@16.11.0", "http://registry.npmjs.org/react-is/-/react-is-16.11.0.tgz", {}, "sha512-gbBVYR2p8mnriqAwWx9LbuUrShnAuSCNnuPGyc7GJrMVQtPDAh8iLpv7FRuMPFb56KkaVZIYSz1PrjI9q0QPCw=="],
"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=="],
"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=="],
"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=="],
"@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=="],
"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=="],
}
}

View File

@@ -11,6 +11,8 @@ services:
SERVER_NAME: ${SERVER_NAME:-:80}
APP_ENV: prod
APP_SECRET: ${APP_SECRET}
APP_PUBLIC_HOSTNAME: ${APP_PUBLIC_HOSTNAME:-localhost}
APP_CONTACT_MAIL_ADDRESS: ${APP_CONTACT_MAIL_ADDRESS:-7system7@gmail.com}
DATABASE_URL: >-
postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?serverVersion=${POSTGRES_VERSION}&charset=utf8
POSTGRES_URL: db
@@ -31,6 +33,11 @@ services:
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
MINIO_ENDPOINT: http://minio:9000
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9000}
# 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:
- app_var:/app/var
- caddy_data:/data
@@ -88,6 +95,7 @@ services:
RELAYHOST_PASSWORD: ${MAIL_RELAYHOST_PASSWORD:-}
volumes:
- postfix_spool:/var/spool/postfix
- ./docker/aliases:/tmp/aliases:ro
db:
image: postgres:${POSTGRES_VERSION:-18}-alpine
restart: unless-stopped
@@ -113,3 +121,5 @@ volumes:
caddy_config:
postfix_spool:
minio_data:

View File

@@ -1,94 +1,103 @@
{
"type": "project",
"license": "proprietary",
"require": {
"php": ">=8.5",
"ext-iconv": "*",
"ext-json": "*",
"doctrine/dbal": "^3.7",
"doctrine/doctrine-bundle": ">=2.11 <2.14",
"doctrine/doctrine-migrations-bundle": "^3.0",
"doctrine/orm": "^2.6",
"endroid/qr-code": "^6.1",
"league/flysystem-aws-s3-v3": "^3.0",
"league/flysystem-bundle": "^3.6",
"liip/imagine-bundle": "^2.13",
"pentatrion/vite-bundle": "^8.2",
"scheb/2fa-backup-code": "^8.5",
"scheb/2fa-bundle": "^8.5",
"scheb/2fa-totp": "^8.5",
"symfony/console": "7.4.*",
"symfony/flex": "^2.10.0",
"symfony/form": "7.4.*",
"symfony/framework-bundle": "7.4.*",
"symfony/http-client": "7.4.*",
"symfony/mailer": "7.4.*",
"symfony/mercure": "^0.6",
"symfony/mercure-bundle": "*",
"symfony/monolog-bundle": "^3.8",
"symfony/security-bundle": "7.4.*",
"symfony/translation": "7.4.*",
"symfony/twig-bundle": "7.4.*",
"symfony/validator": "7.4.*",
"symfony/yaml": "7.4.*",
"web-auth/webauthn-framework": "^5.2"
"name": "splendid-bear/mineseeker",
"version": "2026.2.1",
"license": "GPL-3.0-or-later",
"author": "https://www.splendidbear.org",
"bugs": "https://source.splendidbear.org",
"description": "This is a minesweeper game that is inspired from MSN Messenger's game.",
"minimum-stability": "dev",
"type": "project",
"prefer-stable": true,
"private": true,
"require": {
"php": ">=8.5",
"ext-gd": "*",
"ext-iconv": "*",
"ext-json": "*",
"doctrine/dbal": "^4.3",
"doctrine/doctrine-bundle": "^3.2",
"doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.5",
"endroid/qr-code": "^6.1",
"firebase/php-jwt": "^7.0",
"league/flysystem-aws-s3-v3": "^3.0",
"league/flysystem-bundle": "^3.6",
"liip/imagine-bundle": "^2.13",
"pentatrion/vite-bundle": "^8.2",
"scheb/2fa-backup-code": "^8.5",
"scheb/2fa-bundle": "^8.5",
"scheb/2fa-totp": "^8.5",
"symfony/console": "7.4.*",
"symfony/flex": "^2.10.0",
"symfony/form": "7.4.*",
"symfony/framework-bundle": "7.4.*",
"symfony/http-client": "7.4.*",
"symfony/mailer": "7.4.*",
"symfony/mercure": "^0.6",
"symfony/mercure-bundle": "*",
"symfony/monolog-bundle": "^3.8",
"symfony/security-bundle": "7.4.*",
"symfony/translation": "7.4.*",
"symfony/twig-bundle": "7.4.*",
"symfony/validator": "7.4.*",
"symfony/yaml": "7.4.*",
"web-auth/webauthn-framework": "^5.2"
},
"require-dev": {
"roave/security-advisories": "dev-master",
"symfony/dotenv": "7.4.*",
"symfony/maker-bundle": "^1.5",
"symfony/stopwatch": "7.4.*",
"symfony/web-profiler-bundle": "7.4.*"
},
"config": {
"preferred-install": {
"*": "dist"
},
"require-dev": {
"firebase/php-jwt": "^7.0",
"roave/security-advisories": "dev-master",
"symfony/dotenv": "7.4.*",
"symfony/maker-bundle": "^1.5",
"symfony/stopwatch": "7.4.*",
"symfony/web-profiler-bundle": "7.4.*"
},
"config": {
"preferred-install": {
"*": "dist"
},
"sort-packages": true,
"allow-plugins": {
"symfony/flex": true
}
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"symfony/polyfill-iconv": "*",
"symfony/polyfill-ctype": "*",
"symfony/polyfill-php73": "*",
"symfony/polyfill-php72": "*",
"symfony/polyfill-php71": "*",
"symfony/polyfill-php70": "*",
"symfony/polyfill-php56": "*"
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install --symlink --relative %PUBLIC_DIR%": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"
],
"post-update-cmd": [
"@auto-scripts"
]
},
"conflict": {
"symfony/symfony": "*",
"doctrine/persistence": "<1.3"
},
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.4.*"
}
"sort-packages": true,
"allow-plugins": {
"symfony/flex": true
}
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"symfony/polyfill-iconv": "*",
"symfony/polyfill-ctype": "*",
"symfony/polyfill-php73": "*",
"symfony/polyfill-php72": "*",
"symfony/polyfill-php71": "*",
"symfony/polyfill-php70": "*",
"symfony/polyfill-php56": "*"
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install --symlink --relative %PUBLIC_DIR%": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"
],
"post-update-cmd": [
"@auto-scripts"
]
},
"conflict": {
"symfony/symfony": "*",
"doctrine/persistence": "<1.3"
},
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.4.*"
}
}
}

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)%'
orm:
auto_generate_proxy_classes: '%kernel.debug%'
enable_native_lazy_objects: true
naming_strategy: doctrine.orm.naming_strategy.underscore
auto_mapping: true
schema_ignore_classes:
- App\Entity\UserStats
- App\Entity\RecentBattle
mappings:
App:
is_bundle: false

View File

@@ -8,6 +8,13 @@ framework:
session:
handler_id: ~
# Trust headers from reverse proxy (Caddy)
# This ensures absolute_url() uses HTTPS scheme when behind a reverse proxy
# Production: TRUSTED_PROXIES from .env (Gitea secret)
# Development: TRUSTED_PROXIES from compose.override.yaml
trusted_proxies: '%env(TRUSTED_PROXIES)%'
trusted_headers: ['x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host', 'x-forwarded-port']
#esi: true
#fragments: true
php_errors:

View File

@@ -0,0 +1,8 @@
framework:
# In production with FrankenPHP, the reverse proxy (Caddy) is in the same container
# Requests come from 127.0.0.1, so we must trust that IP to process X-Forwarded-Proto headers
# TRUSTED_PROXIES is set in the .env file (stored in Gitea secrets)
# Typical value for Docker: 172.18.0.0/16 (or the specific Docker network CIDR)
# This must be provided by the PROD_ENV_FILE secret in Gitea
trusted_proxies: '%env(TRUSTED_PROXIES)%'
trusted_headers: ['x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host', 'x-forwarded-port']

View File

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

View File

@@ -25,6 +25,11 @@ services:
resource: '../src/Controller'
tags: [ 'controller.service_arguments' ]
App\Service\BattleCardGenerator:
arguments:
$cacheDir: '%kernel.project_dir%/var/og-cache'
$minioMediaStorage: '@mineseeker.media.storage'
Aws\S3\S3Client:
arguments:
- version: 'latest'

5
docker/aliases Normal file
View File

@@ -0,0 +1,5 @@
# Postfix aliases file
# Mail addressed to system users are redirected to this address
postmaster: root
root: root

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

@@ -1,50 +1,51 @@
{
"name": "mine-seeker",
"version": "1.0.0",
"description": "Mine Seeker Game by system7",
"keywords": [
"mine",
"seeker",
"game",
"multiplayer",
"websocket"
],
"author": "Laszlo Lang <system7>",
"license": "UNLICENSED",
"private": true,
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/changa-one": "^5.2.8",
"@fontsource/open-sans": "^5.2.7",
"@fontsource/rajdhani": "^5.2.7",
"@fortawesome/fontawesome-free": "^7.2.0",
"@mui/material": "^9.0.0",
"@mui/x-charts": "^9.0.1",
"@tanstack/react-query": "^5.0.0",
"howler": "^2.1.2",
"lodash": "^4.18.1",
"prop-types": "^15.7.2",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^9.0.0",
"@stylistic/eslint-plugin": "^4.0.0",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.0.0",
"eslint-plugin-react": "^7.0.0",
"eslint-plugin-react-hooks": "^5.0.0",
"globals": "^15.0.0",
"sass": "^1.77.0",
"vite": "^8.0.8",
"vite-plugin-symfony": "^8.2.4"
},
"scripts": {
"dev": "vite",
"watch": "vite build --watch",
"build": "vite build && cp -r node_modules/@fortawesome/fontawesome-free/webfonts public/build/webfonts",
"lint": "eslint assets/js/"
}
"name": "mine-seeker",
"version": "2026.2.1",
"author": "https://www.splendidbear.org",
"license": "GPL-3.0-or-later",
"bugs": "https://source.splendidbear.org",
"description": "Mine Seeker Game by system7",
"private": true,
"keywords": [
"mine",
"seeker",
"game",
"multiplayer",
"websocket"
],
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/changa-one": "^5.2.8",
"@fontsource/open-sans": "^5.2.7",
"@fontsource/rajdhani": "^5.2.7",
"@fortawesome/fontawesome-free": "^7.2.0",
"@mui/material": "^9.0.0",
"@mui/x-charts": "^9.0.2",
"@tanstack/react-query": "^5.99.2",
"howler": "^2.2.4",
"lodash": "^4.18.1",
"prop-types": "^15.8.1",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "10.0.1",
"@stylistic/eslint-plugin": "5.10.0",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "10.2.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "7.1.1",
"globals": "17.5.0",
"sass": "^1.99.0",
"vite": "^8.0.8",
"vite-plugin-symfony": "^8.2.4"
},
"scripts": {
"dev": "vite",
"watch": "vite build --watch",
"build": "vite build",
"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

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