Private
Public Access
1
0

Compare commits

...

61 Commits

Author SHA1 Message Date
199bb7e525 chg: pkg: fix all eslint issues - & add example of the testing env #10 2026-04-28 08:10:18 +02:00
5daaf71ae7 chg: pkg: new version release !skipChangelog 2026-04-23 21:42:21 +02:00
0aeec47996 new: pkg: add tracking code for the app #10
All checks were successful
Deploy to Production / deploy (push) Successful in 30s
2026-04-23 21:41:47 +02:00
3d67b8f2d9 chg: pkg: new version release !skipChangelog 2026-04-22 12:15:29 +02:00
dd9a190fd9 fix: usr: the error message cannot be seen during avatar changing #10
All checks were successful
Deploy to Production / deploy (push) Successful in 3m7s
2026-04-22 12:15:06 +02:00
f5e5019ea8 chg: pkg: new version release !skipChangelog 2026-04-21 22:47:04 +02:00
3f51eb5db6 chg: usr: increase the 2 MB avatar maximum file size to 10 MB #10
All checks were successful
Deploy to Production / deploy (push) Successful in 29s
2026-04-21 22:46:44 +02:00
55ef7c9301 chg: pkg: new version release !skipChangelog 2026-04-21 21:05:54 +02:00
ddfa395c6b chg: pkg: upgrade front-end & back-end deps to the latest available version #10
All checks were successful
Deploy to Production / deploy (push) Successful in 31s
2026-04-21 20:13:00 +02:00
8694627817 chg: pkg: new version release !skipChangelog 2026-04-21 18:21:17 +02:00
f796819af4 chg: pkg: new version release !skipChangelog 2026-04-21 18:12:32 +02:00
b209ad4220 chg: pkg: the original CI/CD workflow is restored - the work with tests is postponed #10
Some checks failed
Deploy to Production / test (push) Failing after 6s
Deploy to Production / deploy (push) Successful in 33s
2026-04-21 18:11:54 +02:00
df1eefdfe0 chg: pkg: new version release !skipChangelog
Some checks failed
CI - Tests / tests (push) Failing after 2m43s
CI - Tests / lint (push) Failing after 8s
2026-04-21 18:00:05 +02:00
7aaaf120b2 chg: usr: add sound when the game start #10 2026-04-21 17:59:18 +02:00
e48d651eb5 new: pkg: add CI/CD improvements - add new CI workflow - & improve the deployment w/ tests #10 2026-04-21 17:58:05 +02:00
d704be5bff new: pkg: add test cases to back-end w/ real database connection in it #10 2026-04-21 17:56:04 +02:00
6bf908b43e chg: dev: create AGENTS.md file for future maintenance #9 2026-04-21 14:30:38 +02:00
085e010907 chg: dev: remove the wrongly implemented font installation in docker - & replace it with static font on BattleCardGenerator (it solves the shareable image problem on bare-metal too) #8 2026-04-21 14:18:59 +02:00
8935216525 chg: pkg: new version release !skipChangelog 2026-04-21 13:58:08 +02:00
1d8efa4e61 chg: usr: fine-tune the recent battle list #8
All checks were successful
Deploy to Production / deploy (push) Successful in 27s
2026-04-21 13:57:44 +02:00
69fce52bed chg: pkg: new version release !skipChangelog 2026-04-21 11:47:58 +02:00
13adf908bf chg: dev: small changes on docs - and improve text on homepage #8
All checks were successful
Deploy to Production / deploy (push) Successful in 3m14s
2026-04-21 11:47:21 +02:00
3bbfb8740f chg: dev: massive refactor on front-end for unification and readiness #8 2026-04-21 11:30:07 +02:00
0d04ec91e7 fix: usr: do not hide the end-game overlay ever #8 2026-04-21 08:48:44 +02:00
20a969705d chg: dev: update all doc blocks on back-end #8 2026-04-20 21:24:39 +02:00
4944d2aa21 chg: dev: small refactors on back-end #8 2026-04-20 21:11:17 +02:00
2ec37a802b chg: dev: add RecentBattle entity that is a Materialized View to speed up the view - and further refactor on ProfileController #8 2026-04-20 21:08:15 +02:00
6a5ba84b5e chg: dev: create the UserStats entity what is a Materialized View to store Profile stats for every user - & massive ProfileController refactor #8 2026-04-20 20:44:33 +02:00
6be0d52fb7 chg: dev: refactor the SecurityController #7 2026-04-20 12:13:08 +02:00
f493f94368 chg: dev: refactor the code - there was unnecessary codes and wrongly formatted or designed code that are related to Repositories #7 2026-04-20 11:10:00 +02:00
cd93a26c2c fix: usr: the username was not recognized properly #7 2026-04-20 10:50:58 +02:00
175581cdd5 chg: pkg: upgrade the doctrine related back-end pkgs to the latest available version #7 2026-04-20 09:05:36 +02:00
5f856e4d70 chg: usr: add filter to the Profile page's recent plays and an infite list too #7 2026-04-19 22:11:58 +02:00
e0495d182e chg: pkg: upgrade to the latest doctrine pkg on back-end #7 2026-04-19 22:09:03 +02:00
0b7c1406cf chg: pkg: new version release !skipChangelog 2026-04-19 21:41:33 +02:00
30edc5782b fix: usr: the PostgreSQL logo was horrible #7
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-04-19 21:41:04 +02:00
d92a7f3aa0 chg: pkg: new version release !skipChangelog 2026-04-19 21:31:44 +02:00
f72cd45afd chg: pkg: new version release !skipChangelog
All checks were successful
Deploy to Production / deploy (push) Successful in 15s
2026-04-19 21:31:22 +02:00
51bd909879 chg: usr: add ReCaptcha overlay again to protect the game #7 2026-04-19 21:31:08 +02:00
db37ab45b2 chg: pkg: upgrade all front-end packages to the latest available version - and fix all eslint warnings & errors #7 2026-04-19 21:04:15 +02:00
9256db7f8c chg: pkg: upgrade fe packages #7 2026-04-19 20:57:00 +02:00
d9059acb78 chg: dev: massive refactor on fetches - create centralized dataProvider #7 2026-04-19 20:56:51 +02:00
5da8a04c18 chg: pkg: new version release !skipChangelog 2026-04-19 18:33:18 +02:00
ba8a0befb0 new: pkg: add Firebase deps to back-end #7
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-04-19 18:32:45 +02:00
5ac291de81 new: usr: add missing buttons for overlays #7 2026-04-19 18:22:28 +02:00
991b114a3c new: usr: a new feature came up - the abandoned plays can be restored, if both users are registered users #7 2026-04-19 18:04:01 +02:00
c79584c7d2 chg: usr: fix the '0' in Battle reports #6 2026-04-19 09:25:58 +02:00
e77c8a8f7c chg: usr: fix missing icons on "Battle report" #6 2026-04-19 09:10:17 +02:00
c2308ba408 fix: usr: the bomb using was not recorded correctly - the old data will be corrupted #6 2026-04-19 09:05:53 +02:00
e5a22cdfe3 chg: pkg: upgrade fe deps #5
All checks were successful
Deploy to Production / deploy (push) Successful in 17s
2026-04-18 22:12:20 +02:00
09b0d21621 new: usr: add new profile charts and stats - & add new logo to the tech stack #5 2026-04-18 22:12:07 +02:00
9aef27a0eb chg: usr: improve the Battle reports to change unnecessary data with interesting data #5 2026-04-18 17:56:50 +02:00
c00ed57240 fix: dev: the react is crashing on some cases #5 2026-04-18 17:53:02 +02:00
ef4cf6ef69 chg: pkg: new version release !skipChangelog 2026-04-18 13:45:10 +02:00
dc9c5f6545 chg: usr: add extended data to battle reports and sharing image to make viewable bonus points #5
All checks were successful
Deploy to Production / deploy (push) Successful in 12s
2026-04-18 13:44:15 +02:00
25f2aaab8c new: usr: add initialization bonus points' system to the gameplay #5 2026-04-18 12:57:20 +02:00
0cc9cdaf07 chg: pkg: new version release !skipChangelog 2026-04-18 11:44:18 +02:00
247f437445 fix: pkg: the font-awesome simplifying to work on bare-metal - & fix all warnings at build time #4
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-04-18 11:42:46 +02:00
0e94367223 new: usr: add rules page #4 2026-04-18 11:11:52 +02:00
a9ee28b395 fix: usr: the css problem had been solved on reponsive gfx on homepage #4 2026-04-18 10:34:46 +02:00
bd074c5c9d chg: pkg: new version release !skipChangelog 2026-04-18 08:49:59 +02:00
191 changed files with 13567 additions and 3043 deletions

3
.env.test Normal file
View File

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

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

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

View File

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

5
.gitignore vendored
View File

@@ -14,3 +14,8 @@ nohup.out
/var/
/vendor/
###< symfony/framework-bundle ###
###> phpunit/phpunit ###
/phpunit.xml
/.phpunit.cache/
###< phpunit/phpunit ###

464
AGENTS.md Normal file
View File

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

View File

@@ -1,6 +1,182 @@
# Changelog
## v2026.2.9-0 (2026-04-23)
### New
* Add tracking code for the app #10. [Lang]
## v2026.2.8-3 (2026-04-22)
### Fix
* The error message cannot be seen during avatar changing #10. [Lang]
## v2026.2.8-2 (2026-04-21)
### Changes
* Increase the 2 MB avatar maximum file size to 10 MB #10. [Lang]
## v2026.2.8-1 (2026-04-21)
### Changes
* Upgrade front-end & back-end deps to the latest available version #10. [Lang]
## v2026.2.8-0 (2026-04-21)
### New
* Add CI/CD improvements - add new CI workflow - & improve the deployment w/ tests #10. [Lang]
* Add test cases to back-end w/ real database connection in it #10. [Lang]
### Changes
* The original CI/CD workflow is restored - the work with tests is postponed #10. [Lang]
* Add sound when the game start #10. [Lang]
* Create AGENTS.md file for future maintenance #9. [Lang]
* Remove the wrongly implemented font installation in docker - & replace it with static font on BattleCardGenerator (it solves the shareable image problem on bare-metal too) #8. [Lang]
## v2026.2.7-1 (2026-04-21)
### Changes
* Fine-tune the recent battle list #8. [Lang]
## v2026.2.7-0 (2026-04-21)
### Changes
* Small changes on docs - and improve text on homepage #8. [Lang]
* Massive refactor on front-end for unification and readiness #8. [Lang]
* Update all doc blocks on back-end #8. [Lang]
* Small refactors on back-end #8. [Lang]
* Add RecentBattle entity that is a Materialized View to speed up the view - and further refactor on ProfileController #8. [Lang]
* Create the UserStats entity what is a Materialized View to store Profile stats for every user - & massive ProfileController refactor #8. [Lang]
* Refactor the SecurityController #7. [Lang]
* Refactor the code - there was unnecessary codes and wrongly formatted or designed code that are related to Repositories #7. [Lang]
* Upgrade the doctrine related back-end pkgs to the latest available version #7. [Lang]
* Add filter to the Profile page's recent plays and an infite list too #7. [Lang]
* Upgrade to the latest doctrine pkg on back-end #7. [Lang]
### Fix
* Do not hide the end-game overlay ever #8. [Lang]
* The username was not recognized properly #7. [Lang]
## v2026.2.6-1 (2026-04-19)
### Fix
* The PostgreSQL logo was horrible #7. [Lang]
## v2026.2.6-0 (2026-04-19)
### Changes
* Add ReCaptcha overlay again to protect the game #7. [Lang]
* Upgrade all front-end packages to the latest available version - and fix all eslint warnings & errors #7. [Lang]
* Upgrade fe packages #7. [Lang]
* Massive refactor on fetches - create centralized dataProvider #7. [Lang]
## v2026.2.5-0 (2026-04-19)
### New
* Add Firebase deps to back-end #7. [Lang]
* Add missing buttons for overlays #7. [Lang]
* A new feature came up - the abandoned plays can be restored, if both users are registered users #7. [Lang]
### Changes
* Fix the '0' in Battle reports #6. [Lang]
* Fix missing icons on "Battle report" #6. [Lang]
### Fix
* The bomb using was not recorded correctly - the old data will be corrupted #6. [Lang]
## v2026.2.4-0 (2026-04-18)
### New
* Add new profile charts and stats - & add new logo to the tech stack #5. [Lang]
### Changes
* Upgrade fe deps #5. [Lang]
* Improve the Battle reports to change unnecessary data with interesting data #5. [Lang]
### Fix
* The react is crashing on some cases #5. [Lang]
## v2026.2.3-0 (2026-04-18)
### New
* Add initialization bonus points' system to the gameplay #5. [Lang]
### Changes
* Add extended data to battle reports and sharing image to make viewable bonus points #5. [Lang]
## v2026.2.2-9 (2026-04-18)
### New
* Add rules page #4. [Lang]
### Fix
* The font-awesome simplifying to work on bare-metal - & fix all warnings at build time #4. [Lang]
* The css problem had been solved on reponsive gfx on homepage #4. [Lang]
## v2026.2.1-8 (2026-04-18)
### Fix
* Quickfix for https-only login - & add user data when the user is not logged in #4. [Lang]
## v2026.2.1-7 (2026-04-16)
### Changes

View File

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

View File

@@ -1,4 +1,4 @@
.PHONY: help start start-build stop build down ps logs prune-everything db-reset mercure-jwt
.PHONY: help start start-build stop build down ps logs prune-everything db-reset mercure-jwt cache-clear og-cache-clear test-db-setup test-db-reset test
.DEFAULT_GOAL := help
@@ -11,6 +11,12 @@ help:
@echo " make down - Stop and remove containers/networks"
@echo " make prune-everything - Prune volumes, networks and images (DANGEROUS!)"
@echo " make db-reset - Reset the database (drop, create, migrate) (DANGEROUS!)"
@echo " make ccp - Clear the production cache"
@echo " make cache-clear - Clear all caches (Vite, Symfony, node_modules)"
@echo " make og-cache-clear - Clear Open Graph cache only"
@echo " make test-db-setup - One-time setup: Create test database and run migrations"
@echo " make test-db-reset - Reset test database (drop, create, migrate)"
@echo " make test - Run PHPUnit tests"
start:
docker compose up -d
@@ -51,3 +57,49 @@ db-reset:
bin/console doctrine:database:drop --force --if-exists --no-interaction
bin/console doctrine:database:create --if-not-exists --no-interaction
bin/console doctrine:migrations:migrate --no-interaction
ccp:
bin/console cache:clear --no-warmup --env=prod
cache-clear:
@echo "Clearing all caches..."
@rm -rf node_modules/.vite
@rm -rf .vite
@rm -rf var/og-cache
@php bin/console cache:clear --no-warmup
@echo "✓ Vite cache cleared"
@echo "✓ OG cache cleared"
@echo "✓ Symfony cache cleared"
@echo ""
@echo "Rebuilding assets..."
@bun run build
@echo ""
@echo "✓ All caches cleared and assets rebuilt!"
@echo " Next step: Refresh browser with Ctrl+Shift+R"
og-cache-clear:
@echo "Clearing Open Graph cache..."
@rm -rf var/og-cache
@echo "✓ OG cache cleared!"
@echo " Battle card images will be regenerated on next access"
test-db-setup:
@echo "Setting up test database..."
@bin/console dbal:run-sql "SELECT 1 FROM pg_database WHERE datname='mineseeker_test'" 2>/dev/null | grep -q 1 || \
(bin/console dbal:run-sql "CREATE DATABASE mineseeker_test" && echo "✓ Database 'mineseeker_test' created")
@bin/console doctrine:migrations:migrate --env=test --no-interaction --allow-no-migration 2>&1 | grep -v "WARNING" || true
@echo "✓ Test database setup complete!"
@echo " Database: mineseeker_test"
@echo " Run tests with: make test"
test-db-reset:
@echo "Resetting test database..."
@bin/console dbal:run-sql "DROP DATABASE IF EXISTS mineseeker_test" --quiet
@bin/console dbal:run-sql "CREATE DATABASE mineseeker_test" --quiet
@bin/console doctrine:migrations:migrate --env=test --no-interaction --quiet
@echo "✓ Test database reset complete!"
@echo " Database: mineseeker_test"
@echo " Run tests with: make test"
test:
@php -d memory_limit=512M bin/phpunit --testdox --colors=always

View File

@@ -287,8 +287,93 @@ git push origin v2026.01
---
## Documentation
For detailed information about game mechanics, bonus systems, fonts, testing, and other technical details, see the [docs](./docs/) directory:
- **[AI Agent Guidelines](./AGENTS.md)** — Comprehensive guide for AI coding agents working on this project
- **[Bonus Points System](./docs/game-mechanics/BONUS_POINTS_SYSTEM.md)** — Complete reference for all bonus point types, calculation rules, and implementation details
- **[Fonts](./docs/FONTS.md)** — TrueType fonts used for server-side image generation
- **[Testing Guide](./docs/testing/TESTING.md)** — Complete testing setup with Foundry factories, database isolation, and best practices
- **[Factory Documentation](./docs/testing/FACTORIES.md)** — Detailed API reference for all test data factories
---
## License
LGPL-3.0 — see [LICENSE](LICENSE) for details.
&copy; 2026 [SplendidBear](https://www.splendidbear.org)
---
## Testing & CI/CD
MineSeeker has a comprehensive test suite with **71 automated tests** and continuous integration/deployment pipelines.
### Quick Start
```bash
# Setup test database (first time only)
make test-db-setup
# Run all tests
make test
# Run with documentation output
vendor/bin/phpunit --testdox
```
### Test Suite
- **71 tests** with **227 assertions**
- **Controller tests** - HTTP endpoints, authentication, routing
- **DTO tests** - Data serialization and calculations
- **Entity tests** - Domain logic and defaults
- **Service tests** - Business logic and external APIs
- **Integration tests** - Foundry factories and database isolation
**Test execution time:** ~6-8 seconds
### Continuous Integration
**Automated testing** runs on every push/pull request:
```yaml
# .gitea/workflows/ci.yml-bak
✓ PHP 8.3 setup with all extensions
✓ PostgreSQL 18 service container
✓ Composer and npm dependency installation
✓ Asset building with Vite
✓ Database migrations
✓ Full test suite execution
✓ Code linting (ESLint, PHP-CS-Fixer)
```
### Continuous Deployment
**Automated deployment** on version tags (e.g., `v1.2.3`):
```yaml
# .gitea/workflows/deploy.yml
1. Run full test suite (blocks deployment if fails)
2. Checkout tagged version
3. Build Docker image
4. Run database migrations
5. Restart services
6. Health check verification
```
**Deploy to production:**
```bash
git tag -a v1.2.3 -m "Release version 1.2.3"
git push origin v1.2.3
```
### Documentation
- **[Testing Guide](docs/testing/TESTING.md)** - Comprehensive testing documentation
- **[Factory Reference](docs/testing/FACTORIES.md)** - Foundry factory API
- **[CI/CD Guide](docs/CI_CD.md)** - Pipeline configuration and workflows
---

View File

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

View File

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

View File

@@ -1,14 +1,24 @@
.hero-auth {
position: absolute;
top: 28px;
right: 36px;
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
#hero-auth {
padding: 20px;
.hero-auth {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
z-index: 10;
}
}
.hero-auth-user {
.hero-auth-user {
font: 600 13px 'Rajdhani', sans-serif;
color: rgba(149, 207, 245, 0.75);
letter-spacing: 0.5px;
@@ -17,6 +27,13 @@
gap: 6px;
i { font-size: 15px; }
}
@media screen and (max-width: 1100px) {
.hero-auth {
justify-content: center;
}
}
}
.hero-auth-btn {

View File

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

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;
@@ -33,3 +42,11 @@ main div.txt a {
&:hover { color: #c5e8ff; }
}
main div.txt img {
border-radius: 10px;
}
main div.txt .img-container {
text-align: center;
}

View File

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

View File

@@ -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);
@@ -14,7 +23,6 @@ footer {
gap: 40px;
}
// Left: brand block
.footer-brand {
display: flex;
flex-direction: column;
@@ -55,7 +63,6 @@ footer {
line-height: 1.5;
}
// Right: navigation
.footer-nav-label {
font: 700 11px 'Rajdhani', sans-serif;
text-transform: uppercase;
@@ -91,7 +98,6 @@ footer {
}
}
// Bottom copyright bar
.footer-copy {
border-top: 1px solid rgba(255, 255, 255, 0.05);
padding: 16px 60px;

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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.
@@ -320,7 +320,6 @@ footer nav ul li {
}
footer nav ul li:nth-child(even) {
width: 50px;
text-align: center;
}
@@ -401,8 +400,4 @@ footer nav ul li a:hover {
footer nav ul li {
display: block;
}
footer nav ul li:nth-child(even) {
display: none;
}
}

View File

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

View File

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

Binary file not shown.

View File

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

View File

@@ -1,36 +1,34 @@
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, { Fragment, useMemo, useRef } from 'react';
import { string } from 'prop-types';
import { useProfileDataProvider } from '@mine-hooks/useGameDataProvider';
export const AvatarUpload = ({ uploadUrl, initialThumbUrl, initials }) => {
const inputRef = useRef(null);
const [thumbUrl, setThumbUrl] = React.useState(initialThumbUrl || null);
const { uploadAvatarMutation: { isPending, error, mutate } } = useProfileDataProvider();
function handleClick() {
inputRef.current?.click();
}
const errorMessage = useMemo(() => error?.message ?? null, [error]);
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,32 +38,39 @@ 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));
}
},
});
};
return (
<div
className={`profile-avatar${loading ? ' profile-avatar--loading' : ''}`}
title="Click to change profile picture"
onClick={handleClick}
>
{thumbUrl
? <img src={thumbUrl} alt={initials} className="profile-avatar__img" />
: <span className="profile-avatar__initials">{initials}</span>
}
<div className="profile-avatar__overlay">
<i className="fa fa-camera" />
<Fragment>
<div
className={`profile-avatar${isPending ? ' profile-avatar--loading' : ''}`}
title="Click to change profile picture"
onClick={() => inputRef.current?.click()}
>
{thumbUrl
? <img src={thumbUrl} alt={initials} className="profile-avatar__img" />
: <span className="profile-avatar__initials">{initials}</span>
}
<div className="profile-avatar__overlay">
<i className="fa fa-camera" />
</div>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
style={{ display: 'none' }}
onChange={handleChange}
/>
</div>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
style={{ display: 'none' }}
onChange={handleChange}
/>
{error && <div className="profile-avatar__error">{error}</div>}
</div>
{errorMessage && <div className="profile-avatar__error">{errorMessage}</div>}
</Fragment>
);
}
};
AvatarUpload.propTypes = {
uploadUrl: string.isRequired,
initialThumbUrl: string,
initials: string.isRequired,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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, isEnvDev }) => {
export const GameBoard = ({ gameAssoc, gameInherited, opponentName = '', isEnvDev }) => {
const { gridReady } = useGame();
const { onClick, resign } = useServerCommunication(gameAssoc, gameInherited, isEnvDev);
const { onClick, resign } = useServerCommunication(gameAssoc, gameInherited, opponentName, isEnvDev);
const [captchaVerified, setCaptchaVerified] = useState(false);
const siteKey = document.getElementById('mine-wrapper')?.dataset.recaptchaSiteKey;
if (!gridReady) {
return (
@@ -24,6 +29,12 @@ export const GameBoard = ({ gameAssoc, gameInherited, isEnvDev }) => {
);
}
if (!captchaVerified && siteKey) {
return (
<CaptchaOverlay siteKey={siteKey} onVerified={() => setCaptchaVerified(true)} />
);
}
return (
<GridControl
gameAssoc={gameAssoc}
@@ -32,3 +43,10 @@ export const GameBoard = ({ gameAssoc, gameInherited, isEnvDev }) => {
/>
);
};
GameBoard.propTypes = {
gameAssoc: string.isRequired,
gameInherited: bool.isRequired,
opponentName: string,
isEnvDev: bool,
};

View File

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

View File

@@ -8,38 +8,13 @@
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { formatSince } from '@global-utils/format';
import Dialog from '@mui/material/Dialog';
import { styled } from '@mui/material/styles';
import { useLobbyDataProvider } from '@mine-hooks';
import { bool, func, string } from 'prop-types';
const DIALOG_SX = {
'& .MuiDialog-paper': {
background: '#07090d',
backgroundImage: `
linear-gradient(rgba(35, 111, 135, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(35, 111, 135, 0.08) 1px, transparent 1px)
`,
backgroundSize: '46px 46px',
border: '1px solid rgba(35, 111, 135, 0.4)',
borderRadius: '12px',
boxShadow: '0 0 80px rgba(35, 111, 135, 0.15), 0 32px 80px rgba(0, 0, 0, 0.9)',
width: '500px',
maxWidth: '94vw',
overflow: 'hidden',
color: '#fff',
},
'& .MuiBackdrop-root': {
background: 'rgba(2, 4, 8, 0.88)',
backdropFilter: 'blur(4px)',
},
};
const formatSince = isoStr => {
const diff = Math.floor((Date.now() - new Date(isoStr)) / 60000);
if (1 > diff) return 'just now';
if (1 === diff) return '1 min ago';
return `${diff} min ago`;
};
const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc, isEnvDev = false }) => {
const [players, setPlayers] = useState([]);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(false);
@@ -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
<StyledDialog
open={open}
onClose={0 < waitingCountdown ? undefined : onClose}
disableEscapeKeyDown={0 < waitingCountdown}
sx={DIALOG_SX}
>
<div className="opd">
<div className="opd-header">
@@ -256,7 +235,7 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
<div className="opd-info">
<span className="opd-name">{player.name}</span>
<span className="opd-since">
<i className="fa fa-clock-o" />
<i className="fa fa-clock" />
{' '}Waiting {formatSince(player.since)}
</span>
</div>
@@ -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,47 +8,57 @@
*/
import { Fragment, useState } from 'react';
import { OnlinePlayersDialog } from '@mine-components';
import { bool, string } from 'prop-types';
const WaitingOverlayContent = ({ shareUrl, currentGameAssoc }) => {
const WaitingOverlayContent = ({ shareUrl, currentGameAssoc, opponentName = '', inviteOnly = false }) => {
const [dialogOpen, setDialogOpen] = useState(false);
const inviteHeader = inviteOnly && opponentName
? `Invite ${opponentName}`
: 'Invite a Friend';
return (
<Fragment>
<div className="waiting-options">
<div className={`waiting-options${inviteOnly ? ' waiting-options--invite-only' : ''}`}>
<div className="waiting-option">
<div className="waiting-option-header">
<i className="fa fa-link" />
<span>Invite a Friend</span>
<span>{inviteHeader}</span>
</div>
<p className="waiting-option-desc">Share this link with your opponent</p>
<ShareLinkBox
url={shareUrl}
/>
</div>
<div className="waiting-divider">
<span>OR</span>
</div>
<div className="waiting-option">
<div className="waiting-option-header">
<i className="fa fa-users" />
<span>Challenge a Player</span>
</div>
<p className="waiting-option-desc">Browse online players and challenge them</p>
<button
className="browse-players-btn"
onClick={() => setDialogOpen(true)}
>
<i className="fa fa-search" />
Browse Players
</button>
</div>
{!inviteOnly && (
<Fragment>
<div className="waiting-divider">
<span>OR</span>
</div>
<div className="waiting-option">
<div className="waiting-option-header">
<i className="fa fa-users" />
<span>Challenge a Player</span>
</div>
<p className="waiting-option-desc">Browse online players and challenge them</p>
<button
className="browse-players-btn"
onClick={() => setDialogOpen(true)}
>
<i className="fa fa-search" />
Browse Players
</button>
</div>
</Fragment>
)}
</div>
<OnlinePlayersDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
currentGameAssoc={currentGameAssoc}
/>
{!inviteOnly && (
<OnlinePlayersDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
currentGameAssoc={currentGameAssoc}
/>
)}
</Fragment>
);
};
@@ -57,10 +67,12 @@ const ShareLinkBox = ({ url }) => {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(url).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2500);
}).catch(() => {});
navigator.clipboard.writeText(url)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2500);
})
.catch(() => null);
};
return (
@@ -83,3 +95,10 @@ const ShareLinkBox = ({ url }) => {
};
export default WaitingOverlayContent;
WaitingOverlayContent.propTypes = {
shareUrl: string.isRequired,
currentGameAssoc: string,
opponentName: string,
inviteOnly: bool,
};

View File

@@ -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,11 +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);
});
@@ -58,21 +62,33 @@ 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 && (
<button
className={`game-overlay-share${copied ? ' copied' : ''}`}
onClick={handleShare}
title="Copy share link"
aria-label="Copy share link"
>
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} />
{copied ? 'Copied!' : 'Share Battle'}
</button>
<div className="game-overlay-actions">
<button
className={`game-overlay-share${copied ? ' copied' : ''}`}
onClick={handleShare}
title="Copy share link"
aria-label="Copy share link"
>
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} />
{copied ? 'Copied!' : 'Share Battle'}
</button>
<a
className="game-overlay-profile"
href={isAuthenticated ? '/profile' : '/'}
title={isAuthenticated ? 'Go to your profile' : 'Go to homepage'}
aria-label={isAuthenticated ? 'Go to your profile' : 'Go to homepage'}
>
<i className={`fa ${isAuthenticated ? 'fa-user' : 'fa-house'}`} />
{isAuthenticated ? 'My Profile' : 'Homepage'}
</a>
</div>
)}
</div>
</div>
@@ -99,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;
@@ -53,7 +54,10 @@ const GridField = memo(function GridField({ cell, onClick, onMouseEnter }) {
/>
)}
<div className={fieldClass}>
<div className="field-corner">
<div
style={{ background: "url('/images/bg-corner-outbg.png') no-repeat top left / 100% 100%" }}
className="field-corner"
>
{isNaN(currentImage) && (
<div className="flag-mine">
<img src={currentImage} alt="" />
@@ -72,3 +76,16 @@ const GridField = memo(function GridField({ cell, onClick, onMouseEnter }) {
});
export default GridField;
GridField.propTypes = {
cell: shape({
currentImage: string,
currentObj: string,
active: bool,
lastClickedRed: bool,
lastClickedBlue: bool,
bombTargetArea: number,
}).isRequired,
onClick: func.isRequired,
onMouseEnter: func.isRequired,
};

View File

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

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

View File

@@ -49,6 +49,7 @@ export const GameProvider = ({ children }) => {
mine: new Howl({ src: ['/sound/mine.mp3'] }),
warning: new Howl({ src: ['/sound/warning.mp3'] }),
won: new Howl({ src: ['/sound/won.mp3'] }),
starting: new Howl({ src: ['/sound/starting.mp3'] }),
});
/** Sync helpers (keep ref + state in lockstep) */
@@ -132,7 +133,18 @@ export const GameProvider = ({ children }) => {
};
const applyStep = stepData => {
const { player, bomb: isBomb, minesFound = 0, revealedCells = [], redPoints: rp, bluePoints: bp } = stepData;
const {
player,
bomb: isBomb,
minesFound = 0,
revealedCells = [],
redPoints: rp,
bluePoints: bp,
redBonusPoints = 0,
blueBonusPoints = 0,
redBonusStats = {},
blueBonusStats = {},
} = stepData;
if (isBomb) {
sounds.current.bomb.play();
@@ -176,6 +188,18 @@ export const GameProvider = ({ children }) => {
syncBlue(p => ({ ...p, mines: 'blue' === player ? bp : p.mines }));
}
/** Update bonus points and stats */
syncRed(p => ({
...p,
bonusPoints: 'red' === player ? redBonusPoints : p.bonusPoints,
bonusStats: 'red' === player ? redBonusStats : p.bonusStats,
}));
syncBlue(p => ({
...p,
bonusPoints: 'blue' === player ? blueBonusPoints : p.bonusPoints,
bonusStats: 'blue' === player ? blueBonusStats : p.bonusStats,
}));
syncRed(p => ({ ...p, enabledBomb: rp <= bp }));
syncBlue(p => ({ ...p, enabledBomb: bp <= rp }));
@@ -195,7 +219,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);
@@ -228,21 +255,23 @@ 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)
webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef,
// Sync helpers
/** Refs (needed by useServerComm for async-safe reads) */
webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, lastClickedRef, endRef,
/** Sync helpers */
syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue,
// Game logic called by useServerComm
/** Game logic called by useServerComm */
showOverlay, hideOverlay,
applyRevealedCell, applyStep,
makeGameEndIfItEnds, resignProcess,
// UI action
/** UI action */
onBombToggle,
/** Sounds */
sounds,
}}
>
{children}

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

4
bin/phpunit Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env php
<?php
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';

232
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", "http://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
"@babel/core": ["@babel/core@7.29.0", "http://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", { "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", "http://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", { "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", "http://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", { "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", "http://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.29.2", "http://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
"@babel/parser": ["@babel/parser@7.29.2", "http://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
"@babel/runtime": ["@babel/runtime@7.29.2", "http://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
@@ -94,23 +106,23 @@
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "http://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.21.2", "http://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="],
"@eslint/config-array": ["@eslint/config-array@0.23.5", "http://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", { "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", "http://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", { "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", "http://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", { "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", "http://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", { "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", "http://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", {}, "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", "http://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
"@fontsource/changa-one": ["@fontsource/changa-one@5.2.8", "http://registry.npmjs.org/@fontsource/changa-one/-/changa-one-5.2.8.tgz", {}, "sha512-gagiU8sMWLs9ejh41NmrYlGdgasSYWFegz5/+22WqYdJVS9HZbaUEXj/6jj3ZKgi+dQZT0kYI+Nha2UQGbO/mA=="],
"@fontsource/open-sans": ["@fontsource/open-sans@5.2.7", "", {}, "sha512-8yfgDYjE5O0vmTPdrcjV35y4yMnctsokmi9gN49Gcsr0sjzkMkR97AnKDe6OqZh2SFkYlR28fxOvi21bYEgMSw=="],
"@fontsource/open-sans": ["@fontsource/open-sans@5.2.7", "http://registry.npmjs.org/@fontsource/open-sans/-/open-sans-5.2.7.tgz", {}, "sha512-8yfgDYjE5O0vmTPdrcjV35y4yMnctsokmi9gN49Gcsr0sjzkMkR97AnKDe6OqZh2SFkYlR28fxOvi21bYEgMSw=="],
"@fontsource/rajdhani": ["@fontsource/rajdhani@5.2.7", "http://registry.npmjs.org/@fontsource/rajdhani/-/rajdhani-5.2.7.tgz", {}, "sha512-7Gy10U688fCdeFfYKebUF2TZotdgH/ghKyMsseXPmB60lpaUHC8aoCSJl5/OpZ+KHKSU2TqBfKfteVkcIXxTAQ=="],
@@ -126,6 +138,8 @@
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "http://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "http://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "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,15 +160,15 @@
"@mui/utils": ["@mui/utils@9.0.0", "http://registry.npmjs.org/@mui/utils/-/utils-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@mui/types": "^9.0.0", "@types/prop-types": "^15.7.15", "clsx": "^2.1.1", "prop-types": "^15.8.1", "react-is": "^19.2.4" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-bQcqyg/gjULUqTuyUjSAFr6LQGLvtkNtDbJerAtoUn9kGZ0hg5QJiN1PLHMLbeFpe3te1831uq7GFl2ITokGdg=="],
"@mui/x-charts": ["@mui/x-charts@9.0.1", "http://registry.npmjs.org/@mui/x-charts/-/x-charts-9.0.1.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@mui/utils": "9.0.0", "@mui/x-charts-vendor": "^9.0.0", "@mui/x-internal-gestures": "^9.0.0", "@mui/x-internals": "^9.0.0", "bezier-easing": "^2.1.0", "clsx": "^2.1.1", "prop-types": "^15.8.1", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", "@mui/material": "^7.3.0 || ^9.0.0", "@mui/system": "^7.3.0 || ^9.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled"] }, "sha512-0LyhlGhUm07wGJY0d0U+hSljGS1EHKWgPBsTJ/lBNGDrNc4DI9zSbp4h802LN/eLwMUVXJSI7DH2W3Ef3WsqnQ=="],
"@mui/x-charts": ["@mui/x-charts@9.0.2", "http://registry.npmjs.org/@mui/x-charts/-/x-charts-9.0.2.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@mui/utils": "9.0.0", "@mui/x-charts-vendor": "^9.0.0", "@mui/x-internal-gestures": "^9.0.2", "@mui/x-internals": "^9.0.0", "bezier-easing": "^2.1.0", "clsx": "^2.1.1", "prop-types": "^15.8.1", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", "@mui/material": "^7.3.0 || ^9.0.0", "@mui/system": "^7.3.0 || ^9.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled"] }, "sha512-bKgjGD+uJbDN/g7tMjVmlNdm+iM4UkCJoYruQmgpQ0l+cip8Kn4kmn1iD//rZ35an+LdWaUZ4MHvMzV76D6EJw=="],
"@mui/x-charts-vendor": ["@mui/x-charts-vendor@9.0.0", "http://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@types/d3-array": "^3.2.2", "@types/d3-color": "^3.1.3", "@types/d3-format": "^3.0.4", "@types/d3-interpolate": "^3.0.4", "@types/d3-path": "^3.1.1", "@types/d3-scale": "^4.0.9", "@types/d3-shape": "^3.1.8", "@types/d3-time": "^3.0.4", "@types/d3-time-format": "^4.0.3", "@types/d3-timer": "^3.0.2", "d3-array": "^3.2.4", "d3-color": "^3.1.0", "d3-format": "^3.1.2", "d3-interpolate": "^3.0.1", "d3-path": "^3.1.0", "d3-scale": "^4.0.2", "d3-shape": "^3.2.0", "d3-time": "^3.1.0", "d3-time-format": "^4.1.0", "d3-timer": "^3.0.1", "flatqueue": "^3.0.0", "internmap": "^2.0.3" } }, "sha512-Do91i+fZiNj/4LN5oaGpJoutolzDBDwdfw6tHrx2LKXDMCRlaImCfreLbdbkk7dFsi9fuIP7hWiMV4vDJKPJTA=="],
"@mui/x-internal-gestures": ["@mui/x-internal-gestures@9.0.0", "http://registry.npmjs.org/@mui/x-internal-gestures/-/x-internal-gestures-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.6" } }, "sha512-+fW1EUai25GJbivGRsi3GX4GYsSvzFPvUEjmMgB4POkRBDjrEZNaLdVWfapT6DlWv/Vfbi08bYSuyvhPXGMZjw=="],
"@mui/x-internal-gestures": ["@mui/x-internal-gestures@9.0.2", "http://registry.npmjs.org/@mui/x-internal-gestures/-/x-internal-gestures-9.0.2.tgz", { "dependencies": { "@babel/runtime": "^7.28.6" } }, "sha512-xCp99a7cSb7iH1bj4G524ooMOFe92H8m/rONCUiKyj7LvV1YUGzTfHgJysQgDCZJqHYaW7YAGLvwMUyEMZVzqQ=="],
"@mui/x-internals": ["@mui/x-internals@9.0.0", "http://registry.npmjs.org/@mui/x-internals/-/x-internals-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@mui/utils": "9.0.0", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-E/4rdg69JjhyybpPGypCjAKSKLLnSdCFM+O6P/nkUg47+qt3uftxQEhjQO53rcn6ahHl6du/uNZ9BLgeY6kYxQ=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "http://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "http://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
@@ -162,7 +176,7 @@
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "http://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@oxc-project/types": ["@oxc-project/types@0.124.0", "http://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="],
"@oxc-project/types": ["@oxc-project/types@0.126.0", "", {}, "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ=="],
"@parcel/watcher": ["@parcel/watcher@2.5.6", "http://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],
@@ -196,43 +210,43 @@
"@popperjs/core": ["@popperjs/core@2.11.8", "http://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.16", "", { "os": "android", "cpu": "arm64" }, "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.16", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "arm" }, "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm" }, "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "s390x" }, "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "x64" }, "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "x64" }, "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", { "os": "none", "cpu": "arm64" }, "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.16", "", { "os": "none", "cpu": "arm64" }, "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.3" }, "cpu": "none" }, "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.16", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", { "os": "win32", "cpu": "x64" }, "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "x64" }, "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g=="],
"@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", "http://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.56.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": "^9.0.0 || ^10.0.0" } }, "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ=="],
"@tanstack/query-core": ["@tanstack/query-core@5.97.0", "http://registry.npmjs.org/@tanstack/query-core/-/query-core-5.97.0.tgz", {}, "sha512-QdpLP5VzVMgo4VtaPppRA2W04UFjIqX+bxke/ZJhE5cfd5UPkRzqIAJQt9uXkQJjqE8LBOMbKv7f8HCsZltXlg=="],
"@tanstack/query-core": ["@tanstack/query-core@5.99.2", "http://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.2.tgz", {}, "sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA=="],
"@tanstack/react-query": ["@tanstack/react-query@5.97.0", "http://registry.npmjs.org/@tanstack/react-query/-/react-query-5.97.0.tgz", { "dependencies": { "@tanstack/query-core": "5.97.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-y4So4eGcQoK2WVMAcDNZE9ofB/p5v1OlKvtc1F3uqHwrtifobT7q+ZnXk2mRkc8E84HKYSlAE9z6HXl2V0+ySQ=="],
"@tanstack/react-query": ["@tanstack/react-query@5.99.2", "http://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.2.tgz", { "dependencies": { "@tanstack/query-core": "5.99.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vM91UEe45QUS9ED6OklsVL15i8qKcRqNwpWzPTVWvRPRSEgDudDgHpvyTjcdlwHcrKNa80T+xXYcchT2noPnZA=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "http://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
@@ -256,6 +270,8 @@
"@types/d3-timer": ["@types/d3-timer@3.0.2", "http://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "http://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", {}, "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", "http://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz", { "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", "http://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", { "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", "http://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", {}, "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", "http://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", {}, "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", "http://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", {}, "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", "http://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", {}, "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", "http://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz", { "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", "http://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", { "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", "http://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", { "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", "http://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", {}, "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", "http://registry.npmjs.org/globals/-/globals-17.5.0.tgz", {}, "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", "http://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"hermes-parser": ["hermes-parser@0.25.1", "http://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "http://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
"howler": ["howler@2.1.2", "http://registry.npmjs.org/howler/-/howler-2.1.2.tgz", {}, "sha512-oKrTFaVXsDRoB/jik7cEpWKTj7VieoiuzMYJ7E/EU5ayvmpRhumCv3YQ3823zi9VTJkSWAhbryHnlZAionGAJg=="],
"howler": ["howler@2.2.4", "http://registry.npmjs.org/howler/-/howler-2.2.4.tgz", {}, "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="],
"ignore": ["ignore@5.3.2", "http://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -586,6 +596,8 @@
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "http://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json5": ["json5@2.2.3", "http://registry.npmjs.org/json5/-/json5-2.2.3.tgz", { "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", "http://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", { "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", "http://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", {}, "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=="],
@@ -686,11 +700,11 @@
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "http://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["postcss@8.5.9", "http://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="],
"postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"prelude-ls": ["prelude-ls@1.2.1", "http://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prop-types": ["prop-types@15.7.2", "http://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.8.1" } }, "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ=="],
"prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"punycode": ["punycode@2.3.1", "http://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
@@ -718,7 +732,7 @@
"reusify": ["reusify@1.1.0", "http://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rolldown": ["rolldown@1.0.0-rc.15", "http://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="],
"rolldown": ["rolldown@1.0.0-rc.16", "", { "dependencies": { "@oxc-project/types": "=0.126.0", "@rolldown/pluginutils": "1.0.0-rc.16" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-x64": "1.0.0-rc.16", "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g=="],
"run-parallel": ["run-parallel@1.2.0", "http://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
@@ -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,15 +808,15 @@
"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", "http://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", { "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=="],
"vite": ["vite@8.0.8", "http://registry.npmjs.org/vite/-/vite-8.0.8.tgz", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="],
"vite": ["vite@8.0.9", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.16", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw=="],
"vite-plugin-symfony": ["vite-plugin-symfony@8.2.4", "http://registry.npmjs.org/vite-plugin-symfony/-/vite-plugin-symfony-8.2.4.tgz", { "dependencies": { "debug": "^4.4.1", "fast-glob": "^3.3.3", "picocolors": "^1.1.1", "sirv": "^3.0.1" }, "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-ph98EMPx8FhA6QIp43ZiK0zjODV9jumB7EHXfZEhRme2lo9oBa9sAXCNCCIvdVk/m9EWkNZpcRBjequjXZiSuA=="],
@@ -822,35 +832,31 @@
"word-wrap": ["word-wrap@1.2.5", "http://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"yallist": ["yallist@3.1.1", "http://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", {}, "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", "http://registry.npmjs.org/zod/-/zod-4.3.6.tgz", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zod-validation-error": ["zod-validation-error@4.0.2", "http://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "http://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "http://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/config-array/minimatch": ["minimatch@10.2.5", "http://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "http://registry.npmjs.org/globals/-/globals-14.0.0.tgz", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@mui/material/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"@mui/private-theming/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"@mui/styled-engine/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"@mui/system/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"@mui/utils/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"@mui/x-charts/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "http://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "http://registry.npmjs.org/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "http://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"babel-plugin-macros/resolve": ["resolve@1.22.12", "http://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
"eslint-plugin-react/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"eslint/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "http://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"eslint/espree": ["espree@11.2.0", "http://registry.npmjs.org/espree/-/espree-11.2.0.tgz", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
"eslint/minimatch": ["minimatch@10.2.5", "http://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "http://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -858,30 +864,16 @@
"micromatch/picomatch": ["picomatch@2.3.2", "http://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"prop-types/react-is": ["react-is@16.11.0", "http://registry.npmjs.org/react-is/-/react-is-16.11.0.tgz", {}, "sha512-gbBVYR2p8mnriqAwWx9LbuUrShnAuSCNnuPGyc7GJrMVQtPDAh8iLpv7FRuMPFb56KkaVZIYSz1PrjI9q0QPCw=="],
"prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-transition-group/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.16", "", {}, "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA=="],
"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=="],
"@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@5.0.5", "http://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"@mui/material/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"eslint/minimatch/brace-expansion": ["brace-expansion@5.0.5", "http://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"@mui/private-theming/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"@eslint/config-array/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "http://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"@mui/styled-engine/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"@mui/system/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"@mui/utils/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"@mui/x-charts/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "http://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"eslint-plugin-react/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-transition-group/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "http://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"eslint/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "http://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
}
}

View File

@@ -11,14 +11,15 @@
"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",
"league/flysystem-bundle": "^3.6",
"liip/imagine-bundle": "^2.13",
@@ -32,9 +33,9 @@
"symfony/framework-bundle": "7.4.*",
"symfony/http-client": "7.4.*",
"symfony/mailer": "7.4.*",
"symfony/mercure": "^0.6",
"symfony/mercure-bundle": "*",
"symfony/monolog-bundle": "^3.8",
"symfony/mercure": "^0.7",
"symfony/mercure-bundle": "^0.4",
"symfony/monolog-bundle": "^4.0",
"symfony/security-bundle": "7.4.*",
"symfony/translation": "7.4.*",
"symfony/twig-bundle": "7.4.*",
@@ -43,12 +44,16 @@
"web-auth/webauthn-framework": "^5.2"
},
"require-dev": {
"firebase/php-jwt": "^7.0",
"dama/doctrine-test-bundle": "^8.6",
"phpunit/phpunit": "^13.1",
"roave/security-advisories": "dev-master",
"symfony/browser-kit": "7.4.*",
"symfony/css-selector": "7.4.*",
"symfony/dotenv": "7.4.*",
"symfony/maker-bundle": "^1.5",
"symfony/stopwatch": "7.4.*",
"symfony/web-profiler-bundle": "7.4.*"
"symfony/web-profiler-bundle": "7.4.*",
"zenstruck/foundry": "^2.9"
},
"config": {
"preferred-install": {

3228
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,4 +16,6 @@ return [
Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true],
League\FlysystemBundle\FlysystemBundle::class => ['all' => true],
Liip\ImagineBundle\LiipImagineBundle::class => ['all' => true],
Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['dev' => true, 'test' => true],
DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
];

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

@@ -1,4 +1,4 @@
framework:
test: true
session:
storage_id: session.storage.mock_file
storage_factory_id: session.storage.factory.mock_file

View File

@@ -0,0 +1,16 @@
when@dev: &dev
# See full configuration: https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#full-default-bundle-configuration
zenstruck_foundry:
persistence:
# Flush only once per call of `PersistentObjectFactory::create()`
flush_once: true
# If you use the `make:factory --test` command, you may need to uncomment the following.
# See https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#generate
#services:
# App\Tests\Factory\:
# resource: '%kernel.project_dir%/tests/Factory/'
# autowire: true
# autoconfigure: true
when@test: *dev

View File

@@ -28,6 +28,7 @@ services:
App\Service\BattleCardGenerator:
arguments:
$cacheDir: '%kernel.project_dir%/var/og-cache'
$fontPath: '%kernel.project_dir%/assets/fonts/Carlito-Bold.ttf'
$minioMediaStorage: '@mineseeker.media.storage'
Aws\S3\S3Client:

443
docs/CI_CD.md Normal file
View File

@@ -0,0 +1,443 @@
# CI/CD Integration Guide
This document explains how automated tests are integrated into the MineSeeker deployment pipeline using Gitea Actions.
## Overview
MineSeeker uses **Gitea Actions** (GitHub Actions compatible) for continuous integration and deployment:
- **CI Pipeline** (`.gitea/workflows/ci.yml`) - Runs on every push/PR to main/develop
- **CD Pipeline** (`.gitea/workflows/deploy.yml`) - Runs on version tags, includes tests before deployment
---
## CI Workflow (Continuous Integration)
**Trigger:** Push or Pull Request to `main` or `develop` branches
**File:** `.gitea/workflows/ci.yml`
### Jobs
#### 1. **Tests Job**
Runs the full PHPUnit test suite with:
- PostgreSQL 18 service container
- PHP 8.3 with required extensions
- Composer dependency installation
- Node.js for asset building
- Database setup and migrations
- 71 PHPUnit tests with testdox output
**Steps:**
1. Checkout code
2. Setup PHP 8.3 with extensions (pdo_pgsql, gd, intl, zip, sodium)
3. Validate `composer.json`
4. Cache and install Composer dependencies
5. Setup Node.js 20 and install dependencies
6. Build frontend assets with Vite
7. Create `.env.test` configuration
8. Setup test database with migrations
9. Run PHPUnit tests with `--testdox` output
10. (Optional) Generate coverage report on PRs
#### 2. **Lint Job**
Code quality checks:
- ESLint for JavaScript/JSX
- PHP-CS-Fixer (if installed)
---
## CD Workflow (Continuous Deployment)
**Trigger:** Push of version tags (e.g., `v1.0.0`, `v1.2.3`)
**File:** `.gitea/workflows/deploy.yml`
### Jobs
#### 1. **Test Job** (Pre-deployment)
**Critical:** Deployment only proceeds if tests pass.
- Runs same test suite as CI workflow
- Uses `--stop-on-failure` flag for fast feedback
- Blocks deployment on any test failure
#### 2. **Deploy Job** (Production Deployment)
**Depends on:** `test` job must complete successfully
**Steps:**
1. Checkout tagged version
2. Write production `.env` from secrets
3. Build Docker image
4. Run database migrations
5. Clear production cache
6. Start/restart services with `docker compose up -d`
7. Health check (curl to verify app is running)
8. Notify success/failure
---
## Configuration Requirements
### Gitea Repository Variables
Set these in Gitea repository settings:
```
PROD_APP_DIR=/path/to/production/app
```
### Gitea Repository Secrets
```
PROD_ENV_FILE=<contents of production .env file>
```
### Test Database Configuration
The CI/CD pipeline uses a PostgreSQL service container with these credentials:
```env
POSTGRES_USER=mineseeker_test
POSTGRES_PASSWORD=test_password
POSTGRES_DB=mineseeker_test
```
---
## Running Tests Locally
Before pushing, run tests locally to catch issues early:
```bash
# Setup test database (first time only)
make test-db-setup
# Run tests
make test
# Run with testdox output
vendor/bin/phpunit --testdox
# Run with coverage
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html=var/coverage
```
---
## Deployment Workflow
### Standard Deployment Process
1. **Develop features** on feature branches
2. **Open Pull Request** to `develop` or `main`
- CI workflow runs automatically
- Tests must pass before merge
3. **Merge PR** after review and passing tests
4. **Create version tag** when ready to deploy:
```bash
git tag -a v1.2.3 -m "Release version 1.2.3"
git push origin v1.2.3
```
5. **Deployment runs automatically:**
- Tests run first
- If tests pass, Docker image builds
- Migrations run
- Services restart
- Health check verifies deployment
### Rollback Process
If deployment fails or issues are discovered:
```bash
# Tag and deploy previous stable version
git push origin v1.2.2
# Or SSH to production and manually rollback
cd /path/to/production/app
git checkout v1.2.2
docker compose build
docker compose up -d
```
---
## Test Suite Details
### Test Coverage
The CI/CD pipeline runs **71 tests** across:
- **Controller Tests** (29 tests)
- GameController, ProfileController, SecurityController
- HTTP status codes, authentication, routing
- **DTO Tests** (9 tests)
- ProfileGameDto, ProfileStatsDto, ProfileChartDataDto
- Serialization, null handling, calculations
- **Entity Tests** (8 tests)
- UserStats calculations and defaults
- **Service Tests** (13 tests)
- MercureJwtService, RecaptchaService
- Token generation, API verification
- **Integration Tests** (12 tests)
- Factory usage examples
- Database isolation
### Test Execution Time
Typical test suite runtime: **6-8 seconds**
---
## Best Practices
### Before Committing
✅ **Always run tests locally:**
```bash
make test
```
✅ **Check code style:**
```bash
vendor/bin/php-cs-fixer fix --dry-run
npm run lint
```
✅ **Verify assets build:**
```bash
npm run build
```
### When Creating PRs
✅ **Wait for CI checks** to pass before requesting review
✅ **Fix failing tests** immediately - don't merge broken code
✅ **Review test output** in Gitea Actions logs
### When Deploying
✅ **Tag semantic versions:** `v1.2.3` (major.minor.patch)
✅ **Write meaningful tag messages:**
```bash
git tag -a v1.2.3 -m "Add bonus points to battle cards, fix avatar upload bug"
```
✅ **Monitor deployment logs** in Gitea Actions
✅ **Verify health check** passes after deployment
✅ **Test critical features** in production after deployment
---
## Troubleshooting
### Tests Pass Locally but Fail in CI
**Possible causes:**
1. **Database state:** CI uses fresh database, local may have leftover data
```bash
make test-db-reset # Reset local test database
```
2. **Environment differences:** Check `.env.test` matches CI configuration
3. **Cached dependencies:** CI caches may be stale
- Clear cache in Gitea Actions settings
- Or add `--no-cache` to composer install
### Deployment Fails After Tests Pass
**Common issues:**
1. **Migration conflicts:** Manually run migrations on production
```bash
docker compose run --rm app php bin/console doctrine:migrations:migrate
```
2. **Missing environment variables:** Check `PROD_ENV_FILE` secret is up-to-date
3. **Docker build errors:** Check Dockerfile and build context
4. **Health check timeout:** Increase sleep time or check application startup
### Database Migration Issues
If migrations fail during deployment:
```bash
# SSH to production server
cd /path/to/production/app
# Check migration status
docker compose run --rm app php bin/console doctrine:migrations:status
# Manually run migrations
docker compose run --rm app php bin/console doctrine:migrations:migrate --no-interaction
# If migration is stuck, mark as executed
docker compose run --rm app php bin/console doctrine:migrations:version YYYYMMDDHHMMSS --add
```
---
## Monitoring and Notifications
### Viewing CI/CD Logs
1. Go to Gitea repository
2. Click **Actions** tab
3. Select workflow run
4. View detailed logs for each step
### Setting Up Notifications
**Gitea webhook notifications:**
Configure webhooks in repository settings to notify:
- Slack/Discord when builds fail
- Email on deployment success/failure
- Custom endpoints for monitoring systems
**Example webhook payload:**
```json
{
"event": "workflow_run",
"repository": "Mine",
"workflow": "deploy.yml",
"status": "success",
"tag": "v1.2.3"
}
```
---
## Advanced Configuration
### Running Tests with Coverage
Enable coverage in CI (requires Xdebug):
```yaml
- name: Run tests with coverage
run: |
XDEBUG_MODE=coverage vendor/bin/phpunit \
--coverage-clover=coverage.xml \
--coverage-html=var/coverage
```
### Parallel Test Execution
For larger test suites, use ParaTest:
```bash
composer require --dev brianium/paratest
```
```yaml
- name: Run tests in parallel
run: vendor/bin/paratest --processes=4
```
### Database Seeding for E2E Tests
Add step before tests:
```yaml
- name: Seed test data
run: |
php bin/console doctrine:fixtures:load --no-interaction --env=test
```
---
## Security Considerations
### Secrets Management
❗ **Never commit secrets to repository**
✅ **Use Gitea secrets** for sensitive data:
- `PROD_ENV_FILE` - Production environment variables
- Database credentials
- API keys
✅ **Rotate secrets regularly**
✅ **Use environment-specific secrets** (staging, production)
### Database Security
✅ **Test database is isolated** - No production data access
✅ **Credentials are ephemeral** - Service containers use temporary passwords
✅ **No data persistence** - Test database recreated on each run
---
## Performance Optimization
### Caching Strategies
CI/CD workflows cache:
1. **Composer dependencies** - `vendor/` directory
2. **Node modules** - `node_modules/` directory
3. **Docker layers** - Image build cache
### Reducing Build Time
✅ **Use `composer install --no-dev`** in production builds
✅ **Multi-stage Docker builds** - Separate assets from PHP
✅ **Parallel jobs** - Tests and linting run concurrently
✅ **Skip unnecessary steps** - Use conditionals (`if:` statements)
---
## Future Enhancements
### Planned Improvements
- [ ] Automated browser tests with Symfony Panther
- [ ] Visual regression testing for UI changes
- [ ] Performance benchmarking in CI
- [ ] Automated security scanning (Symfony Security Checker)
- [ ] Staging environment deployments before production
- [ ] Blue-green deployment strategy
- [ ] Automated rollback on health check failure
---
## References
- **Gitea Actions Documentation:** https://docs.gitea.com/usage/actions/overview
- **GitHub Actions Reference:** https://docs.github.com/en/actions (compatible syntax)
- **PHPUnit Documentation:** https://phpunit.de/documentation.html
- **Symfony Testing:** https://symfony.com/doc/current/testing.html
- **Docker Compose:** https://docs.docker.com/compose/
---
**Last Updated:** 2026-04-21
**MineSeeker Version:** 1.0.0
**CI/CD Platform:** Gitea Actions

47
docs/FONTS.md Normal file
View File

@@ -0,0 +1,47 @@
# Font Files
This directory contains TrueType Font (TTF) files used for server-side image generation with PHP GD.
## Carlito-Bold.ttf
- **Font:** Carlito Bold
- **Source:** Google Fonts (Carlito Project)
- **License:** SIL Open Font License 1.1
- **URL:** https://github.com/googlefonts/carlito
- **Usage:** Used by `BattleCardGenerator` service for generating battle card OG images
- **Note:** Carlito is a metric-compatible font family to Calibri
## Why TTF instead of @fontsource?
The `@fontsource` npm packages provide WOFF/WOFF2 files for web usage, but PHP's GD library (`imagettftext()`) requires TrueType Font (TTF) files for server-side text rendering.
## Alternatives
If you want to use a different font:
1. **Install system fonts:**
```bash
# Find available TTF fonts
find /usr/share/fonts -name "*.ttf" -type f
# Copy desired font
cp /usr/share/fonts/path/to/Font-Bold.ttf assets/fonts/
```
2. **Download from Google Fonts:**
```bash
# Visit https://fonts.google.com
# Download the font family
# Extract the TTF file from the zip
```
3. **Update service configuration:**
Edit `config/services.yaml` and update the `$fontPath` parameter.
## Cache Clearing
After changing fonts, clear the OG image cache:
```bash
rm -rf var/og-cache/*
```

195
docs/README.md Normal file
View File

@@ -0,0 +1,195 @@
# Mine-Seeker Game Documentation
This directory contains comprehensive documentation about the Mine-Seeker game mechanics, testing, and deployment.
## Game Mechanics
### [Bonus Points System](./game-mechanics/BONUS_POINTS_SYSTEM.md)
Complete reference for the bonus points system including:
- All 6 bonus point types (Blind Hit, Chain Combo, Edge Mine, Endgame Mine, Safe Cell Bonus, Biggest Reveal)
- Calculation rules and examples
- Bonus statistics tracking
- Player name formatting in dialogs
- Database schema
- Implementation notes
- Testing checklist
**Recommended for**: Developers working on bonus system, AI assistants implementing or debugging bonus features, understanding game scoring mechanics.
---
## Testing
### [Testing Guide](./testing/TESTING.md)
Complete testing setup and best practices including:
- Test environment configuration
- Database isolation with DAMA Doctrine Test Bundle
- Test database setup (`mineseeker_test`)
- Modern PHPUnit attributes (`#[Test]`, `#[TestDox]`)
- Writing effective tests
- Running tests (all, specific, with coverage)
- Best practices (AAA pattern, factories, type hints)
- CI/CD integration examples
- Troubleshooting guide
**Recommended for**: All developers, understanding test infrastructure, writing new tests, debugging test failures.
### [Factory Documentation](./testing/FACTORIES.md)
Detailed API reference for Zenstruck Foundry factories:
- All available factories (User, Gamer, PlayedGame, Step, Grid, etc.)
- Factory usage examples
- Creating related entities
- Repository access
- Database isolation explanation
- Best practices and patterns
**Recommended for**: Writing tests with test data, understanding factory patterns, creating complex test scenarios.
### [Quick Reference](./testing/QUICK_REFERENCE.md)
Quick command reference for testing:
- Common test commands
- Factory usage cheat sheet
- Troubleshooting tips
**Recommended for**: Quick lookups during development.
---
## CI/CD & Deployment
### [CI/CD Integration Guide](./CI_CD.md)
Comprehensive guide for continuous integration and deployment:
- Gitea Actions workflows (CI and CD pipelines)
- Automated testing on every push/PR
- Deployment process with pre-deployment testing
- Configuration requirements (secrets, variables)
- Rollback procedures
- Security considerations
- Performance optimization
- Monitoring and notifications
- Troubleshooting deployment issues
**Recommended for**: Understanding deployment pipeline, setting up CI/CD, debugging deployment failures, production operations.
### [CI/CD Quick Reference](./testing/CI_CD_QUICK_REFERENCE.md)
Quick reference for CI/CD operations:
- Common commands (testing, deployment, rollback)
- Workflow diagrams (CI and CD pipelines)
- Status badges for README
- Common issues and solutions
- Environment variables
- Monitoring links
**Recommended for**: Quick deployments, troubleshooting, day-to-day operations.
---
## Technical Documentation
### [Fonts](./FONTS.md)
TrueType fonts used for server-side image generation:
- Font file locations
- Usage in battle card generation
- Web vs. server-side font formats
**Recommended for**: Working on OG image generation, understanding font rendering.
---
## Quick Reference
### Test Suite Overview
| Metric | Value |
|--------|-------|
| Total Tests | 71 |
| Total Assertions | 227 |
| Execution Time | ~6-8 seconds |
| Coverage | Controller, DTO, Entity, Service, Integration |
### Bonus Points at a Glance
| Bonus Type | Points | Condition |
|-----------|--------|-----------|
| Blind Hit | +2 | Mine with no revealed numbered neighbors |
| Edge Mine | +1 | Mine on board boundary (row/col 0 or 15) |
| Endgame Mine | +3 | Mine clicked when ≤10 mines remain |
| Safe Cell | +0.5 each | ≥2 safe cells revealed (min requirement) |
| Chain Combo | Tracked | Consecutive mine clicks (no safe clicks) |
| Biggest Reveal | Tracked | Largest number of safe cells revealed |
### Available Test Factories
| Factory | Entity | Purpose |
|---------|--------|---------|
| `UserFactory` | `User` | Registered users |
| `GamerFactory` | `Gamer` | Anonymous/guest players |
| `PlayedGameFactory` | `PlayedGame` | Game records |
| `StepFactory` | `Step` | Game moves |
| `GridFactory` | `Grid` | Game grids (16×16) |
| `GridRowFactory` | `GridRow` | Grid rows |
| `WebAuthnCredentialFactory` | `WebAuthnCredential` | Passkey credentials |
| `ContactMessageFactory` | `ContactMessage` | Contact messages |
### Running Tests
```bash
# Setup (first time only)
make test-db-setup
# Run all tests
make test
# Run with documentation output
vendor/bin/phpunit --testdox
# Specific file
vendor/bin/phpunit tests/Controller/ProfileControllerTest.php
# With coverage (requires Xdebug/PCOV)
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html var/coverage
```
### CI/CD Commands
```bash
# Deploy to production
git tag -a v1.2.3 -m "Release version 1.2.3"
git push origin v1.2.3
# View CI/CD runs
# https://source.splendidbear.org/SplendidBear-Websites/Mine/actions
# Rollback deployment
git push origin v1.2.2 # Deploy previous version
```
---
## Files Using This Information
### Game Mechanics
- Backend: `/src/Util/TopicManager.php`
- Frontend: `/assets/js/mine-seeker/contexts/GameProvider.jsx`
- UI: `/assets/js/mine-seeker/components/BonusStatsDialog.jsx`
- Constants: `/assets/js/mine-seeker/utils/constants.jsx`
### Testing
- Test Base Class: `/tests/WebTestCase.php`
- Factories: `/tests/Factory/`
- Example Tests: `/tests/Integration/FactoryExampleTest.php`
- PHPUnit Config: `/phpunit.dist.xml`
- Bundle Config: `/config/bundles.php`
### CI/CD
- CI Workflow: `/.gitea/workflows/ci.yml`
- CD Workflow: `/.gitea/workflows/deploy.yml`
- Dockerfile: `/Dockerfile`
- Docker Compose: `/compose.yaml`
---
## Additional Resources
- **[Main README](../README.md)** - Project overview, installation, deployment
- **[AI Agent Guidelines](../AGENTS.md)** - Comprehensive guide for AI coding agents
- **[Zenstruck Foundry](https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html)** - Official Foundry documentation
- **[PHPUnit](https://phpunit.de/)** - PHPUnit documentation
- **[Symfony Testing](https://symfony.com/doc/current/testing.html)** - Symfony testing guide
- **[Gitea Actions](https://docs.gitea.com/usage/actions/overview)** - Gitea Actions documentation
- **[Docker Compose](https://docs.docker.com/compose/)** - Docker Compose reference

View File

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

265
docs/testing/FACTORIES.md Normal file
View File

@@ -0,0 +1,265 @@
# Foundry Factories for MineSeeker
Factory classes for creating test data. For general Foundry usage, see [Zenstruck Foundry Docs](https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html).
---
## Available Factories
All factories are in `tests/Factory/`:
| Factory | Entity | Use For |
|---------|--------|---------|
| `UserFactory` | `User` | Registered users with auth |
| `GamerFactory` | `Gamer` | Anonymous/guest players |
| `PlayedGameFactory` | `PlayedGame` | Game records |
| `StepFactory` | `Step` | Individual game moves |
| `GridFactory` | `Grid` | 16×16 game grids |
| `GridRowFactory` | `GridRow` | Grid row data |
| `WebAuthnCredentialFactory` | `WebAuthnCredential` | Passkey credentials |
| `ContactMessageFactory` | `ContactMessage` | Contact form messages |
---
## Quick Examples
### UserFactory
```php
// Basic user
$user = UserFactory::createOne();
// Unverified user
$user = UserFactory::createOne(['isVerified' => false]);
// Admin user
$user = UserFactory::createOne(['roles' => ['ROLE_ADMIN']]);
// Multiple users
UserFactory::createMany(5);
```
### GamerFactory
```php
// Anonymous gamer
$gamer = GamerFactory::new()->anonymous()->create();
```
### PlayedGameFactory
```php
// Game with registered players
$game = PlayedGameFactory::new()
->withRegisteredPlayers()
->create();
// Game with anonymous players
$game = PlayedGameFactory::new()
->withAnonymousPlayers()
->create();
// Red wins
$game = PlayedGameFactory::new()
->withRegisteredPlayers()
->redWins()
->create();
// Blue wins
$game = PlayedGameFactory::new()
->withRegisteredPlayers()
->blueWins()
->create();
// Resigned game
$game = PlayedGameFactory::new()
->resigned('red')
->create();
```
### StepFactory
```php
// Mine hit
$step = StepFactory::new()
->forPlayer('red')
->mine()
->create(['playedGame' => $game]);
// Safe cell
$step = StepFactory::new()
->forPlayer('blue')
->safe()
->create(['playedGame' => $game]);
// With revealed cells
$step = StepFactory::new()
->withRevealedCells([
['row' => 5, 'col' => 5],
['row' => 5, 'col' => 6],
])
->create(['playedGame' => $game]);
```
### GridFactory
```php
// Full 16×16 grid
$grid = GridFactory::createOne(['playedGame' => $game]);
// Automatically creates 16 rows
self::assertCount(16, $grid->gridRow);
```
### WebAuthnCredentialFactory
```php
// Basic credential
$credential = WebAuthnCredentialFactory::createOne(['user' => $user]);
// Named credential, recently used
$credential = WebAuthnCredentialFactory::new()
->withName('YubiKey 5C')
->recentlyUsed()
->create(['user' => $user]);
```
### ContactMessageFactory
```php
// Basic message
$message = ContactMessageFactory::createOne();
// Without consent
$message = ContactMessageFactory::new()
->withoutConsent()
->create();
```
---
## Factory API Reference
### Common Methods (All Factories)
```php
// Create single entity
Factory::createOne([
'property' => 'value',
]);
// Create multiple
Factory::createMany(5);
// Create with factory methods
Factory::new()
->customMethod()
->create(['property' => 'value']);
// Access repository
Factory::repository()->findAll();
Factory::repository()->count([]);
Factory::repository()->findBy(['field' => 'value']);
```
### PlayedGameFactory Methods
| Method | Description |
|--------|-------------|
| `withRegisteredPlayers()` | Creates game with 2 registered users |
| `withAnonymousPlayers()` | Creates game with 2 anonymous gamers |
| `withMixedPlayers()` | One registered, one anonymous |
| `redWins()` | Red has 26 points |
| `blueWins()` | Blue has 26 points |
| `resigned(string $player)` | Set resignation ('red' or 'blue') |
### StepFactory Methods
| Method | Description |
|--------|-------------|
| `forPlayer(string $player)` | Set player ('red' or 'blue') |
| `mine()` | Step hits a mine |
| `safe()` | Step reveals safe cell |
| `withRevealedCells(array $cells)` | Set revealed cells array |
### GamerFactory Methods
| Method | Description |
|--------|-------------|
| `anonymous()` | Set username as "Guest_12345" |
### WebAuthnCredentialFactory Methods
| Method | Description |
|--------|-------------|
| `withName(string $name)` | Set credential name |
| `recentlyUsed()` | Set lastUsedAt to now |
### ContactMessageFactory Methods
| Method | Description |
|--------|-------------|
| `withoutConsent()` | Set consent to false |
| `anonymous()` | Remove IP address |
---
## MineSeeker-Specific Patterns
### Complete Game Setup
```php
// Create full game with steps and grid
$game = PlayedGameFactory::new()
->withRegisteredPlayers()
->create();
// Add steps
StepFactory::createMany(10, [
'playedGame' => $game,
'player' => 'red',
]);
// Add grid
$grid = GridFactory::createOne(['playedGame' => $game]);
```
### Testing Battle History
```php
// Create multiple finished games for a user
$user = UserFactory::createOne();
PlayedGameFactory::new()
->redWins()
->create(['red' => $user]);
PlayedGameFactory::new()
->blueWins()
->create(['blue' => $user]);
```
---
## Database Isolation
Tests automatically run in isolated transactions:
```php
public function testDatabaseIsolation(): void
{
UserFactory::createMany(5);
self::assertCount(5, UserFactory::repository()->findAll());
// Automatically rolled back after test
}
```
No manual cleanup needed. Each test starts with a clean database.
---
## External Resources
- **[Zenstruck Foundry Documentation](https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html)** - Complete Foundry guide
- **[DAMA Doctrine Test Bundle](https://github.com/dmaicher/doctrine-test-bundle)** - Transaction isolation details

336
docs/testing/TESTING.md Normal file
View File

@@ -0,0 +1,336 @@
# Testing Guide for MineSeeker
MineSeeker-specific testing setup and workflows. For general PHPUnit/Symfony testing, see [Symfony Testing Docs](https://symfony.com/doc/current/testing.html).
## Example of the current tests
```shell
$ bin/phpunit (master->origin/master|✚1…2⚑1)
PHPUnit 13.1.7 by Sebastian Bergmann and contributors.
Runtime: PHP 8.5.5
Configuration: /var/www/splendid/Mine/phpunit.dist.xml
................................................................. 65 / 71 ( 91%)
...... 71 / 71 (100%)
Time: 00:07.319, Memory: 86.50 MB
OK (71 tests, 227 assertions)
Faker seed used: 918823
```
## Quick Start
```bash
# One-time setup
make test-db-setup
# Run tests
make test
```
---
## Test Database Configuration
MineSeeker uses a **separate test database** (`mineseeker_test`) with automatic transaction rollback for isolated tests.
### Stack
- **PHPUnit 13** - Testing framework
- **[Zenstruck Foundry](https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html)** - Factory library for test fixtures
- **[DAMA Doctrine Test Bundle](https://github.com/dmaicher/doctrine-test-bundle)** - Transaction isolation (rollback after each test)
### Configuration
**`phpunit.dist.xml`**:
```xml
<env name="DATABASE_URL" value="postgresql://...mineseeker_test..." />
<env name="DAMA_DISABLE_STATIC_CONNECTION" value="0" />
```
**`config/bundles.php`**:
```php
Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['dev' => true, 'test' => true],
DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
```
---
## Setup
### One-time Setup
```bash
make test-db-setup
```
Creates `mineseeker_test` database and runs migrations.
### Reset Test Database
```bash
make test-db-reset
```
Drops and recreates test database (useful after schema changes).
---
## Running Tests
```bash
# All tests
make test
# Specific file
vendor/bin/phpunit tests/Controller/ProfileControllerTest.php
# Specific method
vendor/bin/phpunit --filter testCreateUser
# With test names
vendor/bin/phpunit --testdox
```
---
## MineSeeker Factories
See **[FACTORIES.md](./FACTORIES.md)** for complete factory API.
### Available Factories
| Factory | Entity | Location |
|---------|--------|----------|
| `UserFactory` | Registered users | `tests/Factory/UserFactory.php` |
| `GamerFactory` | Anonymous players | `tests/Factory/GamerFactory.php` |
| `PlayedGameFactory` | Game records | `tests/Factory/PlayedGameFactory.php` |
| `StepFactory` | Game moves | `tests/Factory/StepFactory.php` |
| `GridFactory` | 16×16 game grids | `tests/Factory/GridFactory.php` |
| `GridRowFactory` | Grid rows | `tests/Factory/GridRowFactory.php` |
| `WebAuthnCredentialFactory` | Passkey credentials | `tests/Factory/WebAuthnCredentialFactory.php` |
| `ContactMessageFactory` | Contact messages | `tests/Factory/ContactMessageFactory.php` |
### Example
```php
use App\Tests\Factory\PlayedGameFactory;
use App\Tests\WebTestCase;
class GameTest extends WebTestCase
{
public function testGameCreation(): void
{
$game = PlayedGameFactory::new()
->withRegisteredPlayers()
->redWins()
->create();
self::assertEquals(26, $game->redPoints);
}
}
```
---
## Test Structure
All tests extend `App\Tests\WebTestCase`:
```php
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Controller;
use App\Tests\Factory\UserFactory;
use App\Tests\WebTestCase;
class MyControllerTest extends WebTestCase
{
public function testExample(): void
{
$user = UserFactory::createOne();
$client = static::createClient();
$client->request('GET', '/profile');
self::assertResponseRedirects('/login');
}
}
```
**Important**: Always extend `App\Tests\WebTestCase`, not `Symfony\Bundle\FrameworkBundle\Test\WebTestCase`.
---
## Directory Structure
```
tests/
├── Controller/ # HTTP endpoint tests
├── Dto/ # Data Transfer Object tests
├── Entity/ # Entity logic tests
├── Service/ # Service layer tests
├── Integration/ # Integration tests
├── Factory/ # Foundry factories
├── WebTestCase.php # Base test class
└── bootstrap.php # PHPUnit bootstrap
```
---
## MineSeeker-Specific Patterns
### Testing Game Flow
```php
public function testRedPlayerWinsGame(): void
{
$game = PlayedGameFactory::new()
->withRegisteredPlayers()
->create();
// Create steps to simulate gameplay
for ($i = 0; $i < 26; $i++) {
StepFactory::new()
->forPlayer('red')
->mine()
->create(['playedGame' => $game]);
}
self::assertEquals(26, $game->redPoints);
}
```
### Testing Battle Sharing
```php
public function testBattleSharePageReturnsValidGame(): void
{
$game = PlayedGameFactory::new()
->withRegisteredPlayers()
->redWins()
->create();
$client = static::createClient();
$client->request('GET', '/battle/' . $game->uuid);
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('h1', 'Battle Report');
}
```
### Testing Authentication
```php
public function testProfileRequiresAuthentication(): void
{
$client = static::createClient();
$client->request('GET', '/profile');
self::assertResponseRedirects('/login');
}
public function testAuthenticatedUserCanAccessProfile(): void
{
$user = UserFactory::createOne();
$client = static::createClient();
$client->loginUser($user->_real());
$client->request('GET', '/profile');
self::assertResponseIsSuccessful();
}
```
---
## Troubleshooting
### Tests interfering with each other?
Ensure you extend `App\Tests\WebTestCase`, not the base Symfony class.
### Schema out of sync?
```bash
make test-db-reset
```
### Memory limit errors?
```bash
php -d memory_limit=1024M vendor/bin/phpunit
```
Or increase in `phpunit.dist.xml`:
```xml
<ini name="memory_limit" value="1024M"/>
```
### Test database doesn't exist?
```bash
make test-db-setup
```
---
## External Resources
- **[Symfony Testing](https://symfony.com/doc/current/testing.html)** - Symfony testing guide
- **[Zenstruck Foundry](https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html)** - Factory documentation
- **[DAMA Doctrine Test Bundle](https://github.com/dmaicher/doctrine-test-bundle)** - Transaction isolation
- **[PHPUnit](https://docs.phpunit.de/)** - PHPUnit documentation
---
## Modern PHPUnit Attributes
MineSeeker tests use modern PHP 8 attributes instead of method name prefixes:
```php
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
#[TestDox('Security Controller')]
class SecurityControllerTest extends WebTestCase
{
#[Test]
#[TestDox('Login page loads successfully with form fields')]
public function loginPageLoadsSuccessfully(): void
{
// Test implementation
}
}
```
**Benefits:**
- ✅ More readable method names (no `test` prefix required)
- ✅ Self-documenting with `TestDox` descriptions
- ✅ Better IDE support and refactoring
- ✅ Cleaner `--testdox` output
**Run with documentation:**
```bash
vendor/bin/phpunit --testdox
```
Output:
```
Security Controller
✔ Login page loads successfully with form fields
✔ Login page has links to register and forgot password
✔ Register page loads successfully with form
```

View File

@@ -21,31 +21,31 @@
"@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",
"vite": "^8.0.8",
"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.9",
"vite-plugin-symfony": "^8.2.4"
},
"scripts": {
"dev": "vite",
"watch": "vite build --watch",
"build": "vite build && cp -r node_modules/@fortawesome/fontawesome-free/webfonts public/build/webfonts",
"build": "vite build",
"lint": "eslint assets/js/"
}
}

48
phpunit.dist.xml Normal file
View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
colors="true"
failOnDeprecation="true"
failOnNotice="true"
failOnWarning="true"
bootstrap="tests/bootstrap.php"
cacheDirectory=".phpunit.cache"
>
<php>
<ini name="display_errors" value="1" />
<ini name="error_reporting" value="-1" />
<server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" />
<env name="SYMFONY_DEPRECATIONS_HELPER" value="disabled" />
<env name="DATABASE_URL" value="postgresql://system7:bazmeg@127.0.0.1:15432/mineseeker_test?serverVersion=16&amp;charset=utf8" />
<env name="DAMA_DISABLE_STATIC_CONNECTION" value="0" />
</php>
<testsuites>
<testsuite name="Project Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<source ignoreSuppressionOfDeprecations="true"
ignoreIndirectDeprecations="true"
restrictNotices="true"
restrictWarnings="true"
>
<include>
<directory>src</directory>
</include>
<deprecationTrigger>
<method>Doctrine\Deprecations\Deprecation::trigger</method>
<method>Doctrine\Deprecations\Deprecation::delegateTriggerToBackend</method>
<function>trigger_deprecation</function>
</deprecationTrigger>
</source>
<extensions>
<bootstrap class="Zenstruck\Foundry\PHPUnit\FoundryExtension" />
</extensions>
</phpunit>

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

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