Private
Public Access
1
0

Compare commits

...

22 Commits

Author SHA1 Message Date
13adf908bf chg: dev: small changes on docs - and improve text on homepage #8
All checks were successful
Deploy to Production / deploy (push) Successful in 3m14s
2026-04-21 11:47:21 +02:00
3bbfb8740f chg: dev: massive refactor on front-end for unification and readiness #8 2026-04-21 11:30:07 +02:00
0d04ec91e7 fix: usr: do not hide the end-game overlay ever #8 2026-04-21 08:48:44 +02:00
20a969705d chg: dev: update all doc blocks on back-end #8 2026-04-20 21:24:39 +02:00
4944d2aa21 chg: dev: small refactors on back-end #8 2026-04-20 21:11:17 +02:00
2ec37a802b chg: dev: add RecentBattle entity that is a Materialized View to speed up the view - and further refactor on ProfileController #8 2026-04-20 21:08:15 +02:00
6a5ba84b5e chg: dev: create the UserStats entity what is a Materialized View to store Profile stats for every user - & massive ProfileController refactor #8 2026-04-20 20:44:33 +02:00
6be0d52fb7 chg: dev: refactor the SecurityController #7 2026-04-20 12:13:08 +02:00
f493f94368 chg: dev: refactor the code - there was unnecessary codes and wrongly formatted or designed code that are related to Repositories #7 2026-04-20 11:10:00 +02:00
cd93a26c2c fix: usr: the username was not recognized properly #7 2026-04-20 10:50:58 +02:00
175581cdd5 chg: pkg: upgrade the doctrine related back-end pkgs to the latest available version #7 2026-04-20 09:05:36 +02:00
5f856e4d70 chg: usr: add filter to the Profile page's recent plays and an infite list too #7 2026-04-19 22:11:58 +02:00
e0495d182e chg: pkg: upgrade to the latest doctrine pkg on back-end #7 2026-04-19 22:09:03 +02:00
0b7c1406cf chg: pkg: new version release !skipChangelog 2026-04-19 21:41:33 +02:00
30edc5782b fix: usr: the PostgreSQL logo was horrible #7
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-04-19 21:41:04 +02:00
d92a7f3aa0 chg: pkg: new version release !skipChangelog 2026-04-19 21:31:44 +02:00
f72cd45afd chg: pkg: new version release !skipChangelog
All checks were successful
Deploy to Production / deploy (push) Successful in 15s
2026-04-19 21:31:22 +02:00
51bd909879 chg: usr: add ReCaptcha overlay again to protect the game #7 2026-04-19 21:31:08 +02:00
db37ab45b2 chg: pkg: upgrade all front-end packages to the latest available version - and fix all eslint warnings & errors #7 2026-04-19 21:04:15 +02:00
9256db7f8c chg: pkg: upgrade fe packages #7 2026-04-19 20:57:00 +02:00
d9059acb78 chg: dev: massive refactor on fetches - create centralized dataProvider #7 2026-04-19 20:56:51 +02:00
5da8a04c18 chg: pkg: new version release !skipChangelog 2026-04-19 18:33:18 +02:00
126 changed files with 3914 additions and 2857 deletions

View File

@@ -1,6 +1,64 @@
# Changelog
## 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
main div.txt {
color: rgba(255, 255, 255, 0.85);
max-width: 900px;

View File

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

View File

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

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
footer {
background: #040608;
border-top: 1px solid rgba(35, 111, 135, 0.12);

View File

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

View File

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

View File

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

View File

@@ -427,10 +427,88 @@
}
}
.profile-games__filter-wrap {
position: relative;
display: flex;
align-items: center;
margin-bottom: 4px;
}
.profile-games__filter-icon {
position: absolute;
left: 14px;
font-size: 12px;
color: rgba(149, 207, 245, 0.4);
pointer-events: none;
}
.profile-games__filter {
width: 100%;
background: rgba(255, 255, 255, 0.025);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 6px;
padding: 9px 14px 9px 36px;
font: 500 13px 'Rajdhani', sans-serif;
color: #fff;
letter-spacing: 0.5px;
transition: border-color 200ms ease, background 200ms ease;
&::placeholder {
color: rgba(255, 255, 255, 0.3);
}
&:focus {
outline: none;
background: rgba(255, 255, 255, 0.045);
border-color: rgba(35, 111, 135, 0.55);
}
}
.profile-games {
display: flex;
flex-direction: column;
gap: 6px;
&.is-filtering + .profile-games__load-more {
display: none;
}
&.is-filtering .profile-game--hidden:not(.profile-game--filtered-out) {
display: grid;
}
.profile-game--filtered-out {
display: none;
}
&__load-more {
align-self: center;
margin-top: 14px;
background: rgba(35, 111, 135, 0.12);
color: rgba(149, 207, 245, 0.75);
border: 1px solid rgba(35, 111, 135, 0.3);
border-radius: 6px;
padding: 9px 20px;
font: 600 12px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 1.5px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: background 200ms ease, border-color 200ms ease, color 200ms ease;
i {
font-size: 11px;
opacity: 0.8;
}
&:hover {
background: rgba(35, 111, 135, 0.22);
border-color: rgba(35, 111, 135, 0.55);
color: rgba(149, 207, 245, 1);
}
}
}
.profile-game {
@@ -469,6 +547,10 @@
border-left-color: rgba(255, 193, 7, 0.4);
opacity: 0.85;
}
&--hidden {
display: none;
}
}
.profile-game__badge {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
#mine-wrapper .game-wrapper .game-overlay {
background: rgba(255, 255, 255, 0.2);
display: flex;
@@ -676,3 +685,96 @@
}
}
// CaptchaOverlay Styles
.captcha-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(7, 9, 13, 0.95);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.captcha-content {
text-align: center;
color: #fff;
max-width: 400px;
padding: 40px;
}
.captcha-icon {
font-size: 64px;
color: #236f87;
margin-bottom: 24px;
}
.captcha-title {
font: 800 32px 'Rajdhani', sans-serif;
margin: 0 0 16px;
letter-spacing: 1px;
}
.captcha-description {
color: rgba(149, 207, 245, 0.7);
font: 400 16px 'Rajdhani', sans-serif;
margin: 0 0 32px;
letter-spacing: 0.5px;
}
.captcha-button {
background: linear-gradient(#236f87 0%, #1a5068 100%);
border: 2px solid #2e7a9a;
border-radius: 8px;
color: #e0f4ff;
cursor: pointer;
font: 800 18px 'Rajdhani', sans-serif;
letter-spacing: 2px;
padding: 16px 40px;
text-transform: uppercase;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 12px;
opacity: 1;
i {
font-size: 16px;
}
&:disabled {
opacity: 0.7;
cursor: wait;
}
&:hover:not(:disabled) {
background: linear-gradient(#2d8aa8 0%, #236f87 100%);
border-color: #5ba4d4;
color: #fff;
box-shadow: 0 8px 24px rgba(35, 111, 135, 0.4);
transform: translateY(-2px);
}
&:active:not(:disabled) {
transform: translateY(0);
}
&.captcha-button--error {
background: linear-gradient(#8a2323 0%, #681a1a 100%);
border-color: #9a2e2e;
&:hover {
background: linear-gradient(#a82d2d 0%, #872323 100%);
border-color: #d45b5b;
box-shadow: 0 8px 24px rgba(135, 35, 35, 0.4);
}
}
&.captcha-button--loading {
opacity: 0.7;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
* Copyright (c) 2019 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -1,7 +1,7 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
* Copyright (c) 2019 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

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

View File

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

View File

@@ -1,34 +1,21 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React, { useEffect, useState } from 'react';
import { array } from 'prop-types';
import { formatDuration } from '@global-utils/format';
import Dialog from '@mui/material/Dialog';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import Avatar from './battle-dialog/Avatar';
import StatRow from './battle-dialog/StatRow';
import BonusPoints from './battle-dialog/BonusPoints';
import { createTheme, styled, ThemeProvider } from '@mui/material/styles';
import { Avatar, BonusPoints, StatRow } from '@global-components';
const darkTheme = createTheme({ palette: { mode: 'dark' } });
const DIALOG_SX = {
'& .MuiDialog-paper': {
background: '#07090d',
backgroundImage: `
linear-gradient(rgba(35, 111, 135, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(35, 111, 135, 0.08) 1px, transparent 1px)
`,
backgroundSize: '46px 46px',
border: '1px solid rgba(35, 111, 135, 0.4)',
borderRadius: '12px',
boxShadow: '0 0 80px rgba(35, 111, 135, 0.15), 0 32px 80px rgba(0,0,0,0.9)',
width: '580px',
maxWidth: '94vw',
overflow: 'hidden',
color: '#fff',
},
'& .MuiBackdrop-root': {
background: 'rgba(2, 4, 8, 0.88)',
backdropFilter: 'blur(4px)',
},
};
const RESULT_META = {
win: {
label: 'Victory',
@@ -53,7 +40,7 @@ const RESULT_META = {
},
};
export default function BattleDialog({ games }) {
export const BattleDialog = ({ games }) => {
const [open, setOpen] = useState(false);
const [game, setGame] = useState(null);
const [copied, setCopied] = useState(false);
@@ -73,7 +60,7 @@ export default function BattleDialog({ games }) {
}, [games]);
if (!game) {
return <ThemeProvider theme={darkTheme}><Dialog open={false} sx={DIALOG_SX} /></ThemeProvider>;
return <ThemeProvider theme={darkTheme}><StyledDialog open={false} /></ThemeProvider>;
}
const meta = RESULT_META[game.result] ?? RESULT_META.draw;
@@ -86,18 +73,6 @@ export default function BattleDialog({ games }) {
const canContinue = !resign && 26 > maxPoints;
const playUrl = `${window.location.origin}/play/${game.uuid}`;
const formatDuration = (from, to) => {
if (!from || !to) return null;
const diffMs = new Date(to.replace(' ', 'T')) - new Date(from.replace(' ', 'T'));
if (isNaN(diffMs) || 0 >= diffMs) return null;
const totalSec = Math.floor(diffMs / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
if (0 < h) return `${h}h ${m}m ${s}s`;
if (0 < m) return `${m}m ${s}s`;
return `${s}s`;
};
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'
@@ -113,7 +88,7 @@ export default function BattleDialog({ games }) {
return (
<ThemeProvider theme={darkTheme}>
<Dialog open={open} onClose={() => setOpen(false)} sx={DIALOG_SX}>
<StyledDialog open={open} onClose={() => setOpen(false)}>
<div className="bd">
<div className="bd-header">
<div className="bd-header-left">
@@ -122,7 +97,7 @@ export default function BattleDialog({ games }) {
<i className="fa fa-crosshairs" /> Match Details
</h2>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<div className="bd-header-actions">
{canContinue ? (
<a
className="bd-continue"
@@ -160,33 +135,19 @@ export default function BattleDialog({ games }) {
<span className="bd-vs-score__sep">:</span>
<span className="bd-vs-score__blue">{game.bluePoints ?? '—'}</span>
</div>
<div className="bd-vs-score" style={{ marginBottom: 8 }}>
<span style={{
font: '700 13px \'Rajdhani\', sans-serif',
color: '#f67d52',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
<i className="fa fa-star" style={{ fontSize: 11 }} /> {(game.redBonusPoints ?? 0).toFixed(1)}
<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 style={{
font: '700 13px \'Rajdhani\', sans-serif',
color: '#95cff5',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
{(game.blueBonusPoints ?? 0).toFixed(1)} <i className="fa fa-star" style={{ fontSize: 11 }} />
<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={{ background: meta.bg, border: `1px solid ${meta.border}`, color: meta.color }}
style={{ '--bd-result-bg': meta.bg, '--bd-result-border': meta.border, '--bd-result-color': meta.color }}
>
<i className={`fa ${meta.icon}`} /> {meta.label}
</div>
@@ -226,7 +187,33 @@ export default function BattleDialog({ games }) {
game={game}
/>
</div>
</Dialog>
</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

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

View File

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

View File

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

View File

@@ -1,8 +1,18 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React from 'react';
import { BarChart } from '@mui/x-charts/BarChart';
import { LineChart } from '@mui/x-charts/LineChart';
import { PieChart } from '@mui/x-charts/PieChart';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import { shape, arrayOf, number, string } from 'prop-types';
const darkTheme = createTheme({
palette: {
@@ -136,3 +146,20 @@ export default function ProfileCharts({ chartData }) {
</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

@@ -7,92 +7,48 @@
* file that was distributed with this source code.
*/
import React from 'react';
import React, { useMemo } from 'react';
import { string } from 'prop-types';
export default function Avatar({ name, color, avatarUrl, bonusPoints = 0 }) {
export const Avatar = ({ name, color, avatarUrl, bonusPoints = 0 }) => {
const isRed = 'red' === color;
const initials = (name || '?').slice(0, 2).toUpperCase();
const initials = useMemo(() => (name || '?').slice(0, 2).toUpperCase(), [name]);
const gradient = isRed
? 'linear-gradient(135deg, rgba(173,10,5,0.6) 0%, rgba(246,125,82,0.4) 100%)'
: 'linear-gradient(135deg, rgba(35,111,135,0.6) 0%, rgba(41,128,185,0.4) 100%)';
const glow = isRed
? '0 0 0 3px rgba(173,10,5,0.2), 0 0 28px rgba(173,10,5,0.35)'
: '0 0 0 3px rgba(35,111,135,0.2), 0 0 28px rgba(35,111,135,0.35)';
const border = isRed
? 'rgba(173,10,5,0.5)'
: 'rgba(35,111,135,0.5)';
const textColor = isRed ? '#f67d52' : '#95cff5';
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 style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, position: 'relative' }}>
<div style={{ position: 'relative' }}>
<div style={{
width: 72, height: 72, borderRadius: '50%',
background: avatarUrl ? 'transparent' : gradient,
border: `2px solid ${border}`,
boxShadow: glow,
display: 'flex', alignItems: 'center', justifyContent: 'center',
font: '800 24px \'Rajdhani\', sans-serif',
color: textColor,
letterSpacing: 2,
overflow: 'hidden',
}}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt={name}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
initials
)}
<div 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 style={{
position: 'absolute',
bottom: -6,
right: -6,
background: '#ffd700',
borderRadius: '50%',
width: 28,
height: 28,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '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)',
zIndex: 10,
}}
>
<i className="fa fa-star" style={{ color: '#000', fontSize: 14 }} />
<div className="bd-avatar-bonus">
<i className="fa fa-star" />
</div>
)}
</div>
<span style={{
font: '700 15px \'Rajdhani\', sans-serif',
color: textColor,
letterSpacing: 1,
maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
textAlign: 'center',
}}
>
{name}
</span>
<span style={{
font: '600 10px \'Rajdhani\', sans-serif',
textTransform: 'uppercase',
letterSpacing: 2,
color: 'rgba(255,255,255,0.3)',
}}
>
{isRed ? 'Red' : 'Blue'}
</span>
<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

@@ -1,7 +1,17 @@
import { useMemo } from 'react';
import StatRow from './StatRow';
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
export default function BonusPoints({ game }) {
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
@@ -45,95 +55,68 @@ export default function BonusPoints({ game }) {
],
);
if (!hasBonuspoints) {
return '';
}
if (!hasBonuspoints) return '';
return (
<div style={{
padding: '16px 20px 0',
borderTop: '1px solid rgba(255,255,255,0.08)',
marginTop: 16,
marginBottom: 16,
}}
>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
{/* Red Bonus */}
<div style={{
padding: 16,
border: '1px solid rgba(173,10,5,0.2)',
borderRadius: 6,
background: 'rgba(173,10,5,0.05)',
}}
>
<span style={{
font: '700 12px \'Rajdhani\', sans-serif',
textTransform: 'uppercase',
letterSpacing: 2,
color: '#ffd700',
display: 'block',
marginBottom: 12,
}}
>
<i className="fa fa-star" style={{ marginRight: 8 }} /> Red Bonus Statistics
<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 style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
<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 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 style={{
padding: 16,
border: '1px solid rgba(149,207,245,0.2)',
borderRadius: 6,
background: 'rgba(149,207,245,0.05)',
}}
>
<span style={{
font: '700 12px \'Rajdhani\', sans-serif',
textTransform: 'uppercase',
letterSpacing: 2,
color: '#ffd700',
display: 'block',
marginBottom: 12,
}}
>
<i className="fa fa-star" style={{ marginRight: 8 }} /> Blue Bonus Statistics
<div className="bd-bonus__column bd-bonus__column--blue">
<span className="bd-bonus__heading">
<i className="fa fa-star" /> Blue Bonus Statistics
</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
<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 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

@@ -8,33 +8,24 @@
*/
import React from 'react';
import { node, string } from 'prop-types';
export default function StatRow({ icon, label, value, valueColor }) {
return (
<div style={{
display: 'flex', alignItems: 'center',
gap: 10, padding: '9px 0',
borderBottom: '1px solid rgba(255,255,255,0.05)',
}}
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}
>
<i className={`fa ${icon}`} style={{ width: 16, color: 'rgba(149,207,245,0.4)', fontSize: 13 }} />
<span style={{
font: '500 13px \'Rajdhani\', sans-serif',
color: 'rgba(255,255,255,0.45)',
flex: 1,
letterSpacing: 0.5,
}}
>
{label}
</span>
<span style={{
font: '700 13px \'Rajdhani\', sans-serif',
color: valueColor || 'rgba(255,255,255,0.75)',
letterSpacing: 0.5,
}}
>
{value}
</span>
</div>
);
}
{value}
</span>
</div>
);
StatRow.propTypes = {
icon: string.isRequired,
label: string.isRequired,
value: node.isRequired,
valueColor: string,
};

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ 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();
@@ -34,3 +35,9 @@ const MineSeeker = ({ env, gameId, opponentName = '' }) => {
};
export default MineSeeker;
MineSeeker.propTypes = {
env: string.isRequired,
gameId: string,
opponentName: string,
};

View File

@@ -8,6 +8,7 @@
*/
import React from 'react';
import { func, number, string } from 'prop-types';
const BonusBox = ({ color, points, onClick, title }) => (
<button
@@ -23,3 +24,10 @@ const BonusBox = ({ color, points, onClick, title }) => (
);
export default BonusBox;
BonusBox.propTypes = {
color: string.isRequired,
points: number.isRequired,
onClick: func.isRequired,
title: string,
};

View File

@@ -9,67 +9,12 @@
import React from 'react';
import Dialog from '@mui/material/Dialog';
import { BONUS_LABELS } from '@mine-utils';
const DIALOG_SX = {
'& .MuiDialog-paper': {
background: '#07090d',
backgroundImage: `
linear-gradient(rgba(35, 111, 135, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(35, 111, 135, 0.08) 1px, transparent 1px)
`,
backgroundSize: '46px 46px',
border: '1px solid rgba(35, 111, 135, 0.4)',
borderRadius: '12px',
boxShadow: '0 0 80px rgba(35, 111, 135, 0.15), 0 32px 80px rgba(0, 0, 0, 0.9)',
width: '560px',
maxWidth: '94vw',
overflow: 'hidden',
color: '#fff',
},
'& .MuiBackdrop-root': {
background: 'rgba(2, 4, 8, 0.88)',
backdropFilter: 'blur(4px)',
},
};
const formatPlayerName = name => {
if (name && name.startsWith('anon_')) {
return 'Anonymous';
}
if (name && 10 < name.length) {
return name.substring(0, 7) + '...';
}
return name || 'Unknown';
};
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>
);
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 }) => (
<Dialog open={open} onClose={onClose} sx={DIALOG_SX}>
<StyledDialog open={open} onClose={onClose}>
<div className="bsd">
<div className="bsd-header">
<div className="bsd-header-text">
@@ -91,7 +36,44 @@ const BonusStatsDialog = ({ open, onClose, red, blue }) => (
Bonus points are awarded alongside the main score for skillful play.
</p>
</div>
</Dialog>
</StyledDialog>
);
const StyledDialog = styled(Dialog)({
'& .MuiDialog-paper': {
background: '#07090d',
backgroundImage: `
linear-gradient(rgba(35, 111, 135, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(35, 111, 135, 0.08) 1px, transparent 1px)
`,
backgroundSize: '46px 46px',
border: '1px solid rgba(35, 111, 135, 0.4)',
borderRadius: '12px',
boxShadow: '0 0 80px rgba(35, 111, 135, 0.15), 0 32px 80px rgba(0, 0, 0, 0.9)',
width: '560px',
maxWidth: '94vw',
overflow: 'hidden',
color: '#fff',
},
'& .MuiBackdrop-root': {
background: 'rgba(2, 4, 8, 0.88)',
backdropFilter: 'blur(4px)',
},
});
export default BonusStatsDialog;
BonusStatsDialog.propTypes = {
open: bool.isRequired,
onClose: func.isRequired,
red: shape({
name: string,
bonusPoints: number,
bonusStats: object,
}).isRequired,
blue: shape({
name: string,
bonusPoints: number,
bonusStats: object,
}).isRequired,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@
*/
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);
@@ -94,3 +95,10 @@ const ShareLinkBox = ({ url }) => {
};
export default WaitingOverlayContent;
WaitingOverlayContent.propTypes = {
shareUrl: string.isRequired,
currentGameAssoc: string,
opponentName: string,
inviteOnly: bool,
};

View File

@@ -13,6 +13,7 @@ import GridField from './GridField';
import UserControl from '../user/UserControl';
import GameTimer from '../GameTimer';
import { BOMB_SYMBOLS, bombRadius } from '@mine-utils';
import { func, string } from 'prop-types';
const GridControl = ({ gameAssoc, onClick, resign }) => {
const {
@@ -22,12 +23,14 @@ const GridControl = ({ gameAssoc, onClick, resign }) => {
} = useGame();
const [copied, setCopied] = useState(false);
const shareUrl = gameAssoc ? `${window.location.origin}/battle/${gameAssoc}` : null;
const shareUrl = gameAssoc ? `${window.location.origin}/play/${gameAssoc}` : null;
const endShareUrl = gameAssoc ? `${window.location.origin}/battle/${gameAssoc}` : null;
const isAuthenticated = '1' === document.getElementById('mine-wrapper')?.dataset.isAuthenticated;
const handleShare = () => {
if (!shareUrl) return;
navigator.clipboard.writeText(shareUrl).then(() => {
const url = endRef.current ? endShareUrl : shareUrl;
if (!url) return;
navigator.clipboard.writeText(url).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2200);
});
@@ -59,10 +62,11 @@ const GridControl = ({ gameAssoc, onClick, resign }) => {
<div className={`game-overlay${overlay ? '' : ' hide'}`}>
<div className="game-overlay-window">
<h1>{overlayTitle}</h1>
{'string' === typeof overlaySubTitle ? (
{'string' === typeof overlaySubTitle && (
<h2>{overlaySubTitle}</h2>
) : (
overlaySubTitle
)}
{'string' !== typeof overlaySubTitle && (
<Fragment>{overlaySubTitle}</Fragment>
)}
{gameAssoc && endRef.current && (
<div className="game-overlay-actions">
@@ -111,3 +115,9 @@ const GridControl = ({ gameAssoc, onClick, resign }) => {
};
export default GridControl;
GridControl.propTypes = {
gameAssoc: string,
onClick: func.isRequired,
resign: func.isRequired,
};

View File

@@ -9,6 +9,7 @@
import React, { memo, useMemo } from 'react';
import { IMAGES } from '@mine-utils';
import { func, shape, bool, number, string } from 'prop-types';
const bombSrc = area => {
if (null === area) return null;
@@ -75,3 +76,16 @@ const GridField = memo(function GridField({ cell, onClick, onMouseEnter }) {
});
export default GridField;
GridField.propTypes = {
cell: shape({
currentImage: string,
currentObj: string,
active: bool,
lastClickedRed: bool,
lastClickedBlue: bool,
bombTargetArea: number,
}).isRequired,
onClick: func.isRequired,
onMouseEnter: func.isRequired,
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,13 +11,15 @@ import React, { Fragment, useState } from 'react';
import { useGame } from '@mine-contexts';
import User from './User';
import BonusStatsDialog from '../BonusStatsDialog';
import { func } from 'prop-types';
const UserControl = ({ resign }) => {
const { webPlayer, activePlayer, mines, foundMines, red, blue, onBombToggle } = useGame();
const { webPlayer, activePlayer, foundMines, red, blue, onBombToggle } = useGame();
const [bonusDialogOpen, setBonusDialogOpen] = useState(false);
const activeColor = activePlayer ? 'blue' : 'red';
const resignClass = 'resign' + (activeColor !== webPlayer ? ' disabled' : '');
const minesClass = 'active-mines' + (foundMines ? ' found-mine' : '');
const remainingMines = 51 - red.mines - blue.mines;
const handleBombClick = (color, player) => {
const p = 'red' === color ? red : blue;
@@ -41,7 +43,7 @@ const UserControl = ({ resign }) => {
<div className="active-mines-container">
<i className="fa fa-star" />
<div className={minesClass}>
<div className="active-mines-nbr">{mines}</div>
<div className="active-mines-nbr">{remainingMines}</div>
<div className="active-mines-shine" />
</div>
<i className="fa fa-star" />
@@ -68,3 +70,7 @@ const UserControl = ({ resign }) => {
}
export default UserControl;
UserControl.propTypes = {
resign: func.isRequired,
};

View File

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

View File

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

View File

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

View File

@@ -8,11 +8,10 @@
*/
import React, { useEffect, useRef } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useGame } from '@mine-contexts';
import { DESC, IMAGES } from '@mine-utils';
import useStepTimer from './useStepTimer';
import { ChallengeCountdown, WaitingOverlayContent } from '@mine-components';
import { useGameDataProvider, useStepTimer } from '@mine-hooks';
const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev) => {
const {
@@ -28,6 +27,17 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
cells,
} = useGame();
/** Get all API queries and mutations from data provider */
const {
connectQuery,
startMutation,
joinMutation,
stepMutation,
heartbeatMutation,
challengeRespondMutation,
leaveMutation,
} = useGameDataProvider(gameAssoc);
const eventSourceRef = useRef(null);
const rpcUsersRef = useRef(null);
const stepCacheRef = useRef([]);
@@ -42,40 +52,6 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
const HEARTBEAT_INTERVAL_MS = 1500;
/** REST mutations / queries */
const connectQuery = useQuery({
queryKey: ['game-connect', gameAssoc],
queryFn: () => fetch('/api/game/connect/' + gameAssoc)
.then(r => r.text())
.then(b64 => JSON.parse(window.atob(b64))),
enabled: false,
retry: false,
});
const startMutation = useMutation({
mutationFn: () => fetch('/api/game/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gameAssoc }),
}),
});
const joinMutation = useMutation({
mutationFn: () => fetch('/api/game/join/' + gameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}).catch(e => isEnvDev && console.error('Join error', e)),
});
const stepMutation = useMutation({
mutationFn: dataPack => fetch('/api/game/step/' + gameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dataPack),
}).then(r => r.json()),
});
/** Game-start helpers (triggered by server events) */
const wInit = (revealedCells = [], lastStep = {}, gameState = {}, isGameFinished = false) => {
@@ -176,7 +152,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
overlayTitle = 'Choose an opponent!';
overlaySubtitle = gameAssoc ? (
<WaitingOverlayContent
shareUrl={`${window.location.origin}/battle/${gameAssoc}`}
shareUrl={`${window.location.origin}/play/${gameAssoc}`}
currentGameAssoc={gameAssoc}
/>
) : '';
@@ -188,7 +164,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
Promise.resolve().then(() => setGridReady(true));
};
const makeGameStart = (payload, lastStep = {}) => {
const makeGameStart = payload => {
/** Don't start a finished game */
if (isGameFinishedRef.current) {
return;
@@ -234,7 +210,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
* For a truly restored game, keep the "Waiting for opponent..." overlay
* up until we actually see a heartbeat from the other player.
*/
if (!isTrueRestoredRef.current || 0 !== opponentLastSeenRef.current) {
if (!endRef.current && (!isTrueRestoredRef.current || 0 !== opponentLastSeenRef.current)) {
hideOverlay();
}
};
@@ -242,11 +218,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
const publishHeartbeat = () => {
const me = webPlayerRef.current;
if (!me || endRef.current) return;
fetch('/api/game/heartbeat/' + gameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ color: me }),
}).catch(e => isEnvDev && console.warn('Heartbeat publish failed', e));
heartbeatMutation.mutate(me);
};
const startHeartbeat = () => {
@@ -264,7 +236,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
/** Mercure / SSE message handlers */
const wSubscribe = (payload, rpcUsers = null, lastStep = null) => {
const wSubscribe = (payload, rpcUsers = null) => {
isEnvDev && console.info((payload.user ?? 'user') + ' subscribed');
const firstUser = !rpcUsers;
@@ -279,7 +251,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
&& (!connectionLostRef.current
|| (connectionLostRef.current && false === activePlayerRef.current && !endRef.current))
) {
makeGameStart(payload, lastStep);
makeGameStart(payload);
}
};
@@ -315,31 +287,31 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
const handleAccept = () => {
clearTimeout(declineTimeout);
fetch('/api/game/challenge/respond/' + challengerGameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accepted: true, targetGameAssoc: gameAssoc }),
}).then(() => {
showOverlay('Challenge accepted!', 'Waiting for the challenger to join...');
}).catch(() => {
});
challengeRespondMutation.mutate(
{ challengerGameAssoc, accepted: true, targetGameAssoc: gameAssoc },
{
onSuccess: () => {
showOverlay('Challenge accepted!', 'Waiting for the challenger to join...');
},
},
);
};
const handleDecline = () => {
clearTimeout(declineTimeout);
fetch('/api/game/challenge/respond/' + challengerGameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accepted: false, targetGameAssoc: gameAssoc }),
}).then(() => {
showOverlay('We are waiting for your opponent...', gameAssoc ? (
<WaitingOverlayContent
shareUrl={window.location.origin + '/play/' + gameAssoc}
currentGameAssoc={gameAssoc}
/>
) : '');
}).catch(() => {
});
challengeRespondMutation.mutate(
{ challengerGameAssoc, accepted: false, targetGameAssoc: gameAssoc },
{
onSuccess: () => {
showOverlay('We are waiting for your opponent...', gameAssoc ? (
<WaitingOverlayContent
shareUrl={`${window.location.origin}/play/${gameAssoc}`}
currentGameAssoc={gameAssoc}
/>
) : '');
},
},
);
};
declineTimeout = setTimeout(handleDecline, 30000);
@@ -393,7 +365,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
if (me && payload.color && payload.color !== me) {
const wasFirst = 0 === opponentLastSeenRef.current;
opponentLastSeenRef.current = Date.now();
if (wasFirst && isTrueRestoredRef.current) {
if (wasFirst && isTrueRestoredRef.current && !endRef.current) {
hideOverlay();
}
}
@@ -404,7 +376,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
if (undefined !== payload.data) {
wTopic(payload);
} else if (undefined === payload.msg) {
wSubscribe(payload, rpcUsersRef.current, lastStepRef.current);
wSubscribe(payload, rpcUsersRef.current);
} else {
wUnsubscribe(payload);
}
@@ -423,7 +395,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
const subscriberJwt = wrapper.dataset.mercureSubscriberJwt;
const url = new URL(hubUrl, window.location.origin);
url.searchParams.append('topic', 'mineseeker/channel/' + gameAssoc);
url.searchParams.append('topic', `mineseeker/channel/${gameAssoc}`);
if (subscriberJwt) url.searchParams.append('authorization', subscriberJwt);
if (eventSourceRef.current) eventSourceRef.current.close();
@@ -452,6 +424,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
openEventSource();
return;
}
try {
if (gameInherited) {
const serverData = await connectQuery.refetch().then(r => {
@@ -483,7 +456,12 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
/** Open event source after showing overlay */
openEventSource();
} else {
await startMutation.mutateAsync();
const startResponse = await startMutation.mutateAsync();
if (!startResponse?.success) {
showOverlay('Error', 'Failed to start game. Please try again.');
isEnvDev && console.error('Start game failed:', startResponse);
return;
}
openEventSource();
wInit();
}
@@ -493,11 +471,14 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
startHeartbeat();
} catch (e) {
isEnvDev && console.error('Connection error', e);
showOverlay('Error', 'Connection failed. Please try again.');
setTimeout(() => window.location.reload(), 500);
}
})();
window.addEventListener('pagehide', () => navigator.sendBeacon('/api/game/leave/' + gameAssoc));
window.addEventListener('pagehide', () => {
leaveMutation.mutate();
});
return () => {
stopHeartbeat();
@@ -525,9 +506,11 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
try {
const result = await stepMutation.mutateAsync(dataPack);
applyStep(result);
if (result.uuid && !endRef.current) {
setGameUuid(result.uuid);
}
makeGameEndIfItEnds(result.bluePoints, result.redPoints, false, result.leftMines);
} catch (e) {
isEnvDev && console.error('Step error', e);
@@ -537,6 +520,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
const clickResign = () => {
const color = activePlayerRef.current ? 'blue' : 'red';
const stepElapsed = getStepElapsed(activePlayerRef.current, isGameRunningRef.current);
stepMutation.mutate(
{ resign: color, stepElapsed },
{
@@ -551,7 +535,9 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
const resign = () => {
const activeColor = activePlayerRef.current ? 'blue' : 'red';
if (webPlayerRef.current !== activeColor) return;
showOverlay('Are u sure u want to resign?!', (
<div className="resign">
<a onClick={clickResign}>Yes</a>

View File

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

View File

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

View File

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

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

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

158
bun.lock
View File

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

View File

@@ -11,13 +11,13 @@
"private": true,
"require": {
"php": ">=8.5",
"ext-gd": "*",
"ext-iconv": "*",
"ext-json": "*",
"ext-gd": "*",
"doctrine/dbal": "^3.7",
"doctrine/doctrine-bundle": ">=2.11 <2.14",
"doctrine/doctrine-migrations-bundle": "^3.0",
"doctrine/orm": "^2.6",
"doctrine/dbal": "^4.3",
"doctrine/doctrine-bundle": "^3.2",
"doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.5",
"endroid/qr-code": "^6.1",
"firebase/php-jwt": "^7.0",
"league/flysystem-aws-s3-v3": "^3.0",

544
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -22,7 +22,7 @@
"@fortawesome/fontawesome-free": "^7.2.0",
"@mui/material": "^9.0.0",
"@mui/x-charts": "^9.0.2",
"@tanstack/react-query": "^5.99.0",
"@tanstack/react-query": "^5.99.2",
"howler": "^2.2.4",
"lodash": "^4.18.1",
"prop-types": "^15.8.1",
@@ -31,13 +31,13 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^9.39.4",
"@stylistic/eslint-plugin": "^4.4.1",
"@eslint/js": "10.0.1",
"@stylistic/eslint-plugin": "5.10.0",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint": "10.2.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"globals": "^15.15.0",
"eslint-plugin-react-hooks": "7.1.1",
"globals": "17.5.0",
"sass": "^1.99.0",
"vite": "^8.0.8",
"vite-plugin-symfony": "^8.2.4"

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@@ -32,7 +32,7 @@ use Symfony\Component\Routing\Attribute\Route;
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 09.
* @since 2019. 10. 27.
*/
#[AsController]
class GameController extends AbstractController
@@ -91,7 +91,7 @@ class GameController extends AbstractController
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$contactMessage->setIpAddress($request->getClientIp());
$contactMessage->ipAddress = $request->getClientIp();
$em->persist($contactMessage);
$em->flush();

View File

@@ -2,7 +2,7 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2019 @ www.splendidbear.org
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -15,6 +15,8 @@ use App\Repository\PlayedGameRepository;
use App\Service\ResolveUserNamesService;
use App\Util\RpcManager;
use App\Util\TopicManager;
use DateTimeInterface;
use Exception;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
@@ -49,10 +51,17 @@ class MercureController extends AbstractController
#[Route('/api/game/start', name: 'MineSeekerBundle_api_game_start', methods: ['POST'])]
public function start(Request $request): JsonResponse
{
$data = $request->toArray();
$result = $this->rpcManager->saveGrid($data['gameAssoc']);
try {
$data = $request->toArray();
$result = $this->rpcManager->saveGrid($data['gameAssoc']);
return $this->json(['success' => $result]);
return $this->json(['success' => $result]);
} catch (Exception $e) {
return $this->json(
['success' => false, 'error' => 'Failed to start game: ' . $e->getMessage()],
Response::HTTP_INTERNAL_SERVER_ERROR
);
}
}
#[Route('/api/game/connect/{gameAssoc}', name: 'MineSeekerBundle_api_game_connect', methods: ['GET'])]
@@ -61,7 +70,7 @@ class MercureController extends AbstractController
try {
$payload = $this->rpcManager->getConnectInformation($gameAssoc);
return new Response($payload, Response::HTTP_OK, ['Content-Type' => 'text/plain']);
} catch (\Exception $e) {
} catch (Exception $e) {
return new Response('', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
@@ -120,9 +129,11 @@ class MercureController extends AbstractController
{
$data = $request->toArray();
$color = $data['color'] ?? '';
if ('red' !== $color && 'blue' !== $color) {
return $this->json(['success' => false], Response::HTTP_BAD_REQUEST);
}
$this->topicManager->publishHeartbeat($gameAssoc, $color);
return $this->json(['success' => true]);
@@ -135,16 +146,16 @@ class MercureController extends AbstractController
$result = array_map(static function (PlayedGame $g): array {
$name = match (true) {
null !== $g->getRed() => $g->getRed()->getUsername(),
null !== $g->getRedAnon() => $g->getRedAnon()->getUserName(),
null !== $g->getBlue() => $g->getBlue()->getUsername(),
default => $g->getBlueAnon()?->getUserName() ?? 'Unknown',
null !== $g->red => $g->red->getUsername(),
null !== $g->redAnon => $g->redAnon->userName,
null !== $g->blue => $g->blue->getUsername(),
default => $g->blueAnon?->userName ?? 'Unknown',
};
return [
'gameAssoc' => $g->getGameAssoc(),
'gameAssoc' => $g->gameAssoc,
'name' => $name,
'since' => $g->getCreated()?->format(\DateTimeInterface::ATOM) ?? '',
'since' => $g->created?->format(DateTimeInterface::ATOM) ?? '',
];
}, $games);

View File

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

View File

@@ -15,16 +15,17 @@ use App\Form\ForgotPasswordFormType;
use App\Form\RegistrationFormType;
use App\Form\ResetPasswordFormType;
use App\Repository\UserRepository;
use App\Service\Email\SendActivationEmailService;
use App\Service\Email\SendPasswordResetEmailService;
use App\Service\Email\SendUserActivationNotificationService;
use App\Service\Email\SendUserRegistrationNotificationService;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use LogicException;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
@@ -44,21 +45,28 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class SecurityController extends AbstractController
{
public function __construct(
#[Autowire(env: 'APP_CONTACT_MAIL_ADDRESS')]
private readonly string $appContactMailAddress,
private readonly EntityManagerInterface $em,
private readonly RequestStack $requestStack,
private readonly UserRepository $userRepository,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly AuthenticationUtils $authenticationUtils,
private readonly SendActivationEmailService $activationEmail,
private readonly SendPasswordResetEmailService $passwordResetEmail,
private readonly SendUserActivationNotificationService $activationNotificationEmail,
private readonly SendUserRegistrationNotificationService $registrationNotificationEmail,
) {
}
#[Route('/login', name: 'MineSeekerBundle_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
public function login(): Response
{
if ($this->getUser()) {
return $this->redirectToRoute('MineSeekerBundle_homepage');
}
return $this->render('Security/login.html.twig', [
'last_username' => $authenticationUtils->getLastUsername(),
'error' => $authenticationUtils->getLastAuthenticationError(),
'last_username' => $this->authenticationUtils->getLastUsername(),
'error' => $this->authenticationUtils->getLastAuthenticationError(),
]);
}
@@ -69,30 +77,25 @@ class SecurityController extends AbstractController
}
#[Route('/register', name: 'MineSeekerBundle_register')]
public function register(
Request $request,
UserPasswordHasherInterface $hasher,
EntityManagerInterface $em,
MailerInterface $mailer,
): Response {
public function register(): Response
{
if ($this->getUser()) {
return $this->redirectToRoute('MineSeekerBundle_homepage');
}
$user = new User();
$form = $this->createForm(RegistrationFormType::class, $user);
$form->handleRequest($request);
$form->handleRequest($this->requestStack->getCurrentRequest());
if ($form->isSubmitted() && $form->isValid()) {
$token = bin2hex(random_bytes(32));
$user
->setIsVerified(false)
->setVerificationToken($token)
->setPassword($hasher->hashPassword($user, $form->get('plainPassword')->getData()));
$user->isVerified = false;
$user->verificationToken = $token;
$user->password = $this->passwordHasher->hashPassword($user, $form->get('plainPassword')->getData());
$em->persist($user);
$em->flush();
$this->em->persist($user);
$this->em->flush();
$activationUrl = $this->generateUrl(
'MineSeekerBundle_activate',
@@ -105,32 +108,10 @@ class SecurityController extends AbstractController
$activationUrl = str_replace('http://', 'https://', $activationUrl);
}
$mailer->send(
new TemplatedEmail()
->from('noreply@mineseeker.hu')
->to($user->getEmail())
->subject('Activate your MineSeeker account')
->htmlTemplate('emails/activation.html.twig')
->context([
'username' => $user->getUsername(),
'activation_url' => $activationUrl,
])
);
$this->activationEmail->send($user, $activationUrl);
$this->registrationNotificationEmail->send($user, new DateTime());
/** Send admin notification about new user registration */
$mailer->send(
new TemplatedEmail()
->from('noreply@mineseeker.hu')
->to($this->appContactMailAddress)
->subject('🎉 New User Registration: ' . $user->getUsername())
->htmlTemplate('emails/user_registration_notification.html.twig')
->context([
'user' => $user,
'registeredAt' => new DateTime(),
])
);
$this->addFlash('verify_email', $user->getEmail());
$this->addFlash('verify_email', $user->email);
return $this->redirectToRoute('MineSeekerBundle_register');
}
@@ -139,29 +120,24 @@ class SecurityController extends AbstractController
}
#[Route('/forgot-password', name: 'MineSeekerBundle_forgot_password')]
public function forgotPassword(
Request $request,
UserRepository $userRepository,
EntityManagerInterface $em,
MailerInterface $mailer,
): Response {
public function forgotPassword(): Response
{
if ($this->getUser()) {
return $this->redirectToRoute('MineSeekerBundle_homepage');
}
$form = $this->createForm(ForgotPasswordFormType::class);
$form->handleRequest($request);
$form->handleRequest($this->requestStack->getCurrentRequest());
if ($form->isSubmitted() && $form->isValid()) {
$email = $form->get('email')->getData();
$user = $userRepository->findOneByEmail($email);
$user = $this->userRepository->findOneByEmail($email);
if ($user && $user->isVerified()) {
if ($user && $user->isVerified) {
$token = bin2hex(random_bytes(32));
$user
->setResetToken($token)
->setResetTokenExpiresAt(new DateTime('+1 hour'));
$em->flush();
$user->resetToken = $token;
$user->resetTokenExpiresAt = new DateTime('+1 hour');
$this->em->flush();
$resetUrl = $this->generateUrl(
'MineSeekerBundle_reset_password',
@@ -174,20 +150,9 @@ class SecurityController extends AbstractController
$resetUrl = str_replace('http://', 'https://', $resetUrl);
}
$mailer->send(
new TemplatedEmail()
->from('noreply@mineseeker.hu')
->to($email)
->subject('Reset your MineSeeker password')
->htmlTemplate('emails/reset_password.html.twig')
->context([
'username' => $user->getUsername(),
'reset_url' => $resetUrl,
])
);
$this->passwordResetEmail->send($email, $user->getUsername(), $resetUrl);
}
// Always show the same flash to prevent email enumeration
$this->addFlash('reset_sent', $email);
return $this->redirectToRoute('MineSeekerBundle_forgot_password');
@@ -197,29 +162,24 @@ class SecurityController extends AbstractController
}
#[Route('/reset-password/{token}', name: 'MineSeekerBundle_reset_password')]
public function resetPassword(
string $token,
Request $request,
UserRepository $userRepository,
EntityManagerInterface $em,
UserPasswordHasherInterface $hasher,
): Response {
$user = $userRepository->findOneByResetToken($token);
public function resetPassword(string $token): Response
{
$user = $this->userRepository->findOneByResetToken($token);
if (!$user || $user->getResetTokenExpiresAt() < new DateTime()) {
if (!$user || $user->resetTokenExpiresAt < new DateTime()) {
$this->addFlash('error', 'This password reset link is invalid or has expired.');
return $this->redirectToRoute('MineSeekerBundle_forgot_password');
}
$form = $this->createForm(ResetPasswordFormType::class);
$form->handleRequest($request);
$form->handleRequest($this->requestStack->getCurrentRequest());
if ($form->isSubmitted() && $form->isValid()) {
$user
->setPassword($hasher->hashPassword($user, $form->get('plainPassword')->getData()))
->setResetToken(null)
->setResetTokenExpiresAt(null);
$em->flush();
$user->password = $this->passwordHasher->hashPassword($user, $form->get('plainPassword')->getData());
$user->resetToken = null;
$user->resetTokenExpiresAt = null;
$this->em->flush();
$this->addFlash('success', 'Your password has been reset. You can now sign in.');
@@ -230,30 +190,20 @@ class SecurityController extends AbstractController
}
#[Route('/activate/{token}', name: 'MineSeekerBundle_activate')]
public function activate(string $token, EntityManagerInterface $em, MailerInterface $mailer): Response
public function activate(string $token): Response
{
$user = $em->getRepository(User::class)->findOneBy(['verificationToken' => $token]);
$user = $this->em->getRepository(User::class)->findOneBy(['verificationToken' => $token]);
if (!$user) {
$this->addFlash('error', 'This activation link is invalid or has already been used.');
return $this->redirectToRoute('MineSeekerBundle_login');
}
$user->setIsVerified(true)->setVerificationToken(null);
$em->flush();
$user->isVerified = true;
$user->verificationToken = null;
$this->em->flush();
/** Send admin notification about account activation */
$mailer->send(
new TemplatedEmail()
->from('noreply@mineseeker.hu')
->to($this->appContactMailAddress)
->subject('✅ User Account Activated: ' . $user->getUsername())
->htmlTemplate('emails/user_activation_notification.html.twig')
->context([
'user' => $user,
'activatedAt' => new DateTime(),
])
);
$this->activationNotificationEmail->send($user, new DateTime());
$this->addFlash('success', 'Your account is now active. Welcome, ' . $user->getUsername() . '!');

View File

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

View File

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

View File

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

105
src/Dto/BattleShareDto.php Normal file
View File

@@ -0,0 +1,105 @@
<?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.
*/
namespace App\Dto;
use App\Entity\PlayedGame;
/**
* Class BattleShareDto
*
* @package App\Dto
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 20.
*/
final readonly class BattleShareDto
{
public function __construct(
public PlayedGame $game,
public string $redName,
public string $blueName,
public ?int $redPts,
public ?int $bluePts,
public ?string $resign,
public ?string $redAvatar,
public ?string $blueAvatar,
public float $redBonusPoints,
public float $blueBonusPoints,
public array $redBonusStats,
public array $blueBonusStats,
public string $ogTitle,
public string $ogDesc,
) {
}
public static function fromPlayedGame(PlayedGame $game): self
{
$redName = $game->red?->getUsername() ?? ($game->redAnon !== null ? 'Anonymous' : 'Guest');
$blueName = $game->blue?->getUsername() ?? ($game->blueAnon !== null ? 'Anonymous' : 'Guest');
$redPts = $game->redPoints;
$bluePts = $game->bluePoints;
$resign = $game->resign;
$summary = self::buildSummary($redName, $blueName, $redPts, $bluePts, $resign);
return new self(
game: $game,
redName: $redName,
blueName: $blueName,
redPts: $redPts,
bluePts: $bluePts,
resign: $resign,
redAvatar: $game->red?->avatarPath,
blueAvatar: $game->blue?->avatarPath,
redBonusPoints: (float)($game->redBonusPoints ?? 0),
blueBonusPoints: (float)($game->blueBonusPoints ?? 0),
redBonusStats: $game->redBonusStats ?? [],
blueBonusStats: $game->blueBonusStats ?? [],
ogTitle: "MineSeeker · $summary",
ogDesc: "Watch the battle replay: $summary — played on MineSeeker, the multiplayer minesweeper.",
);
}
private static function buildSummary(
string $redName,
string $blueName,
?int $redPts,
?int $bluePts,
?string $resign,
): string {
if ($resign === 'red') {
return "$redName resigned — $blueName wins";
}
if ($resign === 'blue') {
return "$blueName resigned — $redName wins";
}
if ($redPts !== null && $bluePts !== null) {
if ($redPts > $bluePts) {
return "$redName defeated $blueName ($redPts $bluePts)";
}
if ($bluePts > $redPts) {
return "$blueName defeated $redName ($bluePts $redPts)";
}
return "$redName and $blueName drew ($redPts $bluePts)";
}
return "$redName vs $blueName";
}
public function toTemplateContext(): array
{
return get_object_vars($this);
}
}

View File

@@ -0,0 +1,43 @@
<?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.
*/
namespace App\Dto;
use JsonSerializable;
/**
* Class ProfileChartDataDto
*
* @package App\Dto
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 20.
*/
final readonly class ProfileChartDataDto implements JsonSerializable
{
public function __construct(
public array $months,
public array $wins,
public array $losses,
public array $draws,
public int $pieWins,
public int $pieLosses,
public int $pieDraws,
public array $recentGames,
) {
}
public function jsonSerialize(): array
{
return get_object_vars($this);
}
}

View File

@@ -0,0 +1,111 @@
<?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.
*/
namespace App\Dto;
use App\Entity\User;
use App\Repository\PlayedGameRepository;
use DateTime;
/**
* Class ProfileChartDataFactory
*
* @package App\Dto
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 20.
*/
readonly final class ProfileChartDataFactory
{
public function __construct(private PlayedGameRepository $repo) { }
public function buildChartData(User $user, int $userId, ProfileStatsDto $stats): ProfileChartDataDto
{
$monthlyData = $this->buildMonthlyData($user, $userId);
return new ProfileChartDataDto(
months: array_column(array_values($monthlyData), 'label'),
wins: array_column(array_values($monthlyData), 'wins'),
losses: array_column(array_values($monthlyData), 'losses'),
draws: array_column(array_values($monthlyData), 'draws'),
pieWins: $stats->wins,
pieLosses: $stats->losses,
pieDraws: $stats->draws,
recentGames: $this->buildRecentGamesSeries($user, $userId),
);
}
private function buildMonthlyData(User $user, int $userId): array
{
$monthlyData = [];
for ($i = 5; $i >= 0; $i--) {
$dt = new DateTime("first day of -$i months midnight");
$key = $dt->format('Y-m');
$monthlyData[$key] = ['label' => $dt->format('M'), 'wins' => 0, 'losses' => 0, 'draws' => 0];
}
$since = new DateTime('first day of -5 months midnight');
$recentGames = $this->repo->findFinishedForUserSince($user, $since);
foreach ($recentGames as $game) {
if (!$game->updated) {
continue;
}
$month = $game->updated->format('Y-m');
if (!isset($monthlyData[$month])) {
continue;
}
$isRed = $game->red?->id === $userId;
$myPts = $isRed ? $game->redPoints : $game->bluePoints;
$oppPts = $isRed ? $game->bluePoints : $game->redPoints;
$resign = $game->resign;
$myColor = $isRed ? 'red' : 'blue';
$oppColor = $isRed ? 'blue' : 'red';
$result = 'draws';
if ($resign === $myColor) {
$result = 'losses';
} elseif ($resign === $oppColor) {
$result = 'wins';
} elseif ($myPts !== null && $oppPts !== null) {
if ($myPts > $oppPts) $result = 'wins';
elseif ($myPts < $oppPts) $result = 'losses';
}
$monthlyData[$month][$result]++;
}
return $monthlyData;
}
private function buildRecentGamesSeries(User $user, int $userId): array
{
$recent = $this->repo->findRecentFinishedForUser($user, 15);
$recent = array_reverse($recent);
$labels = [];
$mines = [];
$bonus = [];
foreach ($recent as $i => $game) {
$isRed = $game->red?->id === $userId;
$labels[] = '#' . ($i + 1);
$mines[] = (int)($isRed ? $game->redPoints : $game->bluePoints);
$bonus[] = (float)($isRed ? $game->redBonusPoints : $game->blueBonusPoints) ?: 0;
}
return ['labels' => $labels, 'mines' => $mines, 'bonus' => $bonus];
}
}

View File

@@ -0,0 +1,56 @@
<?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.
*/
namespace App\Dto;
use JsonSerializable;
/**
* Class ProfileGameDto
*
* @package App\Dto
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 20.
*/
final readonly class ProfileGameDto implements JsonSerializable
{
public function __construct(
public ?int $id,
public ?string $uuid,
public string $redName,
public string $blueName,
public ?string $redAvatar,
public ?string $blueAvatar,
public ?int $redPoints,
public ?int $bluePoints,
public ?bool $redExplodedBomb,
public ?bool $blueExplodedBomb,
public ?string $resign,
public ?string $created,
public ?string $date,
public bool $isRed,
public string $result,
public ?int $myPoints,
public ?int $oppPoints,
public float $redBonusPoints,
public float $blueBonusPoints,
public array $redBonusStats,
public array $blueBonusStats,
) {
}
public function jsonSerialize(): array
{
return get_object_vars($this);
}
}

View File

@@ -0,0 +1,134 @@
<?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.
*/
namespace App\Dto;
use App\Entity\PlayedGame;
use App\Entity\RecentBattle;
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
/**
* Class ProfileGameDtoFactory
*
* @package App\Dto
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 20.
*/
final readonly class ProfileGameDtoFactory
{
public function __construct(private CacheManager $cacheManager) { }
public function create(PlayedGame $game, int $userId): ProfileGameDto
{
$isRed = $game->red?->id === $userId;
$resign = $game->resign;
$myColor = $isRed ? 'red' : 'blue';
$oppColor = $isRed ? 'blue' : 'red';
$myPts = $isRed ? $game->redPoints : $game->bluePoints;
$oppPts = $isRed ? $game->bluePoints : $game->redPoints;
$redAvatarPath = $game->red?->avatarPath;
$blueAvatarPath = $game->blue?->avatarPath;
return new ProfileGameDto(
id: $game->id,
uuid: $game->uuid?->toRfc4122(),
redName: $game->red?->getUsername() ?? $game->redAnon?->userName ?? 'Guest',
blueName: $game->blue?->getUsername() ?? $game->blueAnon?->userName ?? 'Guest',
redAvatar: $redAvatarPath ? $this->cacheManager->generateUrl($redAvatarPath, 'avatar_thumb') : null,
blueAvatar: $blueAvatarPath ? $this->cacheManager->generateUrl($blueAvatarPath, 'avatar_thumb') : null,
redPoints: $game->redPoints,
bluePoints: $game->bluePoints,
redExplodedBomb: $game->redExplodedBomb,
blueExplodedBomb: $game->blueExplodedBomb,
resign: $resign,
created: $game->created?->format('Y-m-d H:i'),
date: $game->updated?->format('Y-m-d H:i'),
isRed: $isRed,
result: $this->resolveResult($resign, $myColor, $oppColor, $myPts, $oppPts),
myPoints: $myPts,
oppPoints: $oppPts,
redBonusPoints: $game->redBonusPoints ?? 0.0,
blueBonusPoints: $game->blueBonusPoints ?? 0.0,
redBonusStats: $game->redBonusStats ?? [],
blueBonusStats: $game->blueBonusStats ?? [],
);
}
private function resolveResult(
?string $resign,
string $myColor,
string $oppColor,
?int $myPts,
?int $oppPts,
): string {
if ($resign === $myColor) {
return 'loss';
}
if ($resign === $oppColor) {
return 'win';
}
if ($myPts !== null && $oppPts !== null) {
if ($myPts > $oppPts) {
return 'win';
}
if ($myPts < $oppPts) {
return 'loss';
}
}
return 'draw';
}
/**
* Build a ProfileGameDto directly from the recent_battles materialized view row.
* Avatar paths are still resolved via LiipImagine (they are stored as storage paths).
*/
public function createFromRecentBattle(RecentBattle $battle): ProfileGameDto
{
$myPts = $battle->isRed ? $battle->redPoints : $battle->bluePoints;
$oppPts = $battle->isRed ? $battle->bluePoints : $battle->redPoints;
return new ProfileGameDto(
id: $battle->gameId,
uuid: $battle->uuid,
redName: $battle->redName,
blueName: $battle->blueName,
redAvatar: $battle->redAvatarPath
? $this->cacheManager->generateUrl($battle->redAvatarPath, 'avatar_thumb')
: null,
blueAvatar: $battle->blueAvatarPath
? $this->cacheManager->generateUrl($battle->blueAvatarPath, 'avatar_thumb')
: null,
redPoints: $battle->redPoints,
bluePoints: $battle->bluePoints,
redExplodedBomb: $battle->redExplodedBomb,
blueExplodedBomb: $battle->blueExplodedBomb,
resign: $battle->resign,
created: $battle->created?->format('Y-m-d H:i'),
date: $battle->updated?->format('Y-m-d H:i'),
isRed: $battle->isRed,
result: $battle->result,
myPoints: $myPts,
oppPoints: $oppPts,
redBonusPoints: $battle->redBonusPoints,
blueBonusPoints: $battle->blueBonusPoints,
redBonusStats: $battle->redBonusStats,
blueBonusStats: $battle->blueBonusStats,
);
}
}

View File

@@ -0,0 +1,82 @@
<?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.
*/
namespace App\Dto;
use App\Entity\UserStats;
/**
* Class ProfileStatsDto
*
* @package App\Dto
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 20.
*/
final readonly class ProfileStatsDto
{
public function __construct(
public int $total,
public int $wins,
public int $losses,
public int $draws,
public int $minesHit,
public int $winRate,
public int $avgScore,
public float $bonusPoints,
public float $avgBonus,
public int $bestChain,
public int $blindHits,
public int $edgeMines,
) {
}
public static function fromUserStats(?UserStats $stats): self
{
if ($stats === null) {
return self::empty();
}
return new self(
total: $stats->totalGames,
wins: $stats->wins,
losses: $stats->losses,
draws: $stats->draws,
minesHit: $stats->totalMines,
winRate: $stats->getWinRate(),
avgScore: $stats->getAvgScore(),
bonusPoints: (float)$stats->totalBonusPoints,
avgBonus: (float)$stats->avgBonus,
bestChain: $stats->bestChain,
blindHits: $stats->blindHits,
edgeMines: $stats->edgeMines,
);
}
public static function empty(): self
{
return new self(
total: 0,
wins: 0,
losses: 0,
draws: 0,
minesHit: 0,
winRate: 0,
avgScore: 0,
bonusPoints: 0.0,
avgBonus: 0.0,
bestChain: 0,
blindHits: 0,
edgeMines: 0,
);
}
}

View File

@@ -0,0 +1,37 @@
<?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.
*/
namespace App\Dto;
/**
* Class ProfileViewDto
*
* @package App\Dto
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 20.
*/
final readonly class ProfileViewDto
{
public function __construct(
public ProfileStatsDto $stats,
public array $recent,
public array $gamesData,
public ProfileChartDataDto $chartData,
) {
}
public function toTemplateContext(): array
{
return get_object_vars($this);
}
}

View File

@@ -29,100 +29,33 @@ use Doctrine\ORM\Mapping\Table;
* @link www.splendidbear.org
* @since 2026. 04. 15.
*/
#[Entity(repositoryClass: ContactMessageRepository::class)]
#[Table(name: 'contact_messages')]
#[Entity(repositoryClass: ContactMessageRepository::class), Table(name: 'contact_messages')]
class ContactMessage
{
#[Id, GeneratedValue, Column]
private ?int $id = null;
public private(set) ?int $id = null;
#[Column]
private string $name;
public string $name;
#[Column]
private string $email;
public string $email;
#[Column(type: Types::TEXT)]
private string $content;
public string $content;
#[Column]
private bool $consent = false;
public bool $consent = false;
#[Column]
private DateTimeImmutable $createdAt;
public private(set) DateTimeImmutable $createdAt;
#[Column(length: 45, nullable: true)]
private ?string $ipAddress = null;
public ?string $ipAddress = null;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $email): self
{
$this->email = $email;
return $this;
}
public function getContent(): string
{
return $this->content;
}
public function setContent(string $content): self
{
$this->content = $content;
return $this;
}
public function isConsent(): bool
{
return $this->consent;
}
public function setConsent(bool $consent): self
{
$this->consent = $consent;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getIpAddress(): ?string
{
return $this->ipAddress;
}
public function setIpAddress(?string $ipAddress): self
{
$this->ipAddress = $ipAddress;
return $this;
}
}

View File

@@ -26,82 +26,26 @@ use Doctrine\ORM\Mapping\Id;
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 09.
* @since 2019. 10. 27.
*/
#[Entity(repositoryClass: GamerRepository::class)]
class Gamer
{
#[Id, GeneratedValue, Column]
private ?int $id = null;
public private(set) ?int $id = null;
#[Column(length: 100)]
private ?string $userName = null;
public ?string $userName = null;
#[Column(length: 20, nullable: true)]
private ?string $ip = null;
public ?string $ip = null;
#[Column(length: 100, nullable: true)]
private ?string $country = null;
public ?string $country = null;
#[Column(nullable: true)]
private ?string $userAgent = null;
public ?string $userAgent = null;
#[Column(type: Types::DATETIME_MUTABLE)]
private ?DateTime $connTimestamp = null;
public function getId(): ?int
{
return $this->id;
}
public function getUserName(): ?string
{
return $this->userName;
}
public function setUserName(?string $userName): void
{
$this->userName = $userName;
}
public function getIp(): ?string
{
return $this->ip;
}
public function setIp(?string $ip): void
{
$this->ip = $ip;
}
public function getCountry(): ?string
{
return $this->country;
}
public function setCountry(?string $country): void
{
$this->country = $country;
}
public function getUserAgent(): ?string
{
return $this->userAgent;
}
public function setUserAgent(?string $userAgent): void
{
$this->userAgent = $userAgent;
}
public function getConnTimestamp(): ?DateTime
{
return $this->connTimestamp;
}
public function setConnTimestamp(?DateTime $connTimestamp): void
{
$this->connTimestamp = $connTimestamp;
}
public ?DateTime $connTimestamp = null;
}

View File

@@ -2,7 +2,7 @@
/*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
* Copyright (c) 2019 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -29,20 +29,20 @@ use Doctrine\ORM\Mapping\OneToOne;
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 09.
* @since 2019. 10. 27.
*/
#[Entity(repositoryClass: GridRepository::class)]
class Grid
{
#[Id, GeneratedValue, Column]
private ?int $id = null;
public private(set) ?int $id = null;
#[OneToOne(inversedBy: 'grid', cascade: ['persist'])]
private ?PlayedGame $playedGame = null;
public ?PlayedGame $playedGame = null;
#[OneToMany(mappedBy: 'grid', targetEntity: GridRow::class, cascade: ['persist'])]
#[OneToMany(targetEntity: GridRow::class, mappedBy: 'grid', cascade: ['persist'])]
#[JoinColumn(name: 'grid_row', referencedColumnName: 'id', onDelete: 'CASCADE')]
private Collection $gridRow;
public private(set) Collection $gridRow;
public function __construct()
@@ -50,30 +50,10 @@ class Grid
$this->gridRow = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getPlayedGame(): ?PlayedGame
{
return $this->playedGame;
}
public function setPlayedGame(?PlayedGame $playedGame): void
{
$this->playedGame = $playedGame;
}
public function getGridRow(): Collection
{
return $this->gridRow;
}
public function addGridRow(GridRow $gridRow): void
{
$this->gridRow->add($gridRow);
$gridRow->setGrid($this);
$gridRow->grid = $this;
}
public function removeGridRow(GridRow $gridRow): void

View File

@@ -1,8 +1,8 @@
<?php declare(strict_types=1);
/*
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
* Copyright (c) 2019 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -27,44 +27,18 @@ use Doctrine\ORM\Mapping\ManyToOne;
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 09.
* @since 2019. 10. 27.
*/
#[Entity(repositoryClass: GridRowRepository::class)]
class GridRow
{
#[Id, GeneratedValue, Column]
private ?int $id = null;
public private(set) ?int $id = null;
#[ManyToOne(cascade: ['persist'], inversedBy: 'gridRow')]
#[JoinColumn(name: 'grid', referencedColumnName: 'id', onDelete: 'CASCADE')]
private ?Grid $grid = null;
public ?Grid $grid = null;
#[Column(name: 'grid_col', type: Types::JSON, nullable: false)]
private ?array $gridCol = null;
public function getId(): ?int
{
return $this->id;
}
public function getGrid(): ?Grid
{
return $this->grid;
}
public function setGrid(?Grid $grid): void
{
$this->grid = $grid;
}
public function getGridCol(): ?array
{
return $this->gridCol;
}
public function setGridCol(?array $gridCol): void
{
$this->gridCol = $gridCol;
}
public ?array $gridCol = null;
}

View File

@@ -2,7 +2,7 @@
/*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
* Copyright (c) 2019 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -33,74 +33,74 @@ use Symfony\Component\Uid\Uuid;
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 09.
* @since 2019. 10. 27.
*/
#[Entity(repositoryClass: PlayedGameRepository::class)]
class PlayedGame
{
#[Id, GeneratedValue, Column]
private ?int $id = null;
public private(set) ?int $id = null;
#[Column(type: 'uuid', unique: true)]
private ?Uuid $uuid = null;
public ?Uuid $uuid = null;
#[Column(length: 50)]
private ?string $gameAssoc = null;
public ?string $gameAssoc = null;
#[Column(length: 5, nullable: true)]
private ?int $redPoints = null;
public ?int $redPoints = null;
#[Column(length: 5, nullable: true)]
private ?int $bluePoints = null;
public ?int $bluePoints = null;
#[Column(nullable: true)]
private ?bool $redExplodedBomb = null;
public ?bool $redExplodedBomb = null;
#[Column(nullable: true)]
private ?bool $blueExplodedBomb = null;
public ?bool $blueExplodedBomb = null;
#[Column(length: 7, nullable: true)]
private ?string $resign = null;
public ?string $resign = null;
#[Column(nullable: true)]
private ?float $redBonusPoints = null;
public ?float $redBonusPoints = null;
#[Column(nullable: true)]
private ?float $blueBonusPoints = null;
public ?float $blueBonusPoints = null;
#[Column(nullable: true)]
private ?array $redBonusStats = null;
public ?array $redBonusStats = null;
#[Column(nullable: true)]
private ?array $blueBonusStats = null;
public ?array $blueBonusStats = null;
#[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?DateTime $created = null;
public ?DateTime $created = null;
#[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?DateTime $updated = null;
public ?DateTime $updated = null;
#[OneToOne(mappedBy: 'playedGame', cascade: ['persist'])]
private ?Grid $grid = null;
public ?Grid $grid = null;
#[ManyToOne]
#[JoinColumn(name: 'red_id', referencedColumnName: 'id', nullable: true)]
private ?User $red = null;
public ?User $red = null;
#[ManyToOne]
#[JoinColumn(name: 'red_anon', referencedColumnName: 'id', nullable: true)]
private ?Gamer $redAnon = null;
public ?Gamer $redAnon = null;
#[ManyToOne]
#[JoinColumn(name: 'blue_id', referencedColumnName: 'id', nullable: true)]
private ?User $blue = null;
public ?User $blue = null;
#[ManyToOne]
#[JoinColumn(name: 'blue_anon', referencedColumnName: 'id', nullable: true)]
private ?Gamer $blueAnon = null;
public ?Gamer $blueAnon = null;
#[OneToMany(mappedBy: 'playedGame', targetEntity: Step::class)]
private Collection $steps;
public private(set) Collection $steps;
public function __construct()
@@ -108,196 +108,4 @@ class PlayedGame
$this->steps = new ArrayCollection();
$this->uuid = Uuid::v4();
}
public function getId(): ?int
{
return $this->id;
}
public function getUuid(): ?Uuid
{
return $this->uuid;
}
public function setUuid(?Uuid $uuid): void
{
$this->uuid = $uuid;
}
public function getGameAssoc(): ?string
{
return $this->gameAssoc;
}
public function setGameAssoc(?string $gameAssoc): void
{
$this->gameAssoc = $gameAssoc;
}
public function getGrid(): ?Grid
{
return $this->grid;
}
public function setGrid(?Grid $grid): void
{
$this->grid = $grid;
}
public function getRed(): ?User
{
return $this->red;
}
public function setRed(?User $red): void
{
$this->red = $red;
}
public function getRedAnon(): ?Gamer
{
return $this->redAnon;
}
public function setRedAnon(?Gamer $redAnon): void
{
$this->redAnon = $redAnon;
}
public function getBlue(): ?User
{
return $this->blue;
}
public function setBlue(?User $blue): void
{
$this->blue = $blue;
}
public function getBlueAnon(): ?Gamer
{
return $this->blueAnon;
}
public function setBlueAnon(?Gamer $blueAnon): void
{
$this->blueAnon = $blueAnon;
}
public function getRedPoints(): ?int
{
return $this->redPoints;
}
public function setRedPoints(?int $redPoints): void
{
$this->redPoints = $redPoints;
}
public function getBluePoints(): ?int
{
return $this->bluePoints;
}
public function setBluePoints(?int $bluePoints): void
{
$this->bluePoints = $bluePoints;
}
public function getRedExplodedBomb(): ?bool
{
return $this->redExplodedBomb;
}
public function setRedExplodedBomb(?bool $redExplodedBomb): void
{
$this->redExplodedBomb = $redExplodedBomb;
}
public function getBlueExplodedBomb(): ?bool
{
return $this->blueExplodedBomb;
}
public function setBlueExplodedBomb(?bool $blueExplodedBomb): void
{
$this->blueExplodedBomb = $blueExplodedBomb;
}
public function getResign(): ?string
{
return $this->resign;
}
public function setResign(?string $resign): void
{
$this->resign = $resign;
}
public function getRedBonusPoints(): ?float
{
return $this->redBonusPoints;
}
public function setRedBonusPoints(?float $redBonusPoints): void
{
$this->redBonusPoints = $redBonusPoints;
}
public function getBlueBonusPoints(): ?float
{
return $this->blueBonusPoints;
}
public function setBlueBonusPoints(?float $blueBonusPoints): void
{
$this->blueBonusPoints = $blueBonusPoints;
}
public function getRedBonusStats(): ?array
{
return $this->redBonusStats;
}
public function setRedBonusStats(?array $redBonusStats): void
{
$this->redBonusStats = $redBonusStats;
}
public function getBlueBonusStats(): ?array
{
return $this->blueBonusStats;
}
public function setBlueBonusStats(?array $blueBonusStats): void
{
$this->blueBonusStats = $blueBonusStats;
}
public function getCreated(): ?DateTime
{
return $this->created;
}
public function setCreated(?DateTime $created): void
{
$this->created = $created;
}
public function getUpdated(): ?DateTime
{
return $this->updated;
}
public function setUpdated(?DateTime $updated): void
{
$this->updated = $updated;
}
public function getSteps(): Collection
{
return $this->steps;
}
}

100
src/Entity/RecentBattle.php Normal file
View File

@@ -0,0 +1,100 @@
<?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.
*/
namespace App\Entity;
use App\Repository\RecentBattleRepository;
use DateTime;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping\Table;
/**
* Class RecentBattle
*
* Read-only entity mapped to the recent_battles materialized view.
* Each row represents one game from the perspective of a single user.
*
* @package App\Entity
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 20.
*/
#[Entity(repositoryClass: RecentBattleRepository::class, readOnly: true), Table(name: 'recent_battles')]
class RecentBattle
{
/** Composite PK: (user_id, game_id) — mapped via the unique index. */
#[Id, Column]
public int $userId = 0;
#[Id, Column]
public int $gameId = 0;
#[Column(name: 'uuid')]
public string $uuid = '';
#[Column]
public bool $isRed = false;
#[Column]
public string $redName = '';
#[Column]
public string $blueName = '';
#[Column(nullable: true)]
public ?string $redAvatarPath = null;
#[Column(nullable: true)]
public ?string $blueAvatarPath = null;
#[Column(nullable: true)]
public ?int $redPoints = null;
#[Column(nullable: true)]
public ?int $bluePoints = null;
#[Column(nullable: true)]
public ?bool $redExplodedBomb = null;
#[Column(nullable: true)]
public ?bool $blueExplodedBomb = null;
#[Column(nullable: true)]
public ?string $resign = null;
#[Column]
public float $redBonusPoints = 0.0;
#[Column]
public float $blueBonusPoints = 0.0;
#[Column(type: Types::JSON)]
public array $redBonusStats = [];
#[Column(type: Types::JSON)]
public array $blueBonusStats = [];
#[Column]
public string $result = 'draw';
#[Column]
public bool $oppIsGuest = false;
#[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
public ?DateTime $created = null;
#[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
public ?DateTime $updated = null;
}

View File

@@ -1,8 +1,8 @@
<?php declare(strict_types=1);
/*
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
* Copyright (c) 2019 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@@ -27,108 +27,32 @@ use Doctrine\ORM\Mapping\ManyToOne;
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 09.
* @since 2019. 10. 27.
*/
#[Entity(repositoryClass: StepRepository::class)]
class Step
{
#[Id, GeneratedValue, Column]
private ?int $id = null;
public private(set) ?int $id = null;
#[Column(length: 3)]
private ?int $row = null;
public ?int $row = null;
#[Column(length: 3)]
private ?int $col = null;
public ?int $col = null;
#[Column(nullable: true)]
private ?bool $wBomb = null;
public ?bool $wBomb = null;
#[Column(length: 10, nullable: true)]
private ?string $player = null;
public ?string $player = null;
#[Column(type: Types::JSON, nullable: true)]
private ?array $revealedCells = null;
public ?array $revealedCells = null;
#[ManyToOne(inversedBy: 'steps')]
private ?PlayedGame $playedGame = null;
public ?PlayedGame $playedGame = null;
#[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?DateTime $created = null;
public function getId(): ?int
{
return $this->id;
}
public function getRow(): ?int
{
return $this->row;
}
public function setRow(?int $row): void
{
$this->row = $row;
}
public function getCol(): ?int
{
return $this->col;
}
public function setCol(?int $col): void
{
$this->col = $col;
}
public function getWBomb(): ?bool
{
return $this->wBomb;
}
public function setWBomb(?bool $wBomb): void
{
$this->wBomb = $wBomb;
}
public function getPlayer(): ?string
{
return $this->player;
}
public function setPlayer(?string $player): void
{
$this->player = $player;
}
public function getRevealedCells(): ?array
{
return $this->revealedCells;
}
public function setRevealedCells(?array $revealedCells): void
{
$this->revealedCells = $revealedCells;
}
public function getPlayedGame(): ?PlayedGame
{
return $this->playedGame;
}
public function setPlayedGame(?PlayedGame $playedGame): void
{
$this->playedGame = $playedGame;
}
public function getCreated(): ?DateTime
{
return $this->created;
}
public function setCreated(?DateTime $created): void
{
$this->created = $created;
}
public ?DateTime $created = null;
}

View File

@@ -36,68 +36,59 @@ use Symfony\Component\Security\Core\User\UserInterface;
* @link www.splendidbear.org
* @since 2026. 04. 09.
*/
#[Table(name: 'app_user')]
#[Entity(repositoryClass: UserRepository::class)]
#[Entity(repositoryClass: UserRepository::class), Table(name: 'app_user')]
#[UniqueEntity(fields: ['username'], message: 'This username is already taken.')]
#[UniqueEntity(fields: ['email'], message: 'This email address is already registered.')]
class User implements UserInterface, PasswordAuthenticatedUserInterface, TotpTwoFactorInterface, BackupCodeInterface
{
#[Id, GeneratedValue, Column]
private ?int $id = null;
public private(set) ?int $id = null;
#[Column(length: 180, unique: true)]
private ?string $username = null;
public ?string $username = null;
#[Column]
private array $roles = [];
public array $roles = [];
#[Column(nullable: true)]
private ?string $password = null;
public ?string $password = null;
#[Column(length: 254, unique: true, nullable: true)]
private ?string $email = null;
public ?string $email = null;
#[Column]
private bool $isVerified = false;
public bool $isVerified = false;
#[Column(length: 64, nullable: true)]
private ?string $verificationToken = null;
public ?string $verificationToken = null;
#[Column(length: 64, nullable: true)]
private ?string $resetToken = null;
public ?string $resetToken = null;
#[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?DateTime $resetTokenExpiresAt = null;
public ?DateTime $resetTokenExpiresAt = null;
#[Column(length: 255, nullable: true)]
private ?string $totpSecret = null;
public ?string $totpSecret = null;
/** Stored as nullable JSON; the get hook always exposes a non-null array. */
#[Column(type: Types::JSON, nullable: true)]
private ?array $backupCodes = [];
public ?array $backupCodes = [] {
get => $this->backupCodes ?? [];
}
#[Column(length: 255, nullable: true)]
private ?string $avatarPath = null;
public ?string $avatarPath = null;
#[Column(nullable: true)]
private ?bool $consentGiven = null;
public ?bool $consentGiven = null;
public function getId(): ?int
{
return $this->id;
}
public function getUsername(): ?string
{
return $this->username;
}
public function setUsername(string $username): self
{
$this->username = $username;
return $this;
}
public function getUserIdentifier(): string
{
return (string)$this->username;
@@ -105,15 +96,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, TotpTwo
public function getRoles(): array
{
$roles = $this->roles;
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
public function setRoles(array $roles): self
{
$this->roles = $roles;
return $this;
return array_unique([...$this->roles, 'ROLE_USER']);
}
public function getPassword(): ?string
@@ -121,82 +104,10 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, TotpTwo
return $this->password;
}
public function setPassword(?string $password): self
{
$this->password = $password;
return $this;
}
public function eraseCredentials(): void
{
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(?string $email): self
{
$this->email = $email;
return $this;
}
public function isVerified(): bool
{
return $this->isVerified;
}
public function setIsVerified(bool $isVerified): self
{
$this->isVerified = $isVerified;
return $this;
}
public function getVerificationToken(): ?string
{
return $this->verificationToken;
}
public function setVerificationToken(?string $verificationToken): self
{
$this->verificationToken = $verificationToken;
return $this;
}
public function getResetToken(): ?string
{
return $this->resetToken;
}
public function setResetToken(?string $resetToken): self
{
$this->resetToken = $resetToken;
return $this;
}
public function getResetTokenExpiresAt(): ?DateTime
{
return $this->resetTokenExpiresAt;
}
public function setResetTokenExpiresAt(?DateTime $resetTokenExpiresAt): self
{
$this->resetTokenExpiresAt = $resetTokenExpiresAt;
return $this;
}
public function getAvatarPath(): ?string
{
return $this->avatarPath;
}
public function setAvatarPath(?string $avatarPath): self
{
$this->avatarPath = $avatarPath;
return $this;
}
public function isTotpAuthenticationEnabled(): bool
{
return null !== $this->totpSecret;
@@ -215,46 +126,13 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, TotpTwo
return new TotpConfiguration($this->totpSecret, TotpConfiguration::ALGORITHM_SHA1, 30, 6);
}
public function getTotpSecret(): ?string
{
return $this->totpSecret;
}
public function setTotpSecret(?string $totpSecret): self
{
$this->totpSecret = $totpSecret;
return $this;
}
public function isBackupCode(string $code): bool
{
return \in_array($code, $this->backupCodes ?? [], true);
return \in_array($code, $this->backupCodes, true);
}
public function invalidateBackupCode(string $code): void
{
$this->backupCodes = array_values(array_filter($this->backupCodes ?? [], fn($c) => $c !== $code));
}
public function getBackupCodes(): array
{
return $this->backupCodes ?? [];
}
public function setBackupCodes(array $backupCodes): self
{
$this->backupCodes = $backupCodes;
return $this;
}
public function isConsentGiven(): ?bool
{
return $this->consentGiven;
}
public function setConsentGiven(?bool $consentGiven): self
{
$this->consentGiven = $consentGiven;
return $this;
$this->backupCodes = array_values(array_filter($this->backupCodes, fn($c) => $c !== $code));
}
}

89
src/Entity/UserStats.php Normal file
View File

@@ -0,0 +1,89 @@
<?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.
*/
namespace App\Entity;
use App\Repository\UserStatsRepository;
use DateTime;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping\Table;
/**
* Class UserStats
*
* Read-only entity mapped to the user_stats materialized view.
*
* @package App\Entity
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 20.
*/
#[Entity(repositoryClass: UserStatsRepository::class, readOnly: true)]
class UserStats
{
#[Id, Column(name: 'user_id')]
public int $userId = 0;
#[Column]
public int $totalGames = 0;
#[Column]
public int $wins = 0;
#[Column]
public int $losses = 0;
#[Column]
public int $draws = 0;
#[Column]
public int $totalMines = 0;
#[Column(type: Types::DECIMAL, precision: 10, scale: 1)]
public string $totalBonusPoints = '0.0';
#[Column(type: Types::DECIMAL, precision: 10, scale: 1)]
public string $avgBonus = '0.0';
#[Column]
public int $bestChain = 0;
#[Column]
public int $blindHits = 0;
#[Column]
public int $edgeMines = 0;
#[Column]
public int $gamesWithScores = 0;
#[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
public ?DateTime $lastGameAt = null;
public function getWinRate(): int
{
return $this->gamesWithScores > 0
? (int)round($this->wins / $this->gamesWithScores * 100)
: 0;
}
public function getAvgScore(): int
{
return $this->gamesWithScores > 0
? (int)round($this->totalMines / $this->gamesWithScores)
: 0;
}
}

View File

@@ -20,6 +20,8 @@ use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping\JoinColumn;
use Doctrine\ORM\Mapping\ManyToOne;
use Doctrine\ORM\Mapping\Table;
use JsonException;
use RuntimeException;
/**
* Class WebAuthnCredential
@@ -31,34 +33,33 @@ use Doctrine\ORM\Mapping\Table;
* @link www.splendidbear.org
* @since 2026. 04. 12.
*/
#[Table(name: 'app_webauthn_credential')]
#[Entity(repositoryClass: WebAuthnCredentialRepository::class)]
#[Entity(repositoryClass: WebAuthnCredentialRepository::class), Table(name: 'app_webauthn_credential')]
class WebAuthnCredential
{
#[Id, GeneratedValue, Column]
private ?int $id = null;
public private(set) ?int $id = null;
#[ManyToOne(targetEntity: User::class)]
#[JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?User $user = null;
public ?User $user = null;
#[Column(type: Types::TEXT)]
private ?string $credentialData = null;
public ?string $credentialData = null;
#[Column(length: 255)]
private ?string $credentialName = null;
public ?string $credentialName = null;
#[Column(type: Types::DATETIME_MUTABLE)]
private ?DateTime $createdAt = null;
public ?DateTime $createdAt = null;
#[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?DateTime $lastUsedAt = null;
public ?DateTime $lastUsedAt = null;
#[Column]
private bool $isBackupEligible = false;
public bool $isBackupEligible = false;
#[Column]
private bool $isBackupAuthenticated = false;
public bool $isBackupAuthenticated = false;
public function __construct()
@@ -66,106 +67,27 @@ class WebAuthnCredential
$this->createdAt = new DateTime();
}
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): self
{
$this->user = $user;
return $this;
}
public function getCredentialData(): ?string
{
return $this->credentialData;
}
public function setCredentialData(?string $credentialData): self
{
$this->credentialData = $credentialData;
return $this;
}
public function getCredentialName(): ?string
{
return $this->credentialName;
}
public function setCredentialName(?string $credentialName): self
{
$this->credentialName = $credentialName;
return $this;
}
public function getCreatedAt(): ?DateTime
{
return $this->createdAt;
}
public function setCreatedAt(?DateTime $createdAt): self
{
$this->createdAt = $createdAt;
return $this;
}
public function getLastUsedAt(): ?DateTime
{
return $this->lastUsedAt;
}
public function setLastUsedAt(?DateTime $lastUsedAt): self
{
$this->lastUsedAt = $lastUsedAt;
return $this;
}
public function isBackupEligible(): bool
{
return $this->isBackupEligible;
}
public function setBackupEligible(bool $isBackupEligible): self
{
$this->isBackupEligible = $isBackupEligible;
return $this;
}
public function isBackupAuthenticated(): bool
{
return $this->isBackupAuthenticated;
}
public function setBackupAuthenticated(bool $isBackupAuthenticated): self
{
$this->isBackupAuthenticated = $isBackupAuthenticated;
return $this;
}
public function getPublicKeyCredentialSource()
{
// Return the raw credential data (JSON decoded)
if ($this->credentialData === null) {
return null;
}
return json_decode($this->credentialData, true);
try {
return json_decode($this->credentialData, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new RuntimeException("Unable to parse JSON: {$e->getMessage()}");
}
}
public function setPublicKeyCredentialSource($source): self
{
// Handle both array and object input
if (is_array($source)) {
$this->credentialData = json_encode($source);
} else {
$this->credentialData = (string)$source;
try {
$this->credentialData = is_array($source) ? json_encode($source, JSON_THROW_ON_ERROR) : (string)$source;
} catch (JsonException $e) {
throw new RuntimeException("Unable to encode JSON: {$e->getMessage()}");
}
return $this;
}
}

View File

@@ -0,0 +1,63 @@
<?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.
*/
namespace App\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20260419195925
*
* @package App\Migrations
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 19.
*/
final class Version20260419195925 extends AbstractMigration
{
public function getDescription(): string
{
return <<<OUT
Upgrade all primary keys to use IDENTITY generation strategy, and add comments to some columns.
This is \'cause the Doctrine updgrade.
OUT;
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE app_user ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('ALTER TABLE app_webauthn_credential ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('ALTER TABLE contact_messages ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('COMMENT ON COLUMN contact_messages.created_at IS \'\'');
$this->addSql('ALTER TABLE gamer ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('ALTER TABLE grid ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('ALTER TABLE grid_row ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('ALTER TABLE played_game ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('COMMENT ON COLUMN played_game.uuid IS \'\'');
$this->addSql('ALTER TABLE step ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE app_user ALTER id DROP IDENTITY');
$this->addSql('ALTER TABLE app_webauthn_credential ALTER id DROP IDENTITY');
$this->addSql('ALTER TABLE contact_messages ALTER id DROP IDENTITY');
$this->addSql('COMMENT ON COLUMN contact_messages.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE gamer ALTER id DROP IDENTITY');
$this->addSql('ALTER TABLE grid ALTER id DROP IDENTITY');
$this->addSql('ALTER TABLE grid_row ALTER id DROP IDENTITY');
$this->addSql('ALTER TABLE played_game ALTER id DROP IDENTITY');
$this->addSql('COMMENT ON COLUMN played_game.uuid IS \'(DC2Type:uuid)\'');
$this->addSql('ALTER TABLE step ALTER id DROP IDENTITY');
}
}

View File

@@ -0,0 +1,66 @@
<?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.
*/
namespace App\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20260420064216
*
* Realign each PostgreSQL IDENTITY counter to MAX(id)+1.
*
* Required because Version20260419195925 converted the ID columns from sequences to
* GENERATED BY DEFAULT AS IDENTITY (DBAL 4 / ORM 3 default), but PostgreSQL starts
* the new IDENTITY counter at 1 instead of inheriting the old sequence's last_value.
* The first INSERT on every table thus produced a duplicate-key collision.
*
* @package App\Migrations
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 20.
*/
final class Version20260420064216 extends AbstractMigration
{
private const TABLES = [
'played_game',
'grid',
'grid_row',
'step',
'app_user',
'app_webauthn_credential',
'gamer',
'contact_messages',
];
public function getDescription(): string
{
return 'Realign IDENTITY counters to MAX(id)+1 after the sequence-to-identity conversion.';
}
public function up(Schema $schema): void
{
foreach (self::TABLES as $table) {
$this->addSql(sprintf(
"SELECT setval(pg_get_serial_sequence('%s', 'id'), COALESCE((SELECT MAX(id) FROM %s), 0) + 1, false)",
$table,
$table,
));
}
}
public function down(Schema $schema): void
{
$this->throwIrreversibleMigrationException('Identity counter realignment cannot be reversed safely.');
}
}

View File

@@ -0,0 +1,117 @@
<?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.
*/
namespace App\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20260420120000
*
* Creates user_stats materialized view for ProfileController optimization.
*
* @package App\Migrations
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 20.
*/
final class Version20260420120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create user_stats materialized view for profile statistics.';
}
public function up(Schema $schema): void
{
$this->addSql('
CREATE MATERIALIZED VIEW user_stats AS
SELECT
u.id AS user_id,
COUNT(CASE WHEN pg.red_id = u.id OR pg.blue_id = u.id THEN 1 END) AS total_games,
COUNT(CASE WHEN
pg.red_id = u.id AND (pg.red_points > pg.blue_points AND pg.resign IS NULL)
OR pg.blue_id = u.id AND (pg.blue_points > pg.red_points AND pg.resign IS NULL)
OR pg.red_id = u.id AND pg.resign = \'blue\'
OR pg.blue_id = u.id AND pg.resign = \'red\'
THEN 1 END) AS wins,
COUNT(CASE WHEN
pg.red_id = u.id AND (pg.blue_points > pg.red_points AND pg.resign IS NULL)
OR pg.blue_id = u.id AND (pg.red_points > pg.blue_points AND pg.resign IS NULL)
OR pg.red_id = u.id AND pg.resign = \'red\'
OR pg.blue_id = u.id AND pg.resign = \'blue\'
THEN 1 END) AS losses,
COUNT(CASE WHEN
(pg.red_id = u.id OR pg.blue_id = u.id)
AND pg.red_points IS NOT NULL
AND pg.blue_points IS NOT NULL
AND pg.resign IS NULL
AND pg.red_points = pg.blue_points
THEN 1 END) AS draws,
COALESCE(SUM(
CASE WHEN pg.red_id = u.id THEN pg.red_points ELSE pg.blue_points END
), 0) AS total_mines,
COALESCE(SUM(
CASE WHEN pg.red_id = u.id THEN pg.red_bonus_points ELSE pg.blue_bonus_points END
), 0)::numeric(10,1) AS total_bonus_points,
COALESCE(AVG(
CASE WHEN pg.red_id = u.id THEN pg.red_bonus_points ELSE pg.blue_bonus_points END
), 0)::numeric(10,1) AS avg_bonus,
COALESCE(MAX(
CASE WHEN pg.red_id = u.id THEN (pg.red_bonus_stats->>\'chainBest\')::int ELSE (pg.blue_bonus_stats->>\'chainBest\')::int END
), 0) AS best_chain,
COALESCE(SUM(
CASE WHEN pg.red_id = u.id THEN (pg.red_bonus_stats->>\'blindHits\')::int ELSE (pg.blue_bonus_stats->>\'blindHits\')::int END
), 0) AS blind_hits,
COALESCE(SUM(
CASE WHEN pg.red_id = u.id THEN (pg.red_bonus_stats->>\'edgeMines\')::int ELSE (pg.blue_bonus_stats->>\'edgeMines\')::int END
), 0) AS edge_mines,
COUNT(CASE WHEN
pg.red_id = u.id AND pg.red_points IS NOT NULL
OR pg.blue_id = u.id AND pg.blue_points IS NOT NULL
THEN 1 END) AS games_with_scores,
MAX(pg.updated) AS last_game_at
FROM app_user u
LEFT JOIN played_game pg ON (pg.red_id = u.id OR pg.blue_id = u.id)
AND (pg.red_points IS NOT NULL OR pg.blue_points IS NOT NULL OR pg.resign IS NOT NULL)
GROUP BY u.id
');
$this->addSql('CREATE UNIQUE INDEX idx_user_stats_user_id ON user_stats (user_id)');
$this->addSql('CREATE INDEX idx_user_stats_last_game_at ON user_stats (last_game_at DESC)');
$this->addSql('
CREATE OR REPLACE FUNCTION refresh_user_stats()
RETURNS TRIGGER AS $$
BEGIN
REFRESH MATERIALIZED VIEW CONCURRENTLY user_stats;
RETURN NULL;
END;
$$ LANGUAGE plpgsql
');
$this->addSql('
CREATE TRIGGER trigger_refresh_user_stats
AFTER INSERT OR UPDATE OR DELETE ON played_game
FOR EACH STATEMENT
EXECUTE FUNCTION refresh_user_stats()
');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TRIGGER IF EXISTS trigger_refresh_user_stats ON played_game');
$this->addSql('DROP FUNCTION IF EXISTS refresh_user_stats()');
$this->addSql('DROP MATERIALIZED VIEW IF EXISTS user_stats');
}
}

View File

@@ -0,0 +1,124 @@
<?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.
*/
namespace App\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20260420130000
*
* Creates recent_battles materialized view for ProfileController optimization.
*
* @package App\Migrations
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 20.
*/
final class Version20260420130000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create recent_battles materialized view for profile recent games list.';
}
public function up(Schema $schema): void
{
$this->addSql('
CREATE MATERIALIZED VIEW recent_battles AS
SELECT
pg.id AS game_id,
pg.uuid::text AS uuid,
u.id AS user_id,
CASE WHEN pg.red_id = u.id THEN true ELSE false END AS is_red,
COALESCE(ru.username, ra.user_name, \'Guest\') AS red_name,
COALESCE(bu.username, ba.user_name, \'Guest\') AS blue_name,
ru.avatar_path AS red_avatar_path,
bu.avatar_path AS blue_avatar_path,
pg.red_points,
pg.blue_points,
pg.red_exploded_bomb,
pg.blue_exploded_bomb,
pg.resign,
COALESCE(pg.red_bonus_points, 0.0) AS red_bonus_points,
COALESCE(pg.blue_bonus_points, 0.0) AS blue_bonus_points,
COALESCE(pg.red_bonus_stats, \'[]\'::json) AS red_bonus_stats,
COALESCE(pg.blue_bonus_stats, \'[]\'::json) AS blue_bonus_stats,
CASE
WHEN pg.red_id = u.id AND pg.resign = \'red\' THEN \'loss\'
WHEN pg.blue_id = u.id AND pg.resign = \'blue\' THEN \'loss\'
WHEN pg.red_id = u.id AND pg.resign = \'blue\' THEN \'win\'
WHEN pg.blue_id = u.id AND pg.resign = \'red\' THEN \'win\'
WHEN pg.red_id = u.id AND pg.red_points IS NOT NULL AND pg.blue_points IS NOT NULL
AND pg.red_points > pg.blue_points THEN \'win\'
WHEN pg.blue_id = u.id AND pg.blue_points IS NOT NULL AND pg.red_points IS NOT NULL
AND pg.blue_points > pg.red_points THEN \'win\'
WHEN pg.red_id = u.id AND pg.red_points IS NOT NULL AND pg.blue_points IS NOT NULL
AND pg.red_points < pg.blue_points THEN \'loss\'
WHEN pg.blue_id = u.id AND pg.blue_points IS NOT NULL AND pg.red_points IS NOT NULL
AND pg.blue_points < pg.red_points THEN \'loss\'
ELSE \'draw\'
END AS result,
-- Whether the opponent in this game is an anonymous guest (no app_user account)
CASE
WHEN pg.red_id = u.id AND pg.blue_id IS NULL THEN true
WHEN pg.blue_id = u.id AND pg.red_id IS NULL THEN true
ELSE false
END AS opp_is_guest,
pg.created,
pg.updated
FROM app_user u
JOIN played_game pg
ON pg.red_id = u.id
OR pg.blue_id = u.id
LEFT JOIN app_user ru ON ru.id = pg.red_id
LEFT JOIN app_user bu ON bu.id = pg.blue_id
LEFT JOIN gamer ra ON ra.id = pg.red_anon
LEFT JOIN gamer ba ON ba.id = pg.blue_anon
');
$this->addSql('CREATE UNIQUE INDEX idx_recent_battles_pk ON recent_battles (user_id, game_id)');
$this->addSql('CREATE INDEX idx_recent_battles_user_upd ON recent_battles (user_id, updated DESC)');
$this->addSql('
CREATE OR REPLACE FUNCTION refresh_recent_battles()
RETURNS TRIGGER AS $$
BEGIN
REFRESH MATERIALIZED VIEW CONCURRENTLY recent_battles;
RETURN NULL;
END;
$$ LANGUAGE plpgsql
');
$this->addSql('
CREATE TRIGGER trigger_refresh_recent_battles
AFTER INSERT OR UPDATE OR DELETE ON played_game
FOR EACH STATEMENT
EXECUTE FUNCTION refresh_recent_battles()
');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TRIGGER IF EXISTS trigger_refresh_recent_battles ON played_game');
$this->addSql('DROP FUNCTION IF EXISTS refresh_recent_battles()');
$this->addSql('DROP MATERIALIZED VIEW IF EXISTS recent_battles');
}
}

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