Private
Public Access
1
0

Compare commits

...

152 Commits

Author SHA1 Message Date
199bb7e525 chg: pkg: fix all eslint issues - & add example of the testing env #10 2026-04-28 08:10:18 +02:00
5daaf71ae7 chg: pkg: new version release !skipChangelog 2026-04-23 21:42:21 +02:00
0aeec47996 new: pkg: add tracking code for the app #10
All checks were successful
Deploy to Production / deploy (push) Successful in 30s
2026-04-23 21:41:47 +02:00
3d67b8f2d9 chg: pkg: new version release !skipChangelog 2026-04-22 12:15:29 +02:00
dd9a190fd9 fix: usr: the error message cannot be seen during avatar changing #10
All checks were successful
Deploy to Production / deploy (push) Successful in 3m7s
2026-04-22 12:15:06 +02:00
f5e5019ea8 chg: pkg: new version release !skipChangelog 2026-04-21 22:47:04 +02:00
3f51eb5db6 chg: usr: increase the 2 MB avatar maximum file size to 10 MB #10
All checks were successful
Deploy to Production / deploy (push) Successful in 29s
2026-04-21 22:46:44 +02:00
55ef7c9301 chg: pkg: new version release !skipChangelog 2026-04-21 21:05:54 +02:00
ddfa395c6b chg: pkg: upgrade front-end & back-end deps to the latest available version #10
All checks were successful
Deploy to Production / deploy (push) Successful in 31s
2026-04-21 20:13:00 +02:00
8694627817 chg: pkg: new version release !skipChangelog 2026-04-21 18:21:17 +02:00
f796819af4 chg: pkg: new version release !skipChangelog 2026-04-21 18:12:32 +02:00
b209ad4220 chg: pkg: the original CI/CD workflow is restored - the work with tests is postponed #10
Some checks failed
Deploy to Production / test (push) Failing after 6s
Deploy to Production / deploy (push) Successful in 33s
2026-04-21 18:11:54 +02:00
df1eefdfe0 chg: pkg: new version release !skipChangelog
Some checks failed
CI - Tests / tests (push) Failing after 2m43s
CI - Tests / lint (push) Failing after 8s
2026-04-21 18:00:05 +02:00
7aaaf120b2 chg: usr: add sound when the game start #10 2026-04-21 17:59:18 +02:00
e48d651eb5 new: pkg: add CI/CD improvements - add new CI workflow - & improve the deployment w/ tests #10 2026-04-21 17:58:05 +02:00
d704be5bff new: pkg: add test cases to back-end w/ real database connection in it #10 2026-04-21 17:56:04 +02:00
6bf908b43e chg: dev: create AGENTS.md file for future maintenance #9 2026-04-21 14:30:38 +02:00
085e010907 chg: dev: remove the wrongly implemented font installation in docker - & replace it with static font on BattleCardGenerator (it solves the shareable image problem on bare-metal too) #8 2026-04-21 14:18:59 +02:00
8935216525 chg: pkg: new version release !skipChangelog 2026-04-21 13:58:08 +02:00
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
15ba26ccf2 chg: pkg: new version release !skipChangelog
All checks were successful
Deploy to Production / deploy (push) Successful in 3s
2026-04-14 16:47:15 +02:00
d3fa0cbbf9 fix: dev: quickfix for email sending #4
All checks were successful
Deploy to Production / deploy (push) Successful in 12s
2026-04-14 16:38:55 +02:00
ed776ca75b chg: pkg: new version release !skipChangelog
All checks were successful
Deploy to Production / deploy (push) Successful in 8s
2026-04-14 15:59:20 +02:00
67133a4efa chg: pkg: new version release !skipChangelog
All checks were successful
Deploy to Production / deploy (push) Successful in 28s
2026-04-14 15:54:00 +02:00
6044ee5c50 chg: pkg: new version release !skipChangelog
Some checks failed
Deploy to Production / deploy (push) Failing after 3m2s
2026-04-14 15:47:53 +02:00
184925ab13 chg: pkg: new version release !skipChangelog
Some checks failed
Deploy to Production / deploy (push) Failing after 0s
2026-04-14 15:37:17 +02:00
5a5467fda9 chg: pkg: new version release !skipChangelog
Some checks failed
Deploy to Production / deploy (push) Failing after 0s
2026-04-14 15:35:12 +02:00
f135746826 chg: pkg: new version release !skipChangelog
Some checks failed
Deploy to Production / deploy (push) Failing after 0s
2026-04-14 13:37:03 +02:00
62915fe5e4 hg: pkg: new version release !skipChangelog
Some checks failed
Deploy to Production / deploy (push) Has been cancelled
2026-04-14 13:31:21 +02:00
68a25aafa4 chg: pkg: new version release !skipChangelog
Some checks failed
Deploy to Production / deploy (push) Has been cancelled
2026-04-14 13:24:11 +02:00
9fff5bd5d1 chg: pkg: new version release !skipChangelog
Some checks failed
Deploy to Production / deploy (push) Has been cancelled
2026-04-14 13:06:25 +02:00
304663adb7 chg: pkg: new version release !skipChangelog
Some checks failed
Deploy to Production / deploy (push) Failing after 0s
2026-04-14 13:05:08 +02:00
cd593e99fc chg: pkg: new version release !skipChangelog
Some checks failed
Deploy to Production / deploy (push) Failing after 0s
2026-04-14 13:03:14 +02:00
9d51654aec chg: pkg: implement CD script to Gitea and add docs to the process #4
Some checks failed
Deploy to Production / deploy (push) Has been cancelled
2026-04-14 12:55:47 +02:00
82465322f2 pkg: usr: solve the not-working mailing on dev env under docker #4 2026-04-14 10:37:02 +02:00
055e59d896 new: usr: registered users have avatars next to the timer #4 2026-04-13 21:09:27 +02:00
3db8a30115 chg: pkg: remove unnecessary cdn based fonts #4 2026-04-13 20:38:22 +02:00
2e8d878337 chg: pkg: update docs #4 2026-04-13 18:31:59 +02:00
28221e092a chg: pkg: add JWT generation script to make Mercure safe #4 2026-04-13 16:10:41 +02:00
0c0b8ae920 new: usr: Add opportunity to use profile picture. #4 2026-04-13 15:50:28 +02:00
98f6e8cb6e chg: usr: fix missing favicon #4 2026-04-13 11:58:00 +02:00
4239177563 chg: pkg: make compatible the whole project with bare metal AND with docker #4 2026-04-13 11:56:50 +02:00
e9c6795eb7 new: usr: add more stats and a dialog for the recent battle that can be shareable #4 2026-04-12 20:03:20 +02:00
fb8a54f687 new: usr: implement the 2FA authentication (TOTP and backup codes) #4 2026-04-12 17:55:57 +02:00
0144a3953c chg: usr: add modern Webauthn authentication #4 2026-04-12 15:19:03 +02:00
acbe9c7f63 chg: dev: refactor all forms to have Symfony Form Types & Validation Constrainsts - & implement Google ReCapthca v3 #4 2026-04-12 08:49:47 +02:00
e2b227ed7a chg: usr: add forgot password functionality #4 2026-04-12 08:10:36 +02:00
c0dcc2896a chg: dev: increase the minimum PHP version to the latest major - and massive refactor on back-end, like Controllers and Repositories #4 2026-04-12 08:01:46 +02:00
92bfa5b301 chg: usr: redesign the resign dialog #4 2026-04-12 07:08:14 +02:00
826690769f chg: usr: re-implement the waiting for opponent dialog - refactor its gfx - & add online user selection dialog #4 2026-04-11 22:20:21 +02:00
6b3e19b063 chg: usr: improve the gfx on homepage - implement login/register and activation for authentication - and add the first version of profile page #4 2026-04-11 20:45:51 +02:00
eff849549b chg: usr: refactor and redesign the gfx on front-end #4 2026-04-11 19:23:59 +02:00
63a0f442ed chg: pkg: upgrade to the latest LTS Symfony package and backend #4 2026-04-11 18:29:37 +02:00
1cf21622b8 Merge branch 'master' of https://source.splendidbear.org/SplendidBear-Websites/Mine 2026-04-11 15:52:25 +02:00
85a440017c chg: pkg: update the vite related stuff because CORS and React errors - reinit the miration #4 2026-04-11 15:50:53 +02:00
d388e25192 chg: usr: add timers to each player - renew the whole migration #4 2026-04-11 15:19:59 +02:00
5b55a6ce73 chg: dev: use namespaces for front-end #4 2026-04-10 21:53:50 +02:00
c660c13ea2 chg: dev: replace webpack w/ vite & remove old, legacy jQuery from the code #4 2026-04-10 21:06:22 +02:00
d186a96f0d chg: dev: more, massive refactor for front-end #4 2026-04-10 19:09:05 +02:00
b57442bec1 chg: dev: massive refactor on front-end - and remove unnecessary deps #4 2026-04-10 17:57:26 +02:00
086d6c601e chg: dev: change the code style to fit the current standard #4 2026-04-10 12:57:03 +02:00
15806a6e04 chg: dev: refactor to use Attributes instead of yaml markdown #4 2026-04-10 12:33:38 +02:00
fe2de91e91 chg: dev: outsource the Grid generation and interactions to the backend #4 2026-04-10 12:23:21 +02:00
76143fca3e chg: dev: remove unnecessary variables and prune the Facebook registration method #4 2026-04-10 10:48:06 +02:00
7219471a86 chg: dev: replace the legacy gos/web-socket-bundle & replace it with Mercure protocol #4 2026-04-09 22:00:53 +02:00
b55c223d8a chg: pkg: make a massive refactor to the backend and remove all unnecessary deps - and make small refactors for the frontend too #4 2026-04-09 20:21:01 +02:00
23547f4237 chg: usr: created the first working solution since 7 yrs #4 2026-04-09 15:08:00 +02:00
4cdca43ecc chg: pkg: add some changes on BE - add eslint and editorconfig - and add some deps #4 2026-04-09 15:01:38 +02:00
fa0fc0743d chg: usr: make the first working version - the stepping is broken due to the algorythm structure #4 2026-04-09 12:10:37 +02:00
dd4b410624 chg: dev: change the composer default php minimum environment #3 2019-10-28 22:26:52 +01:00
1b5e87c033 chg: dev: change the default url to wss on frontend #3 2019-10-28 22:16:26 +01:00
2daab7140e chg: dev: refactor Rpc and Topic classes #3 2019-10-27 18:51:28 +01:00
8355cc90ed chg: dev: refactor classes and reformat some layout #3 2019-10-27 18:51:03 +01:00
c909c036a3 new: usr: add beta logo to the corner #3 2019-10-27 16:36:14 +01:00
3bbc96c76c new: usr: add mineseeker game to the symfony 4 project #3 2019-10-27 13:35:33 +01:00
6caf340302 new: dev: upgrade to the latest symfony v4 #3 2019-10-26 18:07:36 +02:00
98b71d75e9 chg: dev: remove deprecated files #3 2019-10-26 17:43:47 +02:00
aae8b9ebec chg: usr: doc in README.md #3 2019-10-26 17:40:10 +02:00
9ef0711acc chg: dev: gitignore a js.map file #2 2019-10-26 16:57:56 +02:00
5afc237ffb deploy version 1.1.0 !deploy #11 2019-10-26 16:52:25 +02:00
13749186fb chg: dev: reinit project - disable redis module and make the project compatible w/ PHP7.3 #2 2019-10-26 16:51:04 +02:00
1339 changed files with 41411 additions and 363836 deletions

View File

@@ -1,3 +0,0 @@
{
"presets": ["es2015", "react"]
}

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.git
.gitignore
node_modules
var/cache
var/log
var/sessions
public/build
*.md
docker-compose*.yml
compose.override.yaml
.env
.env.local
.env.*.local

884
.editorconfig Normal file
View File

@@ -0,0 +1,884 @@
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
max_line_length = 120
tab_width = 2
trim_trailing_whitespace = true
ij_continuation_indent_size = 8
ij_formatter_off_tag = @formatter:off
ij_formatter_on_tag = @formatter:on
ij_formatter_tags_enabled = true
ij_smart_tabs = false
ij_visual_guides = 80,120
ij_wrap_on_typing = false
[*.blade.php]
ij_visual_guides =
ij_blade_keep_indents_on_empty_lines = false
[*.css]
indent_size = 2
tab_width = 2
ij_continuation_indent_size = 4
ij_visual_guides =
ij_css_align_closing_brace_with_properties = false
ij_css_blank_lines_around_nested_selector = 1
ij_css_blank_lines_between_blocks = 1
ij_css_block_comment_add_space = false
ij_css_brace_placement = end_of_line
ij_css_enforce_quotes_on_format = false
ij_css_hex_color_long_format = false
ij_css_hex_color_lower_case = false
ij_css_hex_color_short_format = false
ij_css_hex_color_upper_case = false
ij_css_keep_blank_lines_in_code = 2
ij_css_keep_indents_on_empty_lines = false
ij_css_keep_single_line_blocks = false
ij_css_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow
ij_css_space_after_colon = true
ij_css_space_before_opening_brace = true
ij_css_use_double_quotes = true
ij_css_value_alignment = do_not_align
[*.feature]
indent_size = 2
ij_visual_guides =
ij_gherkin_keep_indents_on_empty_lines = false
[*.less]
indent_size = 2
ij_visual_guides =
ij_less_align_closing_brace_with_properties = false
ij_less_blank_lines_around_nested_selector = 1
ij_less_blank_lines_between_blocks = 1
ij_less_block_comment_add_space = false
ij_less_brace_placement = 0
ij_less_enforce_quotes_on_format = false
ij_less_hex_color_long_format = false
ij_less_hex_color_lower_case = false
ij_less_hex_color_short_format = false
ij_less_hex_color_upper_case = false
ij_less_keep_blank_lines_in_code = 2
ij_less_keep_indents_on_empty_lines = false
ij_less_keep_single_line_blocks = false
ij_less_line_comment_add_space = false
ij_less_line_comment_at_first_column = false
ij_less_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow
ij_less_space_after_colon = true
ij_less_space_before_opening_brace = true
ij_less_use_double_quotes = true
ij_less_value_alignment = 0
[*.neon]
ij_visual_guides =
[*.sass]
indent_size = 2
ij_visual_guides =
ij_sass_align_closing_brace_with_properties = false
ij_sass_blank_lines_around_nested_selector = 1
ij_sass_blank_lines_between_blocks = 1
ij_sass_brace_placement = 0
ij_sass_enforce_quotes_on_format = false
ij_sass_hex_color_long_format = false
ij_sass_hex_color_lower_case = false
ij_sass_hex_color_short_format = false
ij_sass_hex_color_upper_case = false
ij_sass_keep_blank_lines_in_code = 2
ij_sass_keep_indents_on_empty_lines = false
ij_sass_keep_single_line_blocks = false
ij_sass_line_comment_add_space = false
ij_sass_line_comment_at_first_column = false
ij_sass_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow
ij_sass_space_after_colon = true
ij_sass_space_before_opening_brace = true
ij_sass_use_double_quotes = true
ij_sass_value_alignment = 0
[*.scss]
indent_size = 2
tab_width = 2
ij_continuation_indent_size = 2
ij_visual_guides =
ij_scss_align_closing_brace_with_properties = false
ij_scss_blank_lines_around_nested_selector = 1
ij_scss_blank_lines_between_blocks = 1
ij_scss_block_comment_add_space = false
ij_scss_brace_placement = 0
ij_scss_enforce_quotes_on_format = false
ij_scss_hex_color_long_format = false
ij_scss_hex_color_lower_case = false
ij_scss_hex_color_short_format = false
ij_scss_hex_color_upper_case = false
ij_scss_keep_blank_lines_in_code = 2
ij_scss_keep_indents_on_empty_lines = false
ij_scss_keep_single_line_blocks = false
ij_scss_line_comment_add_space = false
ij_scss_line_comment_at_first_column = false
ij_scss_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow
ij_scss_space_after_colon = true
ij_scss_space_before_opening_brace = true
ij_scss_use_double_quotes = true
ij_scss_value_alignment = 0
[*.twig]
indent_size = 2
tab_width = 2
ij_continuation_indent_size = 2
ij_visual_guides =
ij_twig_keep_indents_on_empty_lines = false
ij_twig_spaces_inside_comments_delimiters = true
ij_twig_spaces_inside_delimiters = true
ij_twig_spaces_inside_variable_delimiters = true
[*.vue]
indent_size = 2
tab_width = 2
ij_continuation_indent_size = 4
ij_visual_guides =
ij_vue_indent_children_of_top_level = template
ij_vue_interpolation_new_line_after_start_delimiter = true
ij_vue_interpolation_new_line_before_end_delimiter = true
ij_vue_interpolation_wrap = off
ij_vue_keep_indents_on_empty_lines = false
ij_vue_spaces_within_interpolation_expressions = true
[.editorconfig]
ij_visual_guides =
ij_editorconfig_align_group_field_declarations = false
ij_editorconfig_space_after_colon = true
ij_editorconfig_space_after_comma = true
ij_editorconfig_space_before_colon = true
ij_editorconfig_space_before_comma = false
ij_editorconfig_spaces_around_assignment_operators = true
[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.rng,*.tld,*.wsdl,*.xlf,*.xml,*.xsd,*.xsl,*.xslt,*.xul,phpunit.xml.dist}]
indent_size = 2
tab_width = 2
ij_visual_guides =
ij_xml_align_attributes = true
ij_xml_align_text = false
ij_xml_attribute_wrap = normal
ij_xml_block_comment_add_space = false
ij_xml_block_comment_at_first_column = true
ij_xml_keep_blank_lines = 2
ij_xml_keep_indents_on_empty_lines = false
ij_xml_keep_line_breaks = true
ij_xml_keep_line_breaks_in_text = true
ij_xml_keep_whitespaces = false
ij_xml_keep_whitespaces_around_cdata = preserve
ij_xml_keep_whitespaces_inside_cdata = false
ij_xml_line_comment_at_first_column = true
ij_xml_space_after_tag_name = false
ij_xml_space_around_equals_in_attribute = false
ij_xml_space_inside_empty_tag = true
ij_xml_text_wrap = normal
[{*.ats,*.cts,*.mts,*.ts,*.tsx}]
indent_size = 2
tab_width = 2
ij_continuation_indent_size = 2
ij_visual_guides =
ij_typescript_align_imports = false
ij_typescript_align_multiline_array_initializer_expression = false
ij_typescript_align_multiline_binary_operation = false
ij_typescript_align_multiline_chained_methods = false
ij_typescript_align_multiline_extends_list = false
ij_typescript_align_multiline_for = true
ij_typescript_align_multiline_parameters = true
ij_typescript_align_multiline_parameters_in_calls = false
ij_typescript_align_multiline_ternary_operation = false
ij_typescript_align_object_properties = 0
ij_typescript_align_union_types = false
ij_typescript_align_var_statements = 0
ij_typescript_array_initializer_new_line_after_left_brace = false
ij_typescript_array_initializer_right_brace_on_new_line = false
ij_typescript_array_initializer_wrap = off
ij_typescript_assignment_wrap = off
ij_typescript_binary_operation_sign_on_next_line = false
ij_typescript_binary_operation_wrap = off
ij_typescript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/**
ij_typescript_blank_lines_after_imports = 1
ij_typescript_blank_lines_around_class = 1
ij_typescript_blank_lines_around_field = 0
ij_typescript_blank_lines_around_field_in_interface = 0
ij_typescript_blank_lines_around_function = 1
ij_typescript_blank_lines_around_method = 1
ij_typescript_blank_lines_around_method_in_interface = 1
ij_typescript_block_brace_style = end_of_line
ij_typescript_block_comment_add_space = false
ij_typescript_block_comment_at_first_column = true
ij_typescript_call_parameters_new_line_after_left_paren = false
ij_typescript_call_parameters_right_paren_on_new_line = false
ij_typescript_call_parameters_wrap = off
ij_typescript_catch_on_new_line = false
ij_typescript_chained_call_dot_on_new_line = true
ij_typescript_class_brace_style = end_of_line
ij_typescript_class_decorator_wrap = split_into_lines
ij_typescript_class_field_decorator_wrap = off
ij_typescript_class_method_decorator_wrap = off
ij_typescript_comma_on_new_line = false
ij_typescript_do_while_brace_force = never
ij_typescript_else_on_new_line = false
ij_typescript_enforce_trailing_comma = whenmultiline
ij_typescript_enum_constants_wrap = on_every_item
ij_typescript_extends_keyword_wrap = off
ij_typescript_extends_list_wrap = off
ij_typescript_field_prefix = _
ij_typescript_file_name_style = relaxed
ij_typescript_finally_on_new_line = false
ij_typescript_for_brace_force = never
ij_typescript_for_statement_new_line_after_left_paren = false
ij_typescript_for_statement_right_paren_on_new_line = false
ij_typescript_for_statement_wrap = off
ij_typescript_force_quote_style = true
ij_typescript_force_semicolon_style = true
ij_typescript_function_expression_brace_style = end_of_line
ij_typescript_function_parameter_decorator_wrap = off
ij_typescript_if_brace_force = never
ij_typescript_import_merge_members = global
ij_typescript_import_prefer_absolute_path = global
ij_typescript_import_sort_members = true
ij_typescript_import_sort_module_name = false
ij_typescript_import_use_node_resolution = true
ij_typescript_imports_wrap = on_every_item
ij_typescript_indent_case_from_switch = true
ij_typescript_indent_chained_calls = true
ij_typescript_indent_package_children = 0
ij_typescript_jsdoc_include_types = false
ij_typescript_jsx_attribute_value = braces
ij_typescript_keep_blank_lines_in_code = 2
ij_typescript_keep_first_column_comment = true
ij_typescript_keep_indents_on_empty_lines = false
ij_typescript_keep_line_breaks = true
ij_typescript_keep_simple_blocks_in_one_line = false
ij_typescript_keep_simple_methods_in_one_line = false
ij_typescript_jsx_self_closing_tag = true
ij_typescript_line_comment_add_space = true
ij_typescript_line_comment_at_first_column = false
ij_typescript_method_brace_style = end_of_line
ij_typescript_method_call_chain_wrap = off
ij_typescript_method_parameters_new_line_after_left_paren = false
ij_typescript_method_parameters_right_paren_on_new_line = false
ij_typescript_method_parameters_wrap = off
ij_typescript_object_literal_wrap = on_every_item
ij_typescript_object_types_wrap = on_every_item
ij_typescript_parentheses_expression_new_line_after_left_paren = false
ij_typescript_parentheses_expression_right_paren_on_new_line = false
ij_typescript_place_assignment_sign_on_next_line = false
ij_typescript_prefer_as_type_cast = false
ij_typescript_prefer_explicit_types_function_expression_returns = false
ij_typescript_prefer_explicit_types_function_returns = false
ij_typescript_prefer_explicit_types_vars_fields = false
ij_typescript_prefer_parameters_wrap = false
ij_typescript_property_prefix =
ij_typescript_reformat_c_style_comments = false
ij_typescript_space_after_colon = true
ij_typescript_space_after_comma = true
ij_typescript_space_after_dots_in_rest_parameter = false
ij_typescript_space_after_generator_mult = true
ij_typescript_space_after_property_colon = true
ij_typescript_space_after_quest = true
ij_typescript_space_after_type_colon = true
ij_typescript_space_after_unary_not = false
ij_typescript_space_before_async_arrow_lparen = true
ij_typescript_space_before_catch_keyword = true
ij_typescript_space_before_catch_left_brace = true
ij_typescript_space_before_catch_parentheses = true
ij_typescript_space_before_class_lbrace = true
ij_typescript_space_before_class_left_brace = true
ij_typescript_space_before_colon = true
ij_typescript_space_before_comma = false
ij_typescript_space_before_do_left_brace = true
ij_typescript_space_before_else_keyword = true
ij_typescript_space_before_else_left_brace = true
ij_typescript_space_before_finally_keyword = true
ij_typescript_space_before_finally_left_brace = true
ij_typescript_space_before_for_left_brace = true
ij_typescript_space_before_for_parentheses = true
ij_typescript_space_before_for_semicolon = false
ij_typescript_space_before_function_left_parenth = false
ij_typescript_space_before_generator_mult = false
ij_typescript_space_before_if_left_brace = true
ij_typescript_space_before_if_parentheses = true
ij_typescript_space_before_method_call_parentheses = false
ij_typescript_space_before_method_left_brace = true
ij_typescript_space_before_method_parentheses = false
ij_typescript_space_before_property_colon = false
ij_typescript_space_before_quest = true
ij_typescript_space_before_switch_left_brace = true
ij_typescript_space_before_switch_parentheses = true
ij_typescript_space_before_try_left_brace = true
ij_typescript_space_before_type_colon = false
ij_typescript_space_before_unary_not = false
ij_typescript_space_before_while_keyword = true
ij_typescript_space_before_while_left_brace = true
ij_typescript_space_before_while_parentheses = true
ij_typescript_spaces_around_additive_operators = true
ij_typescript_spaces_around_arrow_function_operator = true
ij_typescript_spaces_around_assignment_operators = true
ij_typescript_spaces_around_bitwise_operators = true
ij_typescript_spaces_around_equality_operators = true
ij_typescript_spaces_around_logical_operators = true
ij_typescript_spaces_around_multiplicative_operators = true
ij_typescript_spaces_around_relational_operators = true
ij_typescript_spaces_around_shift_operators = true
ij_typescript_spaces_around_unary_operator = false
ij_typescript_spaces_within_array_initializer_brackets = true
ij_typescript_spaces_within_brackets = false
ij_typescript_spaces_within_catch_parentheses = false
ij_typescript_spaces_within_for_parentheses = false
ij_typescript_spaces_within_if_parentheses = false
ij_typescript_spaces_within_imports = true
ij_typescript_spaces_within_interpolation_expressions = false
ij_typescript_spaces_within_method_call_parentheses = false
ij_typescript_spaces_within_method_parentheses = false
ij_typescript_spaces_within_object_literal_braces = true
ij_typescript_spaces_within_object_type_braces = true
ij_typescript_spaces_within_parentheses = false
ij_typescript_spaces_within_switch_parentheses = false
ij_typescript_spaces_within_type_assertion = false
ij_typescript_spaces_within_union_types = true
ij_typescript_spaces_within_while_parentheses = false
ij_typescript_special_else_if_treatment = true
ij_typescript_ternary_operation_signs_on_next_line = false
ij_typescript_ternary_operation_wrap = off
ij_typescript_union_types_wrap = on_every_item
ij_typescript_use_chained_calls_group_indents = false
ij_typescript_use_double_quotes = false
ij_typescript_use_explicit_js_extension = never
ij_typescript_use_import_type = auto
ij_typescript_use_path_mapping = always
ij_typescript_use_public_modifier = false
ij_typescript_use_semicolon_after_statement = true
ij_typescript_var_declaration_wrap = normal
ij_typescript_while_brace_force = never
ij_typescript_while_on_new_line = false
ij_typescript_wrap_comments = false
[{*.bash,*.sh,*.zsh}]
indent_size = 2
tab_width = 2
ij_visual_guides =
ij_shell_binary_ops_start_line = false
ij_shell_keep_column_alignment_padding = false
ij_shell_minify_program = false
ij_shell_redirect_followed_by_space = false
ij_shell_switch_cases_indented = false
ij_shell_use_unix_line_separator = true
[{*.cjs,*.es6,*.js,*.mjs,*.jsx}]
indent_size = 2
tab_width = 2
ij_continuation_indent_size = 2
ij_visual_guides =
ij_javascript_align_imports = false
ij_javascript_align_multiline_array_initializer_expression = false
ij_javascript_align_multiline_binary_operation = false
ij_javascript_align_multiline_chained_methods = false
ij_javascript_align_multiline_extends_list = false
ij_javascript_align_multiline_for = true
ij_javascript_align_multiline_parameters = true
ij_javascript_align_multiline_parameters_in_calls = false
ij_javascript_align_multiline_ternary_operation = false
ij_javascript_align_object_properties = 0
ij_javascript_align_union_types = false
ij_javascript_align_var_statements = 0
ij_javascript_array_initializer_new_line_after_left_brace = false
ij_javascript_array_initializer_right_brace_on_new_line = false
ij_javascript_array_initializer_wrap = off
ij_javascript_assignment_wrap = off
ij_javascript_binary_operation_sign_on_next_line = false
ij_javascript_binary_operation_wrap = off
ij_javascript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/**
ij_javascript_blank_lines_after_imports = 1
ij_javascript_blank_lines_around_class = 1
ij_javascript_blank_lines_around_field = 0
ij_javascript_blank_lines_around_function = 1
ij_javascript_blank_lines_around_method = 1
ij_javascript_block_brace_style = end_of_line
ij_javascript_block_comment_add_space = false
ij_javascript_block_comment_at_first_column = true
ij_javascript_call_parameters_new_line_after_left_paren = false
ij_javascript_call_parameters_right_paren_on_new_line = false
ij_javascript_call_parameters_wrap = off
ij_javascript_catch_on_new_line = false
ij_javascript_chained_call_dot_on_new_line = true
ij_javascript_class_brace_style = end_of_line
ij_javascript_class_decorator_wrap = split_into_lines
ij_javascript_class_field_decorator_wrap = off
ij_javascript_class_method_decorator_wrap = off
ij_javascript_comma_on_new_line = false
ij_javascript_do_while_brace_force = never
ij_javascript_else_on_new_line = false
ij_javascript_enforce_trailing_comma = whenmultiline
ij_javascript_extends_keyword_wrap = off
ij_javascript_extends_list_wrap = off
ij_javascript_field_prefix = _
ij_javascript_file_name_style = relaxed
ij_javascript_finally_on_new_line = false
ij_javascript_for_brace_force = never
ij_javascript_for_statement_new_line_after_left_paren = false
ij_javascript_for_statement_right_paren_on_new_line = false
ij_javascript_for_statement_wrap = off
ij_javascript_force_quote_style = true
ij_javascript_force_semicolon_style = true
ij_javascript_function_expression_brace_style = end_of_line
ij_javascript_function_parameter_decorator_wrap = off
ij_javascript_if_brace_force = never
ij_javascript_import_merge_members = global
ij_javascript_import_prefer_absolute_path = global
ij_javascript_import_sort_members = true
ij_javascript_import_sort_module_name = false
ij_javascript_import_use_node_resolution = true
ij_javascript_imports_wrap = on_every_item
ij_javascript_indent_case_from_switch = true
ij_javascript_indent_chained_calls = true
ij_javascript_indent_package_children = 0
ij_javascript_jsx_attribute_value = braces
ij_javascript_keep_blank_lines_in_code = 2
ij_javascript_keep_first_column_comment = true
ij_javascript_keep_indents_on_empty_lines = false
ij_javascript_keep_line_breaks = true
ij_javascript_keep_simple_blocks_in_one_line = false
ij_javascript_keep_simple_methods_in_one_line = false
ij_javascript_jsx_self_closing_tag = true
ij_javascript_line_comment_add_space = true
ij_javascript_line_comment_at_first_column = false
ij_javascript_method_brace_style = end_of_line
ij_javascript_method_call_chain_wrap = off
ij_javascript_method_parameters_new_line_after_left_paren = false
ij_javascript_method_parameters_right_paren_on_new_line = false
ij_javascript_method_parameters_wrap = off
ij_javascript_object_literal_wrap = on_every_item
ij_javascript_object_types_wrap = on_every_item
ij_javascript_parentheses_expression_new_line_after_left_paren = false
ij_javascript_parentheses_expression_right_paren_on_new_line = false
ij_javascript_place_assignment_sign_on_next_line = false
ij_javascript_prefer_as_type_cast = false
ij_javascript_prefer_explicit_types_function_expression_returns = false
ij_javascript_prefer_explicit_types_function_returns = false
ij_javascript_prefer_explicit_types_vars_fields = false
ij_javascript_prefer_parameters_wrap = false
ij_javascript_property_prefix =
ij_javascript_reformat_c_style_comments = true
ij_javascript_space_after_colon = true
ij_javascript_space_after_comma = true
ij_javascript_space_after_dots_in_rest_parameter = false
ij_javascript_space_after_generator_mult = true
ij_javascript_space_after_property_colon = true
ij_javascript_space_after_quest = true
ij_javascript_space_after_type_colon = true
ij_javascript_space_after_unary_not = false
ij_javascript_space_before_async_arrow_lparen = true
ij_javascript_space_before_catch_keyword = true
ij_javascript_space_before_catch_left_brace = true
ij_javascript_space_before_catch_parentheses = true
ij_javascript_space_before_class_lbrace = true
ij_javascript_space_before_class_left_brace = true
ij_javascript_space_before_colon = true
ij_javascript_space_before_comma = false
ij_javascript_space_before_do_left_brace = true
ij_javascript_space_before_else_keyword = true
ij_javascript_space_before_else_left_brace = true
ij_javascript_space_before_finally_keyword = true
ij_javascript_space_before_finally_left_brace = true
ij_javascript_space_before_for_left_brace = true
ij_javascript_space_before_for_parentheses = true
ij_javascript_space_before_for_semicolon = false
ij_javascript_space_before_function_left_parenth = false
ij_javascript_space_before_generator_mult = false
ij_javascript_space_before_if_left_brace = true
ij_javascript_space_before_if_parentheses = true
ij_javascript_space_before_method_call_parentheses = false
ij_javascript_space_before_method_left_brace = true
ij_javascript_space_before_method_parentheses = false
ij_javascript_space_before_property_colon = false
ij_javascript_space_before_quest = true
ij_javascript_space_before_switch_left_brace = true
ij_javascript_space_before_switch_parentheses = true
ij_javascript_space_before_try_left_brace = true
ij_javascript_space_before_type_colon = false
ij_javascript_space_before_unary_not = false
ij_javascript_space_before_while_keyword = true
ij_javascript_space_before_while_left_brace = true
ij_javascript_space_before_while_parentheses = true
ij_javascript_spaces_around_additive_operators = true
ij_javascript_spaces_around_arrow_function_operator = true
ij_javascript_spaces_around_assignment_operators = true
ij_javascript_spaces_around_bitwise_operators = true
ij_javascript_spaces_around_equality_operators = true
ij_javascript_spaces_around_logical_operators = true
ij_javascript_spaces_around_multiplicative_operators = true
ij_javascript_spaces_around_relational_operators = true
ij_javascript_spaces_around_shift_operators = true
ij_javascript_spaces_around_unary_operator = false
ij_javascript_spaces_within_array_initializer_brackets = false
ij_javascript_spaces_within_brackets = false
ij_javascript_spaces_within_catch_parentheses = false
ij_javascript_spaces_within_for_parentheses = false
ij_javascript_spaces_within_if_parentheses = false
ij_javascript_spaces_within_imports = true
ij_javascript_spaces_within_interpolation_expressions = false
ij_javascript_spaces_within_method_call_parentheses = false
ij_javascript_spaces_within_method_parentheses = false
ij_javascript_spaces_within_object_literal_braces = true
ij_javascript_spaces_within_object_type_braces = true
ij_javascript_spaces_within_parentheses = false
ij_javascript_spaces_within_switch_parentheses = false
ij_javascript_spaces_within_type_assertion = false
ij_javascript_spaces_within_union_types = true
ij_javascript_spaces_within_while_parentheses = false
ij_javascript_special_else_if_treatment = true
ij_javascript_ternary_operation_signs_on_next_line = false
ij_javascript_ternary_operation_wrap = off
ij_javascript_union_types_wrap = on_every_item
ij_javascript_use_chained_calls_group_indents = false
ij_javascript_use_double_quotes = false
ij_javascript_use_explicit_js_extension = never
ij_javascript_use_import_type = auto
ij_javascript_use_path_mapping = always
ij_javascript_use_public_modifier = false
ij_javascript_use_semicolon_after_statement = true
ij_javascript_var_declaration_wrap = normal
ij_javascript_while_brace_force = never
ij_javascript_while_on_new_line = false
ij_javascript_wrap_comments = true
[{*.ctp,*.hphp,*.inc,*.module,*.php,*.php4,*.php5,*.phtml}]
indent_size = 4
tab_width = 4
ij_continuation_indent_size = 4
ij_visual_guides =
ij_php_align_assignments = false
ij_php_align_class_constants = true
ij_php_align_enum_cases = false
ij_php_align_group_field_declarations = false
ij_php_align_inline_comments = false
ij_php_align_key_value_pairs = true
ij_php_align_match_arm_bodies = true
ij_php_align_multiline_array_initializer_expression = false
ij_php_align_multiline_binary_operation = false
ij_php_align_multiline_chained_methods = true
ij_php_align_multiline_extends_list = false
ij_php_align_multiline_for = true
ij_php_align_multiline_parameters = true
ij_php_align_multiline_parameters_in_calls = false
ij_php_align_multiline_ternary_operation = false
ij_php_align_named_arguments = false
ij_php_align_phpdoc_comments = true
ij_php_align_phpdoc_param_names = true
ij_php_anonymous_brace_style = end_of_line
ij_php_api_weight = 28
ij_php_array_initializer_new_line_after_left_brace = false
ij_php_array_initializer_right_brace_on_new_line = true
ij_php_array_initializer_wrap = off
ij_php_assignment_wrap = off
ij_php_attributes_wrap = off
ij_php_author_weight = 1
ij_php_binary_operation_sign_on_next_line = false
ij_php_binary_operation_wrap = off
ij_php_blank_lines_after_class_header = 0
ij_php_blank_lines_after_function = 1
ij_php_blank_lines_after_imports = 1
ij_php_blank_lines_after_opening_tag = 0
ij_php_blank_lines_after_package = 0
ij_php_blank_lines_around_class = 1
ij_php_blank_lines_around_constants = 0
ij_php_blank_lines_around_enum_cases = 0
ij_php_blank_lines_around_field = 1
ij_php_blank_lines_around_method = 1
ij_php_blank_lines_before_class_end = 0
ij_php_blank_lines_before_imports = 1
ij_php_blank_lines_before_method_body = 0
ij_php_blank_lines_before_package = 1
ij_php_blank_lines_before_return_statement = 0
ij_php_blank_lines_between_imports = 0
ij_php_block_brace_style = end_of_line
ij_php_call_parameters_new_line_after_left_paren = false
ij_php_call_parameters_right_paren_on_new_line = false
ij_php_call_parameters_wrap = off
ij_php_catch_on_new_line = false
ij_php_category_weight = 28
ij_php_class_brace_style = next_line
ij_php_comma_after_last_argument = false
ij_php_comma_after_last_argument_style = when_multiline
ij_php_comma_after_last_array_element = true
ij_php_comma_after_last_closure_use_var = false
ij_php_comma_after_last_closure_use_var_style = when_multiline
ij_php_comma_after_last_match_arm = false
ij_php_comma_after_last_parameter = false
ij_php_comma_after_last_parameter_style = when_multiline
ij_php_concat_spaces = true
ij_php_copyright_weight = 28
ij_php_deprecated_weight = 28
ij_php_do_while_brace_force = never
ij_php_else_if_style = as_is
ij_php_else_on_new_line = false
ij_php_example_weight = 28
ij_php_extends_keyword_wrap = off
ij_php_extends_list_wrap = off
ij_php_fields_default_visibility = private
ij_php_filesource_weight = 28
ij_php_finally_on_new_line = false
ij_php_for_brace_force = never
ij_php_for_statement_new_line_after_left_paren = false
ij_php_for_statement_right_paren_on_new_line = false
ij_php_for_statement_wrap = off
ij_php_force_empty_classes_in_one_line = false
ij_php_force_empty_methods_in_one_line = false
ij_php_force_short_declaration_array_style = false
ij_php_getters_setters_naming_style = camel_case
ij_php_getters_setters_order_style = getters_first
ij_php_global_weight = 28
ij_php_group_use_wrap = on_every_item
ij_php_heredoc_on_same_line = false
ij_php_if_brace_force = never
ij_php_if_lparen_on_next_line = false
ij_php_if_rparen_on_next_line = false
ij_php_ignore_weight = 28
ij_php_import_sorting = alphabetic
ij_php_indent_break_from_case = true
ij_php_indent_case_from_switch = true
ij_php_indent_code_in_php_tags = false
ij_php_internal_weight = 28
ij_php_keep_blank_lines_after_lbrace = 2
ij_php_keep_blank_lines_before_right_brace = 2
ij_php_keep_blank_lines_in_code = 2
ij_php_keep_blank_lines_in_declarations = 2
ij_php_keep_control_statement_in_one_line = true
ij_php_keep_first_column_comment = false
ij_php_keep_indents_on_empty_lines = false
ij_php_keep_line_breaks = true
ij_php_keep_rparen_and_lbrace_on_one_line = true
ij_php_keep_simple_classes_in_one_line = true
ij_php_keep_simple_methods_in_one_line = true
ij_php_lambda_brace_style = end_of_line
ij_php_license_weight = 28
ij_php_line_comment_add_space = false
ij_php_line_comment_at_first_column = true
ij_php_link_weight = 28
ij_php_lower_case_boolean_const = true
ij_php_lower_case_keywords = true
ij_php_lower_case_null_const = true
ij_php_method_brace_style = next_line
ij_php_method_call_chain_wrap = normal
ij_php_method_parameters_new_line_after_left_paren = true
ij_php_method_parameters_right_paren_on_new_line = true
ij_php_method_parameters_wrap = on_every_item
ij_php_method_weight = 5
ij_php_modifier_list_wrap = false
ij_php_multiline_chained_calls_first_call_on_new_line = false
ij_php_multiline_chained_calls_semicolon_on_new_line = false
ij_php_multiline_closure_lambda_on_new_line = false
ij_php_namespace_brace_style = 2
ij_php_new_line_after_php_opening_tag = false
ij_php_null_type_position = in_the_end
ij_php_package_weight = 0
ij_php_param_weight = 2
ij_php_parameters_attributes_wrap = off
ij_php_parentheses_expression_new_line_after_left_paren = false
ij_php_parentheses_expression_right_paren_on_new_line = false
ij_php_phpdoc_blank_line_before_tags = true
ij_php_phpdoc_blank_lines_around_parameters = true
ij_php_phpdoc_keep_blank_lines = true
ij_php_phpdoc_param_spaces_between_name_and_description = 1
ij_php_phpdoc_param_spaces_between_tag_and_type = 1
ij_php_phpdoc_param_spaces_between_type_and_name = 1
ij_php_phpdoc_use_fqcn = false
ij_php_phpdoc_wrap_long_lines = true
ij_php_place_assignment_sign_on_next_line = false
ij_php_place_parens_for_constructor = 0
ij_php_property_read_weight = 28
ij_php_property_weight = 28
ij_php_property_write_weight = 28
ij_php_return_type_on_new_line = false
ij_php_return_weight = 3
ij_php_see_weight = 28
ij_php_since_weight = 28
ij_php_sort_phpdoc_elements = true
ij_php_space_after_colon = true
ij_php_space_after_colon_in_enum_backed_type = true
ij_php_space_after_colon_in_named_argument = true
ij_php_space_after_colon_in_return_type = true
ij_php_space_after_comma = true
ij_php_space_after_for_semicolon = true
ij_php_space_after_quest = true
ij_php_space_after_type_cast = false
ij_php_space_after_unary_not = false
ij_php_space_before_array_initializer_left_brace = false
ij_php_space_before_catch_keyword = true
ij_php_space_before_catch_left_brace = true
ij_php_space_before_catch_parentheses = true
ij_php_space_before_class_left_brace = true
ij_php_space_before_closure_left_parenthesis = true
ij_php_space_before_colon = true
ij_php_space_before_colon_in_enum_backed_type = false
ij_php_space_before_colon_in_named_argument = false
ij_php_space_before_colon_in_return_type = false
ij_php_space_before_comma = false
ij_php_space_before_do_left_brace = true
ij_php_space_before_else_keyword = true
ij_php_space_before_else_left_brace = true
ij_php_space_before_finally_keyword = true
ij_php_space_before_finally_left_brace = true
ij_php_space_before_for_left_brace = true
ij_php_space_before_for_parentheses = true
ij_php_space_before_for_semicolon = false
ij_php_space_before_if_left_brace = true
ij_php_space_before_if_parentheses = true
ij_php_space_before_method_call_parentheses = false
ij_php_space_before_method_left_brace = true
ij_php_space_before_method_parentheses = false
ij_php_space_before_quest = true
ij_php_space_before_short_closure_left_parenthesis = false
ij_php_space_before_switch_left_brace = true
ij_php_space_before_switch_parentheses = true
ij_php_space_before_try_left_brace = true
ij_php_space_before_unary_not = false
ij_php_space_before_while_keyword = true
ij_php_space_before_while_left_brace = true
ij_php_space_before_while_parentheses = true
ij_php_space_between_ternary_quest_and_colon = false
ij_php_spaces_around_additive_operators = true
ij_php_spaces_around_arrow = false
ij_php_spaces_around_assignment_in_declare = false
ij_php_spaces_around_assignment_operators = true
ij_php_spaces_around_bitwise_operators = true
ij_php_spaces_around_equality_operators = true
ij_php_spaces_around_logical_operators = true
ij_php_spaces_around_multiplicative_operators = true
ij_php_spaces_around_null_coalesce_operator = true
ij_php_spaces_around_pipe_in_union_type = false
ij_php_spaces_around_relational_operators = true
ij_php_spaces_around_shift_operators = true
ij_php_spaces_around_unary_operator = false
ij_php_spaces_around_var_within_brackets = false
ij_php_spaces_within_array_initializer_braces = false
ij_php_spaces_within_brackets = false
ij_php_spaces_within_catch_parentheses = false
ij_php_spaces_within_for_parentheses = false
ij_php_spaces_within_if_parentheses = false
ij_php_spaces_within_method_call_parentheses = false
ij_php_spaces_within_method_parentheses = false
ij_php_spaces_within_parentheses = false
ij_php_spaces_within_short_echo_tags = true
ij_php_spaces_within_switch_parentheses = false
ij_php_spaces_within_while_parentheses = false
ij_php_special_else_if_treatment = false
ij_php_subpackage_weight = 28
ij_php_ternary_operation_signs_on_next_line = true
ij_php_ternary_operation_wrap = off
ij_php_throws_weight = 4
ij_php_todo_weight = 28
ij_php_treat_multiline_arrays_and_lambdas_multiline = false
ij_php_unknown_tag_weight = 28
ij_php_upper_case_boolean_const = false
ij_php_upper_case_null_const = false
ij_php_uses_weight = 28
ij_php_var_weight = 28
ij_php_variable_naming_style = mixed
ij_php_version_weight = 28
ij_php_while_brace_force = never
ij_php_while_on_new_line = false
[{*.har,*.jsb2,*.jsb3,*.json,*.jsonc,*.postman_collection,*.postman_collection.json,*.postman_environment,*.postman_environment.json,.babelrc,.eslintrc,.prettierrc,.stylelintrc,.ws-context,composer.lock,jest.config}]
indent_size = 2
ij_visual_guides =
ij_json_array_wrapping = split_into_lines
ij_json_keep_blank_lines_in_code = 0
ij_json_keep_indents_on_empty_lines = false
ij_json_keep_line_breaks = true
ij_json_keep_trailing_comma = false
ij_json_object_wrapping = split_into_lines
ij_json_property_alignment = do_not_align
ij_json_space_after_colon = true
ij_json_space_after_comma = true
ij_json_space_before_colon = false
ij_json_space_before_comma = false
ij_json_spaces_within_braces = false
ij_json_spaces_within_brackets = false
ij_json_wrap_long_lines = false
[{*.htm,*.html,*.sht,*.shtm,*.shtml}]
indent_size = 2
tab_width = 2
ij_continuation_indent_size = 2
ij_visual_guides =
ij_html_add_new_line_before_tags = body,div,p,form,h1,h2,h3
ij_html_align_attributes = true
ij_html_align_text = false
ij_html_attribute_wrap = on_every_item
ij_html_block_comment_add_space = false
ij_html_block_comment_at_first_column = true
ij_html_do_not_align_children_of_min_lines = 0
ij_html_do_not_break_if_inline_tags = title,h1,h2,h3,h4,h5,h6
ij_html_do_not_indent_children_of_tags = thead,tbody,tfoot
ij_html_enforce_quotes = true
ij_html_inline_tags = a,abbr,acronym,b,basefont,bdo,big,br,cite,cite,code,dfn,em,font,i,img,input,kbd,label,q,s,samp,select,small,strike,strong,sub,sup,textarea,tt,u,var
ij_html_keep_blank_lines = 2
ij_html_keep_indents_on_empty_lines = false
ij_html_keep_line_breaks = true
ij_html_keep_line_breaks_in_text = true
ij_html_keep_whitespaces = false
ij_html_keep_whitespaces_inside = span,pre,textarea
ij_html_line_comment_at_first_column = true
ij_html_new_line_after_last_attribute = when_multiline
ij_html_new_line_before_first_attribute = when_multiline
ij_html_quote_style = double
ij_html_remove_new_line_before_tags = br
ij_html_space_after_tag_name = false
ij_html_space_around_equality_in_attribute = false
ij_html_space_inside_empty_tag = true
ij_html_self_closing_tag = true
ij_html_text_wrap = normal
[{*.http,*.rest}]
ij_continuation_indent_size = 4
ij_visual_guides =
ij_http-request_call_parameters_wrap = normal
ij_http-request_method_parameters_wrap = split_into_lines
ij_http-request_space_before_comma = true
ij_http-request_spaces_around_assignment_operators = true
[{*.markdown,*.md}]
ij_visual_guides =
ij_markdown_force_one_space_after_blockquote_symbol = true
ij_markdown_force_one_space_after_header_symbol = true
ij_markdown_force_one_space_after_list_bullet = true
ij_markdown_force_one_space_between_words = true
ij_markdown_format_tables = true
ij_markdown_insert_quote_arrows_on_wrap = true
ij_markdown_keep_indents_on_empty_lines = false
ij_markdown_keep_line_breaks_inside_text_blocks = true
ij_markdown_max_lines_around_block_elements = 1
ij_markdown_max_lines_around_header = 1
ij_markdown_max_lines_between_paragraphs = 1
ij_markdown_min_lines_around_block_elements = 1
ij_markdown_min_lines_around_header = 1
ij_markdown_min_lines_between_paragraphs = 1
ij_markdown_wrap_text_if_long = true
ij_markdown_wrap_text_inside_blockquotes = true
[{*.mk,GNUmakefile,GNUmakefile.inc,makefile,makefile.inc}]
ij_visual_guides =
[{*.yaml,*.yml}]
indent_size = 2
ij_visual_guides =
ij_yaml_align_values_properties = do_not_align
ij_yaml_autoinsert_sequence_marker = true
ij_yaml_block_mapping_on_new_line = false
ij_yaml_indent_sequence_value = true
ij_yaml_keep_indents_on_empty_lines = false
ij_yaml_keep_line_breaks = true
ij_yaml_line_comment_add_space = false
ij_yaml_line_comment_add_space_on_reformat = false
ij_yaml_line_comment_at_first_column = true
ij_yaml_sequence_on_new_line = false
ij_yaml_space_before_colon = false
ij_yaml_spaces_within_braces = true
ij_yaml_spaces_within_brackets = true

59
.env.dist Normal file
View File

@@ -0,0 +1,59 @@
# This file is a "template" of which env vars need to be defined for your application
# Copy this file to .env file for development, create environment variables when deploying to production
# https://symfony.com/doc/current/best_practices/configuration.html#infrastructure-related-configuration
###> symfony/framework-bundle ###
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 ###
###> doctrine/doctrine-bundle ###
# Docker PostgreSQL is exposed on port 15432 — use that for bare-metal dev.
# Replace POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB with the values from your .env.
DATABASE_URL=postgresql://POSTGRES_USER:POSTGRES_PASSWORD@127.0.0.1:15432/POSTGRES_DB?serverVersion=18&charset=utf8
###< doctrine/doctrine-bundle ###
###> google/recaptcha ###
RECAPTCHA_SITE_KEY=changethis
RECAPTCHA_SECRET_KEY=changethis
###< google/recaptcha ###
###> minio/minio ###
MINIO_ROOT_USER=changethis
MINIO_ROOT_PASSWORD=changethis
MINIO_ENDPOINT=http://localhost:9000
MINIO_PUBLIC_URL=https://your-minio-subdomain.example.com
###< minio/minio ###
###> symfony/mailer ###
MAILER_DSN=smtp://localhost:1025
MAIL_DOMAIN=localhost
###< symfony/mailer ###
###> symfony/mercure-bundle ###
# See https://symfony.com/doc/current/mercure.html#configuration
# The URL of the Mercure hub, used by the app to publish updates (can be a local URL)
MERCURE_URL=https://mine.local/.well-known/mercure
# The public URL of the Mercure hub, used by the browser to connect
MERCURE_PUBLIC_URL=https://mine.local/.well-known/mercure
# The secret used to sign the JWTs
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
# Publisher JWT (must match publisher_jwt in your Caddyfile)
MERCURE_JWT_TOKEN=changethis
# Subscriber JWT sent to the browser so it can authenticate its EventSource connection
MERCURE_SUBSCRIBER_JWT=changethis
###< symfony/mercure-bundle ###
###> web-auth/webauthn-framework ###
WEBAUTHN_RP_ID=mine.local
WEBAUTHN_ORIGIN=https://mine.local
###< web-auth/webauthn-framework ###

3
.env.test Normal file
View File

@@ -0,0 +1,3 @@
# define your env variables for the test env here
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'

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 = []

142
.gitea/workflows/ci.yml-bak Normal file
View File

@@ -0,0 +1,142 @@
name: CI - Tests
on:
push:
branches:
- master
- develop
pull_request:
branches:
- master
- develop
jobs:
tests:
runs-on: splendid-bear
services:
postgres:
image: postgres:18-alpine
env:
POSTGRES_USER: mineseeker_test
POSTGRES_PASSWORD: test_password
POSTGRES_DB: mineseeker_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP 8.3
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: pdo_pgsql, gd, intl, zip, sodium
coverage: none
tools: composer:v2
- name: Validate composer.json
run: composer validate --strict
- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: vendor
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-
- name: Install PHP dependencies
run: composer install --prefer-dist --no-progress --no-interaction
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Cache node modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install Node dependencies
run: npm ci
- name: Build assets
run: npm run build
- name: Create .env.test file
run: |
cat > .env.test << 'ENVEOF'
APP_ENV=test
APP_SECRET=test-secret-key-for-ci-testing-only
DATABASE_URL="postgresql://mineseeker_test:test_password@localhost:5432/mineseeker_test?serverVersion=18&charset=utf8"
KERNEL_CLASS='App\Kernel'
SYMFONY_DEPRECATIONS_HELPER=999999
PANTHER_APP_ENV=panther
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
ENVEOF
- name: Setup test database
run: make test-db-setup
- name: Run PHPUnit tests
run: vendor/bin/phpunit --testdox --colors=always
- name: Run PHPUnit tests with coverage (optional)
if: github.event_name == 'pull_request'
run: |
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text --coverage-clover=coverage.xml
- name: Upload coverage reports (optional)
if: github.event_name == 'pull_request'
uses: codecov/codecov-action@v4
with:
file: ./coverage.xml
flags: unittests
name: phpunit-coverage
fail_ci_if_error: false
lint:
runs-on: splendid-bear
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP 8.3
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer:v2, phpstan
- name: Install PHP dependencies
run: composer install --prefer-dist --no-progress --no-interaction
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Node dependencies
run: npm ci
- name: Run ESLint
run: npm run lint || true
- name: Check code style (PHP)
run: |
if [ -f "vendor/bin/php-cs-fixer" ]; then
vendor/bin/php-cs-fixer fix --dry-run --diff
else
echo "PHP-CS-Fixer not installed, skipping..."
fi

View File

@@ -0,0 +1,39 @@
name: Deploy to Production
on:
push:
tags:
- 'v*'
jobs:
deploy:
runs-on: splendid-bear
steps:
- name: Checkout tag
env:
GITEA_TOKEN: ${{ gitea.token }}
run: |
set -e
export HOME=/tmp
git config --global credential.helper '!f() { echo "username=oauth2"; echo "password=$GITEA_TOKEN"; }; f'
git config --global --add safe.directory "${{ vars.PROD_APP_DIR }}"
cd "${{ vars.PROD_APP_DIR }}"
git remote set-url origin "${{ gitea.server_url }}/${{ gitea.repository }}.git"
git fetch --tags --force
git checkout "${{ gitea.ref_name }}"
- name: Write .env
env:
PROD_ENV_FILE: ${{ secrets.PROD_ENV_FILE }}
run: |
printf '%s' "$PROD_ENV_FILE" > "${{ vars.PROD_APP_DIR }}/.env"
- name: Build image
run: |
cd "${{ vars.PROD_APP_DIR }}"
docker compose build
- name: Start services
run: |
cd "${{ vars.PROD_APP_DIR }}"
docker compose up -d

View File

@@ -0,0 +1,126 @@
name: Deploy to Production
on:
push:
tags:
- 'v*'
jobs:
test:
runs-on: splendid-bear
services:
postgres:
image: postgres:18-alpine
env:
POSTGRES_USER: mineseeker_test
POSTGRES_PASSWORD: test_password
POSTGRES_DB: mineseeker_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP 8.3
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: pdo_pgsql, gd, intl, zip, sodium
coverage: none
tools: composer:v2
- name: Install PHP dependencies
run: composer install --prefer-dist --no-progress --no-interaction
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Node dependencies
run: npm ci
- name: Build assets
run: npm run build
- name: Create .env.test file
run: |
cat > .env.test << 'ENVEOF'
APP_ENV=test
APP_SECRET=test-secret-key-for-ci-testing-only
DATABASE_URL="postgresql://mineseeker_test:test_password@localhost:5432/mineseeker_test?serverVersion=18&charset=utf8"
KERNEL_CLASS='App\Kernel'
SYMFONY_DEPRECATIONS_HELPER=999999
ENVEOF
- name: Setup test database
run: make test-db-setup
- name: Run PHPUnit tests
run: vendor/bin/phpunit --testdox --colors=always --stop-on-failure
deploy:
needs: test
runs-on: splendid-bear
steps:
- name: Checkout tag
env:
GITEA_TOKEN: ${{ gitea.token }}
run: |
set -e
export HOME=/tmp
git config --global credential.helper '!f() { echo "username=oauth2"; echo "password=$GITEA_TOKEN"; }; f'
git config --global --add safe.directory "${{ vars.PROD_APP_DIR }}"
cd "${{ vars.PROD_APP_DIR }}"
git remote set-url origin "${{ gitea.server_url }}/${{ gitea.repository }}.git"
git fetch --tags --force
git checkout "${{ gitea.ref_name }}"
- name: Write .env
env:
PROD_ENV_FILE: ${{ secrets.PROD_ENV_FILE }}
run: |
printf '%s' "$PROD_ENV_FILE" > "${{ vars.PROD_APP_DIR }}/.env"
- name: Build image
run: |
cd "${{ vars.PROD_APP_DIR }}"
docker compose build
- name: Run database migrations
run: |
cd "${{ vars.PROD_APP_DIR }}"
docker compose run --rm app php bin/console doctrine:migrations:migrate --no-interaction
- name: Clear cache
run: |
cd "${{ vars.PROD_APP_DIR }}"
docker compose run --rm app php bin/console cache:clear --env=prod
- name: Start services
run: |
cd "${{ vars.PROD_APP_DIR }}"
docker compose up -d
- name: Health check
run: |
sleep 5
curl -f http://localhost:10080/ || exit 1
- name: Notify deployment success
if: success()
run: |
echo "✅ Deployment successful for tag ${{ gitea.ref_name }}"
- name: Notify deployment failure
if: failure()
run: |
echo "❌ Deployment failed for tag ${{ gitea.ref_name }}"
exit 1

59
.gitignore vendored
View File

@@ -1,40 +1,21 @@
/app/config/parameters.yml
/build/
/phpunit.xml
/var/*
!/var/cache
/var/cache/*
!var/cache/.gitkeep
!/var/logs
/var/logs/*
!var/logs/.gitkeep
!/var/sessions
/var/sessions/*
!var/sessions/.gitkeep
!var/SymfonyRequirements.php
/vendor/
/web/bundles/
# s7 mods
/!/
/.idea/
/.idea/*
/.idea/workspace.xml
/.idea/dataSources.ids
/.idea/dataSources.xml
/.idea/dataSources.local.xml
web/css/*
web/js/*
web/uploads/*
phpunit.phar
phpunit-report/*
/node_modules/
/src/Mine/SeekerBundle/Resources/public/js/node/
/src/Mine/SeekerBundle/Resources/public/js/src/
###> system7 - jotunheimr ###
compose.override.yaml
+bak/
.idea/
node_modules/
nohup.out
src/Mine/SeekerBundle/Resources/public/js/index.js
src/Mine/SeekerBundle/Resources/public/js/index.min.js
npm-debug.log
/public/build/
/config/reference.php
###< system7 - jotunheimr ###
###> symfony/framework-bundle ###
/.env
/public/bundles/
/var/
/vendor/
###< symfony/framework-bundle ###
###> phpunit/phpunit ###
/phpunit.xml
/.phpunit.cache/
###< phpunit/phpunit ###

464
AGENTS.md Normal file
View File

@@ -0,0 +1,464 @@
# AI Agent Guidelines for MineSeeker
This document provides guidelines and context for AI coding agents working on the MineSeeker project.
## Project Overview
**MineSeeker** is a real-time multiplayer 1v1 minesweeper game built with Symfony (PHP) and React. Players compete to claim mines on a shared 16×16 grid, with the first to reach 26 mines winning.
### Tech Stack
- **Backend:** Symfony 7.2 (PHP 8.3)
- **Frontend:** React 18, Vite 6
- **Database:** PostgreSQL 17 with materialized views
- **Storage:** MinIO (S3-compatible)
- **Real-time:** Mercure (Server-Sent Events)
- **Styling:** SCSS, MUI (Material-UI), Emotion
- **Fonts:** @fontsource packages (web), Carlito-Bold.ttf (server-side images)
### Key Features
**Core Gameplay:**
- **Multiplayer-focused:** 1v1 competitive gameplay where players race to claim more mines than their opponent
- **Win condition:** First player to claim 26 out of 51 mines wins
- **Real-time updates:** WebSocket-like gameplay using Mercure (Server-Sent Events)
- **Game restoration:** Players can resume unfinished games from where they left off
- **Bonus points system:** Rewards skilled play (blind hits, chain combos, edge mines, endgame mines, safe cell reveals)
**User Features:**
- **Authentication:** Password + optional TOTP + optional WebAuthn passkeys
- **Anonymous play:** Guest players can play without creating an account
- **Profile statistics:** Detailed stats including wins, losses, draws, win rate, average score, total mines hit, and bonus points
- **Battle history:** View and replay past games move-by-move
**Sharing & Social:**
- **Battle reports:** Shareable public pages for each completed game (`/battle/{uuid}`)
- **OG image generation:** Automatic creation of 1200×630 PNG images for social media sharing (using PHP GD)
- **Open Graph tags:** Battle share pages include rich preview cards with player names, avatars, scores, and bonus points
---
## Architecture Overview
### Backend (Symfony)
```
src/
├── Controller/ # HTTP endpoints (game, profile, battle sharing)
├── Entity/ # Doctrine ORM entities
├── Repository/ # Database queries (uses QueryBuilder, not raw SQL)
├── Service/ # Business logic (BattleCardGenerator, WebAuthn, Email)
├── Dto/ # Data Transfer Objects (immutable, readonly)
├── Util/ # Game logic (TopicManager for Mercure)
└── Migrations/ # Database schema changes
```
**Important patterns:**
- Use Doctrine ORM QueryBuilder (not raw SQL) in repositories
- DTOs are `final readonly` classes with constructor property promotion
- Services use dependency injection via `config/services.yaml`
- Materialized views for performance (auto-refreshed via triggers)
### Frontend (React)
```
assets/
├── js/
│ ├── mine-seeker/ # Main game bundle (self-contained)
│ │ ├── MineSeeker.jsx # Root component, wraps GameProvider + QueryClientProvider
│ │ ├── components/ # Game-specific components
│ │ │ ├── GameBoard.jsx # Main game board grid
│ │ │ ├── GameTimer.jsx # Game timer display
│ │ │ ├── BonusBox.jsx # Bonus points indicator
│ │ │ ├── BonusStatsDialog.jsx # Bonus statistics modal
│ │ │ ├── CaptchaOverlay.jsx # Captcha challenge overlay
│ │ │ ├── ChallengeCountdown.jsx # Challenge timer
│ │ │ ├── OnlinePlayersDialog.jsx # Online players list
│ │ │ ├── WaitingOverlayContent.jsx # Waiting for opponent
│ │ │ ├── grid/ # Grid-related components (cells, mines)
│ │ │ ├── profile/ # In-game profile components (PlayerColumn)
│ │ │ ├── timer/ # Timer-related components
│ │ │ └── user/ # User-related components
│ │ ├── contexts/ # React Context API
│ │ │ ├── GameContext.jsx # Game state context
│ │ │ └── GameProvider.jsx # Context provider with state logic
│ │ ├── hooks/ # Custom React hooks
│ │ │ ├── useGameDataProvider.js # React Query data provider
│ │ │ ├── useGameRefs.jsx # Refs for DOM elements
│ │ │ ├── useGameState.jsx # Game state management
│ │ │ ├── useServerCommunication.jsx # Mercure SSE connection
│ │ │ └── useStepTimer.jsx # Step-by-step timer
│ │ └── utils/ # Game-specific utilities
│ │ └── constants.jsx # Game constants, colors, defaults
│ ├── components/ # Shared UI components
│ ├── utils/ # Shared utilities
│ ├── profile.jsx # Profile page entry
│ ├── passkey.jsx # Passkey management entry
│ └── contact.jsx # Contact form entry
├── css/
│ └── homepage/ # SCSS partials (imported by style.homepage.scss)
└── fonts/
└── Carlito-Bold.ttf # TTF font for PHP GD image generation
```
**Important patterns:**
- Vite aliases: `@mine-components`, `@mine-contexts`, `@mine-hooks`, `@mine-utils`, `@global-components`, `@global-utils`
- React Query only available inside `mine-seeker` bundle
- Avoid circular dependencies (e.g., don't import from `@global-components` inside `components/` directory)
- PropTypes required on all components
---
## Common Tasks
### Adding a New Feature
1. **Backend:**
- Create migration for schema changes
- Add/update entities and repositories
- Create DTOs for data transfer
- Add controller endpoints
- Update service configuration if needed
2. **Frontend:**
- Create components in appropriate bundle
- Add PropTypes to all components
- Use existing hooks and utilities
- Follow styled-components pattern for MUI customization
3. **Documentation:**
- Update relevant docs in `docs/` folder
- Add examples if introducing new patterns
### Database Changes
- Always create migrations: `bin/console make:migration`
- Use Doctrine QueryBuilder in repositories (not raw SQL)
- For PostgreSQL-specific features (materialized views, triggers), use raw SQL in migrations only
- Materialized views should auto-refresh via triggers
### Styling
- **Web fonts:** Use `@fontsource` packages (WOFF/WOFF2)
- **Server-side images:** Use TTF fonts in `assets/fonts/` (PHP GD requires TTF)
- **CSS:** Create SCSS partials in `assets/css/homepage/`, import in main file
- **Components:** Use Emotion styled-components or CSS classes
### File Headers
All PHP and JS/JSX files should have this header:
```php
<?php declare(strict_types=1);
/*
* 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.
*/
```
```javascript
/**
* 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.
*/
```
---
## Coding Standards
### PHP
- **PSR Standards:** Follow PSR-1, PSR-12 coding standards
- **Type declarations:** Use strict types (`declare(strict_types=1)`)
- **Property promotion:** Use constructor property promotion for DTOs and services
- **Readonly:** Use `readonly` for immutable properties
- **Final:** Mark DTOs as `final`
- **Doctrine:** Use QueryBuilder with `expr()` methods, not string concatenation
- **Null safety:** Use null coalescing `??` and null-safe operator `?->`
- **Formatting:** 4-space indentation, opening braces on same line for methods/classes
**Example DTO:**
```php
final readonly class ProfileGameDto implements JsonSerializable
{
public function __construct(
public ?int $id,
public string $redName,
public string $blueName,
public bool $bothRegistered,
) {}
}
```
### JavaScript/React
- **Components:** Functional components with hooks
- **PropTypes:** Required on all components
- **Imports:** Use aliases (`@global-components`, `@mine-hooks`, etc.)
- **State:** Use `useState`, `useEffect`, `useCallback`, `useMemo` appropriately
- **Avoid:** Circular dependencies, especially with barrel exports
**Example component:**
```javascript
import React, { useState } from 'react';
import { string, number } from 'prop-types';
export const MyComponent = ({ title, count }) => {
const [value, setValue] = useState(0);
return <div>{title}: {count + value}</div>;
};
MyComponent.propTypes = {
title: string.isRequired,
count: number.isRequired,
};
```
---
## Important Files & Locations
### Configuration
- `config/services.yaml` - Service definitions and parameters
- `vite.config.js` - Vite build config, aliases
- `.env` - Environment variables (not in git)
- `composer.json` - PHP dependencies
- `package.json` - Node dependencies
### Key Services
- `BattleCardGenerator` - Generates 1200×630 PNG OG images using PHP GD
- `TopicManager` - Mercure topic management and game logic
- `WebAuthnService` - Passkey authentication
- `Email services` - Various email senders in `src/Service/Email/`
### Important Entities
- `User` - Registered users
- `PlayedGame` - Game records with moves, grid, scores
- `RecentBattle` - Read-only entity from materialized view
- `UserStats` - Read-only entity from materialized view
### Documentation
- `docs/game-mechanics/BONUS_POINTS_SYSTEM.md` - Bonus points reference
- `docs/FONTS.md` - Font usage and management
- `AGENTS.md` - This file
- `CHANGELOG.md` - Project changelog
---
## Common Pitfalls
### ❌ Don't Do
- **Don't use raw SQL in repositories** (use Doctrine QueryBuilder)
- **Don't create circular imports** (e.g., importing `@global-components` from within `components/`)
- **Don't use system fonts directly** (bundle TTF in `assets/fonts/`)
- **Don't skip PropTypes** on React components
- **Don't use `var`** in JavaScript (use `const` or `let`)
- **Don't define variables after using them** (hoisting issues with `const`)
### ✅ Do
- **Use Doctrine QueryBuilder** with `expr()` methods
- **Import components directly** to avoid circular dependencies
- **Bundle fonts in project** for portability
- **Add PropTypes** to all components
- **Use `const` for immutable values**, `let` for mutable
- **Define variables before using them**
---
## Keeping Components in Sync
### Battle Report Display
The battle report/statistics appear in **two places** that must be kept synchronized:
#### 1. BattleDialog Component (React)
**File:** `assets/js/components/BattleDialog.jsx`
- React dialog component shown on profile page
- Uses Material-UI and styled-components
- Displays game stats, bonus points, winner information
#### 2. Battle Share Page (Twig)
**File:** `templates/Game/battle_share.html.twig`
- Public battle share page (accessible via `/battle/{uuid}`)
- Server-rendered HTML with SCSS styling
- Shows same information as BattleDialog
### Synchronization Rules
**When updating BattleDialog.jsx, also update battle_share.html.twig:**
✅ Adding new stats or fields
✅ Changing display logic (winner calculation, formatting)
✅ Modifying bonus points display
✅ Updating labels or text
**Keep in sync:**
- Game outcome logic (win/loss/draw/abandoned)
- Bonus points formatting
- Player name display
- Score display
- Stats and metadata shown
**Example:** If you add a new stat to BattleDialog showing "fastest mine claim time", you must also add it to battle_share.html.twig so both displays show the same information.
---
## Testing & Building
### Backend
```bash
# Run migrations
bin/console doctrine:migrations:migrate
# Clear cache
bin/console cache:clear
# Refresh materialized views
bin/console dbal:run-sql "REFRESH MATERIALIZED VIEW CONCURRENTLY recent_battles"
```
### Frontend
```bash
# Development build with watch
npm run dev
# Production build
npm run build
# After changing fonts
rm -rf var/og-cache/*
```
---
## Git Workflow
### Commit Messages
Follow conventional commits:
- `feat: add bonus points to battle cards`
- `fix: resolve circular dependency in BattleDialog`
- `refactor: use Doctrine QueryBuilder in RecentBattleRepository`
- `docs: add AGENTS.md for AI coding agents`
- `chore: update dependencies`
### Creating Pull Requests
When creating PRs, include:
1. **Summary** - What was changed and why
2. **Changes** - List of modified files/components
3. **Testing** - How to verify the changes
4. **Screenshots** - For UI changes
---
## Performance Considerations
### Database
- Use materialized views for expensive queries (profile stats, recent battles)
- Auto-refresh materialized views via triggers on source table changes
- Index frequently queried columns
- Use `COALESCE()` for nullable aggregates
### Frontend
- Lazy load large components
- Use React Query for data fetching and caching
- Avoid unnecessary re-renders (use `useMemo`, `useCallback`)
- Bundle code per entry point (profile, passkey, contact, game)
### Images
- Battle card images are cached in `var/og-cache/`
- Images regenerated when games change (via deterministic UUID)
- Use appropriate image sizes (1200×630 for OG images)
---
## Security
### Authentication
- Password + TOTP (optional) + WebAuthn passkeys (optional)
- Backup codes for TOTP recovery
- Session-based authentication with `IS_AUTHENTICATED_REMEMBERED`
### Data Access
- Controllers check `denyAccessUnlessGranted()`
- Materialized views filter by `user_id`
- Guest players use separate `Gamer` entity (no `User` account)
### Input Validation
- Symfony forms for user input
- File upload validation (size, MIME type)
- WebAuthn challenge validation
---
## Useful Commands
```bash
# Symfony
bin/console debug:container ServiceName # Inspect service configuration
bin/console debug:router # List all routes
bin/console make:migration # Create new migration
bin/console doctrine:migrations:list # List migrations
# Database
bin/console dbal:run-sql "SELECT * FROM ..." # Run SQL query
# Assets
npm run build # Build production assets
npm run dev # Build with watch mode
# Git
git log --oneline --graph # View commit history
git diff origin/main...HEAD # See changes since main
```
---
## Need Help?
- **Bonus Points:** See `docs/game-mechanics/BONUS_POINTS_SYSTEM.md`
- **Fonts:** See `docs/FONTS.md`
- **Symfony Docs:** https://symfony.com/doc/current/
- **React Docs:** https://react.dev/
- **Doctrine ORM:** https://www.doctrine-project.org/projects/doctrine-orm/en/current/
---
## Version History
- **2026-04-21:** Initial AGENTS.md created
- Document common patterns, pitfalls, and project structure
- Include coding standards and examples
---
**Happy coding! 🚀**

559
CHANGELOG.md Normal file
View File

@@ -0,0 +1,559 @@
# Changelog
## v2026.2.9-0 (2026-04-23)
### New
* Add tracking code for the app #10. [Lang]
## v2026.2.8-3 (2026-04-22)
### Fix
* The error message cannot be seen during avatar changing #10. [Lang]
## v2026.2.8-2 (2026-04-21)
### Changes
* Increase the 2 MB avatar maximum file size to 10 MB #10. [Lang]
## v2026.2.8-1 (2026-04-21)
### Changes
* Upgrade front-end & back-end deps to the latest available version #10. [Lang]
## v2026.2.8-0 (2026-04-21)
### New
* Add CI/CD improvements - add new CI workflow - & improve the deployment w/ tests #10. [Lang]
* Add test cases to back-end w/ real database connection in it #10. [Lang]
### Changes
* The original CI/CD workflow is restored - the work with tests is postponed #10. [Lang]
* Add sound when the game start #10. [Lang]
* Create AGENTS.md file for future maintenance #9. [Lang]
* Remove the wrongly implemented font installation in docker - & replace it with static font on BattleCardGenerator (it solves the shareable image problem on bare-metal too) #8. [Lang]
## v2026.2.7-1 (2026-04-21)
### Changes
* Fine-tune the recent battle list #8. [Lang]
## v2026.2.7-0 (2026-04-21)
### Changes
* Small changes on docs - and improve text on homepage #8. [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]
## v2026.2.6-1 (2026-04-19)
### Fix
* The PostgreSQL logo was horrible #7. [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]

29
Caddyfile Normal file
View File

@@ -0,0 +1,29 @@
{
{$CADDY_GLOBAL_OPTIONS}
frankenphp {
{$FRANKENPHP_CONFIG}
}
}
{$SERVER_NAME:localhost} {
log
root * /app/public
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
subscriber_jwt {$MERCURE_JWT_SECRET} HS256
anonymous
subscriptions
}
php_server
}

53
Dockerfile Normal file
View File

@@ -0,0 +1,53 @@
FROM oven/bun:1-alpine AS assets
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY vite.config.js ./
COPY assets/ assets/
COPY public/ public/
RUN bun run build
FROM dunglas/frankenphp:latest
RUN install-php-extensions \
pdo_pgsql \
gd \
intl \
zip \
opcache \
apcu \
sodium
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
RUN printf '[PHP]\nupload_max_filesize=10M\npost_max_size=11M\n' > "$PHP_INI_DIR/conf.d/uploads.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"
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
ENV APP_ENV=prod
WORKDIR /app
COPY . .
RUN composer install \
--no-dev \
--no-interaction \
--no-scripts \
--optimize-autoloader
COPY --from=assets /app/public/build ./public/build
RUN mkdir -p var/cache var/log var/sessions && \
chown -R www-data:www-data var/
COPY Caddyfile /etc/caddy/Caddyfile
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

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)

105
Makefile Normal file
View File

@@ -0,0 +1,105 @@
.PHONY: help start start-build stop build down ps logs prune-everything db-reset mercure-jwt cache-clear og-cache-clear test-db-setup test-db-reset test
.DEFAULT_GOAL := help
help:
@echo "Available commands:"
@echo " make start - Start services without building (uses existing images)"
@echo " make start-build - Start services and build images if needed"
@echo " make stop - Stop running services"
@echo " make build - Build Docker images only"
@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"
@echo " make test-db-setup - One-time setup: Create test database and run migrations"
@echo " make test-db-reset - Reset test database (drop, create, migrate)"
@echo " make test - Run PHPUnit tests"
start:
docker compose up -d
start-build:
composer i
bun run build
docker compose up -d --build
stop:
docker compose stop
build:
docker compose build
down:
docker compose down
prune-everything:
@echo "WARNING: This will remove ALL containers, volumes, networks and images!"
@read -p "Type 'yes' to confirm: " confirm; \
if [ "$$confirm" != "yes" ]; then \
echo "Aborted."; \
exit 1; \
fi
docker compose down -v --rmi all --remove-orphans
mercure-jwt:
@php bin/generate-mercure-jwt.php
db-reset:
@echo "WARNING: This will DROP and RECREATE the database!"
@read -p "Type 'yes' to confirm: " confirm; \
if [ "$$confirm" != "yes" ]; then \
echo "Aborted."; \
exit 1; \
fi
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"
test-db-setup:
@echo "Setting up test database..."
@bin/console dbal:run-sql "SELECT 1 FROM pg_database WHERE datname='mineseeker_test'" 2>/dev/null | grep -q 1 || \
(bin/console dbal:run-sql "CREATE DATABASE mineseeker_test" && echo "✓ Database 'mineseeker_test' created")
@bin/console doctrine:migrations:migrate --env=test --no-interaction --allow-no-migration 2>&1 | grep -v "WARNING" || true
@echo "✓ Test database setup complete!"
@echo " Database: mineseeker_test"
@echo " Run tests with: make test"
test-db-reset:
@echo "Resetting test database..."
@bin/console dbal:run-sql "DROP DATABASE IF EXISTS mineseeker_test" --quiet
@bin/console dbal:run-sql "CREATE DATABASE mineseeker_test" --quiet
@bin/console doctrine:migrations:migrate --env=test --no-interaction --quiet
@echo "✓ Test database reset complete!"
@echo " Database: mineseeker_test"
@echo " Run tests with: make test"
test:
@php -d memory_limit=512M bin/phpunit --testdox --colors=always

382
README.md
View File

@@ -1,35 +1,379 @@
This is a Symfony 3 project w/ React JS in standalone mode and w/ WebSocket.
# MineSeeker
0.) Must installed modules w/ npm are in package.json + to global:
A real-time **1v1 multiplayer minesweeper** game played in the browser.
Two players race on the same hidden minefield — uncover safe cells to score points, but hit a mine and you hand the advantage to your opponent.
Games are live and synchronised instantly via a Mercure hub; no page reloads, no polling.
$ npm install webpack -g
Created by [SplendidBear](https://www.splendidbear.org).
You will need a
.babelrc file w/ the presets
webpack.config.js - https://webpack.github.io/docs/webpack-for-browserify-users.html
same as dir where the package.json!!
---
(!) Tutorial: https://egghead.io/lessons/react-introduction-to-properties
## Features
- **Real-time 1v1 gameplay** — moves broadcast instantly over Mercure (server-sent events)
- **Guest & registered play** — jump in anonymously or create an account for stats and history
- **Full authentication stack** — email/password, passkeys (WebAuthn), TOTP 2FA with backup codes
- **Player profiles** — win/loss/draw stats, per-month charts, recent battle history with shareable replay links
- **Profile pictures** — uploaded to MinIO object storage, thumbnails generated on-the-fly by LiipImagine
- **Battle replay sharing** — share a direct link to any finished game
- **Docker-ready** — single `make start-build` brings up the full production-like stack
---
1.) Backend WebSocket server start as daemon - GeniusesOfSymfony/WebSocketBundle
## Tech stack
$ nohup bin/console gos:websocket:server &
| Layer | Technology |
|---|---|
| Backend | PHP 8.5, Symfony 7.4, Doctrine ORM |
| Frontend | React 19, Vite, MUI, SCSS |
| Database | PostgreSQL 18 |
| Real-time | Mercure (built into FrankenPHP / Caddy) |
| File storage | MinIO (S3-compatible) |
| Image processing | LiipImagine + Flysystem |
| Server | FrankenPHP (Caddy + PHP in one binary) |
| Auth | Symfony Security, Scheb 2FA, web-auth/webauthn-framework |
2.) React JS WebPack watch generator w/ babel presets: es2015, react
---
$ webpack -p --config=webpack-prod.config.js
## Requirements
PROD
### Bare-metal development
- PHP >= 8.5 with extensions: `pdo_pgsql`, `gd`, `intl`, `zip`, `sodium`
- Composer 2
- Node.js 22 + Bun
- PostgreSQL 18
- Caddy with FrankenPHP and the Mercure module
- MailHog (or any SMTP server on port 1025)
- MinIO (listening on port 9000)
$ webpack --progress --colors --watch -d
### Docker
- Docker Engine 24+
- Docker Compose v2
- Bun (for building frontend assets before `make start-build`)
- Composer (for `make start-build`)
DEV
---
-d --> Debugger; If you write this line somewhere: debugger;
The browser will stop the code here!!!
## Installation
3.) Connect to Prod
### 1. Clone the repository
ssh xxsvci@laszlolang.com -i ~/.ssh/id_rsa_laszlolang
```bash
git clone https://github.com/splendidbear/mineseeker.git
cd mineseeker
```
### 2. Configure environment
```bash
cp .env.dist .env
```
Edit `.env` and fill in every value. Key ones:
| Variable | What to set |
|---|---|
| `APP_SECRET` | Random 32-byte hex: `openssl rand -hex 32` |
| `POSTGRES_USER/PASSWORD/DB` | Your PostgreSQL credentials |
| `MINIO_ROOT_USER/PASSWORD` | MinIO admin credentials |
| `MINIO_ENDPOINT` | `http://localhost:9000` (bare-metal) |
| `MINIO_PUBLIC_URL` | Public URL browsers use to reach MinIO |
| `RECAPTCHA_SITE_KEY/SECRET_KEY` | Google reCAPTCHA v3 keys for your domain |
| `MERCURE_JWT_SECRET` | Random secret (generated in step 3) |
| `MERCURE_JWT_TOKEN` | Signed publisher JWT (generated in step 3) |
| `MERCURE_SUBSCRIBER_JWT` | Signed subscriber JWT (generated in step 3) |
| `MAILER_DSN` | `smtp://localhost:1025` for MailHog in dev |
### 3. Generate Mercure JWT tokens
```bash
composer install
make mercure-jwt
```
Copy the printed values into `.env` and into the `publisher_jwt` / `subscriber_jwt` lines of your Caddy Mercure block, then reload Caddy:
```bash
sudo systemctl reload caddy
```
### 4a. Run with Docker
```bash
make start-build
```
This installs PHP dependencies, builds the frontend assets with Bun, builds the Docker image, and starts all services (`app`, `db`, `mail`, `minio`, `minio_init`).
The app is available at `http://localhost:10080` (or the domain set in `APP_PUBLIC_HOSTNAME`).
To apply any code changes later, run the same command again.
### 4b. Run bare-metal (development)
```bash
composer install
bun install
bun run dev # Vite dev server with hot-reload
php bin/console doctrine:migrations:migrate --no-interaction
```
Start MinIO and MailHog, then open the URL configured in your Caddy vhost.
### 5. MinIO bucket setup
**Docker** — handled automatically on first start by the `minio_init` service. No action needed.
**Bare-metal** — run once after MinIO is up:
```bash
mc alias set local http://localhost:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD
mc mb local/mineseeker
echo '' | mc pipe local/mineseeker/media/.keep
echo '' | mc pipe local/mineseeker/cache/.keep
# Apply public-read policy for media/ and cache/ — see docker/minio-init.sh for the JSON
```
---
## Development environment
When running the Docker stack locally you typically want to catch outgoing emails instead of relaying them through a real SMTP server.
The production `compose.yaml` uses Postfix for actual mail delivery and must not be edited for local overrides.
Use a `compose.override.yaml` file instead — Docker Compose merges it automatically on top of the base file whenever both are present.
### Email: replace Postfix with MailHog
Create `compose.override.yaml` in the project root (it is git-ignored and never reaches production):
```yaml
services:
app:
environment:
MAILER_DSN: smtp://mail:1025?verify_peer=0
TRUSTED_PROXIES: "0.0.0.0/0"
mail:
image: mailhog/mailhog:latest
ports:
- "1025:1025"
- "8025:8025"
```
This replaces the `mail` service image with MailHog and points the application's mailer at its SMTP port (`1025`).
No other files need to change.
After adding the file, restart the stack:
```bash
make start-build
```
All emails sent by the application are now captured by MailHog.
Open the web UI at **http://localhost:8025** to inspect them.
> **Production note** — `compose.override.yaml` is listed in `.gitignore`.
> Never commit it; the production server must only see `compose.yaml` with Postfix.
---
## Deploying to production
Releases are automated via Gitea Actions. Pushing a tag that starts with `v` (e.g. `v2026.01`) triggers the workflow at `.gitea/workflows/deploy.yml`.
The job runs on a **self-hosted runner** installed on the production server — the server only needs an outbound connection to Gitea, no open SSH port required.
The `app` image is rebuilt with the new code; the database and storage containers are untouched so all data is preserved.
### Gitea repository variables and secrets
**Variable** (plaintext, editable — **Repository → Settings → Variables**):
| Variable | Value |
|---|---|
| `PROD_APP_DIR` | Absolute path on the server (e.g. `/var/www/mineseeker`) |
**Secret** (encrypted, write-only — **Repository → Settings → Secrets**):
| Secret | Value |
|---|---|
| `PROD_ENV_FILE` | Full content of the production `.env` file (see below) |
The workflow writes `PROD_ENV_FILE` to `.env` on every deploy, so you never need to manage the file on the server manually. To update a credential, overwrite the secret in Gitea and push a new tag.
#### `PROD_ENV_FILE` contents
Paste the filled-in `.env` file as the secret value:
```dotenv
APP_NAME=mineseeker
APP_ENV=prod
APP_SECRET="<openssl rand -hex 32>"
DATABASE_URL=postgresql://POSTGRES_USER:POSTGRES_PASSWORD@db:5432/POSTGRES_DB?serverVersion=18&charset=utf8
POSTGRES_USER=mineseeker
POSTGRES_PASSWORD="<strong password>"
POSTGRES_DB=mineseeker
POSTGRES_VERSION=18
MINIO_ROOT_USER=mineseeker
MINIO_ROOT_PASSWORD="<strong password>"
MINIO_ENDPOINT=http://minio:9000
MINIO_PUBLIC_URL=https://aws.mineseeker.hu
MAILER_DSN=smtp://mail:25?verify_peer=0
MAIL_DOMAIN=mineseeker.hu
RECAPTCHA_SITE_KEY="<your reCAPTCHA v3 site key>"
RECAPTCHA_SECRET_KEY="<your reCAPTCHA v3 secret key>"
MERCURE_URL=https://mineseeker.hu/.well-known/mercure
MERCURE_PUBLIC_URL=https://mineseeker.hu/.well-known/mercure
MERCURE_JWT_SECRET="<generated by make mercure-jwt>"
MERCURE_JWT_TOKEN="<generated by make mercure-jwt>"
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.
#### 1. Clone the repository
```bash
git clone https://gitea.mineseeker.hu/youruser/mineseeker.git /var/www/mineseeker
```
#### 2. Generate Mercure JWT tokens (run once locally)
```bash
composer install # only needed for this step
make mercure-jwt
```
Copy the three printed values into the `PROD_ENV_FILE` secret.
#### 3. First deploy
Trigger it by pushing the first tag:
```bash
git tag v2026.01
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`.
#### 4. Verify
```bash
docker compose ps # all services should be healthy/running
docker compose logs app # look for "Starting FrankenPHP"
```
### Releasing
```bash
git tag v2026.01
git push origin v2026.01
```
---
## Documentation
For detailed information about game mechanics, bonus systems, fonts, testing, and other technical details, see the [docs](./docs/) directory:
- **[AI Agent Guidelines](./AGENTS.md)** — Comprehensive guide for AI coding agents working on this project
- **[Bonus Points System](./docs/game-mechanics/BONUS_POINTS_SYSTEM.md)** — Complete reference for all bonus point types, calculation rules, and implementation details
- **[Fonts](./docs/FONTS.md)** — TrueType fonts used for server-side image generation
- **[Testing Guide](./docs/testing/TESTING.md)** — Complete testing setup with Foundry factories, database isolation, and best practices
- **[Factory Documentation](./docs/testing/FACTORIES.md)** — Detailed API reference for all test data factories
---
## License
LGPL-3.0 — see [LICENSE](LICENSE) for details.
&copy; 2026 [SplendidBear](https://www.splendidbear.org)
---
## Testing & CI/CD
MineSeeker has a comprehensive test suite with **71 automated tests** and continuous integration/deployment pipelines.
### Quick Start
```bash
# Setup test database (first time only)
make test-db-setup
# Run all tests
make test
# Run with documentation output
vendor/bin/phpunit --testdox
```
### Test Suite
- **71 tests** with **227 assertions**
- **Controller tests** - HTTP endpoints, authentication, routing
- **DTO tests** - Data serialization and calculations
- **Entity tests** - Domain logic and defaults
- **Service tests** - Business logic and external APIs
- **Integration tests** - Foundry factories and database isolation
**Test execution time:** ~6-8 seconds
### Continuous Integration
**Automated testing** runs on every push/pull request:
```yaml
# .gitea/workflows/ci.yml-bak
✓ PHP 8.3 setup with all extensions
✓ PostgreSQL 18 service container
✓ Composer and npm dependency installation
✓ Asset building with Vite
✓ Database migrations
✓ Full test suite execution
✓ Code linting (ESLint, PHP-CS-Fixer)
```
### Continuous Deployment
**Automated deployment** on version tags (e.g., `v1.2.3`):
```yaml
# .gitea/workflows/deploy.yml
1. Run full test suite (blocks deployment if fails)
2. Checkout tagged version
3. Build Docker image
4. Run database migrations
5. Restart services
6. Health check verification
```
**Deploy to production:**
```bash
git tag -a v1.2.3 -m "Release version 1.2.3"
git push origin v1.2.3
```
### Documentation
- **[Testing Guide](docs/testing/TESTING.md)** - Comprehensive testing documentation
- **[Factory Reference](docs/testing/FACTORIES.md)** - Foundry factory API
- **[CI/CD Guide](docs/CI_CD.md)** - Pipeline configuration and workflows
---

View File

@@ -1,7 +0,0 @@
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>

View File

@@ -1,7 +0,0 @@
<?php
use Symfony\Bundle\FrameworkBundle\HttpCache\HttpCache;
class AppCache extends HttpCache
{
}

View File

@@ -1,63 +0,0 @@
<?php
use Symfony\Component\HttpKernel\Kernel;
use Symfony\Component\Config\Loader\LoaderInterface;
class AppKernel extends Kernel
{
public function registerBundles()
{
$bundles = [
new Symfony\Bundle\FrameworkBundle\FrameworkBundle(),
new Symfony\Bundle\SecurityBundle\SecurityBundle(),
new Symfony\Bundle\TwigBundle\TwigBundle(),
new Symfony\Bundle\MonologBundle\MonologBundle(),
new Symfony\Bundle\SwiftmailerBundle\SwiftmailerBundle(),
new Doctrine\Bundle\DoctrineBundle\DoctrineBundle(),
new Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle(),
new Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle(),
new Symfony\Bundle\AsseticBundle\AsseticBundle(),
new FOS\UserBundle\FOSUserBundle(),
new HWI\Bundle\OAuthBundle\HWIOAuthBundle(),
new Gos\Bundle\WebSocketBundle\GosWebSocketBundle(),
new Gos\Bundle\PubSubRouterBundle\GosPubSubRouterBundle(),
new Doctrine\Bundle\DoctrineCacheBundle\DoctrineCacheBundle(),
new Snc\RedisBundle\SncRedisBundle(),
new CL\Bundle\SlackBundle\CLSlackBundle(),
new Jotunheimr\AdminBundle\JotunheimrAdminBundle(),
new Jotunheimr\UserBundle\JotunheimrUserBundle(),
new Mine\SeekerBundle\MineSeekerBundle(),
];
if (in_array($this->getEnvironment(), ['dev', 'test'], true)) {
$bundles[] = new Symfony\Bundle\DebugBundle\DebugBundle();
$bundles[] = new Symfony\Bundle\WebProfilerBundle\WebProfilerBundle();
$bundles[] = new Sensio\Bundle\DistributionBundle\SensioDistributionBundle();
$bundles[] = new Sensio\Bundle\GeneratorBundle\SensioGeneratorBundle();
}
return $bundles;
}
public function getRootDir()
{
return __DIR__;
}
public function getCacheDir()
{
return dirname(__DIR__).'/var/cache/'.$this->getEnvironment();
}
public function getLogDir()
{
return dirname(__DIR__).'/var/logs';
}
public function registerContainerConfiguration(LoaderInterface $loader)
{
$loader->load($this->getRootDir().'/config/config_'.$this->getEnvironment().'.yml');
}
}

View File

@@ -1,51 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="Cache-control" content="max-age=1209600;public">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="keywords" content="game,mineseeker,mine,seeker,laszlolang.com">
<meta name="robots" content="index,follow">
<meta name="revisit-after" content="2 days">
<meta name="resource-type" content="document">
<meta name="country" content="Hungary">
<meta name="description" content="This is a new minesweeper, multiplayer game.">
<meta name="content-language" content="hu,hun,hungarian">
{% include '@MineSeeker/Recent/favicon.html.twig' %}
<meta property="fb:app_id" content="{{ facebook_api }}">
{% block metas %}{% endblock %}
<title>MineSeeker{% block title %}{% endblock %}</title>
{% block stylesheets %}{% endblock %}
{% include '@MineSeeker/Recent/google-analytics.html.twig' %}
</head>
<body>
<div id="fb-root"></div>
{% block bodyTop %}{% endblock %}
<header>
{% block header %}{% endblock %}
</header>
<main>
{% block body %}{% endblock %}
</main>
<footer>
{% block footer %}{% endblock %}
</footer>
{% block javascripts %}
{% javascripts filter='?uglifyjs2'
'@JotunheimrAdminBundle/Resources/public/js/vendor/plugins/jQuery/jquery-3.0.0.min.js'
'@JotunheimrAdminBundle/Resources/public/js/vendor/plugins/jQuery/jquery-migrate-3.0.0.min.js'
'@JotunheimrAdminBundle/Resources/public/js/vendor/bootstrap/js/bootstrap.min.js' %}
<script type="text/javascript" src="{{ asset_url }}"></script>
{% endjavascripts %}
{% include '@MineSeeker/Recent/facebook.html.twig' %}
{% endblock %}
</body>
</html>

View File

@@ -1,13 +0,0 @@
<?php
use Doctrine\Common\Annotations\AnnotationRegistry;
use Composer\Autoload\ClassLoader;
/**
* @var ClassLoader $loader
*/
$loader = require __DIR__.'/../vendor/autoload.php';
AnnotationRegistry::registerLoader([$loader, 'loadClass']);
return $loader;

View File

@@ -1,107 +0,0 @@
imports:
- { resource: security.yml }
- { resource: services.yml }
- { resource: "@JotunheimrAdminBundle/Resources/config/config.yml" }
- { resource: "@JotunheimrUserBundle/Resources/config/config.yml" }
- { resource: "@MineSeekerBundle/Resources/config/config.yml" }
- { resource: "@MineSeekerBundle/Resources/config/services.yml" }
# Put parameters here that don't need to change on each machine where the app is deployed
# http://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration
parameters:
locale: en
framework:
#esi: ~
translator: { fallbacks: ["%locale%"] }
secret: "%secret%"
router:
resource: "%kernel.root_dir%/config/routing.yml"
strict_requirements: ~
form: ~
csrf_protection: ~
validation: { enable_annotations: true }
#serializer: { enable_annotations: true }
templating:
engines: ['twig']
default_locale: "%locale%"
trusted_hosts: ~
trusted_proxies: ~
session:
# http://symfony.com/doc/current/reference/configuration/framework.html#handler-id
# handler_id: session.handler.native_file
# save_path: "%kernel.root_dir%/../var/sessions/%kernel.environment%"
handler_id: session.handler.pdo
fragments: ~
http_method_override: true
assets: ~
# Twig Configuration
twig:
debug: "%kernel.debug%"
strict_variables: "%kernel.debug%"
globals:
version: "0.37.18 (beta7)"
facebook_api: "%facebook.api%"
facebook_scope: "%facebook.scope%"
facebook_api_version: "%facebook.version%"
# Doctrine Configuration
doctrine:
dbal:
driver: pdo_mysql
host: "%database_host%"
port: "%database_port%"
dbname: "%database_name%"
user: "%database_user%"
password: "%database_password%"
charset: UTF8
# if using pdo_sqlite as your database driver:
# 1. add the path in parameters.yml
# e.g. database_path: "%kernel.root_dir%/data/data.db3"
# 2. Uncomment database_path in parameters.yml.dist
# 3. Uncomment next line:
# path: "%database_path%"
orm:
auto_generate_proxy_classes: "%kernel.debug%"
naming_strategy: doctrine.orm.naming_strategy.underscore
auto_mapping: true
# Assetic Configuration
assetic:
debug: "%kernel.debug%"
use_controller: "%kernel.debug%"
bundles: ~
node: /usr/bin/nodejs
filters:
cssrewrite: ~
uglifyjs2:
bin: "%kernel.root_dir%/../node_modules/uglify-js/bin/uglifyjs"
no_copyright: true
uglifycss:
bin: "%kernel.root_dir%/../node_modules/uglifycss/uglifycss"
# FOS User Configuration
fos_user:
db_driver: orm # other valid values are 'mongodb', 'couchdb' and 'propel'
firewall_name: secured_area
user_class: Jotunheimr\UserBundle\Entity\User
# Facebook OAuth
hwi_oauth:
firewall_names: [secured_area]
resource_owners:
facebook:
type: facebook
client_id: "%facebook.api%"
client_secret: "%facebook.api-secret%"
scope: "%facebook.scope%"
options:
display: popup
auth_type: rerequest
csrf: true
# Slack integration
cl_slack:
api_token: xoxp-107639806167-107029084564-115427085733-cccaa4f96c89c87ce680c7f22acfd001

View File

@@ -1,40 +0,0 @@
imports:
- { resource: parameters_dev.yml }
- { resource: config.yml }
framework:
router:
resource: "%kernel.root_dir%/config/routing_dev.yml"
strict_requirements: true
profiler: { only_exceptions: false }
web_profiler:
toolbar: true
intercept_redirects: false
# Swiftmailer Configuration
swiftmailer:
transport: "%mailer_transport%"
host: "%mailer_host%"
username: "%mailer_user%"
password: "%mailer_password%"
spool: { type: memory }
monolog:
handlers:
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
channels: [!event]
console:
type: console
channels: [!event, !doctrine]
parameters:
facebook.api: 320599508311862
facebook.api-secret: 18d4f48cdd274bccee2678e5eff3f557
facebook.version: 'v2.8'
facebook.scope: 'public_profile,email,user_friends'
mineseeker.websocket: 6450

View File

@@ -1,31 +0,0 @@
imports:
- { resource: parameters_prod.yml }
- { resource: config.yml }
# Swiftmailer Configuration
swiftmailer:
transport: "%mailer_transport%"
host: "%mailer_host%"
username: "%mailer_user%"
password: "%mailer_password%"
spool: { type: memory }
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
nested:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
console:
type: console
parameters:
facebook.api: 320597498312063
facebook.api-secret: c751bec8a3c5313ff2e5a83769bf1109
facebook.version: 'v2.8'
facebook.scope: 'public_profile,email,user_friends'
mineseeker.websocket: 8080

View File

@@ -1,16 +0,0 @@
imports:
- { resource: config_dev.yml }
framework:
test: ~
session:
storage_id: session.storage.mock_file
profiler:
collect: false
web_profiler:
toolbar: false
intercept_redirects: false
swiftmailer:
disable_delivery: true

View File

@@ -1,19 +0,0 @@
# This file is a "template" of what your parameters.yml file should look like
# Set parameters here that may be different on each deployment target of the app, e.g. development, staging, production.
# http://symfony.com/doc/current/best_practices/configuration.html#infrastructure-related-configuration
parameters:
database_host: 127.0.0.1
database_port: ~
database_name: symfony
database_user: root
database_password: ~
# You should uncomment this if you want use pdo_sqlite
# database_path: "%kernel.root_dir%/data.db3"
mailer_transport: smtp
mailer_host: 127.0.0.1
mailer_user: ~
mailer_password: ~
# A secret key that's used to generate certain security-related tokens
secret: ThisTokenIsNotSoSecretChangeIt

View File

@@ -1,12 +0,0 @@
# This file is auto-generated during the composer install
parameters:
database_host: 127.0.0.1
database_port: null
database_name: mine
database_user: root
database_password: bazmeg
mailer_transport: smtp
mailer_host: 127.0.0.1
mailer_user: null
mailer_password: null
secret: bbcd5df99fc340558fb3995c198a9b4764db72ba

View File

@@ -1,15 +0,0 @@
# This file is auto-generated during the composer install
parameters:
database_host: 127.0.0.1
database_port: null
# database_name: xxsvci_mineseeker
# database_user: xxsvci_mine
# database_password: "XTw#8qC$faa*"
database_name: mine
database_user: root
database_password: "bazmeg"
mailer_transport: smtp
mailer_host: 127.0.0.1
mailer_user: null
mailer_password: null
secret: e25d036bb9c7ece0f2049984a1fa2f0cab295aaa

View File

@@ -1,14 +0,0 @@
fos_user:
resource: "@FOSUserBundle/Resources/config/routing/all.xml"
JotunheimrUserBundle:
resource: "@JotunheimrUserBundle/Resources/config/routing.yml"
prefix: /
JotunheimrAdminBundle:
resource: "@JotunheimrAdminBundle/Resources/config/routing.yml"
prefix: /
MineSeekerBundle:
resource: "@MineSeekerBundle/Resources/config/routing.yml"
prefix: /

View File

@@ -1,15 +0,0 @@
_wdt:
resource: "@WebProfilerBundle/Resources/config/routing/wdt.xml"
prefix: /_wdt
_profiler:
resource: "@WebProfilerBundle/Resources/config/routing/profiler.xml"
prefix: /_profiler
_errors:
resource: "@TwigBundle/Resources/config/routing/errors.xml"
prefix: /_error
_main:
resource: routing.yml
schemes: [http]

View File

@@ -1,46 +0,0 @@
security:
encoders:
FOS\UserBundle\Model\UserInterface: bcrypt
role_hierarchy:
ROLE_ADMIN: ROLE_USER
ROLE_SUPER_ADMIN: ROLE_ADMIN
providers:
fos_userbundle:
id: fos_user.user_provider.username_email
firewalls:
secured_area:
anonymous: ~
oauth:
resource_owners:
facebook: /login/check-facebook
google: /login/check-google
my_github: /login/check-github
login_path: /login
failure_path: /login
use_forward: false
oauth_user_provider:
service: jotun.user_provider
remember_me:
secret: "%secret%"
lifetime: 604800
path: /
domain: ~
user_provider: fos_userbundle
form_login:
provider: fos_userbundle
csrf_token_generator: security.csrf.token_manager
default_target_path: /
remember_me: true
logout:
path: /logout
target: /
access_control:
- { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/register, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/resetting, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/play, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/admin, role: ROLE_SUPER_ADMIN }

View File

@@ -1,9 +0,0 @@
# Learn more about services, parameters and containers at
# http://symfony.com/doc/current/book/service_container.html
parameters:
# parameter_name: value
services:
# service_name:
# class: AppBundle\Directory\ClassName
# arguments: ["@another_service_name", "plain_value", "%parameter_name%"]

View File

@@ -0,0 +1,10 @@
/*!*
* 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 '@fortawesome/fontawesome-free/css/all.min.css';

View File

@@ -0,0 +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.
*/
@import '@fontsource/rajdhani';
@import '@fontsource/changa-one';
@import '@fontsource/open-sans/700';

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.
*/
@keyframes appear {
from { opacity: 0; transform: scale(0.94); }
to { opacity: 1; transform: scale(1); }
}
@keyframes rise {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}

View File

@@ -0,0 +1,128 @@
/*!*
* 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 {
font: 600 13px 'Rajdhani', sans-serif;
color: rgba(149, 207, 245, 0.75);
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 6px;
i { font-size: 15px; }
}
@media screen and (max-width: 1100px) {
.hero-auth {
justify-content: center;
}
}
}
.hero-auth-btn {
display: inline-flex;
align-items: center;
gap: 6px;
font: 600 12px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 1.5px;
text-decoration: none;
color: rgba(255, 255, 255, 0.6);
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
padding: 7px 14px;
cursor: pointer;
transition: all 200ms ease;
&:hover {
color: #fff;
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.25);
}
&--register {
color: rgba(149, 207, 245, 0.8);
border-color: rgba(35, 111, 135, 0.4);
background: rgba(35, 111, 135, 0.12);
&:hover {
color: #fff;
background: rgba(35, 111, 135, 0.28);
border-color: rgba(149, 207, 245, 0.5);
}
}
&--security {
color: rgba(149, 207, 245, 0.55);
border-color: rgba(35, 111, 135, 0.22);
background: transparent;
&:hover {
color: rgba(149, 207, 245, 0.9);
background: rgba(35, 111, 135, 0.14);
border-color: rgba(35, 111, 135, 0.45);
}
}
&--out {
background: transparent;
border-color: rgba(173, 10, 5, 0.3);
color: rgba(246, 125, 82, 0.7);
&:hover {
background: rgba(173, 10, 5, 0.15);
border-color: rgba(246, 125, 82, 0.5);
color: #f67d52;
}
}
&--profile {
color: rgba(149, 207, 245, 0.8);
border-color: rgba(35, 111, 135, 0.35);
background: rgba(35, 111, 135, 0.08);
text-decoration: none;
&:hover {
color: #fff;
background: rgba(35, 111, 135, 0.22);
border-color: rgba(149, 207, 245, 0.5);
}
}
}
.hero-auth-avatar {
width: 22px;
height: 22px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
border: 1px solid rgba(35, 111, 135, 0.5);
&--initials {
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(35, 111, 135, 0.45) 0%, rgba(173, 10, 5, 0.3) 100%);
font: 800 9px 'Rajdhani', sans-serif;
color: rgba(149, 207, 245, 0.9);
letter-spacing: 1px;
}
}

View File

@@ -0,0 +1,474 @@
/*!*
* 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;
align-items: center;
padding: 40px 20px 80px;
}
.auth-flash {
width: 100%;
max-width: 420px;
padding: 12px 18px;
border-radius: 5px;
font: 600 14px 'Rajdhani', sans-serif;
letter-spacing: 0.5px;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 8px;
&--success {
background: rgba(26, 104, 68, 0.25);
border: 1px solid rgba(42, 158, 96, 0.4);
color: #a0f0c0;
}
&--error {
background: rgba(173, 10, 5, 0.18);
border: 1px solid rgba(173, 10, 5, 0.4);
color: #f6a090;
}
}
// "Check your inbox" confirmation card
.auth-card--sent {
text-align: center;
padding: 48px 40px;
}
.auth-sent-icon {
font-size: 52px;
color: rgba(149, 207, 245, 0.6);
margin-bottom: 20px;
filter: drop-shadow(0 0 16px rgba(35, 111, 135, 0.4));
}
.auth-sent-email {
font: 700 16px 'Rajdhani', sans-serif;
color: #95cff5;
letter-spacing: 0.5px;
margin: 0 0 20px;
word-break: break-all;
}
.auth-sent-note {
font: 400 14px 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.5);
line-height: 1.7;
margin-bottom: 0;
strong { color: rgba(255, 255, 255, 0.75); }
}
.auth-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(35, 111, 135, 0.2);
border-radius: 10px;
padding: 44px 48px 40px;
width: 100%;
max-width: 420px;
backdrop-filter: blur(4px);
box-shadow: 0 8px 48px rgba(0, 0, 0, 0.5);
}
.auth-title {
font: 800 30px 'Rajdhani', sans-serif;
color: #ffffff;
letter-spacing: 1px;
margin-bottom: 6px;
}
.auth-sub {
font: 400 14px 'Rajdhani', sans-serif;
color: rgba(149, 207, 245, 0.6);
letter-spacing: 0.5px;
margin-bottom: 32px;
}
.auth-error {
background: rgba(173, 10, 5, 0.18);
border: 1px solid rgba(173, 10, 5, 0.4);
border-radius: 5px;
padding: 10px 14px;
font: 600 13px 'Rajdhani', sans-serif;
color: #f6a090;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 8px;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.auth-field {
display: flex;
flex-direction: column;
gap: 7px;
}
.auth-label {
font: 700 11px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 2px;
color: rgba(255, 255, 255, 0.45);
}
.auth-input-wrap {
position: relative;
display: flex;
align-items: center;
}
.auth-input-icon {
position: absolute;
left: 11px;
color: rgba(149, 207, 245, 0.4);
font-size: 13px;
pointer-events: none;
}
.auth-input {
width: 100%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(35, 111, 135, 0.3);
border-radius: 5px;
padding: 11px 14px 11px 34px;
font: 500 15px 'Rajdhani', sans-serif;
color: #ffffff;
letter-spacing: 0.5px;
transition: border-color 200ms ease, background 200ms ease;
&::placeholder { color: rgba(255, 255, 255, 0.2); }
&:focus {
outline: none;
background: rgba(35, 111, 135, 0.1);
border-color: rgba(149, 207, 245, 0.5);
}
&--error {
border-color: rgba(173, 10, 5, 0.6) !important;
}
}
.auth-field-error {
font: 500 12px 'Rajdhani', sans-serif;
color: #f6a090;
letter-spacing: 0.3px;
}
.auth-below-password {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.auth-remember {
display: flex;
align-items: center;
gap: 8px;
font: 500 13px 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.45);
cursor: pointer;
user-select: none;
margin-top: -4px;
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;
justify-content: center;
gap: 8px;
background: linear-gradient(to bottom, #ad0a05 0%, #d4401a 55%, #f67d52 100%);
border: 1px solid rgba(246, 125, 82, 0.3);
border-radius: 5px;
color: #ffffff;
font: 700 16px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 3px;
padding: 14px;
cursor: pointer;
margin-top: 6px;
box-shadow: 0 4px 20px rgba(173, 10, 5, 0.35);
transition: all 220ms ease;
&:hover {
background: linear-gradient(to bottom, #c91008 0%, #e5521e 55%, #ff8c61 100%);
box-shadow: 0 6px 28px rgba(173, 10, 5, 0.6);
transform: translateY(-2px);
}
&:active { transform: translateY(0); }
}
.auth-cancel {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: linear-gradient(to bottom, #1a1a1a 0%, #2d2d2d 55%, #404040 100%);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 5px;
color: #ffffff;
font: 700 16px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 3px;
padding: 14px;
cursor: pointer;
margin-top: 6px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
transition: all 220ms ease;
&:hover {
background: linear-gradient(to bottom, #2d2d2d 0%, #3d3d3d 55%, #505050 100%);
box-shadow: 0 6px 28px rgba(0, 0, 0, 0.6);
transform: translateY(-2px);
}
&:active { transform: translateY(0); }
}
.auth-actions {
display: flex;
gap: 12px;
width: 100%;
align-items: flex-start;
form {
flex: 1;
display: flex;
&.auth-form {
flex-direction: column;
gap: 20px;
}
}
button {
flex: 1;
}
}
.auth-cancel-form {
display: flex;
flex: 1;
.auth-cancel {
flex: 1;
margin-top: 0;
}
}
.auth-cancel-standalone {
margin-top: 12px;
width: 100%;
}
.auth-cancel--block {
width: 100%;
margin-top: 0;
}
.auth-switch {
font: 400 13px 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.35);
text-align: center;
margin-top: 24px;
letter-spacing: 0.3px;
a {
color: #95cff5;
text-decoration: none;
font-weight: 600;
transition: color 180ms;
&:hover { color: #c5e8ff; }
}
}
.auth-forgot-password {
font: 400 13px 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.35);
text-align: center;
letter-spacing: 0.3px;
a {
color: #95cff5;
text-decoration: none;
font-weight: 600;
transition: color 180ms;
&:hover { color: #c5e8ff; }
}
}
.auth-divider {
display: flex;
align-items: center;
margin: 20px 0;
color: #999;
}
.auth-divider::before,
.auth-divider::after {
content: '';
flex: 1;
height: 1px;
background: #ddd;
}
.auth-divider span {
margin: 0 10px;
font-size: 14px;
}
.auth-passkey-btn {
width: 100%;
padding: 12px 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.auth-passkey-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
}
.auth-passkey-btn:active {
transform: translateY(0);
}
.auth-input--code {
font: 700 22px 'Courier New', monospace;
letter-spacing: 6px;
text-align: center;
}
.auth-field-hint {
font: 400 12px 'Rajdhani', sans-serif;
color: rgba(149, 207, 245, 0.45);
letter-spacing: 0.3px;
margin: 6px 0 0;
}
.auth-card--wide {
max-width: 520px;
}
.auth-back {
margin-top: 20px;
text-align: center;
a {
font: 500 13px 'Rajdhani', sans-serif;
color: rgba(149, 207, 245, 0.5);
text-decoration: none;
letter-spacing: 0.5px;
transition: color 180ms;
&:hover { color: rgba(149, 207, 245, 0.9); }
}
}
.twofa-setup {
display: flex;
flex-direction: column;
gap: 24px;
margin-top: 20px;
&__qr {
display: flex;
justify-content: center;
img {
border-radius: 8px;
border: 2px solid rgba(35, 111, 135, 0.3);
background: #fff;
padding: 4px;
}
}
&__manual {
text-align: center;
}
&__manual-label {
font: 400 12px 'Rajdhani', sans-serif;
color: rgba(149, 207, 245, 0.45);
letter-spacing: 0.5px;
margin: 0 0 8px;
}
&__secret {
display: inline-block;
padding: 6px 14px;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(35, 111, 135, 0.3);
border-radius: 4px;
font: 700 14px 'Courier New', monospace;
letter-spacing: 3px;
color: #95cff5;
word-break: break-all;
user-select: all;
}
}

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

@@ -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.
*/
main div.txt {
color: rgba(255, 255, 255, 0.85);
max-width: 900px;
margin: 0 auto;
padding: 60px 40px 80px;
}
main div.txt h2 {
font: bold 28px 'Rajdhani', sans-serif;
color: #ffffff;
margin-bottom: 30px;
letter-spacing: 1px;
}
main div.txt h3 {
font: bold 17px 'Rajdhani', sans-serif;
color: rgba(149, 207, 245, 0.9);
margin: 28px 0 10px;
letter-spacing: 0.5px;
}
main div.txt p,
main div.txt li {
font: 400 15px 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.72);
line-height: 1.75;
}
main div.txt a {
color: #95cff5;
text-decoration: none;
transition: color 180ms;
&:hover { color: #c5e8ff; }
}
main div.txt img {
border-radius: 10px;
}
main div.txt .img-container {
text-align: center;
}

View File

@@ -0,0 +1,84 @@
/*!*
* 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;
font: 800 28px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 6px;
text-decoration: none;
color: #ffffff;
padding: 22px 100px 20px;
border-radius: 4px;
border: 1px solid rgba(246, 125, 82, 0.25);
background: linear-gradient(to bottom, #b30c06 0%, #d63d15 50%, #f67d52 100%);
box-shadow:
0 0 0 1px rgba(173, 10, 5, 0.2),
0 0 30px rgba(173, 10, 5, 0.35),
0 6px 24px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.12);
transition: transform 220ms ease, box-shadow 220ms ease, letter-spacing 220ms ease;
animation: rise 0.8s 0.42s cubic-bezier(0.22, 1, 0.36, 1) both;
}
// Outer glow layer (blurred duplicate, always visible)
.hero-cta::before {
content: '';
position: absolute;
inset: -4px;
border-radius: 7px;
background: linear-gradient(to bottom, #ad0a05, #f67d52);
filter: blur(18px);
opacity: 0.3;
z-index: -1;
transition: opacity 220ms ease;
}
.hero-cta:hover {
transform: translateY(-4px);
letter-spacing: 8px;
box-shadow:
0 0 0 1px rgba(246, 125, 82, 0.3),
0 0 50px rgba(173, 10, 5, 0.65),
0 10px 32px rgba(0, 0, 0, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.15);
}
.hero-cta:hover::before {
opacity: 0.55;
}
.hero-cta:active {
transform: translateY(-1px);
}
// Version / copyright line
.hero-meta {
position: relative;
z-index: 1;
font: 400 12px 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.5);
letter-spacing: 0.5px;
margin-top: 58px;
animation: rise 0.8s 0.55s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.hero-meta a {
color: rgba(149, 207, 245, 0.65);
text-decoration: none;
transition: color 180ms;
}
.hero-meta a:hover {
color: #95cff5;
}

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

@@ -0,0 +1,363 @@
/*!*
* 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;
position: relative;
&:first-of-type {
border-top: 1px solid rgba(35, 111, 135, 0.12);
}
& + & {
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
}
.feature-block__inner {
display: flex;
align-items: center;
gap: 80px;
max-width: 1100px;
margin: 0 auto;
}
.feature-block--reverse .feature-block__inner {
flex-direction: row-reverse;
}
// Visual side
.feature-block__visual {
flex: 0 0 340px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
// Stats icons cluster
.feature-block__visual--stats {
height: 260px;
gap: 0;
i {
position: absolute;
color: rgba(35, 111, 135, 0.5);
transition: color 300ms ease;
}
// Bar chart — large, centre
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: 80px;
top: 0px;
right: 20px;
color: rgba(246, 125, 82, 0.7);
filter: drop-shadow(0 0 25px rgba(246, 125, 82, 0.4));
}
// 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-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
.feature-block__visual--msn {
flex-direction: column;
align-items: center;
gap: 20px;
position: relative;
}
.msn-logo {
width: 90px;
height: 90px;
object-fit: contain;
filter: drop-shadow(0 0 18px rgba(149, 207, 245, 0.3)) brightness(1.1);
flex-shrink: 0;
z-index: 1;
}
.msn-screenshot {
width: 340px;
max-width: 100%;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow:
0 8px 40px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(35, 111, 135, 0.12);
filter: saturate(0.85) brightness(0.9);
transition: filter 300ms ease;
&:hover {
filter: saturate(1) brightness(1);
}
}
// 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;
min-width: 0;
}
.feature-block__label {
font: 700 11px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 4px;
color: rgba(149, 207, 245, 0.55);
margin-bottom: 12px;
}
.feature-block__title {
font: 800 40px 'Rajdhani', sans-serif;
color: #ffffff;
line-height: 1.1;
letter-spacing: 0.5px;
margin-bottom: 18px;
}
.feature-block__body {
font: 400 16px 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.62);
line-height: 1.8;
margin-bottom: 0;
}
.feature-block__cta {
display: inline-flex;
align-items: center;
gap: 8px;
margin-top: 28px;
font: 700 14px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 2px;
text-decoration: none;
color: rgba(149, 207, 245, 0.85);
border: 1px solid rgba(35, 111, 135, 0.4);
background: rgba(35, 111, 135, 0.1);
padding: 11px 24px;
border-radius: 4px;
transition: all 200ms ease;
&:hover {
background: rgba(35, 111, 135, 0.25);
border-color: rgba(149, 207, 245, 0.55);
color: #fff;
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(35, 111, 135, 0.25);
}
}
.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 {
flex-direction: column;
gap: 48px;
text-align: center;
}
.feature-block__visual {
flex: none;
width: 100%;
}
.feature-block__visual--stats {
height: 200px;
}
.feature-block__visual--msn {
flex-direction: row;
justify-content: center;
flex-wrap: wrap;
}
.msn-screenshot {
width: 100%;
}
.feature-block__label,
.feature-block__cta {
margin-left: auto;
margin-right: auto;
}
.feature-block__title { font-size: 32px; }
.feature-block {
padding: 60px 24px;
}
.practice-links {
justify-content: center;
align-items: center;
}
.practice-link {
width: 100%;
justify-content: center;
}
}

View File

@@ -0,0 +1,121 @@
/*!*
* 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);
width: 100%;
}
.footer-inner {
display: flex;
align-items: flex-start;
justify-content: space-between;
max-width: 1100px;
margin: 0 auto;
padding: 60px 60px 52px;
gap: 40px;
}
.footer-brand {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.footer-logo {
width: 72px;
height: 72px;
opacity: 0.55;
filter:
drop-shadow(0 0 12px rgba(35, 111, 135, 0.4))
brightness(1.1);
transition: opacity 250ms ease, filter 250ms ease;
&:hover {
opacity: 0.9;
filter:
drop-shadow(0 0 20px rgba(35, 111, 135, 0.65))
brightness(1.2);
}
}
.footer-name {
font: 700 22px 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.75);
letter-spacing: 2px;
text-transform: uppercase;
margin-top: 4px;
}
.footer-tagline {
font: 400 13px 'Rajdhani', sans-serif;
color: rgba(149, 207, 245, 0.7);
letter-spacing: 0.5px;
max-width: 240px;
line-height: 1.5;
}
.footer-nav-label {
font: 700 11px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 4px;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 18px;
text-align: left;
}
.footer-nav ul {
list-style: none;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
min-width: 180px;
}
.footer-nav ul li a {
display: block;
font: 500 15px 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.7);
text-decoration: none;
text-transform: uppercase;
letter-spacing: 1.5px;
white-space: nowrap;
padding: 6px 0;
transition: color 180ms ease, letter-spacing 180ms ease;
&:hover {
color: #95cff5;
letter-spacing: 2px;
}
}
.footer-copy {
border-top: 1px solid rgba(255, 255, 255, 0.05);
padding: 16px 60px;
max-width: 1100px;
margin: 0 auto;
p {
font: 400 11px 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.45);
letter-spacing: 0.5px;
text-align: center;
}
a {
color: rgba(149, 207, 245, 0.6);
text-decoration: none;
transition: color 180ms;
&:hover { color: #95cff5; }
}
}

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.
*/
header {
position: relative;
width: 100%;
overflow: hidden;
// Minesweeper grid texture
background-color: #07090d;
background-image:
linear-gradient(rgba(35, 111, 135, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(35, 111, 135, 0.1) 1px, transparent 1px);
background-size: 46px 46px;
}
// Deep radial vignette grid fades toward the centre
header::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(
ellipse 85% 75% at 50% 50%,
#07090d 10%,
transparent 75%
);
z-index: 0;
pointer-events: none;
}
// Smoke at the bottom so header bleeds into body
header::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 160px;
background: linear-gradient(to bottom, transparent, #07090d);
z-index: 1;
pointer-events: none;
}

View File

@@ -0,0 +1,58 @@
/*!*
* 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;
flex-direction: row;
align-items: center;
justify-content: center;
text-align: left;
gap: 52px;
&::before, &::after { display: none; }
.hero-logo {
flex-shrink: 0;
margin-bottom: 0;
img { width: 180px; }
}
.hero-body {
align-items: flex-start;
}
.hero-sub {
font-size: 14px;
letter-spacing: 2px;
margin-bottom: 10px;
}
h1 {
font-size: 26px;
margin-bottom: 24px;
letter-spacing: 0;
}
.hero-cta {
padding: 12px 52px 10px;
font-size: 18px;
letter-spacing: 4px;
}
.hero-meta {
margin-top: 20px;
}
}
// Also shrink the bottom fade on sub-pages
header:has(.hero--compact)::after {
height: 60px;
}

View File

@@ -0,0 +1,99 @@
/*!*
* 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;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
min-height: 100vh;
padding: 80px 40px 160px;
gap: 0;
}
// Decorative glow blobs in opposite corners
.hero::before {
content: '';
position: absolute;
top: -60px;
left: -60px;
width: 420px;
height: 420px;
border-radius: 50%;
background: radial-gradient(circle, rgba(173, 10, 5, 0.09) 0%, transparent 65%);
pointer-events: none;
z-index: 0;
}
.hero::after {
content: '';
position: absolute;
bottom: 100px;
right: -60px;
width: 380px;
height: 380px;
border-radius: 50%;
background: radial-gradient(circle, rgba(35, 111, 135, 0.1) 0%, transparent 65%);
pointer-events: none;
z-index: 0;
}
// Logo
.hero-logo {
display: block;
margin-bottom: 72px;
position: relative;
z-index: 1;
animation: appear 0.9s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.hero-logo img {
width: 400px;
max-width: 82vw;
filter:
drop-shadow(0 0 40px rgba(35, 111, 135, 0.35))
drop-shadow(0 6px 20px rgba(0, 0, 0, 0.8));
}
// Body text block
.hero-body {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
}
.hero-sub {
font: 300 17px 'Rajdhani', sans-serif;
color: rgba(149, 207, 245, 0.9);
letter-spacing: 3px;
text-transform: uppercase;
margin-bottom: 18px;
animation: rise 0.8s 0.15s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.hero-sub strong {
font-weight: 600;
color: #95cff5;
}
.hero h1 {
font: 800 58px 'Rajdhani', sans-serif;
color: #ffffff;
line-height: 1.1;
letter-spacing: 1px;
margin-bottom: 56px;
text-shadow: 0 4px 30px rgba(0, 0, 0, 0.7);
animation: rise 0.8s 0.28s cubic-bezier(0.22, 1, 0.36, 1) both;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
/*!*
* 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;
margin: 0;
box-sizing: border-box;
}
html {
// Grid lives on html so it tiles across all pages including content pages
background-color: #07090d;
background-image:
linear-gradient(rgba(35, 111, 135, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(35, 111, 135, 0.1) 1px, transparent 1px);
background-size: 46px 46px;
width: 100%;
height: 100%;
}
body {
background: transparent;
width: 100%;
height: 100%;
}

View File

@@ -0,0 +1,113 @@
/*!*
* 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;
}
.hero-cta {
padding: 20px 72px 18px;
font-size: 24px;
letter-spacing: 5px;
}
}
@media screen and (max-width: 768px) {
.hero--compact {
flex-direction: column;
text-align: center;
padding: 36px 24px 44px;
gap: 28px;
.hero-body { align-items: center; }
}
.profile-stats {
grid-template-columns: repeat(2, 1fr);
}
.profile-charts {
grid-template-columns: 1fr;
}
.profile-header {
flex-direction: column;
text-align: center;
padding: 28px 24px;
}
.profile-email,
.profile-role {
justify-content: center;
}
.profile-game {
grid-template-columns: 26px 64px 18px 1fr 14px;
.profile-game__date { display: none; }
}
.footer-inner {
flex-direction: column;
align-items: center;
text-align: center;
padding: 48px 30px 36px;
}
.footer-brand {
align-items: center;
}
.footer-tagline {
text-align: center;
}
.footer-nav-label {
text-align: center;
}
.footer-nav ul {
align-items: center;
}
.footer-copy {
padding: 16px 30px;
}
}
@media screen and (max-width: 550px) {
.hero {
padding: 60px 24px 140px;
}
.hero-logo img {
width: 260px;
}
.hero-logo {
margin-bottom: 52px;
}
.hero h1 {
font-size: 32px;
margin-bottom: 40px;
}
.hero-sub {
font-size: 14px;
letter-spacing: 2px;
}
.hero-cta {
padding: 18px 48px 16px;
font-size: 20px;
letter-spacing: 4px;
}
}

View File

@@ -0,0 +1,69 @@
/*!*
* 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;
}
.tech-section {
padding: 48px 20px 72px;
text-align: center;
border-top: 1px solid rgba(255, 255, 255, 0.04);
}
.tech-label {
font: 600 11px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 6px;
color: rgba(255, 255, 255, 0.14);
margin-bottom: 28px;
}
.tech-link {
display: inline-block;
line-height: 0;
}
.tech-logos img {
display: inline-block;
width: 52px;
height: 52px;
object-fit: contain;
margin: 8px 24px;
// Force all logos to white, then tint with the game's blue on hover
filter: brightness(0) invert(1) opacity(0.35);
transition: filter 220ms ease, transform 220ms ease;
}
.tech-logos img:hover {
filter:
brightness(0) invert(1)
sepia(1) saturate(3) hue-rotate(175deg) brightness(1.1)
opacity(0.9);
transform: translateY(-4px);
}
.tech-oss {
margin-top: 36px;
font: 400 15px 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.7);
letter-spacing: 0.5px;
max-width: 680px;
margin-left: auto;
margin-right: auto;
padding: 0 24px;
line-height: 1.7;
text-align: center;
i {
color: rgba(220, 60, 50, 0.85);
margin-right: 6px;
font-size: 13px;
}
}

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.
*/
.back-from-game {
display: inline-block;
position: fixed;
top: 20px;
left: 20px;
-ms-transform: scale(1);
-webkit-transform: scale(1);
transform: scale(1);
-webkit-transition: all 250ms cubic-bezier(.17, .67, .83, .67);
transition: all 250ms cubic-bezier(.17, .67, .83, .67);
}
.back-from-game img {
width: 100px;
}
.back-from-game:hover {
-ms-transform: scale(1.2);
-webkit-transform: scale(1.2);
transform: scale(1.2);
-webkit-transition: all 250ms cubic-bezier(.17, .67, .83, .67);
transition: all 250ms cubic-bezier(.17, .67, .83, .67);
}

View File

@@ -0,0 +1,65 @@
/*!*
* 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;
margin: 0;
}
body {
height: 100%;
min-height: 100%;
padding: 0;
margin: 0;
}
main {
width: 100%;
height: 100%;
}
.mine-container {
background-size: cover;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
.clear {
clear: both
}
#mine-wrapper,
#mine-wrapper * {
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
#mine-wrapper {
display: table;
width: 842px;
margin: 0 auto;
}
#mine-wrapper .game-wrapper {
background: #000;
position: relative;
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 10px;
-webkit-border-radius: 10px;
border-radius: 10px;
}

View File

@@ -0,0 +1,234 @@
/*!*
* 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%);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.5) 0%, rgba(125, 185, 232, 0) 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#007db9e8', GradientType=1);
position: relative;
padding: 5px;
-webkit-border-radius: 5px;
border-radius: 5px;
}
#mine-wrapper .game-wrapper .users .user-container .user-control > img {
position: absolute;
width: 55px;
left: -5px;
bottom: 10px;
-ms-transform: rotate(-15deg);
-webkit-transform: rotate(-15deg);
transform: rotate(-15deg);
}
#mine-wrapper .game-wrapper .users .user-container .user-control .user-control-mines {
display: inline-block;
background: #FFFFFF;
font-size: 25px;
text-align: center;
width: 45px;
height: 35px;
margin-left: 25px;
margin-top: 5px;
-webkit-border-radius: 5px;
border-radius: 5px;
}
#mine-wrapper .game-wrapper .users .user-container.user-blue .user-control .user-control-mines {
color: #1a3955;
}
#mine-wrapper .game-wrapper .users .user-container.user-red .user-control .user-control-mines {
color: #b10000;
}
#mine-wrapper .game-wrapper .users .user-container .user-control .bomb-container {
display: inline-block;
float: right;
width: 65px;
height: 45px;
border: 1px solid #000;
-webkit-border-radius: 7px;
border-radius: 7px;
-webkit-transform: translateZ(0);
transform: translateZ(0);
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
-moz-osx-font-smoothing: grayscale;
box-shadow: 0 0 1px rgba(0, 0, 0, 0);
}
#mine-wrapper .game-wrapper .users .user-container .user-control .bomb-container.buzz:hover {
-webkit-animation-name: hvr-buzz-out;
animation-name: hvr-buzz-out;
-webkit-animation-duration: 0.75s;
animation-duration: 0.75s;
-webkit-animation-timing-function: linear;
animation-timing-function: linear;
-webkit-animation-iteration-count: 1;
animation-iteration-count: 1;
}
@-webkit-keyframes hvr-buzz-out {
10% { -webkit-transform: translateX(3px) rotate(2deg); transform: translateX(3px) rotate(2deg); }
20% { -webkit-transform: translateX(-3px) rotate(-2deg); transform: translateX(-3px) rotate(-2deg); }
30% { -webkit-transform: translateX(3px) rotate(2deg); transform: translateX(3px) rotate(2deg); }
40% { -webkit-transform: translateX(-3px) rotate(-2deg); transform: translateX(-3px) rotate(-2deg); }
50% { -webkit-transform: translateX(2px) rotate(1deg); transform: translateX(2px) rotate(1deg); }
60% { -webkit-transform: translateX(-2px) rotate(-1deg); transform: translateX(-2px) rotate(-1deg); }
70% { -webkit-transform: translateX(2px) rotate(1deg); transform: translateX(2px) rotate(1deg); }
80% { -webkit-transform: translateX(-2px) rotate(-1deg); transform: translateX(-2px) rotate(-1deg); }
90% { -webkit-transform: translateX(1px) rotate(0); transform: translateX(1px) rotate(0); }
100% { -webkit-transform: translateX(-1px) rotate(0); transform: translateX(-1px) rotate(0); }
}
@keyframes hvr-buzz-out {
10% { -webkit-transform: translateX(3px) rotate(2deg); transform: translateX(3px) rotate(2deg); }
20% { -webkit-transform: translateX(-3px) rotate(-2deg); transform: translateX(-3px) rotate(-2deg); }
30% { -webkit-transform: translateX(3px) rotate(2deg); transform: translateX(3px) rotate(2deg); }
40% { -webkit-transform: translateX(-3px) rotate(-2deg); transform: translateX(-3px) rotate(-2deg); }
50% { -webkit-transform: translateX(2px) rotate(1deg); transform: translateX(2px) rotate(1deg); }
60% { -webkit-transform: translateX(-2px) rotate(-1deg); transform: translateX(-2px) rotate(-1deg); }
70% { -webkit-transform: translateX(2px) rotate(1deg); transform: translateX(2px) rotate(1deg); }
80% { -webkit-transform: translateX(-2px) rotate(-1deg); transform: translateX(-2px) rotate(-1deg); }
90% { -webkit-transform: translateX(1px) rotate(0); transform: translateX(1px) rotate(0); }
100% { -webkit-transform: translateX(-1px) rotate(0); transform: translateX(-1px) rotate(0); }
}
#mine-wrapper .game-wrapper .users .user-container .user-control .bomb-container .bomb {
width: 100%;
height: 100%;
text-align: center;
cursor: pointer;
}
#mine-wrapper .game-wrapper .users .user-container .user-control .bomb-container .bomb img {
display: inline-block;
height: 100%;
}
#mine-wrapper .game-wrapper .users .user-container .user-control .bomb-container:hover .bomb img {
-webkit-animation-name: hvr-buzz;
animation-name: hvr-buzz;
-webkit-animation-duration: 0.15s;
animation-duration: 0.15s;
-webkit-animation-timing-function: linear;
animation-timing-function: linear;
-webkit-animation-iteration-count: infinite;
animation-iteration-count: infinite;
}
#mine-wrapper .game-wrapper .users .user-container .user-control .bomb-container .bomb {
-webkit-border-radius: 5px;
border-radius: 5px;
}
#mine-wrapper .game-wrapper .users .user-container.user-blue .user-control .bomb-container .bomb {
background: rgb(131, 194, 245);
background: -moz-linear-gradient(top, rgba(131, 194, 245, 1) 0%, rgba(108, 190, 230, 1) 39%, rgba(221, 255, 252, 1) 100%);
background: -webkit-linear-gradient(top, rgba(131, 194, 245, 1) 0%, rgba(108, 190, 230, 1) 39%, rgba(221, 255, 252, 1) 100%);
background: linear-gradient(to bottom, rgba(131, 194, 245, 1) 0%, rgba(108, 190, 230, 1) 39%, rgba(221, 255, 252, 1) 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#83c2f5', endColorstr='#ddfffc', GradientType=0);
border: 3px solid #0b538e;
}
#mine-wrapper .game-wrapper .users .user-container.user-red .user-control .bomb-container .bomb {
background: rgb(255, 175, 159);
background: -moz-linear-gradient(top, rgba(255, 175, 159, 1) 0%, rgba(231, 113, 7, 1) 54%, rgba(231, 113, 7, 1) 54%, rgba(237, 172, 16, 1) 100%);
background: -webkit-linear-gradient(top, rgba(255, 175, 159, 1) 0%, rgba(231, 113, 7, 1) 54%, rgba(231, 113, 7, 1) 54%, rgba(237, 172, 16, 1) 100%);
background: linear-gradient(to bottom, rgba(255, 175, 159, 1) 0%, rgba(231, 113, 7, 1) 54%, rgba(231, 113, 7, 1) 54%, rgba(237, 172, 16, 1) 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffaf9f', endColorstr='#edac10', GradientType=0);
border: 3px solid #c9221c;
}
// Resign button
#mine-wrapper .game-wrapper .users .resign {
background: rgba(70, 73, 66, 1);
background: -moz-linear-gradient(top, rgba(70, 73, 66, 1) 0%, rgba(140, 138, 139, 1) 69%, rgba(96, 89, 97, 1) 100%);
background: -webkit-gradient(left top, left bottom, color-stop(0%, rgba(70, 73, 66, 1)), color-stop(69%, rgba(140, 138, 139, 1)), color-stop(100%, rgba(96, 89, 97, 1)));
background: -webkit-linear-gradient(top, rgba(70, 73, 66, 1) 0%, rgba(140, 138, 139, 1) 69%, rgba(96, 89, 97, 1) 100%);
background: -o-linear-gradient(top, rgba(70, 73, 66, 1) 0%, rgba(140, 138, 139, 1) 69%, rgba(96, 89, 97, 1) 100%);
background: -ms-linear-gradient(top, rgba(70, 73, 66, 1) 0%, rgba(140, 138, 139, 1) 69%, rgba(96, 89, 97, 1) 100%);
background: linear-gradient(to bottom, rgba(70, 73, 66, 1) 0%, rgba(140, 138, 139, 1) 69%, rgba(96, 89, 97, 1) 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#464942', endColorstr='#605961', GradientType=0);
display: block;
position: relative;
width: 95%;
height: 50px;
font-family: 'Open Sans', sans-serif;
font-weight: bold;
font-size: 22px;
text-transform: uppercase;
text-align: center;
line-height: 40px;
border: 3px solid #484742;
color: #fff;
margin: 10px auto 0 auto;
outline: none;
cursor: pointer;
-webkit-border-radius: 5px;
border-radius: 5px;
-webkit-transition: all 250ms ease-in-out;
-moz-transition: all 250ms ease-in-out;
-o-transition: all 250ms ease-in-out;
transition: all 250ms ease-in-out;
}
#mine-wrapper .game-wrapper .users .resign:hover {
background: rgba(70, 73, 66, 1);
color: #FFFFFF;
transition: all 250ms ease-in-out;
}
#mine-wrapper .game-wrapper .users .resign.disabled {
background: rgba(70, 73, 66, 1);
color: #848484;
cursor: default;
}
#mine-wrapper .game-wrapper .users .resign.disabled:hover {
background: rgba(70, 73, 66, 1);
color: #848484;
}
#mine-wrapper .game-wrapper .users .resign .resign-shine {
background: rgba(255, 255, 255, 1);
background: -moz-linear-gradient(top, rgba(255, 255, 255, 1) 0%, rgba(0, 0, 0, 0) 100%);
background: -webkit-gradient(left top, left bottom, color-stop(0%, rgba(255, 255, 255, 1)), color-stop(100%, rgba(0, 0, 0, 0)));
background: -webkit-linear-gradient(top, rgba(255, 255, 255, 1) 0%, rgba(0, 0, 0, 0) 100%);
background: -o-linear-gradient(top, rgba(255, 255, 255, 1) 0%, rgba(0, 0, 0, 0) 100%);
background: -ms-linear-gradient(top, rgba(255, 255, 255, 1) 0%, rgba(0, 0, 0, 0) 100%);
background: linear-gradient(to bottom, rgba(255, 255, 255, 1) 0%, rgba(0, 0, 0, 0) 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#000000', GradientType=0);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 33%;
opacity: 0.7;
-webkit-border-radius: 5px;
border-radius: 5px;
transition: all 250ms ease-in-out;
}
#mine-wrapper .game-wrapper .users .resign:hover .resign-shine,
#mine-wrapper .game-wrapper .users .resign.disabled .resign-shine {
display: none;
transition: all 250ms ease-in-out;
}

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

@@ -0,0 +1,216 @@
/*!*
* 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;
flex-wrap: wrap;
align-items: center;
justify-content: center;
width: 643px;
border: 1px solid #cac3e5;
cursor: none;
}
#mine-wrapper .grid-container {
background: #4E4E4E;
padding: 15px 10px;
-webkit-border-radius: 5px;
border-radius: 5px;
}
#mine-wrapper .grid .field-wrapper {
position: relative;
}
#mine-wrapper .grid .field-wrapper > img.field-target {
position: absolute;
display: none;
width: 45px;
top: -2.5px;
left: -2.5px;
z-index: 100;
}
#mine-wrapper .grid .field-wrapper:hover > img.field-target {
display: block;
}
#mine-wrapper .grid .field-wrapper > img.field-bomb-target {
position: absolute;
display: block;
top: 0;
left: 0;
width: 100%;
z-index: 100;
}
#mine-wrapper .grid .field-wrapper > img.field-blue-last,
#mine-wrapper .grid .field-wrapper > img.field-red-last {
position: absolute;
display: none;
width: 100%;
top: 0;
left: 0;
z-index: 99;
}
#mine-wrapper .grid .field-wrapper > img.field-blue-last.last-clicked,
#mine-wrapper .grid .field-wrapper > img.field-red-last.last-clicked {
display: block;
}
#mine-wrapper .grid .field-wrapper .field {
background: #61defa;
background: -moz-linear-gradient(left, #61defa 0%, #119dec 100%);
background: -webkit-linear-gradient(left, #61defa 0%, #119dec 100%);
background: linear-gradient(to right, #61defa 0%, #119dec 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#61defa', endColorstr='#119dec', GradientType=1);
width: 40px;
height: 40px;
border: 2px solid #51c2fe;
font-family: 'Open Sans', sans-serif;
font-weight: bold;
font-size: 35px;
text-align: center;
line-height: 35px;
}
#mine-wrapper .grid .field-wrapper .field .field-corner {
width: 100%;
height: 100%;
}
#mine-wrapper .grid .field-wrapper .field.active {
background: #fde717;
background: -moz-linear-gradient(left, #fde717 0%, #f5b807 100%);
background: -webkit-linear-gradient(left, #fde717 0%, #f5b807 100%);
background: linear-gradient(to right, #fde717 0%, #f5b807 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fde717', endColorstr='#f5b807', GradientType=1);
border: 2px solid #f6d762;
color: #000;
}
#mine-wrapper .grid .field-wrapper .field.active .flag-number {
-webkit-animation: bubbleNumber 500ms cubic-bezier(.36, .07, .19, .97) both;
animation: bubbleNumber 500ms cubic-bezier(.36, .07, .19, .97) both;
-webkit-transform: scale(1);
transform: scale(1);
}
@keyframes bubbleNumber {
0% {
background: #61defa;
background: -moz-linear-gradient(left, #61defa 0%, #119dec 100%);
background: -webkit-linear-gradient(left, #61defa 0%, #119dec 100%);
background: linear-gradient(to right, #61defa 0%, #119dec 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#61defa', endColorstr='#119dec', GradientType=1);
-webkit-border-radius: 50%;
border-radius: 50%;
-webkit-transform: scale(1);
transform: scale(1);
}
50% {
-webkit-border-radius: 50%;
border-radius: 50%;
-webkit-transform: scale(0);
transform: scale(0);
}
100% {
-webkit-border-radius: 0;
border-radius: 0;
-webkit-transform: scale(1);
transform: scale(1);
}
}
#mine-wrapper .grid .field-wrapper .field.active .flag-mine {
position: relative;
overflow: hidden;
}
#mine-wrapper .grid .field-wrapper .field.active .flag-mine > img {
width: 75%;
margin-left: 15px;
-ms-transform: rotate(7deg);
-webkit-transform: rotate(7deg);
transform: rotate(7deg);
-webkit-animation: mineFlagLoad 500ms cubic-bezier(.36, .07, .19, .97) both;
animation: mineFlagLoad 500ms cubic-bezier(.36, .07, .19, .97) both;
}
@keyframes mineFlagLoad {
0% {
margin-bottom: 0;
margin-left: 15px;
-ms-transform: rotate(9deg);
-webkit-transform: rotate(9deg);
transform: rotate(9deg);
}
50% {
margin-bottom: -5px;
margin-left: 7px;
}
100% {
margin-bottom: 3px;
margin-left: 0;
-ms-transform: rotate(-9deg);
-webkit-transform: rotate(-9deg);
transform: rotate(-9deg);
}
}
#mine-wrapper .grid .field-wrapper .field.active .flag-mine .flag-mine-base {
position: absolute;
background: #000000;
width: 25px;
height: 22px;
bottom: -12px;
left: 50%;
margin-left: -10.5px;
-webkit-border-radius: 50%;
border-radius: 50%;
-webkit-animation: mineBaseLoad 500ms cubic-bezier(.36, .07, .19, .97) both;
animation: mineBaseLoad 500ms cubic-bezier(.36, .07, .19, .97) both;
}
@keyframes mineBaseLoad {
0% { margin-bottom: 0; }
50% { margin-bottom: -5px; }
100% { margin-bottom: 0; }
}
#mine-wrapper .grid .field-wrapper .field.active.mine {
background: #61defa;
background: -moz-linear-gradient(left, #61defa 0%, #119dec 100%);
background: -webkit-linear-gradient(left, #61defa 0%, #119dec 100%);
background: linear-gradient(to right, #61defa 0%, #119dec 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#61defa', endColorstr='#119dec', GradientType=1);
border: 2px solid #51c2fe;
}
#mine-wrapper .grid .field-wrapper .field.active.color-1 { color: #0000ff; }
#mine-wrapper .grid .field-wrapper .field.active.color-2 { color: #079433; }
#mine-wrapper .grid .field-wrapper .field.active.color-3 { color: #fd1400; }
#mine-wrapper .grid .field-wrapper .field.active.color-4 { color: #0c099e; }
#mine-wrapper .grid .field-wrapper .field.active.color-5 { color: #7b4c01; }
#mine-wrapper .grid .field-wrapper .field.active.color-6 { color: #008388; }
#mine-wrapper .grid .field-wrapper .field.active.color-7 { color: #000000; }
#mine-wrapper .grid .field-wrapper .field.active.color-8 { color: #ff0000; }
#mine-wrapper .grid .field-wrapper .field img {
width: 80%;
}

View File

@@ -0,0 +1,99 @@
/*!*
* 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%);
background: radial-gradient(ellipse at center, 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%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcfc', endColorstr='#006a6a6a', GradientType=1);
background-repeat: no-repeat;
background-position: center center;
background-size: 72% 179%;
position: relative;
height: 30px;
}
#mine-wrapper .game-wrapper .users .active-mines-container i {
font-size: 27px;
color: #b1b1b3;
margin-top: 3px;
text-shadow: 0 0 3px #000000;
}
#mine-wrapper .game-wrapper .users .active-mines-container i:first-child {
float: left;
margin-left: 20px;
}
#mine-wrapper .game-wrapper .users .active-mines-container i:last-child {
float: right;
margin-right: 20px;
}
#mine-wrapper .game-wrapper .users .active-mines-container .active-mines {
background: -moz-linear-gradient(top, rgba(0, 0, 0, 1) 0%, rgba(135, 136, 131, 1) 100%);
background: -webkit-linear-gradient(top, rgba(0, 0, 0, 1) 0%, rgba(135, 136, 131, 1) 100%);
background: linear-gradient(to bottom, rgba(0, 0, 0, 1) 0%, rgba(135, 136, 131, 1) 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#3e3f41', endColorstr='#878883', GradientType=0);
position: absolute;
width: 50px;
height: 50px;
top: -7.5px;
left: 50%;
font-family: 'Open Sans', sans-serif;
font-size: 25px;
line-height: 39px;
text-align: center;
color: #FFFFFF;
border: 5px solid #000000;
margin-left: -25px;
z-index: 100;
-webkit-border-radius: 50%;
border-radius: 50%;
}
#mine-wrapper .game-wrapper .users .active-mines-container .active-mines.found-mine {
-webkit-animation: bubbleLeftMine 750ms cubic-bezier(.36, .07, .19, .97) both;
animation: bubbleLeftMine 750ms cubic-bezier(.36, .07, .19, .97) both;
}
@keyframes bubbleLeftMine {
0% { -webkit-transform: scale(1); transform: scale(1); }
50% { border-color: #2e3337; -webkit-transform: scale(2); transform: scale(2); }
100% { -webkit-transform: scale(1); transform: scale(1); }
}
#mine-wrapper .game-wrapper .users .active-mines-container .active-mines .active-mines-shine {
background: -moz-linear-gradient(top, rgba(213, 214, 216, 1) 0%, rgba(106, 106, 106, 1) 100%);
background: -webkit-linear-gradient(top, rgba(213, 214, 216, 1) 0%, rgba(106, 106, 106, 1) 100%);
background: linear-gradient(to bottom, rgba(213, 214, 216, 1) 0%, rgba(106, 106, 106, 1) 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#d5d6d8', endColorstr='#6a6a6a', GradientType=0);
position: absolute;
top: 0;
left: 50%;
width: 30px;
height: 20px;
margin-left: -14.5px;
z-index: 101;
-webkit-border-radius: 50%;
border-radius: 50%;
}
#mine-wrapper .game-wrapper .users .active-mines-container .active-mines .active-mines-nbr {
position: absolute;
top: 0;
width: 100%;
z-index: 102;
}

View File

@@ -0,0 +1,780 @@
/*!*
* 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;
flex-direction: row;
align-items: center;
justify-content: center;
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: 200;
-webkit-border-radius: 10px;
border-radius: 10px;
}
#mine-wrapper .game-wrapper .game-overlay.hide {
display: none;
}
#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);
overflow: hidden;
max-height: 90vh;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
#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;
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;
color: rgba(149, 207, 245, 0.6);
margin: 0 0 30px 0;
letter-spacing: 2px;
text-transform: uppercase;
font-weight: 600;
}
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .resign {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
text-transform: none;
letter-spacing: normal;
font-size: inherit;
a {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
background: linear-gradient(to bottom, #236f87 0%, #1a5068 100%);
border: 2px solid #2e7a9a;
color: #e0f4ff;
font: 800 13px 'Rajdhani', sans-serif;
letter-spacing: 2px;
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%;
max-width: 260px;
position: relative;
overflow: hidden;
box-shadow: 0 4px 12px rgba(35, 111, 135, 0.25);
text-decoration: none;
&::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);
}
&:first-child {
background: linear-gradient(to bottom, #8a2323 0%, #681a1a 100%);
border-color: #9a2e2e;
color: #ffe0e0;
box-shadow: 0 4px 12px rgba(135, 35, 35, 0.25);
&:hover {
background: linear-gradient(to bottom, #a82d2d 0%, #872323 100%);
border-color: #d45b5b;
color: #fff;
box-shadow: 0 8px 24px rgba(135, 35, 35, 0.4);
}
}
}
}
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window:has(.resign) h1 {
text-align: center;
margin-bottom: 24px;
}
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window:has(.resign) h2 {
text-align: center;
color: inherit;
font-size: inherit;
letter-spacing: normal;
text-transform: none;
font-weight: normal;
margin: 0;
}
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window h3 {
font-size: 16px;
color: #236f87;
}
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .share-invite {
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .share-invite-label {
font-size: 13px;
color: #386e8c;
margin: 0 0 8px;
font-style: italic;
}
.waiting-options {
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-template-rows: auto;
gap: 10px;
align-items: stretch;
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;
}
}
.waiting-option {
display: flex;
flex-direction: column;
gap: 16px;
padding: 15px;
background: linear-gradient(135deg, rgba(35, 111, 135, 0.08) 0%, rgba(26, 80, 104, 0.08) 100%);
border: 2px solid rgba(35, 111, 135, 0.2);
border-radius: 12px;
transition: all 350ms cubic-bezier(0.34, 1.56, 0.64, 1);
position: relative;
overflow: hidden;
animation: scaleIn 0.5s ease-out;
&::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(35, 111, 135, 0.15), transparent);
transition: left 0.5s ease;
}
&:hover {
border-color: rgba(35, 111, 135, 0.45);
background: linear-gradient(135deg, rgba(35, 111, 135, 0.12) 0%, rgba(26, 80, 104, 0.12) 100%);
transform: translateY(-4px);
box-shadow: 0 12px 30px rgba(35, 111, 135, 0.2);
&::before {
left: 100%;
}
}
}
.waiting-option-header {
display: flex;
align-items: center;
gap: 14px;
font-weight: 800;
font-size: 17px;
color: #fff;
letter-spacing: 1px;
text-transform: uppercase;
i {
font-size: 20px;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
background: linear-gradient(135deg, rgba(35, 111, 135, 0.4) 0%, rgba(35, 111, 135, 0.2) 100%);
border: 2px solid rgba(35, 111, 135, 0.5);
border-radius: 8px;
transition: all 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
flex-shrink: 0;
}
&:hover i {
background: linear-gradient(135deg, rgba(35, 111, 135, 0.6) 0%, rgba(35, 111, 135, 0.4) 100%);
border-color: rgba(35, 111, 135, 0.8);
transform: scale(1.15) rotate(-8deg);
box-shadow: 0 0 20px rgba(35, 111, 135, 0.4);
}
}
.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;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.waiting-divider {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 12px;
margin: 0;
animation: slideIn 0.7s ease-out 0.4s both;
&::before,
&::after {
content: '';
width: 2px;
height: 20px;
background: linear-gradient(
to bottom,
rgba(35, 111, 135, 0.1),
rgba(35, 111, 135, 0.4),
rgba(35, 111, 135, 0.1)
);
}
&::after {
display: none;
}
span {
font: 700 11px 'Rajdhani', sans-serif;
color: rgba(35, 111, 135, 0.6);
letter-spacing: 2px;
text-transform: uppercase;
padding: 0 8px;
}
@media (max-width: 600px) {
flex-direction: row;
margin: 8px 0;
&::before,
&::after {
content: '';
flex: 1;
width: auto;
height: 1px;
background: linear-gradient(
to right,
rgba(35, 111, 135, 0),
rgba(35, 111, 135, 0.3),
rgba(35, 111, 135, 0)
);
}
&::after {
display: block;
}
}
}
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .share-invite {
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .share-url-box {
display: flex;
align-items: center;
background: linear-gradient(135deg, #d0e8f5 0%, #c5dff0 100%);
border: 2px solid #7ab8d8;
border-radius: 8px;
padding: 0 10px;
cursor: text;
transition: all 300ms ease;
position: relative;
overflow: hidden;
&:hover {
border-color: #236f87;
box-shadow: 0 4px 12px rgba(35, 111, 135, 0.2);
}
&:focus-within {
border-color: #236f87;
box-shadow: 0 0 16px rgba(35, 111, 135, 0.35);
}
}
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .share-url-icon {
color: #236f87;
font-size: 13px;
flex-shrink: 0;
margin-right: 8px;
opacity: 0.7;
}
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .share-url-input {
flex: 1;
background: transparent;
border: 0;
outline: 0;
height: 40px;
color: #1a4a6a;
font-family: 'Courier New', monospace;
font-size: 12px;
font-weight: bold;
letter-spacing: 0.5px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: text;
min-width: 0;
&::selection {
background: rgba(35, 111, 135, 0.3);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes slideIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .browse-players-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
background: linear-gradient(to bottom, #236f87 0%, #1a5068 100%);
border: 2px solid #2e7a9a;
color: #e0f4ff;
font: 700 13px 'Rajdhani', sans-serif;
letter-spacing: 2px;
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%;
font-weight: 800;
position: relative;
overflow: hidden;
box-shadow: 0 4px 12px rgba(35, 111, 135, 0.25);
i {
font-size: 15px;
}
&::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);
}
}
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .share-copy-btn {
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;
z-index: 1000;
}
.captcha-content {
text-align: center;
color: #fff;
max-width: 400px;
padding: 40px;
}
.captcha-icon {
font-size: 64px;
color: #236f87;
margin-bottom: 24px;
}
.captcha-title {
font: 800 32px 'Rajdhani', sans-serif;
margin: 0 0 16px;
letter-spacing: 1px;
}
.captcha-description {
color: rgba(149, 207, 245, 0.7);
font: 400 16px 'Rajdhani', sans-serif;
margin: 0 0 32px;
letter-spacing: 0.5px;
}
.captcha-button {
background: linear-gradient(#236f87 0%, #1a5068 100%);
border: 2px solid #2e7a9a;
border-radius: 8px;
color: #e0f4ff;
cursor: pointer;
font: 800 18px 'Rajdhani', sans-serif;
letter-spacing: 2px;
padding: 16px 40px;
text-transform: uppercase;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 12px;
opacity: 1;
i {
font-size: 16px;
}
&:disabled {
opacity: 0.7;
cursor: wait;
}
&:hover:not(:disabled) {
background: linear-gradient(#2d8aa8 0%, #236f87 100%);
border-color: #5ba4d4;
color: #fff;
box-shadow: 0 8px 24px rgba(35, 111, 135, 0.4);
transform: translateY(-2px);
}
&:active:not(:disabled) {
transform: translateY(0);
}
&.captcha-button--error {
background: linear-gradient(#8a2323 0%, #681a1a 100%);
border-color: #9a2e2e;
&:hover {
background: linear-gradient(#a82d2d 0%, #872323 100%);
border-color: #d45b5b;
box-shadow: 0 8px 24px rgba(135, 35, 35, 0.4);
}
}
&.captcha-button--loading {
opacity: 0.7;
}
}

View File

@@ -0,0 +1,50 @@
/*!*
* 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;
display: none;
}
#mine-wrapper {
display: block;
width: 100%;
}
#mine-wrapper .game-wrapper {
width: 100%;
flex-direction: column-reverse;
}
#mine-wrapper .grid-container {
width: 100%;
padding: 0;
}
#mine-wrapper .grid {
width: 100%;
}
#mine-wrapper .grid .field-wrapper {
width: 6.25%;
aspect-ratio: 1;
}
#mine-wrapper .grid .field-wrapper > img.field-target {
width: 105%;
top: -2.5%;
left: -2.5%;
}
#mine-wrapper .grid .field-wrapper .field {
width: 100%;
height: auto;
}
}

View File

@@ -0,0 +1,111 @@
/*!*
* 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;
justify-content: center;
margin-bottom: 10px;
}
#mine-wrapper .game-timer {
display: flex;
gap: 10px;
align-items: center;
justify-content: center;
min-width: 115px;
border-radius: 8px;
padding: 8px 18px;
font-family: 'Rajdhani', sans-serif;
font-weight: bold;
border: 2px solid transparent;
transition: all 0.4s ease;
}
// Red waiting
#mine-wrapper .game-timer.red-timer {
background: linear-gradient(to bottom, #4a0603 0%, #6b2515 100%);
border-color: #7a1e10;
color: rgba(246, 125, 82, 0.55);
}
// Red active (thinking)
#mine-wrapper .game-timer.red-timer.active {
background: linear-gradient(to bottom, #ad0a05 0%, #f67d52 100%);
border-color: #ff9b6b;
color: #fff;
box-shadow: 0 0 16px rgba(173, 10, 5, 0.75), 0 0 5px rgba(246, 125, 82, 0.5);
}
// Blue waiting
#mine-wrapper .game-timer.blue-timer {
background: linear-gradient(to bottom, #0b2530 0%, #163d55 100%);
border-color: #173650;
color: rgba(149, 207, 245, 0.55);
}
// Blue active (thinking)
#mine-wrapper .game-timer.blue-timer.active {
background: linear-gradient(to bottom, #236f87 0%, #95cff5 100%);
border-color: #b8e5ff;
color: #fff;
box-shadow: 0 0 16px rgba(35, 111, 135, 0.75), 0 0 5px rgba(149, 207, 245, 0.5);
}
#mine-wrapper .game-timer .timer-avatar {
width: 26px;
height: 26px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
font-family: 'Rajdhani', sans-serif;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.15);
}
#mine-wrapper .game-timer .timer-avatar__img {
width: 100%;
height: 100%;
object-fit: cover;
}
#mine-wrapper .game-timer.red-timer .timer-avatar__initials {
color: rgba(246, 125, 82, 0.85);
}
#mine-wrapper .game-timer.blue-timer .timer-avatar__initials {
color: rgba(149, 207, 245, 0.85);
}
#mine-wrapper .game-timer .timer-icon {
font-size: 15px;
opacity: 0.7;
flex-shrink: 0;
}
#mine-wrapper .game-timer.active .timer-icon {
opacity: 1;
animation: timer-icon-pulse 1.6s ease-in-out infinite;
}
@keyframes timer-icon-pulse {
0%, 100% { transform: scale(1); opacity: 0.85; }
50% { transform: scale(1.2); opacity: 1; }
}
#mine-wrapper .game-timer .timer-display {
font-family: 'Courier New', monospace;
font-size: 20px;
letter-spacing: 2px;
}

View File

@@ -0,0 +1,171 @@
/*!*
* 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;
}
#mine-wrapper .game-wrapper .users .user-container {
background: #FFFFFF;
height: 40%;
font-family: 'Open Sans', sans-serif;
padding: 5px;
margin: 5px;
z-index: 99;
-webkit-border-radius: 10px;
border-radius: 10px;
}
#mine-wrapper .game-wrapper .users .user-container.user-blue {
background: rgb(35, 111, 135);
background: -moz-linear-gradient(top, rgba(35, 111, 135, 1) 0%, rgba(149, 207, 245, 1) 100%);
background: -webkit-linear-gradient(top, rgba(35, 111, 135, 1) 0%, rgba(149, 207, 245, 1) 100%);
background: linear-gradient(to bottom, rgba(35, 111, 135, 1) 0%, rgba(149, 207, 245, 1) 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#236f87', endColorstr='#95cff5', GradientType=0);
margin-top: 0;
}
#mine-wrapper .game-wrapper .users .user-container.user-red {
background: rgb(173, 10, 5);
background: -moz-linear-gradient(top, rgba(173, 10, 5, 1) 0%, rgba(246, 125, 82, 1) 100%);
background: -webkit-linear-gradient(top, rgba(173, 10, 5, 1) 0%, rgba(246, 125, 82, 1) 100%);
background: linear-gradient(to bottom, rgba(173, 10, 5, 1) 0%, rgba(246, 125, 82, 1) 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ad0a05', endColorstr='#f67d52', GradientType=0);
}
#mine-wrapper .game-wrapper .users .user-container .user-header {
background: -moz-linear-gradient(top, rgba(255, 255, 255, 0.7) 39%, rgba(255, 255, 255, 0.21) 87%, rgba(0, 0, 0, 0) 100%);
background: -webkit-linear-gradient(top, rgba(255, 255, 255, 0.7) 39%, rgba(255, 255, 255, 0.21) 87%, rgba(0, 0, 0, 0) 100%);
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.7) 39%, rgba(255, 255, 255, 0.21) 87%, rgba(0, 0, 0, 0) 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#00000000', GradientType=0);
position: relative;
font: bolder 25px 'Changa One', cursive;
letter-spacing: 5px;
text-transform: uppercase;
text-align: center;
padding: 6px 5px 20px 5px;
margin-bottom: 40px;
-webkit-border-radius: 5px;
border-radius: 5px;
-webkit-text-shadow: 1px 1px 0 #FFF;
text-shadow: 1px 1px 0 #FFF;
}
#mine-wrapper .game-wrapper .users .user-container.user-blue .user-header {
color: #236f87;
}
#mine-wrapper .game-wrapper .users .user-container.user-red .user-header {
color: #AD0A05;
}
#mine-wrapper .game-wrapper .users .user-container .user-header > img {
position: absolute;
left: 50%;
bottom: 0;
width: 40%;
margin-left: -20%;
margin-bottom: -25%;
}
#mine-wrapper .game-wrapper .users .user-container .user-header > img.user-cursor {
display: block;
width: 30%;
top: 20px;
left: 10px;
margin-left: 0;
-webkit-animation: cursorJumping 1.2s cubic-bezier(.36, .07, .19, .97) infinite;
animation: cursorJumping 1.2s cubic-bezier(.36, .07, .19, .97) infinite;
}
#mine-wrapper .game-wrapper .users .user-container .user-header > img.user-cursor::after {
content: '';
width: 50px;
height: 50px;
background: #1A6844;
animation: animate .5s linear infinite;
position: absolute;
top: 0;
left: 0;
border-radius: 3px;
}
@keyframes cursorJumping {
0% { top: 15px; }
50% { top: 25px; }
100% { top: 15px; }
}
#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 5px;
margin: 0;
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;
border-bottom: 1px dashed #0b3776;
color: #0b3776;
}
#mine-wrapper .game-wrapper .users .user-container.user-red .user-name {
color: #fdf612;
border-top: 1px dashed #fdf612;
border-bottom: 1px dashed #fdf612;
}
#mine-wrapper .game-wrapper .users .user-container .user-caret {
height: 30px;
font-size: 30px;
text-align: center;
line-height: 15px;
}
#mine-wrapper .game-wrapper .users .user-container.user-blue .user-caret > i {
color: #0b3776;
}
#mine-wrapper .game-wrapper .users .user-container.user-red .user-caret > i {
color: #fdf612;
}
#mine-wrapper .game-wrapper .users .user-container .user-desc {
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;
}
#mine-wrapper .game-wrapper .users .user-container.user-red .user-desc {
color: #fdf612;
}

View File

@@ -0,0 +1,356 @@
/*!*
* 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),
linear-gradient(90deg, rgba(35, 111, 135, 0.08) 1px, transparent 1px) !important;
background-size: 46px 46px !important;
border: 1px solid rgba(35, 111, 135, 0.4) !important;
border-radius: 12px !important;
box-shadow: 0 0 80px rgba(35, 111, 135, 0.15),
0 32px 80px rgba(0, 0, 0, 0.9) !important;
width: 500px;
max-width: 94vw !important;
overflow: hidden !important;
}
.opd {
padding: 28px 28px 22px;
position: relative;
}
.opd-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 22px;
gap: 12px;
}
.opd-header-text {
flex: 1;
min-width: 0;
}
.opd-header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
padding-top: 2px;
}
.opd-label {
display: block;
font: 700 11px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 4px;
color: rgba(149, 207, 245, 0.55);
margin-bottom: 6px;
}
.opd-title {
font: 800 28px 'Rajdhani', sans-serif;
color: #fff;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 11px;
i {
color: rgba(35, 111, 135, 0.9);
font-size: 22px;
}
}
.opd-refresh,
.opd-close {
background: transparent;
border: 1px solid rgba(35, 111, 135, 0.3);
border-radius: 6px;
color: rgba(149, 207, 245, 0.55);
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 200ms ease;
flex-shrink: 0;
&:hover:not(:disabled) {
border-color: rgba(149, 207, 245, 0.5);
color: #fff;
background: rgba(35, 111, 135, 0.15);
}
&:disabled {
opacity: 0.4;
cursor: default;
}
}
.opd-refresh--spin i {
animation: opd-spin 0.7s linear infinite;
}
@keyframes opd-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.opd-search-wrap {
display: flex;
align-items: center;
background: rgba(35, 111, 135, 0.07);
border: 1px solid rgba(35, 111, 135, 0.28);
border-radius: 8px;
padding: 0 14px;
margin-bottom: 16px;
transition: border-color 200ms ease, background 200ms ease;
&:focus-within {
border-color: rgba(35, 111, 135, 0.65);
background: rgba(35, 111, 135, 0.12);
}
}
.opd-search-icon {
color: rgba(149, 207, 245, 0.38);
font-size: 13px;
flex-shrink: 0;
margin-right: 10px;
}
.opd-search {
flex: 1;
background: transparent;
border: 0;
outline: 0;
height: 44px;
font: 400 14px 'Rajdhani', sans-serif;
letter-spacing: 0.3px;
color: #fff;
&::placeholder {
color: rgba(149, 207, 245, 0.32);
}
}
.opd-search-clear {
background: transparent;
border: 0;
color: rgba(149, 207, 245, 0.4);
cursor: pointer;
padding: 0 0 0 8px;
font-size: 12px;
transition: color 150ms ease;
&:hover {
color: rgba(149, 207, 245, 0.8);
}
}
.opd-list {
min-height: 110px;
}
.opd-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 30px 0 22px;
gap: 12px;
i {
font-size: 34px;
color: rgba(35, 111, 135, 0.35);
}
p {
font: 400 14px 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.38);
letter-spacing: 0.4px;
}
}
.opd-row {
display: flex;
align-items: center;
gap: 14px;
padding: 11px 12px;
border-radius: 8px;
border: 1px solid transparent;
transition: all 180ms ease;
margin-bottom: 5px;
&:hover {
background: rgba(35, 111, 135, 0.1);
border-color: rgba(35, 111, 135, 0.28);
}
&:last-child {
margin-bottom: 0;
}
}
.opd-avatar {
width: 42px;
height: 42px;
border-radius: 50%;
background: linear-gradient(135deg, rgba(35, 111, 135, 0.55) 0%, rgba(35, 111, 135, 0.28) 100%);
border: 1px solid rgba(35, 111, 135, 0.5);
display: flex;
align-items: center;
justify-content: center;
font: 700 13px 'Rajdhani', sans-serif;
color: rgba(149, 207, 245, 0.9);
letter-spacing: 1px;
flex-shrink: 0;
}
.opd-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.opd-name {
font: 700 15px 'Rajdhani', sans-serif;
color: #fff;
letter-spacing: 0.3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.opd-since {
font: 400 12px 'Rajdhani', sans-serif;
color: rgba(149, 207, 245, 0.48);
i {
margin-right: 4px;
}
}
.opd-join {
display: inline-flex;
align-items: center;
gap: 6px;
background: linear-gradient(to bottom, rgba(35, 111, 135, 0.75) 0%, rgba(26, 80, 104, 0.9) 100%);
border: 1px solid rgba(35, 111, 135, 0.55);
color: rgba(149, 207, 245, 0.9);
font: 700 12px 'Rajdhani', sans-serif;
letter-spacing: 1.5px;
text-transform: uppercase;
text-decoration: none;
padding: 7px 16px;
border-radius: 5px;
cursor: pointer;
transition: all 200ms ease;
flex-shrink: 0;
&:hover:not(:disabled) {
background: linear-gradient(to bottom, rgba(45, 138, 168, 0.9) 0%, rgba(35, 111, 135, 0.95) 100%);
border-color: rgba(149, 207, 245, 0.5);
color: #fff;
box-shadow: 0 0 14px rgba(35, 111, 135, 0.5);
transform: translateY(-1px);
}
&:disabled {
cursor: default;
opacity: 0.55;
}
&.opd-join--waiting {
background: linear-gradient(to bottom, rgba(26, 80, 104, 0.6) 0%, rgba(15, 50, 70, 0.7) 100%);
border-color: rgba(35, 111, 135, 0.3);
color: rgba(149, 207, 245, 0.6);
opacity: 1;
letter-spacing: 1px;
}
}
.opd-declined {
font: 600 12px 'Rajdhani', sans-serif;
color: rgba(255, 120, 120, 0.85);
letter-spacing: 0.5px;
padding: 8px 12px;
margin-bottom: 8px;
border-radius: 6px;
border: 1px solid rgba(180, 60, 60, 0.3);
background: rgba(180, 60, 60, 0.08);
display: flex;
align-items: center;
gap: 7px;
i {
font-size: 14px;
flex-shrink: 0;
}
}
.opd-note {
font: 400 11px 'Rajdhani', sans-serif;
color: rgba(149, 207, 245, 0.32);
text-align: center;
letter-spacing: 0.5px;
margin-top: 14px;
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); }
}

301
assets/css/passkey.scss Normal file
View File

@@ -0,0 +1,301 @@
/*!*
* 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 {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 6px;
font: 600 13px 'Rajdhani', sans-serif;
letter-spacing: 0.5px;
&--enabled {
background: rgba(42, 158, 96, 0.12);
border: 1px solid rgba(42, 158, 96, 0.3);
color: #5ee89a;
}
&--disabled {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.4);
}
}
.twofa-actions {
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
&__form {
margin: 0;
}
}
.twofa-backup-meta {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
&__count {
font: 500 13px 'Rajdhani', sans-serif;
color: rgba(149, 207, 245, 0.55);
display: flex;
align-items: center;
gap: 6px;
}
}
.twofa-backup-reveal {
background: rgba(246, 125, 82, 0.07);
border: 1px solid rgba(246, 125, 82, 0.25);
border-radius: 8px;
padding: 18px 20px;
&__warning {
font: 600 12px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 1.5px;
color: #f6a060;
margin: 0 0 14px;
display: flex;
align-items: center;
gap: 7px;
}
&__grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
}
.twofa-backup-code {
display: block;
text-align: center;
padding: 7px 10px;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(246, 125, 82, 0.2);
border-radius: 4px;
font: 700 13px 'Courier New', monospace;
letter-spacing: 2px;
color: #e0c890;
user-select: all;
}
$primary: #236f87;
$primary-dark: #1a5a70;
$danger: #c0392b;
$warning: #d68910;
$success: #388e3c;
$text: #e0e0e0;
$text-muted: #9e9e9e;
$border: rgba(35, 111, 135, 0.3);
$bg-card: #0a0e14;
$bg-hover: rgba(35, 111, 135, 0.15);
.passkey-manager {
&__actions {
margin: 20px 0;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
&__list {
display: flex;
flex-direction: column;
gap: 15px;
margin-top: 20px;
}
}
.passkey-item {
border: 1px solid $border;
border-radius: 6px;
padding: 15px;
background: $bg-card;
transition: all 0.3s ease;
&:hover {
background: $bg-hover;
border-color: rgba(35, 111, 135, 0.5);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
&__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
&__info {
flex: 1;
}
&__name {
margin: 0 0 5px 0;
font-size: 16px;
font-weight: 600;
color: $text;
}
&__meta {
margin: 0;
font-size: 13px;
color: $text-muted;
line-height: 1.5;
}
&__badges {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
&__actions {
display: flex;
gap: 10px;
justify-content: flex-end;
padding-top: 12px;
border-top: 1px solid $border;
}
}
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
&--info {
background: rgba(25, 118, 210, 0.2);
color: #64b5f6;
}
&--success {
background: rgba(56, 142, 60, 0.2);
color: #81c784;
}
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
&--primary {
background: $primary;
color: white;
&:hover {
background: $primary-dark;
}
}
&--secondary {
background: #546e7a;
color: white;
&:hover {
background: #455a64;
}
}
&--warning {
background: $warning;
color: white;
&:hover {
background: color.adjust($warning, $lightness: -10%);
}
}
&--danger {
background: $danger;
color: white;
&:hover {
background: color.adjust($danger, $lightness: -10%);
}
}
&--sm {
padding: 6px 12px;
font-size: 13px;
}
}
.empty-state {
text-align: center;
padding: 40px 20px;
border: 1px dashed $border;
border-radius: 8px;
&__icon {
font-size: 48px;
color: #455a64;
margin-bottom: 15px;
}
&__text {
font-size: 16px;
color: $text;
margin-bottom: 5px;
}
&__subtext {
font-size: 13px;
color: $text-muted;
margin: 0;
}
}
.registration-status {
padding: 15px;
border-radius: 4px;
margin-top: 15px;
font-size: 14px;
&--success {
background: rgba(56, 142, 60, 0.15);
color: #81c784;
border: 1px solid rgba(56, 142, 60, 0.3);
}
&--error {
background: rgba(192, 57, 43, 0.15);
color: #e57373;
border: 1px solid rgba(192, 57, 43, 0.3);
}
&--loading {
background: rgba(25, 118, 210, 0.15);
color: #64b5f6;
border: 1px solid rgba(25, 118, 210, 0.3);
}
}

View File

@@ -0,0 +1,25 @@
/*!*
* 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';
@use 'homepage/features';
@use 'homepage/tech';
@use 'homepage/footer';
@use 'homepage/profile';
@use 'homepage/battle-dialog';
@use 'homepage/responsive';

View File

@@ -0,0 +1,403 @@
/*!*
* 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.
*/
@import 'fonts-config';
@import 'fontawesome-config';
@import "style";
@import "style.homepage";
::-webkit-input-placeholder {
color: #888982;
}
::-moz-placeholder {
color: #888982;
}
:-ms-input-placeholder {
color: #888982;
}
:-moz-placeholder {
color: #888982;
}
* {
padding: 0;
margin: 0;
outline: 0;
}
*,
*:after,
*::before {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
.ac-custom {
width: 100%;
}
header section .form-check {
display: table;
position: relative;
margin: 20px 0;
}
header section h1 {
margin: 10px 0;
}
header section .input-submit button,
header section .input-submit button:hover,
header section .form-input,
header section .form-input:focus,
header section .form-input:hover {
-webkit-transition: all 250ms ease-in-out;
-moz-transition: all 250ms ease-in-out;
-o-transition: all 250ms ease-in-out;
transition: all 250ms ease-in-out;
}
header section .input-submit button {
background: #83aed9;
display: table;
font: bold 32px 'Rajdhani', sans-serif;
text-transform: uppercase;
text-decoration: none;
width: 500px;
border: 1px solid #658fb8;
color: #FFFFFF;
padding: 25px 150px;
margin-top: 20px;
-webkit-box-shadow: 0 3px 10px rgba(0, 0, 0, 0.3);
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.3);
-webkit-border-radius: 3px;
border-radius: 3px;
}
header section .input-submit button:hover {
background: #86b5e1;
-webkit-box-shadow: 0 7px 15px rgba(0, 0, 0, 0.2);
box-shadow: 0 7px 15px rgba(0, 0, 0, 0.2);
}
header section .form-input {
display: block;
width: 500px;
font: bold 22px 'Rajdhani', sans-serif;
border: 1px solid #dddddd;
color: #000000;
padding: 15px;
margin-bottom: 10px;
-webkit-border-radius: 3px;
border-radius: 3px;
-webkit-box-shadow: 0 3px 5px rgba(0, 0, 0, 0.15);
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.15);
}
header section .form-input:focus,
header section .form-input:hover {
-webkit-box-shadow: 0 7px 10px rgba(0, 0, 0, 0.1);
box-shadow: 0 7px 10px rgba(0, 0, 0, 0.1);
}
header section .failure {
background: #f00;
position: relative;
border: 3px solid #fff;
font: bold 16px 'Rajdhani', sans-serif;
color: #FFFFFF;
padding: 10px;
margin: 30px 0 10px 0;
-webkit-border-radius: 5px;
border-radius: 5px;
}
header section .failure:after,
header section .failure:before {
content: " ";
position: absolute;
bottom: 100%;
left: 50px;
height: 0;
width: 0;
border: solid transparent;
pointer-events: none;
}
header section .failure:after {
border-color: rgba(0, 0, 0, 0);
border-bottom-color: #f00;
border-width: 20px;
margin-left: -20px;
}
header section .failure:before {
border-color: rgba(255, 255, 255, 0);
border-bottom-color: #ffffff;
border-width: 26px;
margin-left: -26px;
}
header section .failure ul {
display: inline-block;
list-style: none;
padding: 0;
margin: 0 10px 0 0;
}
header section h3.or {
font: bold 16px 'Rajdhani', sans-serif;
text-transform: uppercase;
color: #a1a1a1;
margin: 20px 0;
}
header section #id_welcome {
display: flex;
flex-direction: row;
margin-bottom: 115px;
}
header section #id_welcome > div {
padding-right: 20px;
}
header section #id_welcome img {
width: 100px;
border: 5px solid #414040;
-webkit-border-radius: 50%;
border-radius: 50%;
}
header section .buttons,
header section form {
z-index: 2;
}
header section div.buttons > a.fb-login,
header section div.buttons > a.slack-login {
position: relative;
display: block;
width: 500px;
height: 93px;
padding: 25px 0 25px 150px;
margin-bottom: 10px;
overflow: hidden;
cursor: pointer;
-webkit-border-radius: 3px;
border-radius: 3px;
-webkit-box-shadow: 0 3px 5px rgba(0, 0, 0, 0.15);
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.15);
}
header section div.buttons > a.fb-login:hover,
header section div.buttons > a.slack-login:hover {
text-decoration: none;
-webkit-box-shadow: 0 7px 10px rgba(0, 0, 0, 0.1);
box-shadow: 0 7px 10px rgba(0, 0, 0, 0.1);
}
header section div.buttons > a.fb-login i,
header section div.buttons > a.slack-login i {
position: absolute;
font-size: 130px;
top: 0;
left: 15px;
}
header section div.buttons > a.fb-login {
background: #5975b1;
border: 1px solid #50649f;
}
header section div.buttons > a.fb-login:hover {
background: #42598c;
color: #FFFFFF;
}
header section div.buttons > a.slack-login {
background: #FFFFFF;
border: 1px solid #5c3a58;
color: #5c3a58;
}
header section div.buttons > a.slack-login:hover {
background: #e6e6e6;
color: #5c3a58;
}
header section .failure-main {
background: #f00;
max-width: 500px;
border: 3px solid #fff;
font: bold 16px 'Rajdhani', sans-serif;
color: #FFFFFF;
padding: 10px;
margin: 0 0 20px 0;
-webkit-border-radius: 5px;
border-radius: 5px;
}
main div.txt {
width: 100%;
max-width: 1000px;
font-family: 'Rajdhani', sans-serif;
color: #414040;
margin: 50px auto 0 auto;
}
main div.txt h2 {
margin: 0 0 50px 0;
}
main div.txt p {
font: normal 16px 'Rajdhani', sans-serif;
}
main div.txt li {
font: normal 16px 'Rajdhani', sans-serif;
padding-left: 10px;
margin-left: 50px;
}
main .technologies {
text-align: center;
}
main .technologies img {
display: inline-block;
width: 90%;
max-width: 100px;
margin: 20px;
}
main .technologies h1 {
font-weight: bold;
}
footer {
background: #414040;
width: 100%;
min-height: 50px;
margin-top: 50px;
}
footer nav {
display: block;
text-align: center;
}
footer nav ul {
display: inline-block;
list-style: none;
padding: 0;
margin: 0;
}
footer nav ul li {
display: inline-block;
font: bold 16px 'Rajdhani', sans-serif;
color: #FFFFFF;
}
footer nav ul li:nth-child(even) {
text-align: center;
}
footer nav ul li a {
text-align: center;
line-height: 50px;
color: #FFFFFF;
}
footer nav ul li a:hover {
color: #FFFFFF;
}
@media screen and (max-width: 1100px) {
header section #id_welcome {
align-items: center;
justify-content: center;
margin-top: 50px;
}
header section .form-input,
header section .form-check {
margin-left: auto;
margin-right: auto;
}
header section .input-submit button {
margin: 0 auto;
}
header section > div {
width: 100%;
}
header section div.buttons > a.fb-login,
header section div.buttons > a.slack-login {
margin: 0 auto;
}
main div.txt {
padding: 0 20px;
}
}
@media screen and (max-width: 550px) {
header section #id_welcome {
display: block;
}
header section {
padding: 20px;
}
header section .form-input {
width: 100%;
margin-left: auto;
margin-right: auto;
}
header section .form-check {
margin: 20px auto;
}
header section .input-submit button {
width: 100%;
}
header section div.buttons > a.fb-login,
header section div.buttons > a.slack-login {
width: 100%;
}
header section div.buttons > a.fb-login span,
header section div.buttons > a.slack-login span {
display: none;
}
footer nav ul li {
display: block;
}
}

View File

@@ -0,0 +1,24 @@
/*!*
* 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.
*/
@import "style";
@import 'fonts-config';
@import 'fontawesome-config';
@import 'mineseeker/base';
@import 'mineseeker/overlay';
@import 'mineseeker/users';
@import 'mineseeker/bomb';
@import 'mineseeker/mine-counter';
@import 'mineseeker/grid';
@import 'mineseeker/back-button';
@import 'mineseeker/timer';
@import 'mineseeker/bonus-box';
@import 'mineseeker/responsive';
@import 'mineseeker/waiting-dialog';

19
assets/css/style.scss Normal file
View File

@@ -0,0 +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.
*/
.mine-beta {
position: fixed;
top: 0;
right: 0;
width: 150px;
z-index: 4;
transform: rotate(90deg);
}

Binary file not shown.

View File

@@ -0,0 +1,170 @@
<!---
- 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.
-->
<svg viewBox="0 0 800 500" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>
.bg-dark { fill: #07090d; }
.bg-grid { fill: url(#gridPattern); }
.border-accent { stroke: rgba(35, 111, 135, 0.4); stroke-width: 2; fill: none; }
.shadow { filter: drop-shadow(0 8px 20px rgba(0, 0, 0, 0.4)); }
.title-text { font: bold 28px 'Rajdhani', sans-serif; fill: #fff; letter-spacing: 0.5px; }
.header-text { font: bold 16px 'Rajdhani', sans-serif; fill: #236f87; letter-spacing: 0.5px; }
.desc-text { font: 13px 'Rajdhani', sans-serif; fill: rgba(149, 207, 245, 0.7); letter-spacing: 0.3px; }
.divider-line { stroke: rgba(35, 111, 135, 0.25); stroke-width: 1.5; }
.divider-text { font: bold 12px 'Rajdhani', sans-serif; fill: rgba(35, 111, 135, 0.5); letter-spacing: 1px; text-transform: uppercase; }
.icon-color { fill: rgba(35, 111, 135, 0.9); }
.button-gradient { fill: url(#buttonGradient); }
.button-text { font: bold 13px 'Rajdhani', sans-serif; fill: #e0f4ff; letter-spacing: 1.5px; text-transform: uppercase; }
.glow { filter: drop-shadow(0 0 12px rgba(35, 111, 135, 0.3)); }
</style>
<!-- Grid Pattern Background -->
<pattern id="gridPattern" x="0" y="0" width="46" height="46" patternUnits="userSpaceOnUse">
<path d="M 46 0 L 0 0 0 46" fill="none" stroke="rgba(35, 111, 135, 0.08)" stroke-width="1"/>
</pattern>
<!-- Button Gradient -->
<linearGradient id="buttonGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#236f87;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1a5068;stop-opacity:1" />
</linearGradient>
<!-- Icon Definitions -->
<g id="icon-link">
<path d="M 12 8 L 20 8 Q 22 8 22 10 L 22 16 Q 22 18 20 18 L 16 18 M 28 12 L 32 12 Q 34 12 34 14 L 34 20 Q 34 22 32 22 L 28 22 M 22 12 L 28 12"
stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<g id="icon-users">
<circle cx="16" cy="10" r="3.5" fill="currentColor"/>
<path d="M 12 15 Q 12 13 16 13 Q 20 13 20 15 L 20 18 L 12 18 Z" fill="currentColor"/>
<circle cx="30" cy="10" r="3.5" fill="currentColor"/>
<path d="M 26 15 Q 26 13 30 13 Q 34 13 34 15 L 34 18 L 26 18 Z" fill="currentColor"/>
</g>
<g id="icon-search">
<circle cx="18" cy="18" r="8" stroke="currentColor" stroke-width="2" fill="none"/>
<line x1="26" y1="26" x2="32" y2="32" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</g>
<g id="icon-clipboard">
<rect x="12" y="8" width="16" height="24" rx="2" stroke="currentColor" stroke-width="2" fill="none"/>
<rect x="16" y="6" width="8" height="3" fill="currentColor"/>
<line x1="16" y1="14" x2="28" y2="14" stroke="currentColor" stroke-width="1.5"/>
<line x1="16" y1="18" x2="28" y2="18" stroke="currentColor" stroke-width="1.5"/>
<line x1="16" y1="22" x2="24" y2="22" stroke="currentColor" stroke-width="1.5"/>
</g>
</defs>
<!-- Main Background -->
<rect class="bg-dark" width="800" height="500"/>
<rect class="bg-grid" width="800" height="500"/>
<!-- Dialog Container -->
<g class="shadow">
<rect class="bg-dark" x="60" y="40" width="680" height="420" rx="12" class="shadow"/>
<rect class="border-accent" x="60" y="40" width="680" height="420" rx="12"/>
</g>
<!-- Dialog Header -->
<g>
<text x="100" y="85" class="title-text">We are waiting...</text>
<g transform="translate(720, 60)">
<!-- Close Button -->
<circle cx="0" cy="0" r="16" stroke="rgba(35, 111, 135, 0.3)" stroke-width="1" fill="none"/>
<line x1="-6" y1="-6" x2="6" y2="6" stroke="rgba(149, 207, 245, 0.55)" stroke-width="2" stroke-linecap="round"/>
<line x1="6" y1="-6" x2="-6" y2="6" stroke="rgba(149, 207, 245, 0.55)" stroke-width="2" stroke-linecap="round"/>
</g>
</g>
<!-- Option 1: Invite a Friend -->
<g>
<!-- Option Container -->
<rect x="80" y="120" width="280" height="280" rx="8" fill="rgba(35, 111, 135, 0.05)" stroke="rgba(35, 111, 135, 0.15)" stroke-width="1"/>
<!-- Icon -->
<g transform="translate(110, 145)">
<circle cx="0" cy="0" r="22" fill="rgba(35, 111, 135, 0.15)"/>
<g use="#icon-link" class="icon-color" transform="scale(1.8)"/>
</g>
<!-- Title -->
<text x="160" y="165" class="header-text">Invite a Friend</text>
<!-- Description -->
<text x="100" y="195" class="desc-text">Share this link with your opponent</text>
<!-- URL Box -->
<rect x="100" y="210" width="240" height="40" rx="6" fill="#d0e8f5" stroke="#7ab8d8" stroke-width="1"/>
<text x="118" y="237" style="font: 12px 'Courier New', monospace; fill: #1a4a6a; letter-spacing: 0.3px;">play.mineseeker.com/game-id</text>
<!-- Copy Button -->
<g class="glow">
<rect x="100" y="265" width="240" height="40" rx="5" class="button-gradient" stroke="#2e7a9a" stroke-width="1"/>
<g transform="translate(120, 285)">
<rect x="0" y="0" width="6" height="6" fill="#e0f4ff"/>
<rect x="2" y="2" width="4" height="4" fill="none" stroke="#e0f4ff" stroke-width="0.5"/>
</g>
<text x="140" y="287" class="button-text">Copy Link</text>
</g>
</g>
<!-- Divider OR -->
<g>
<line x1="400" y1="200" x2="400" y2="320" class="divider-line"/>
<circle cx="400" cy="260" r="18" fill="#07090d" stroke="rgba(35, 111, 135, 0.25)" stroke-width="1"/>
<text x="400" y="267" class="divider-text" text-anchor="middle">OR</text>
</g>
<!-- Option 2: Challenge a Player -->
<g>
<!-- Option Container -->
<rect x="440" y="120" width="280" height="280" rx="8" fill="rgba(35, 111, 135, 0.05)" stroke="rgba(35, 111, 135, 0.15)" stroke-width="1"/>
<!-- Icon -->
<g transform="translate(470, 145)">
<circle cx="0" cy="0" r="22" fill="rgba(35, 111, 135, 0.15)"/>
<g use="#icon-users" class="icon-color" transform="scale(1.5)"/>
</g>
<!-- Title -->
<text x="510" y="165" class="header-text">Challenge a Player</text>
<!-- Description -->
<text x="460" y="195" class="desc-text">Browse online players and challenge</text>
<!-- Browse Button -->
<g class="glow">
<rect x="460" y="265" width="240" height="40" rx="5" class="button-gradient" stroke="#2e7a9a" stroke-width="1"/>
<g transform="translate(480, 285)">
<!-- Search icon simplified -->
<circle cx="4" cy="4" r="3" fill="none" stroke="#e0f4ff" stroke-width="1"/>
<line x1="7" y1="7" x2="9" y2="9" stroke="#e0f4ff" stroke-width="1" stroke-linecap="round"/>
</g>
<text x="510" y="287" class="button-text">Browse Players</text>
</g>
<!-- Highlight: Players Online -->
<g>
<text x="460" y="230" class="desc-text" style="font-weight: bold;">5 players waiting</text>
<g transform="translate(680, 220)">
<circle r="6" fill="#236f87"/>
<circle cx="-8" cy="3" r="4" fill="rgba(35, 111, 135, 0.6)"/>
<circle cx="8" cy="3" r="4" fill="rgba(35, 111, 135, 0.6)"/>
</g>
</g>
</g>
<!-- Bottom Info -->
<g>
<text x="100" y="435" style="font: 11px 'Rajdhani', sans-serif; fill: rgba(149, 207, 245, 0.35); letter-spacing: 0.5px;">Choose either option to start playing or find an opponent</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.5 KiB

22
assets/js/app.jsx Normal file
View File

@@ -0,0 +1,22 @@
/**
* 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 MineSeeker from './mine-seeker/MineSeeker';
const wrapper = document.getElementById('mine-wrapper');
createRoot(wrapper).render(
<MineSeeker
env={wrapper.dataset.env}
gameId={wrapper.dataset.gameId}
opponentName={wrapper.dataset.opponentName || ''}
/>,
);

View File

@@ -0,0 +1,76 @@
/**
* 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, 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();
const errorMessage = useMemo(() => error?.message ?? null, [error]);
const handleChange = e => {
const file = e.target.files?.[0];
if (!file) 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) {
const img = document.createElement('img');
img.src = data.thumbUrl;
img.alt = navInitials.textContent.trim();
img.className = 'hero-auth-avatar';
navInitials.replaceWith(img);
}
},
});
};
return (
<Fragment>
<div
className={`profile-avatar${isPending ? ' profile-avatar--loading' : ''}`}
title="Click to change profile picture"
onClick={() => inputRef.current?.click()}
>
{thumbUrl
? <img src={thumbUrl} alt={initials} className="profile-avatar__img" />
: <span className="profile-avatar__initials">{initials}</span>
}
<div className="profile-avatar__overlay">
<i className="fa fa-camera" />
</div>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
style={{ display: 'none' }}
onChange={handleChange}
/>
</div>
{errorMessage && <div className="profile-avatar__error">{errorMessage}</div>}
</Fragment>
);
};
AvatarUpload.propTypes = {
uploadUrl: string.isRequired,
initialThumbUrl: string,
initials: string.isRequired,
};

View File

@@ -0,0 +1,223 @@
/**
* 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, styled, ThemeProvider } from '@mui/material/styles';
import { Avatar } from './battle-dialog/Avatar';
import { BonusPoints } from './battle-dialog/BonusPoints';
import { StatRow } from './battle-dialog/StatRow';
const darkTheme = createTheme({ palette: { mode: 'dark' } });
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 bothRegistered = game.bothRegistered;
const shareUrl = `${window.location.origin}/battle/${game.uuid}`;
const canContinue = bothRegistered && !resign && 26 > maxPoints;
const canShare = !canContinue;
const playUrl = `${window.location.origin}/play/${game.uuid}`;
const duration = formatDuration(game.created, game.date);
const pointDiff = Math.abs((game.redPoints ?? 0) - (game.bluePoints ?? 0));
const winnerColor = (game.redPoints ?? 0) > (game.bluePoints ?? 0) ? '#f67d52'
: (game.bluePoints ?? 0) > (game.redPoints ?? 0) ? '#95cff5'
: 'rgba(255,255,255,0.45)';
const handleShare = () => {
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: `
linear-gradient(rgba(35, 111, 135, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(35, 111, 135, 0.08) 1px, transparent 1px)
`,
backgroundSize: '46px 46px',
border: '1px solid rgba(35, 111, 135, 0.4)',
borderRadius: '12px',
boxShadow: '0 0 80px rgba(35, 111, 135, 0.15), 0 32px 80px rgba(0,0,0,0.9)',
width: '580px',
maxWidth: '94vw',
overflow: 'hidden',
color: '#fff',
},
'& .MuiBackdrop-root': {
background: 'rgba(2, 4, 8, 0.88)',
backdropFilter: 'blur(4px)',
},
});

View File

@@ -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

@@ -0,0 +1,118 @@
/**
* 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, { useState, useCallback } from 'react';
import { shape, string } from 'prop-types';
const base64ToArrayBuffer = base64 => {
const binary = atob(base64.replace(/([-_])/g, m => ('-' === m ? '+' : '/')));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
};
const arrayBufferToBase64url = buffer =>
btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/([+\/=])/g, m => ('+' === m ? '-' : '/' === m ? '_' : ''));
const credentialToJSON = credential => ({
id: credential.id,
rawId: arrayBufferToBase64url(credential.rawId),
type: credential.type,
response: {
authenticatorData: arrayBufferToBase64url(credential.response.authenticatorData),
clientDataJSON: arrayBufferToBase64url(credential.response.clientDataJSON),
signature: arrayBufferToBase64url(credential.response.signature),
userHandle: credential.response.userHandle
? arrayBufferToBase64url(credential.response.userHandle)
: null,
},
});
const PasskeyLogin = ({ apiRoutes }) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleLogin = useCallback(async () => {
setLoading(true);
setError('');
try {
const beginResponse = await fetch(apiRoutes.authenticationBegin, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!beginResponse.ok) throw new Error('Failed to get authentication options');
const options = await beginResponse.json();
const publicKey = {
...options,
challenge: base64ToArrayBuffer(options.challenge),
allowCredentials: (options.allowCredentials ?? []).map(cred => ({
...cred,
id: base64ToArrayBuffer(cred.id),
})),
};
const credential = await navigator.credentials.get({ publicKey });
if (!credential) throw new Error('Authentication was cancelled');
const completeResponse = await fetch(apiRoutes.authenticationComplete, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: credentialToJSON(credential) }),
});
if (!completeResponse.ok) {
const body = await completeResponse.json();
throw new Error(body.error || 'Authentication failed');
}
const result = await completeResponse.json();
if (result.success) {
window.location.href = result.redirect || '/';
}
} catch (err) {
setError(err.message);
}
setLoading(false);
}, [apiRoutes]);
return (
<>
<button
type="button"
className="auth-passkey-btn"
onClick={handleLogin}
disabled={loading}
>
<i className={loading ? 'fa fa-spinner fa-spin' : 'fa fa-key'} />
{loading ? 'Waiting for passkey…' : 'Sign In with Passkey'}
</button>
{error && (
<p className="auth-error" style={{ marginTop: '10px' }}>
<i className="fa fa-exclamation-triangle" /> {error}
</p>
)}
</>
);
};
export default PasskeyLogin;
PasskeyLogin.propTypes = {
apiRoutes: shape({
authenticationBegin: string.isRequired,
authenticationComplete: string.isRequired,
}).isRequired,
};

View File

@@ -0,0 +1,428 @@
/**
* 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, 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 StyledDialog = styled(Dialog)({
'& .MuiDialog-paper': {
background: '#0a0e14',
color: '#e0e0e0',
},
'& .MuiTextField-root .MuiInputLabel-root': {
color: '#9e9e9e',
},
'& .MuiTextField-root .MuiOutlinedInput-root': {
'& fieldset': {
borderColor: 'rgba(35, 111, 135, 0.5)',
},
'&:hover fieldset': {
borderColor: 'rgba(35, 111, 135, 0.8)',
},
'&.Mui-focused fieldset': {
borderColor: '#236f87',
},
},
'& .MuiTextField-root .MuiOutlinedInput-input': {
color: '#e0e0e0',
},
'& .MuiFormHelperText-root': {
width: '500px',
maxWidth: '94vw',
overflow: 'hidden',
color: '#e0e0e0',
},
'& .MuiBackdrop-root': {
background: 'rgba(2, 4, 8, 0.88)',
backdropFilter: 'blur(4px)',
},
});
const base64ToArrayBuffer = base64 => {
const binary = atob(base64.replace(/([-_])/g, m => ('-' === m ? '+' : '/')));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
};
const credentialToJSON = credential => {
const attestationObject = credential.response.attestationObject;
const clientDataJSON = credential.response.clientDataJSON;
return {
id: credential.id,
rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))).replace(/([+\/=])/g, m => ('+' === m ? '-' : '/' === m ? '_' : '')),
type: credential.type,
response: {
attestationObject: btoa(String.fromCharCode(...new Uint8Array(attestationObject))).replace(/([+\/=])/g, m => ('+' === m ? '-' : '/' === m ? '_' : '')),
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(clientDataJSON))).replace(/([+\/=])/g, m => ('+' === m ? '-' : '/' === m ? '_' : '')),
},
};
};
const PasskeyManager = ({ credentials, apiRoutes }) => {
const [addModalOpen, setAddModalOpen] = useState(false);
const [renameModalOpen, setRenameModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [selectedCredential, setSelectedCredential] = useState(null);
const [passkeyName, setPasskeyName] = useState('');
const [renameName, setRenameName] = useState('');
const [status, setStatus] = useState({ type: '', message: '' });
const [loading, setLoading] = useState(false);
const [credentialsList, setCredentialsList] = useState(credentials);
useEffect(() => {
setCredentialsList(credentials);
}, [credentials]);
const showStatus = useCallback((message, type) => {
setStatus({ message, type });
}, []);
const closeAddModal = useCallback(() => {
setAddModalOpen(false);
setPasskeyName('');
setStatus({ type: '', message: '' });
}, []);
const openAddModal = useCallback(() => {
setPasskeyName('');
setStatus({ type: '', message: '' });
setAddModalOpen(true);
}, []);
const handleAddPasskey = useCallback(async () => {
if (!passkeyName.trim()) {
showStatus('Please enter a passkey name', 'error');
return;
}
setLoading(true);
showStatus('Starting registration...', 'loading');
try {
const optionsResponse = await fetch(apiRoutes.registrationBegin, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credentialName: passkeyName.trim() }),
});
if (!optionsResponse.ok) throw new Error('Failed to get registration options');
const options = await optionsResponse.json();
showStatus('Please touch your security key or use biometric authentication...', 'loading');
const publicKey = {
...options,
challenge: base64ToArrayBuffer(options.challenge),
user: {
...options.user,
id: base64ToArrayBuffer(options.user.id),
},
attestation: 'direct',
};
const credential = await navigator.credentials.create({ publicKey });
if (!credential) {
showStatus('Registration was cancelled', 'error');
setLoading(false);
return;
}
showStatus('Verifying credential...', 'loading');
const completeResponse = await fetch(apiRoutes.registrationComplete, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
credential: credentialToJSON(credential),
}),
});
if (!completeResponse.ok) throw new Error('Failed to complete registration');
showStatus('Passkey registered successfully!', 'success');
setTimeout(() => window.location.reload(), 1500);
} catch (error) {
console.error('Registration error:', error);
showStatus('Error: ' + error.message, 'error');
}
setLoading(false);
}, [passkeyName, apiRoutes, showStatus]);
const openRenameModal = useCallback(credential => {
setSelectedCredential(credential);
setRenameName(credential.credentialName);
setStatus({ type: '', message: '' });
setRenameModalOpen(true);
}, []);
const closeRenameModal = useCallback(() => {
setRenameModalOpen(false);
setSelectedCredential(null);
setRenameName('');
setStatus({ type: '', message: '' });
}, []);
const handleRename = useCallback(async () => {
if (!renameName.trim() || !selectedCredential) {
showStatus('Please enter a new name', 'error');
return;
}
setLoading(true);
try {
const response = await fetch(`${apiRoutes.credentials}/${selectedCredential.id}/rename`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: renameName.trim() }),
});
if (!response.ok) throw new Error('Failed to rename passkey');
window.location.reload();
} catch (error) {
console.error('Rename error:', error);
showStatus('Error: ' + error.message, 'error');
}
setLoading(false);
}, [renameName, selectedCredential, apiRoutes, showStatus]);
const openDeleteModal = useCallback(credential => {
setSelectedCredential(credential);
setDeleteModalOpen(true);
}, []);
const closeDeleteModal = useCallback(() => {
setDeleteModalOpen(false);
setSelectedCredential(null);
}, []);
const handleDelete = useCallback(async () => {
if (!selectedCredential) return;
setLoading(true);
try {
const response = await fetch(`${apiRoutes.credentials}/${selectedCredential.id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
});
if (response.ok) {
window.location.reload();
} else {
showStatus('Failed to delete passkey', 'error');
}
} catch (error) {
console.error('Delete error:', error);
showStatus('Error: ' + error.message, 'error');
}
setLoading(false);
setDeleteModalOpen(false);
}, [selectedCredential, apiRoutes, showStatus]);
const statusClass = 'error' === status.type
? 'registration-status--error'
: 'success' === status.type
? 'registration-status--success'
: 'loading' === status.type
? 'registration-status--loading'
: '';
return (
<div className="passkey-manager">
<div className="passkey-manager__actions">
<button className="btn btn--primary" onClick={openAddModal} type="button">
<i className="fa fa-plus" /> Add Passkey
</button>
</div>
<div className="passkey-manager__list">
{0 < credentialsList.length ? (
credentialsList.map(credential => (
<div key={credential.id} className="passkey-item" data-credential-id={credential.id}>
<div className="passkey-item__header">
<div className="passkey-item__info">
<h3 className="passkey-item__name">{credential.credentialName}</h3>
<p className="passkey-item__meta">
Created: {credential.createdAt}
{credential.lastUsedAt && (
<>
<br />Last used: {credential.lastUsedAt}
</>
)}
</p>
</div>
<div className="passkey-item__badges">
{credential.isBackupEligible && (
<span className="badge badge--info" title="This passkey can be backed up">
<i className="fa fa-cloud" /> Backup Eligible
</span>
)}
{credential.isBackupAuthenticated && (
<span className="badge badge--success" title="This passkey is backed up">
<i className="fa fa-check" /> Backed Up
</span>
)}
</div>
</div>
<div className="passkey-item__actions">
<button
className="btn btn--sm btn--warning"
onClick={() => openRenameModal(credential)}
type="button"
>
<i className="fa fa-edit" /> Rename
</button>
<button
className="btn btn--sm btn--danger"
onClick={() => openDeleteModal(credential)}
type="button"
>
<i className="fa fa-trash" /> Delete
</button>
</div>
</div>
))
) : (
<div className="empty-state">
<i className="fa fa-key empty-state__icon" />
<p className="empty-state__text">No passkeys registered yet.</p>
<p className="empty-state__subtext">
Add your first passkey to get started with secure, passwordless authentication.
</p>
</div>
)}
</div>
<StyledDialog open={addModalOpen} onClose={closeAddModal}>
<DialogTitle>Add New Passkey</DialogTitle>
<DialogContent>
<TextField
autoFocus
fullWidth
id="passkeyName"
label="Passkey Name"
placeholder="e.g., My Laptop, iPhone"
value={passkeyName}
onChange={e => setPasskeyName(e.target.value)}
onKeyUp={e => 'Enter' === e.key && handleAddPasskey()}
margin="dense"
variant="outlined"
helperText="Give this passkey a descriptive name to help you remember it."
/>
{status.message && (
<div className={`registration-status ${statusClass}`}>
{status.message}
</div>
)}
</DialogContent>
<DialogActions>
<Button onClick={closeAddModal} disabled={loading}>
Cancel
</Button>
<Button onClick={handleAddPasskey} variant="contained" disabled={loading}>
Continue
</Button>
</DialogActions>
</StyledDialog>
<StyledDialog open={renameModalOpen} onClose={closeRenameModal}>
<DialogTitle>Rename Passkey</DialogTitle>
<DialogContent>
<TextField
autoFocus
fullWidth
id="renamePasskeyName"
label="New Name"
value={renameName}
onChange={e => setRenameName(e.target.value)}
onKeyUp={e => 'Enter' === e.key && handleRename()}
margin="dense"
variant="outlined"
/>
{status.message && (
<div className={`registration-status ${statusClass}`}>
{status.message}
</div>
)}
</DialogContent>
<DialogActions>
<Button onClick={closeRenameModal} disabled={loading}>
Cancel
</Button>
<Button onClick={handleRename} variant="contained" disabled={loading}>
Rename
</Button>
</DialogActions>
</StyledDialog>
<StyledDialog open={deleteModalOpen} onClose={closeDeleteModal}>
<DialogTitle>Delete Passkey</DialogTitle>
<DialogContent>
<p>
Are you sure you want to delete the passkey
{selectedCredential && (
<Fragment>
<strong>{selectedCredential.credentialName}</strong>?
This action cannot be undone.
</Fragment>
)}
</p>
{status.message && (
<div className={`registration-status ${statusClass}`}>
{status.message}
</div>
)}
</DialogContent>
<DialogActions>
<Button onClick={closeDeleteModal} disabled={loading}>
Cancel
</Button>
<Button onClick={handleDelete} variant="contained" color="error" disabled={loading}>
Delete
</Button>
</DialogActions>
</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

@@ -0,0 +1,165 @@
/**
* 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: {
mode: 'dark',
background: { paper: 'transparent' },
},
typography: {
fontFamily: '\'Rajdhani\', sans-serif',
},
});
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' },
labelStyle: { fill: 'rgba(255,255,255,0.4)', fontSize: 11, fontFamily: '\'Rajdhani\', sans-serif' },
};
export default function ProfileCharts({ 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}>
<div className="profile-charts">
{0 < total && (
<div className="profile-chart-block">
<h2 className="profile-section__title">
<i className="fa fa-pie-chart" /> Result Breakdown
</h2>
<div className="profile-chart-inner">
<PieChart
series={[{
data: [
{ id: 0, value: pieWins, label: `Wins ${pieWins}`, color: WIN_COLOR },
{ id: 1, value: pieLosses, label: `Losses ${pieLosses}`, color: LOSS_COLOR },
{ id: 2, value: pieDraws, label: `Draws ${pieDraws}`, color: DRAW_COLOR },
],
innerRadius: 52,
paddingAngle: 3,
cornerRadius: 4,
highlightScope: { fade: 'global', highlight: 'item' },
}]}
slotProps={{
legend: {
labelStyle: {
fill: 'rgba(255,255,255,0.55)',
fontSize: 13,
fontFamily: '\'Rajdhani\', sans-serif',
},
},
}}
width={320}
height={200}
margin={{ top: 10, bottom: 10, left: 10, right: 120 }}
/>
</div>
</div>
)}
{hasBars && (
<div className="profile-chart-block">
<h2 className="profile-section__title">
<i className="fa fa-bar-chart" /> Activity (6 months)
</h2>
<div className="profile-chart-inner">
<BarChart
xAxis={[{ scaleType: 'band', data: months, ...axisStyle }]}
yAxis={[{ ...axisStyle }]}
series={[
{ data: wins, label: 'Wins', color: WIN_COLOR, stack: 'total' },
{ data: losses, label: 'Losses', color: LOSS_COLOR, stack: 'total' },
{ data: draws, label: 'Draws', color: DRAW_COLOR, stack: 'total' },
]}
slotProps={{
legend: {
labelStyle: {
fill: 'rgba(255,255,255,0.55)',
fontSize: 13,
fontFamily: '\'Rajdhani\', sans-serif',
},
},
}}
borderRadius={3}
width={460}
height={200}
margin={{ top: 10, bottom: 30, left: 30, right: 120 }}
/>
</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

@@ -0,0 +1,43 @@
/**
* 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, { 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, opponentName = '' }) => {
const isEnvDev = 'dev' === env;
const gameAssoc = useRef('' !== gameId ? gameId : crypto.randomUUID()).current;
const gameInherited = '' !== gameId;
return (
<QueryClientProvider client={queryClient}>
<GameProvider>
<GameBoard
gameAssoc={gameAssoc}
gameInherited={gameInherited}
opponentName={opponentName}
isEnvDev={isEnvDev}
/>
</GameProvider>
</QueryClientProvider>
);
};
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

@@ -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, { 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, opponentName = '', isEnvDev }) => {
const { gridReady } = useGame();
const { onClick, resign } = useServerCommunication(gameAssoc, gameInherited, opponentName, isEnvDev);
const [captchaVerified, setCaptchaVerified] = useState(false);
const siteKey = document.getElementById('mine-wrapper')?.dataset.recaptchaSiteKey;
if (!gridReady) {
return (
<div className="game-overlay">
<div className="game-overlay-window"><h1>Loading</h1></div>
</div>
);
}
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

@@ -0,0 +1,135 @@
/**
* 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, { 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 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);
const redStartTimeRef = useRef(null);
const blueStartTimeRef = useRef(null);
const lastActivePlayerRef = useRef(null);
const pausedRedTimeRef = useRef(0);
const pausedBlueTimeRef = useRef(0);
useEffect(() => {
if (!overlay && !gameStartedRef.current) {
gameStartedRef.current = true;
setIsRunning(true);
setRedTime(0);
setBlueTime(0);
redStartTimeRef.current = Date.now();
blueStartTimeRef.current = Date.now();
pausedRedTimeRef.current = 0;
pausedBlueTimeRef.current = 0;
lastActivePlayerRef.current = activePlayer;
}
}, [activePlayer, overlay]);
useEffect(() => {
if (endRef.current) setIsRunning(false);
}, [endRef]);
useEffect(() => {
if (connectionLost) setIsRunning(false);
}, [connectionLost]);
useEffect(() => {
if (!isRunning) return;
if (lastActivePlayerRef.current !== activePlayer) {
const startRef = lastActivePlayerRef.current ? blueStartTimeRef.current : redStartTimeRef.current;
if (startRef) {
const elapsed = Math.floor((Date.now() - startRef) / 1000);
if (lastActivePlayerRef.current) {
pausedBlueTimeRef.current += elapsed;
} else {
pausedRedTimeRef.current += elapsed;
}
}
if (activePlayer) {
blueStartTimeRef.current = Date.now();
} else {
redStartTimeRef.current = Date.now();
}
lastActivePlayerRef.current = activePlayer;
}
}, [activePlayer, isRunning]);
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);
return;
}
timerIntervalRef.current = setInterval(syncTimes, 100);
return () => {
if (timerIntervalRef.current) clearInterval(timerIntervalRef.current);
};
}, [isRunning, activePlayer, syncTimes]);
useEffect(() => {
const handleFocus = () => {
if (isRunning) syncTimes();
};
window.addEventListener('focus', handleFocus);
return () => window.removeEventListener('focus', handleFocus);
}, [isRunning, activePlayer, syncTimes]);
useEffect(() => () => {
if (timerIntervalRef.current) clearInterval(timerIntervalRef.current);
}, []);
return (
<div className="game-timer-container">
<BonusBox color="red" points={red.bonusPoints ?? 0} onClick={() => setBonusDialogOpen(true)} />
<div className={`game-timer red-timer ${!activePlayer ? 'active' : ''}`}>
<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' : ''}`}>
<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>
);
};
export default GameTimer;

View File

@@ -0,0 +1,294 @@
/**
* 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, { 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 OnlinePlayersDialog = ({ open, onClose, currentGameAssoc, isEnvDev = false }) => {
const [players, setPlayers] = useState([]);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
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 =>
prev.some(p => p.gameAssoc === entry.gameAssoc)
? prev
: [...prev, entry],
);
}, []);
const removePlayer = useCallback(gameAssoc => {
setPlayers(prev => prev.filter(p => p.gameAssoc !== gameAssoc));
}, []);
useEffect(() => {
if (!open) return;
setLoading(true);
setSnapshotLoaded(false);
waitingPlayersQuery.refetch().then(result => {
if (result.data) {
// Filter out current user's game from the snapshot
const filtered = result.data.filter(p => p.gameAssoc !== currentGameAssoc);
setPlayers(filtered);
}
setSnapshotLoaded(true);
setLoading(false);
}).catch(() => {
setPlayers([]);
setSnapshotLoaded(true);
setLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, refreshKey, currentGameAssoc]);
useEffect(() => {
if (!open || !snapshotLoaded) return;
setSearch('');
const wrapper = document.getElementById('mine-wrapper');
const hubUrl = wrapper?.dataset?.mercureHubUrl;
const jwt = wrapper?.dataset?.mercureSubscriberJwt;
if (!hubUrl) return;
const url = new URL(hubUrl, window.location.origin);
url.searchParams.append('topic', 'mineseeker/lobby');
if (jwt) url.searchParams.append('authorization', jwt);
const es = new EventSource(url.toString());
es.onmessage = e => {
const { action, gameAssoc, name, since } = JSON.parse(e.data);
// Don't add the current user's game to the list
if (gameAssoc === currentGameAssoc) return;
if ('join' === action) addPlayer({ gameAssoc, name, since });
if ('leave' === action) removePlayer(gameAssoc);
};
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);
return () => {
window.removeEventListener('challenge-declined', handler);
clearTimeout(declinedTimerRef.current);
};
}, []);
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('');
setWaitingCountdown(30);
challengeMutation.mutate(
{ targetGameAssoc: player.gameAssoc, challengerGameAssoc: currentGameAssoc }
);
};
const visible = players
.filter(p => p.gameAssoc !== currentGameAssoc)
.filter(p => p.name.toLowerCase().includes(search.toLowerCase()));
const shown = visible.slice(0, 5);
const hasMore = 5 < visible.length;
// Debug: log if currentGameAssoc is undefined or if current user appears
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 (
<StyledDialog
open={open}
onClose={0 < waitingCountdown ? undefined : onClose}
>
<div className="opd">
<div className="opd-header">
<div className="opd-header-text">
<span className="opd-label">Multiplayer</span>
<h2 className="opd-title">
<i className="fa fa-users" />
Online Players
</h2>
</div>
<div className="opd-header-actions">
<button
className={`opd-refresh${loading ? ' opd-refresh--spin' : ''}`}
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={() => { if (0 === waitingCountdown) onClose(); }}
disabled={0 < waitingCountdown}
aria-label="Close"
>
<i className="fa fa-times" />
</button>
</div>
</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">
<i className="fa fa-spinner fa-spin" />
<p>Loading players…</p>
</div>
)}
{!loading && 0 === shown.length && (
<div className="opd-empty">
<i className="fa fa-hourglass-half" />
<p>
{search
? 'No players match your search.'
: 'No other players are waiting right now.'}
</p>
</div>
)}
{declinedMsg && (
<div className="opd-declined">
<i className="fa fa-times-circle" />
{' '}{declinedMsg}
</div>
)}
{!loading && shown.map(player => {
const isWaiting = challengingGameAssoc === player.gameAssoc;
return (
<div key={player.gameAssoc} className="opd-row">
<div className="opd-avatar">
{player.name.slice(0, 2).toUpperCase()}
</div>
<div className="opd-info">
<span className="opd-name">{player.name}</span>
<span className="opd-since">
<i className="fa fa-clock" />
{' '}Waiting {formatSince(player.since)}
</span>
</div>
<button
className={`opd-join${isWaiting ? ' opd-join--waiting' : ''}`}
onClick={() => handleChallenge(player)}
disabled={!!challengingGameAssoc}
>
<i className={`fa ${isWaiting ? 'fa-spinner fa-spin' : 'fa-play'}`} />
{isWaiting ? 'Waiting...' : 'Join'}
</button>
</div>
);
})}
</div>
{!loading && hasMore && (
<p className="opd-note">
Showing 5 of {visible.length} waiting players
</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: '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

@@ -0,0 +1,104 @@
/**
* 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, useState } from 'react';
import { OnlinePlayersDialog } from '@mine-components';
import { bool, string } from 'prop-types';
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${inviteOnly ? ' waiting-options--invite-only' : ''}`}>
<div className="waiting-option">
<div className="waiting-option-header">
<i className="fa fa-link" />
<span>{inviteHeader}</span>
</div>
<p className="waiting-option-desc">Share this link with your opponent</p>
<ShareLinkBox
url={shareUrl}
/>
</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>
{!inviteOnly && (
<OnlinePlayersDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
currentGameAssoc={currentGameAssoc}
/>
)}
</Fragment>
);
};
const ShareLinkBox = ({ url }) => {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(url)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2500);
})
.catch(() => null);
};
return (
<div className="share-invite">
<div className="share-url-box" onClick={e => e.currentTarget.querySelector('input').select()}>
<i className="fa fa-link share-url-icon" />
<input
className="share-url-input"
readOnly
value={url}
onClick={e => e.target.select()}
/>
</div>
<button className={`share-copy-btn${copied ? ' copied' : ''}`} onClick={handleCopy}>
<i className={`fa ${copied ? 'fa-check' : 'fa-clipboard'}`} />
{copied ? 'Copied!' : 'Copy Link'}
</button>
</div>
);
};
export default WaitingOverlayContent;
WaitingOverlayContent.propTypes = {
shareUrl: string.isRequired,
currentGameAssoc: string,
opponentName: string,
inviteOnly: bool,
};

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, 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 = ({ gameAssoc, onClick, resign }) => {
const {
overlay, overlayTitle, overlaySubTitle,
webPlayer, activePlayer, bombSelected,
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';
if (activeColor !== webPlayer) return;
setCells(prev => {
const next = prev.map(r => r.map(c =>
null !== c.bombTargetArea ? { ...c, bombTargetArea: null } : c,
));
bombRadius(row, col, prev.length, prev[0]?.length ?? 0).forEach(([r, c], i) => {
if (!next[r]?.[c]) return;
next[r] = [...next[r]];
next[r][c] = { ...next[r][c], bombTargetArea: BOMB_SYMBOLS[i] };
});
return next;
});
};
return (
<Fragment>
<GameTimer />
<div className="game-wrapper">
<div className={`game-overlay${overlay ? '' : ' hide'}`}>
<div className="game-overlay-window">
<h1>{overlayTitle}</h1>
{'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
resign={resign}
/>
<div className="grid-container">
<div className="grid">
{cells.flatMap((row, r) =>
row.map((cell, c) => (
<GridField
key={`${r}_${c}`}
cell={cell}
onClick={() => onClick([r, c])}
onMouseEnter={() => handleHover(r, c)}
/>
)),
)}
</div>
</div>
</div>
</Fragment>
);
};
export default GridControl;
GridControl.propTypes = {
gameAssoc: string,
onClick: func.isRequired,
resign: func.isRequired,
};

View File

@@ -0,0 +1,91 @@
/**
* 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, { 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;
const vert = ['left', 'center', 'right'][area[0]] ?? null;
const hor = ['top', 'middle', 'bottom'][area[1]] ?? null;
if (null === vert || null === hor) return IMAGES.bombEmpty;
return IMAGES.bombPos(hor, vert);
};
const GridField = memo(function GridField({ cell, onClick, onMouseEnter }) {
const { currentImage, currentObj, active, lastClickedRed, lastClickedBlue, bombTargetArea } = cell;
const fieldClass = 'field'
+ (active ? ' active' : '')
+ (active && 'm' === currentObj ? ' mine' : '')
+ ' color-' + currentObj;
const bombSourceString = useMemo(() => bombSrc(bombTargetArea), [bombTargetArea]);
return (
<div
className="field-wrapper"
onClick={onClick}
onMouseEnter={onMouseEnter}
>
<img
className="field-target"
src={IMAGES.target}
alt="Field of target"
/>
{bombSourceString && (
<img
className="field-bomb-target"
src={bombSourceString}
alt="Field of bomb target"
/>
)}
{(lastClickedRed || lastClickedBlue) && (
<img
className={`field-${lastClickedRed ? 'red' : 'blue'}-last last-clicked`}
src={IMAGES.last(lastClickedRed ? 'red' : 'blue')}
alt="Last clicked area"
/>
)}
<div className={fieldClass}>
<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="" />
<div className="flag-mine-base" />
</div>
)}
{!isNaN(currentImage) && 0 !== currentImage && (
<div className="flag-number">
{currentImage}
</div>
)}
</div>
</div>
</div>
);
});
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

@@ -0,0 +1,22 @@
/**
* 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 { 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

@@ -0,0 +1,67 @@
/**
* 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, { memo } from 'react';
import { IMAGES } from '@mine-utils';
import { bool, func, number, string } from 'prop-types';
const User = memo(function User(
{
color,
webPlayer,
name,
desc,
active,
mines,
haveBomb,
enabledBomb,
onClickBombSelector,
},
) {
const buzzClass = 'bomb-container'
+ (active && color === webPlayer && haveBomb && enabledBomb ? ' buzz' : '');
return (
<div className={`user-container user-${color}`}>
<div className="user-header">
<div className="user-color">{color}</div>
{active && <img src={IMAGES.cursor(color)} alt="" className="user-cursor" />}
<img src={IMAGES.figure(color)} alt="" />
</div>
<div className="user-name"> {name} </div>
<div className="user-caret"><i className="fa fa-caret-down" /></div>
<div className="user-desc"> {desc} </div>
<div className="user-control">
<img src={IMAGES.flag(color)} alt="" />
<div className="user-control-mines">{mines}</div>
<div className={buzzClass} onClick={onClickBombSelector}>
<div className="bomb">
{haveBomb && <img src={enabledBomb && active ? IMAGES.bomb : IMAGES.bombDisabled} alt="" />}
{!haveBomb && <img src={IMAGES.bombExploded} alt="" />}
</div>
</div>
<div className="clear" />
</div>
</div>
);
});
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

@@ -0,0 +1,76 @@
/**
* 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, 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, 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;
if (p.haveBomb && p.enabledBomb && activePlayer === player) {
onBombToggle();
}
};
const handleBonusClick = () => {
setBonusDialogOpen(true);
};
return (
<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>
<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>
<BonusStatsDialog
open={bonusDialogOpen}
onClose={() => setBonusDialogOpen(false)}
red={red}
blue={blue}
/>
</Fragment>
);
}
export default UserControl;
UserControl.propTypes = {
resign: func.isRequired,
};

View File

@@ -0,0 +1,16 @@
/**
* 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 { createContext, useContext } from 'react';
const GameContext = createContext(null);
export const useGame = () => useContext(GameContext);
export default GameContext;

View File

@@ -0,0 +1,280 @@
/**
* 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, { useRef } from 'react';
import { Howl } from 'howler';
import GameContext from './GameContext';
import { useGameRefs, useGameState } from '@mine-hooks';
import { DESC, IMAGES, patchCells } from '@mine-utils';
export const GameProvider = ({ children }) => {
const {
webPlayerRef,
activePlayerRef,
bombSelectedRef,
connectionLostRef,
redRef,
blueRef,
lastClickedRef,
endRef,
} = useGameRefs();
const {
webPlayer, setWebPlayer,
activePlayer, setActivePlayer,
overlay, setOverlay,
overlayTitle, setOverlayTitle,
overlaySubTitle, setOverlaySubTitle,
mines, setMines,
bombSelected, setBombSelected,
foundMines, setFoundMines,
red, setRed,
blue, setBlue,
cells, setCells,
gridReady, setGridReady,
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'] }),
mine: new Howl({ src: ['/sound/mine.mp3'] }),
warning: new Howl({ src: ['/sound/warning.mp3'] }),
won: new Howl({ src: ['/sound/won.mp3'] }),
starting: new Howl({ src: ['/sound/starting.mp3'] }),
});
/** Sync helpers (keep ref + state in lockstep) */
const syncWebPlayer = v => {
webPlayerRef.current = v;
setWebPlayer(v);
};
const syncActivePlayer = v => {
activePlayerRef.current = v;
setActivePlayer(v);
};
const syncBombSelected = v => {
bombSelectedRef.current = v;
setBombSelected(v);
};
const syncConnLost = v => {
connectionLostRef.current = v;
setConnectionLost(v);
};
const syncRed = fn => {
const n = fn(redRef.current);
redRef.current = n;
setRed(n);
};
const syncBlue = fn => {
const n = fn(blueRef.current);
blueRef.current = n;
setBlue(n);
};
/** Overlay */
const showOverlay = (title, sub) => {
setOverlay(true);
setOverlayTitle(title);
setOverlaySubTitle(sub);
};
const hideOverlay = () => setOverlay(false);
/** Cell helpers */
const applyRevealedCell = (cell, player, isMainCell = false) => {
const { row, col, value } = cell;
setCells(prev => {
if (prev[row][col].active) return prev;
const patch = 'm' === value
? { currentImage: IMAGES.flag(player), currentObj: 'm', active: true }
: { currentImage: value, currentObj: value, active: true };
if (isMainCell) {
patch.lastClickedRed = 'red' === player;
patch.lastClickedBlue = 'blue' === player;
}
return patchCells(prev, [{ row, col, ...patch }]);
});
if (isMainCell) lastClickedRef.current = { ...lastClickedRef.current, [player]: [row, col] };
};
const showLeftMines = (leftMines = []) => {
if (!leftMines.length) return;
setCells(prev => {
const patches = leftMines
.filter(({ row, col }) => !prev[row][col].active)
.map(({ row, col }) => ({ row, col, currentImage: IMAGES.leftMine }));
return patches.length ? patchCells(prev, patches) : prev;
});
};
/** Game logic */
const changePlayer = () => {
const wasColor = activePlayerRef.current ? 'blue' : 'red';
const nextColor = activePlayerRef.current ? 'red' : 'blue';
const nextVal = activePlayerRef.current ? 0 : 1;
const desc = wasColor === webPlayerRef.current ? DESC.buddy : DESC.you;
syncActivePlayer(nextVal);
syncRed(p => ({ ...p, active: 'red' === nextColor, desc: 'red' === nextColor ? desc : '' }));
syncBlue(p => ({ ...p, active: 'blue' === nextColor, desc: 'blue' === nextColor ? desc : '' }));
};
const applyStep = 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();
} else if (0 < minesFound) {
const cur = ('red' === player ? redRef : blueRef).current.mines;
sounds.current[20 < cur + minesFound ? 'warning' : 'mine'].play();
} else {
sounds.current.click.play();
}
const lc = lastClickedRef.current[player];
setCells(prev => {
let next = prev;
if (lc) next = patchCells(next, [{ row: lc[0], col: lc[1], lastClickedRed: false, lastClickedBlue: false }]);
revealedCells.forEach(({ row, col, value }, idx) => {
if (next[row][col].active) return;
const patch = 'm' === value
? { currentImage: IMAGES.flag(player), currentObj: 'm', active: true }
: { currentImage: value, currentObj: value, active: true };
if (0 === idx) {
patch.lastClickedRed = 'red' === player;
patch.lastClickedBlue = 'blue' === player;
}
next = patchCells(next, [{ row, col, ...patch }]);
});
if (isBomb) next = next.map(r => r.map(c => null !== c.bombTargetArea ? { ...c, bombTargetArea: null } : c));
return next;
});
if (0 < revealedCells.length) {
lastClickedRef.current = { ...lastClickedRef.current, [player]: [revealedCells[0].row, revealedCells[0].col] };
}
if (0 < minesFound) {
setMines(51 - rp - bp);
setFoundMines(true);
setTimeout(() => setFoundMines(false), 500);
syncRed(p => ({ ...p, mines: 'red' === player ? rp : p.mines }));
syncBlue(p => ({ ...p, mines: 'blue' === player ? bp : p.mines }));
}
/** Update bonus points and stats */
syncRed(p => ({
...p,
bonusPoints: 'red' === player ? redBonusPoints : p.bonusPoints,
bonusStats: 'red' === player ? redBonusStats : p.bonusStats,
}));
syncBlue(p => ({
...p,
bonusPoints: 'blue' === player ? blueBonusPoints : p.bonusPoints,
bonusStats: 'blue' === player ? blueBonusStats : p.bonusStats,
}));
syncRed(p => ({ ...p, enabledBomb: rp <= bp }));
syncBlue(p => ({ ...p, enabledBomb: bp <= rp }));
if (isBomb || 0 === minesFound) changePlayer();
if (isBomb) {
syncBombSelected(false);
syncRed(p => 'red' === player ? { ...p, haveBomb: false } : p);
syncBlue(p => 'blue' === player ? { ...p, haveBomb: false } : p);
}
};
const makeGameEndIfItEnds = (bluePoints, redPoints, resign = false, leftMines = []) => {
const redWins = 25 < redPoints;
const blueWins = 25 < bluePoints;
if (redWins || blueWins || resign) {
sounds.current.won.play();
if (!resign) {
endRef.current = true;
showOverlay((redWins ? 'Red' : 'Blue') + ' wins the game!', null);
}
showLeftMines(leftMines);
syncActivePlayer(false);
syncRed(p => ({ ...p, desc: '' }));
syncBlue(p => ({ ...p, desc: '' }));
}
};
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!',
);
endRef.current = true;
makeGameEndIfItEnds(0, 0, true);
};
const onBombToggle = () => {
const next = !bombSelectedRef.current;
syncBombSelected(next);
if (!next) {
setCells(prev => prev.map(r => r.map(c => null !== c.bombTargetArea ? { ...c, bombTargetArea: null } : c)));
}
};
return (
<GameContext.Provider
value={{
/** State (for rendering) */
webPlayer, activePlayer, overlay, overlayTitle, overlaySubTitle,
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 */
showOverlay, hideOverlay,
applyRevealedCell, applyStep,
makeGameEndIfItEnds, resignProcess,
/** UI action */
onBombToggle,
/** Sounds */
sounds,
}}
>
{children}
</GameContext.Provider>
);
};

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