Compare commits

...
42 Commits
Author SHA1 Message Date
Lang 3e90bbe5cf chg: pkg: migrate from shitty Minio to RustFS #16
Deploy to Production / deploy (push) Successful in 3m9s
2026-10-24 17:45:19 +02:00
Lang 1d8d0ac110 fix: pkg: upgrade PHP due to the security considerations #16 2026-10-24 16:23:40 +02:00
Lang 007c91f7e9 chg: pkg: new version release !skipChangelog
Deploy to Production / deploy (push) Successful in 2m49s
2026-09-21 10:24:10 +02:00
Lang e70cb57176 chg: usr: completely disable the contact form #15
Deploy to Production / deploy (push) Successful in 3m0s
2026-09-21 10:23:38 +02:00
Lang db54899ed4 chg: pkg: new version release !skipChangelog
Deploy to Production / deploy (push) Successful in 3m30s
2026-08-18 17:43:52 +02:00
Lang 558c8a3bad chg: pkg: add backup/restore scripts #15
Deploy to Production / deploy (push) Successful in 3m26s
2026-08-18 17:41:43 +02:00
Lang 2be49ebdf1 chg: dev: fix the changing local url names from legacy .local - & change the minio default port #15 2026-08-18 17:19:43 +02:00
Lang 8b428260d7 chg: pkg: new version release !skipChangelog
Deploy to Production / deploy (push) Successful in 27s
2026-07-27 19:30:53 +02:00
Lang 306add033a chg: pkg: new version release !skipChangelog 2026-07-27 19:02:31 +02:00
Lang f451df283c chg: usr: add current version of the app in the footer #14
Deploy to Production / deploy (push) Successful in 3m42s
2026-07-27 19:01:42 +02:00
Lang 8d542e683e chg: pkg: upgrade FE to the latest possible version #14 2026-07-27 18:52:14 +02:00
Lang dff46cdc6f chg: test: all tests passed and migrated to Symfony 8 #14 2026-07-27 18:39:17 +02:00
Lang 82fff0cdf0 chg: pkg: upgrade the BE deps to the latest possible version #14 2026-07-27 18:35:42 +02:00
Lang 4bf7ac358d chg: dev: remove registration notification #14 2026-07-27 18:16:08 +02:00
Lang 1a97c2c4b4 chg: pkg: add a process that removes the unnecessary minio-init when it does not needed anymore #14 2026-07-27 18:09:33 +02:00
Lang 9ea83d7e6e new: dev: hardening the registration process with removing the not activated registrations #14 2026-07-27 18:08:52 +02:00
Lang f3e4ff211d chg: dev: removed the obsolete contact form entry - legacy code from Google ReCapthca #14 2026-07-27 14:57:33 +02:00
Lang 7a86767db1 chg: pkg: new version release !skipChangelog
Deploy to Production / deploy (push) Successful in 2m18s
2026-06-08 12:59:29 +02:00
Lang 2b1689b321 chg: usr: add clickable badges for Cap app-wide #13
Deploy to Production / deploy (push) Successful in 2m43s
2026-06-02 09:48:23 +02:00
Lang cf56b10aba chg: pkg: upgrade both backend and frontend to the latest available version #13 2026-06-01 22:36:19 +02:00
Lang 7063704539 chg: usr: replace Google ReCaptcha with Cap instance #13 2026-06-01 22:24:34 +02:00
Lang 199bb7e525 chg: pkg: fix all eslint issues - & add example of the testing env #10 2026-04-28 08:10:18 +02:00
Lang 5daaf71ae7 chg: pkg: new version release !skipChangelog 2026-04-23 21:42:21 +02:00
Lang 0aeec47996 new: pkg: add tracking code for the app #10
Deploy to Production / deploy (push) Successful in 30s
2026-04-23 21:41:47 +02:00
Lang 3d67b8f2d9 chg: pkg: new version release !skipChangelog 2026-04-22 12:15:29 +02:00
Lang dd9a190fd9 fix: usr: the error message cannot be seen during avatar changing #10
Deploy to Production / deploy (push) Successful in 3m7s
2026-04-22 12:15:06 +02:00
Lang f5e5019ea8 chg: pkg: new version release !skipChangelog 2026-04-21 22:47:04 +02:00
Lang 3f51eb5db6 chg: usr: increase the 2 MB avatar maximum file size to 10 MB #10
Deploy to Production / deploy (push) Successful in 29s
2026-04-21 22:46:44 +02:00
Lang 55ef7c9301 chg: pkg: new version release !skipChangelog 2026-04-21 21:05:54 +02:00
Lang ddfa395c6b chg: pkg: upgrade front-end & back-end deps to the latest available version #10
Deploy to Production / deploy (push) Successful in 31s
2026-04-21 20:13:00 +02:00
Lang 8694627817 chg: pkg: new version release !skipChangelog 2026-04-21 18:21:17 +02:00
Lang f796819af4 chg: pkg: new version release !skipChangelog 2026-04-21 18:12:32 +02:00
Lang b209ad4220 chg: pkg: the original CI/CD workflow is restored - the work with tests is postponed #10
Deploy to Production / test (push) Failing after 6s
Deploy to Production / deploy (push) Successful in 33s
2026-04-21 18:11:54 +02:00
Lang df1eefdfe0 chg: pkg: new version release !skipChangelog
CI - Tests / tests (push) Failing after 2m43s
CI - Tests / lint (push) Failing after 8s
2026-04-21 18:00:05 +02:00
Lang 7aaaf120b2 chg: usr: add sound when the game start #10 2026-04-21 17:59:18 +02:00
Lang 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
Lang d704be5bff new: pkg: add test cases to back-end w/ real database connection in it #10 2026-04-21 17:56:04 +02:00
Lang 6bf908b43e chg: dev: create AGENTS.md file for future maintenance #9 2026-04-21 14:30:38 +02:00
Lang 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
Lang 8935216525 chg: pkg: new version release !skipChangelog 2026-04-21 13:58:08 +02:00
Lang 1d8efa4e61 chg: usr: fine-tune the recent battle list #8
Deploy to Production / deploy (push) Successful in 27s
2026-04-21 13:57:44 +02:00
Lang 69fce52bed chg: pkg: new version release !skipChangelog 2026-04-21 11:47:58 +02:00
115 changed files with 10840 additions and 2450 deletions
+23 -11
View File
@@ -6,6 +6,9 @@
APP_ENV=dev APP_ENV=dev
APP_SECRET=changethis APP_SECRET=changethis
APP_NAME=mineseeker APP_NAME=mineseeker
# Public application release identifier, e.g. 2026.3.0. In Gitea production
# deployments this is supplied by the APP_VERSION repository variable.
APP_VERSION=1.0.0
# APP_PUBLIC_HOSTNAME: The public hostname for your application (used for generating absolute URLs in emails) # APP_PUBLIC_HOSTNAME: The public hostname for your application (used for generating absolute URLs in emails)
# For production, set this to your domain (e.g., mineseeker.com) # For production, set this to your domain (e.g., mineseeker.com)
APP_PUBLIC_HOSTNAME=localhost APP_PUBLIC_HOSTNAME=localhost
@@ -17,20 +20,22 @@ APP_PUBLIC_HOSTNAME=localhost
###< symfony/framework-bundle ### ###< symfony/framework-bundle ###
###> doctrine/doctrine-bundle ### ###> doctrine/doctrine-bundle ###
# Docker PostgreSQL is exposed on port 15432 — use that for bare-metal dev. # Docker PostgreSQL is exposed on port 5452 — use that for bare-metal dev.
# Replace POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB with the values from your .env. # Replace POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB with the values from your .env.
DATABASE_URL=postgresql://POSTGRES_USER:POSTGRES_PASSWORD@127.0.0.1:15432/POSTGRES_DB?serverVersion=18&charset=utf8 DATABASE_URL=postgresql://POSTGRES_USER:POSTGRES_PASSWORD@127.0.0.1:5452/POSTGRES_DB?serverVersion=18&charset=utf8
###< doctrine/doctrine-bundle ### ###< doctrine/doctrine-bundle ###
###> google/recaptcha ### ###> trycap.dev/cap ###
RECAPTCHA_SITE_KEY=changethis # CAP_API_ENDPOINT: Full URL including site-key path, e.g. https://cap.example.com/my-site-key/
RECAPTCHA_SECRET_KEY=changethis # After deploying the Cap service, create a site in the dashboard and paste the endpoint here.
###< google/recaptcha ### CAP_API_ENDPOINT=http://localhost:3000/changethis/
CAP_SECRET_KEY=changethis
###< trycap.dev/cap ###
###> minio/minio ### ###> minio/minio ###
MINIO_ROOT_USER=changethis MINIO_ROOT_USER=changethis
MINIO_ROOT_PASSWORD=changethis MINIO_ROOT_PASSWORD=changethis
MINIO_ENDPOINT=http://localhost:9000 MINIO_ENDPOINT=http://localhost:9010
MINIO_PUBLIC_URL=https://your-minio-subdomain.example.com MINIO_PUBLIC_URL=https://your-minio-subdomain.example.com
###< minio/minio ### ###< minio/minio ###
@@ -42,9 +47,9 @@ MAIL_DOMAIN=localhost
###> symfony/mercure-bundle ### ###> symfony/mercure-bundle ###
# See https://symfony.com/doc/current/mercure.html#configuration # See https://symfony.com/doc/current/mercure.html#configuration
# The URL of the Mercure hub, used by the app to publish updates (can be a local URL) # The URL of the Mercure hub, used by the app to publish updates (can be a local URL)
MERCURE_URL=https://mine.local/.well-known/mercure MERCURE_URL=https://mine.home.arpa/.well-known/mercure
# The public URL of the Mercure hub, used by the browser to connect # The public URL of the Mercure hub, used by the browser to connect
MERCURE_PUBLIC_URL=https://mine.local/.well-known/mercure MERCURE_PUBLIC_URL=https://mine.home.arpa/.well-known/mercure
# The secret used to sign the JWTs # The secret used to sign the JWTs
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!" MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
# Publisher JWT (must match publisher_jwt in your Caddyfile) # Publisher JWT (must match publisher_jwt in your Caddyfile)
@@ -54,6 +59,13 @@ MERCURE_SUBSCRIBER_JWT=changethis
###< symfony/mercure-bundle ### ###< symfony/mercure-bundle ###
###> web-auth/webauthn-framework ### ###> web-auth/webauthn-framework ###
WEBAUTHN_RP_ID=mine.local WEBAUTHN_RP_ID=mine.home.arpa
WEBAUTHN_ORIGIN=https://mine.local WEBAUTHN_ORIGIN=https://mine.home.arpa
###< web-auth/webauthn-framework ### ###< web-auth/webauthn-framework ###
###> symfony/messenger ###
# Choose one of the transports below
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###< symfony/messenger ###
+4
View File
@@ -0,0 +1,4 @@
# define your env variables for the test env here
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'
DATABASE_URL="postgresql://system7:bazmeg@127.0.0.1:5452/mineseeker_test?serverVersion=18&charset=utf8"
+142
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
+49 -1
View File
@@ -33,7 +33,55 @@ jobs:
cd "${{ vars.PROD_APP_DIR }}" cd "${{ vars.PROD_APP_DIR }}"
docker compose build docker compose build
- name: Start services - name: Migrate legacy MinIO and deploy
env:
APP_VERSION: ${{ vars.APP_VERSION }}
run: | run: |
set -euo pipefail
cd "${{ vars.PROD_APP_DIR }}" cd "${{ vars.PROD_APP_DIR }}"
MIGRATION_COMPOSE="docker compose --profile storage-migration"
LEGACY_MINIO_CONTAINER=$($MIGRATION_COMPOSE ps -a -q minio)
if [ -n "$LEGACY_MINIO_CONTAINER" ]; then
echo "Legacy MinIO found; pausing writers and migrating its buckets to RustFS."
APP_WAS_RUNNING=$(docker compose ps --status running -q app)
SCHEDULER_WAS_RUNNING=$(docker compose ps --status running -q scheduler)
MIGRATION_SUCCEEDED=0
restart_old_workers_on_failure() {
result=$?
if [ "$result" -ne 0 ] && [ "$MIGRATION_SUCCEEDED" -eq 0 ]; then
# Before a complete copy, restore the old public MinIO endpoint
# before bringing back the old app.
docker compose stop rustfs || true
MINIO_PUBLISHED_API_PORT=9010 MINIO_PUBLISHED_CONSOLE_PORT=9011 $MIGRATION_COMPOSE up -d minio || true
[ -z "$APP_WAS_RUNNING" ] || docker compose start app || true
[ -z "$SCHEDULER_WAS_RUNNING" ] || docker compose start scheduler || true
elif [ "$result" -ne 0 ]; then
echo "Data migration succeeded; leaving RustFS in place for recovery from the app rollout failure." >&2
fi
return "$result"
}
trap restart_old_workers_on_failure EXIT
docker compose stop app scheduler || true
docker compose up -d db mail
# Move the legacy service off the RustFS public port before starting RustFS.
$MIGRATION_COMPOSE up -d minio
docker compose up -d rustfs
$MIGRATION_COMPOSE up --build --abort-on-container-exit --exit-code-from storage_migrate storage_migrate
MIGRATION_SUCCEEDED=1
echo "Migration succeeded; deploying the RustFS-only stack."
docker compose up -d --wait --wait-timeout 180 app scheduler
# Remove only the legacy service containers. Do not use -v: the old
# MinIO volume is retained as a rollback/backup until manually retired.
$MIGRATION_COMPOSE rm -sf minio storage_migrate
trap - EXIT
else
echo "No legacy MinIO container found; deploying the RustFS-only stack."
docker compose up -d docker compose up -d
fi
+126
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
+6
View File
@@ -1,6 +1,7 @@
###> system7 - jotunheimr ### ###> system7 - jotunheimr ###
compose.override.yaml compose.override.yaml
+bak/ +bak/
/backups/
.idea/ .idea/
node_modules/ node_modules/
nohup.out nohup.out
@@ -14,3 +15,8 @@ nohup.out
/var/ /var/
/vendor/ /vendor/
###< symfony/framework-bundle ### ###< symfony/framework-bundle ###
###> phpunit/phpunit ###
/phpunit.xml
/.phpunit.cache/
###< phpunit/phpunit ###
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "/phpactor.schema.json",
"symfony.enabled": true,
"indexer.exclude_patterns": [
"/vendor/**/Tests/**/*",
"/vendor/**/tests/**/*",
"/var/cache/**/*",
"/vendor/composer/**/*"
]
}
+463
View File
@@ -0,0 +1,463 @@
# 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
├── 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! 🚀**
+139
View File
@@ -1,6 +1,145 @@
# Changelog # Changelog
## v2026.3.1-2 (2026-09-21)
### Changes
* Completely disable the contact form #15. [Lang]
## v2026.3.1-0 (2026-08-18)
### Changes
* Add backup/restore scripts #15. [Lang]
* Fix the changing local url names from legacy .local - & change the minio default port #15. [Lang]
## v2026.3.0-0 (2026-07-27)
### New
* Hardening the registration process with removing the not activated registrations #14. [Lang]
### Changes
* Add current version of the app in the footer #14. [Lang]
* Upgrade FE to the latest possible version #14. [Lang]
* All tests passed and migrated to Symfony 8 #14. [Lang]
* Upgrade the BE deps to the latest possible version #14. [Lang]
* Remove registration notification #14. [Lang]
* Add a process that removes the unnecessary minio-init when it does not needed anymore #14. [Lang]
* Removed the obsolete contact form entry - legacy code from Google ReCapthca #14. [Lang]
## v2026.2.10-0 (2026-06-02)
### Changes
* Add clickable badges for Cap app-wide #13. [Lang]
* Upgrade both backend and frontend to the latest available version #13. [Lang]
* Replace Google ReCaptcha with Cap instance #13. [Lang]
* Fix all eslint issues - & add example of the testing env #10. [Lang]
## 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) ## v2026.2.6-1 (2026-04-19)
### Fix ### Fix
+4 -1
View File
@@ -18,7 +18,10 @@
header X-Forwarded-Host {host} header X-Forwarded-Host {host}
mercure { mercure {
transport_url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db} transport bolt {
path /data/mercure.db
}
protocol_version_compatibility 8
publisher_jwt {$MERCURE_JWT_SECRET} HS256 publisher_jwt {$MERCURE_JWT_SECRET} HS256
subscriber_jwt {$MERCURE_JWT_SECRET} HS256 subscriber_jwt {$MERCURE_JWT_SECRET} HS256
anonymous anonymous
+1 -6
View File
@@ -22,13 +22,8 @@ RUN install-php-extensions \
apcu \ apcu \
sodium 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 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' \ 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" > "$PHP_INI_DIR/conf.d/opcache.ini"
+50 -9
View File
@@ -1,11 +1,11 @@
.PHONY: help start start-build stop build down ps logs prune-everything db-reset mercure-jwt cache-clear og-cache-clear .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 .DEFAULT_GOAL := help
help: help:
@echo "Available commands:" @echo "Available commands:"
@echo " make start - Start services without building (uses existing images)" @echo " make start - Start services (storage migration runs automatically once)"
@echo " make start-build - Start services and build images if needed" @echo " make start-build - Build and start services (storage migration runs automatically once)"
@echo " make stop - Stop running services" @echo " make stop - Stop running services"
@echo " make build - Build Docker images only" @echo " make build - Build Docker images only"
@echo " make down - Stop and remove containers/networks" @echo " make down - Stop and remove containers/networks"
@@ -14,23 +14,46 @@ help:
@echo " make ccp - Clear the production cache" @echo " make ccp - Clear the production cache"
@echo " make cache-clear - Clear all caches (Vite, Symfony, node_modules)" @echo " make cache-clear - Clear all caches (Vite, Symfony, node_modules)"
@echo " make og-cache-clear - Clear Open Graph cache only" @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: start:
docker compose up -d docker compose up -d db mail
@if [ -n "$$(docker compose --profile storage-migration ps -a -q minio)" ]; then \
echo "Legacy MinIO found; migrating its data to RustFS..."; \
docker compose --profile storage-migration up -d minio; \
docker compose up -d rustfs; \
docker compose --profile storage-migration up --abort-on-container-exit --exit-code-from storage_migrate storage_migrate; \
else \
echo "No legacy MinIO container found; starting RustFS only."; \
docker compose up -d rustfs; \
fi
docker compose up -d app scheduler
start-build: start-build:
composer i composer i
bun run build bun run build
docker compose up -d --build docker compose up -d --build db mail
@if [ -n "$$(docker compose --profile storage-migration ps -a -q minio)" ]; then \
echo "Legacy MinIO found; migrating its data to RustFS..."; \
docker compose --profile storage-migration up -d minio; \
docker compose up -d rustfs; \
docker compose --profile storage-migration up --build --abort-on-container-exit --exit-code-from storage_migrate storage_migrate; \
else \
echo "No legacy MinIO container found; starting RustFS only."; \
docker compose up -d rustfs; \
fi
docker compose up -d --build app scheduler
stop: stop:
docker compose stop docker compose --profile storage-migration stop
build: build:
docker compose build docker compose --profile storage-migration build
down: down:
docker compose down docker compose --profile storage-migration down
prune-everything: prune-everything:
@echo "WARNING: This will remove ALL containers, volumes, networks and images!" @echo "WARNING: This will remove ALL containers, volumes, networks and images!"
@@ -39,7 +62,7 @@ prune-everything:
echo "Aborted."; \ echo "Aborted."; \
exit 1; \ exit 1; \
fi fi
docker compose down -v --rmi all --remove-orphans docker compose --profile storage-migration down -v --rmi all --remove-orphans
mercure-jwt: mercure-jwt:
@php bin/generate-mercure-jwt.php @php bin/generate-mercure-jwt.php
@@ -80,5 +103,23 @@ og-cache-clear:
@echo "✓ OG cache cleared!" @echo "✓ OG cache cleared!"
@echo " Battle card images will be regenerated on next access" @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
+96 -18
View File
@@ -14,7 +14,7 @@ Created by [SplendidBear](https://www.splendidbear.org).
- **Guest & registered play** — jump in anonymously or create an account for stats and history - **Guest & registered play** — jump in anonymously or create an account for stats and history
- **Full authentication stack** — email/password, passkeys (WebAuthn), TOTP 2FA with backup codes - **Full authentication stack** — email/password, passkeys (WebAuthn), TOTP 2FA with backup codes
- **Player profiles** — win/loss/draw stats, per-month charts, recent battle history with shareable replay links - **Player profiles** — win/loss/draw stats, per-month charts, recent battle history with shareable replay links
- **Profile pictures** — uploaded to MinIO object storage, thumbnails generated on-the-fly by LiipImagine - **Profile pictures** — uploaded to RustFS object storage, thumbnails generated on-the-fly by LiipImagine
- **Battle replay sharing** — share a direct link to any finished game - **Battle replay sharing** — share a direct link to any finished game
- **Docker-ready** — single `make start-build` brings up the full production-like stack - **Docker-ready** — single `make start-build` brings up the full production-like stack
@@ -28,7 +28,7 @@ Created by [SplendidBear](https://www.splendidbear.org).
| Frontend | React 19, Vite, MUI, SCSS | | Frontend | React 19, Vite, MUI, SCSS |
| Database | PostgreSQL 18 | | Database | PostgreSQL 18 |
| Real-time | Mercure (built into FrankenPHP / Caddy) | | Real-time | Mercure (built into FrankenPHP / Caddy) |
| File storage | MinIO (S3-compatible) | | File storage | RustFS (S3-compatible; one-time migration from legacy MinIO) |
| Image processing | LiipImagine + Flysystem | | Image processing | LiipImagine + Flysystem |
| Server | FrankenPHP (Caddy + PHP in one binary) | | Server | FrankenPHP (Caddy + PHP in one binary) |
| Auth | Symfony Security, Scheb 2FA, web-auth/webauthn-framework | | Auth | Symfony Security, Scheb 2FA, web-auth/webauthn-framework |
@@ -44,7 +44,7 @@ Created by [SplendidBear](https://www.splendidbear.org).
- PostgreSQL 18 - PostgreSQL 18
- Caddy with FrankenPHP and the Mercure module - Caddy with FrankenPHP and the Mercure module
- MailHog (or any SMTP server on port 1025) - MailHog (or any SMTP server on port 1025)
- MinIO (listening on port 9000) - RustFS (API on port 9010, console on port 9011); legacy MinIO source on 9012/9013 during migration
### Docker ### Docker
- Docker Engine 24+ - Docker Engine 24+
@@ -75,10 +75,11 @@ Edit `.env` and fill in every value. Key ones:
|---|---| |---|---|
| `APP_SECRET` | Random 32-byte hex: `openssl rand -hex 32` | | `APP_SECRET` | Random 32-byte hex: `openssl rand -hex 32` |
| `POSTGRES_USER/PASSWORD/DB` | Your PostgreSQL credentials | | `POSTGRES_USER/PASSWORD/DB` | Your PostgreSQL credentials |
| `MINIO_ROOT_USER/PASSWORD` | MinIO admin credentials | | `MINIO_ROOT_USER/PASSWORD` | S3 storage credentials (used by RustFS and legacy MinIO during migration) |
| `MINIO_ENDPOINT` | `http://localhost:9000` (bare-metal) | | `MINIO_ENDPOINT` | `http://localhost:9010` (bare-metal) |
| `MINIO_PUBLIC_URL` | Public URL browsers use to reach MinIO | | `MINIO_PUBLIC_URL` | Public URL browsers use to reach RustFS (including media and LiipImagine thumbnails) |
| `RECAPTCHA_SITE_KEY/SECRET_KEY` | Google reCAPTCHA v3 keys for your domain | | `CAP_API_ENDPOINT` | Full URL of your Cap instance including the site-key path (e.g. `https://cap.example.com/my-site-key/`) |
| `CAP_SECRET_KEY` | Secret key from your Cap site dashboard (stored in `PROD_ENV_FILE` Gitea secret on prod) |
| `MERCURE_JWT_SECRET` | Random secret (generated in step 3) | | `MERCURE_JWT_SECRET` | Random secret (generated in step 3) |
| `MERCURE_JWT_TOKEN` | Signed publisher JWT (generated in step 3) | | `MERCURE_JWT_TOKEN` | Signed publisher JWT (generated in step 3) |
| `MERCURE_SUBSCRIBER_JWT` | Signed subscriber JWT (generated in step 3) | | `MERCURE_SUBSCRIBER_JWT` | Signed subscriber JWT (generated in step 3) |
@@ -103,7 +104,7 @@ sudo systemctl reload caddy
make start-build make start-build
``` ```
This installs PHP dependencies, builds the frontend assets with Bun, builds the Docker image, and starts all services (`app`, `db`, `mail`, `minio`, `minio_init`). This installs PHP dependencies, builds the frontend assets with Bun, builds the Docker images, and starts all services. RustFS migration runs automatically once before the app and scheduler start.
The app is available at `http://localhost:10080` (or the domain set in `APP_PUBLIC_HOSTNAME`). The app is available at `http://localhost:10080` (or the domain set in `APP_PUBLIC_HOSTNAME`).
@@ -118,16 +119,16 @@ bun run dev # Vite dev server with hot-reload
php bin/console doctrine:migrations:migrate --no-interaction php bin/console doctrine:migrations:migrate --no-interaction
``` ```
Start MinIO and MailHog, then open the URL configured in your Caddy vhost. Start RustFS and MailHog, then open the URL configured in your Caddy vhost.
### 5. MinIO bucket setup ### 5. Object storage setup
**Docker** — handled automatically on first start by the `minio_init` service. No action needed. **Docker** — `make start` and `make start-build` run the one-shot migration before starting the app. Production releases pause writers, migrate and verify all MinIO buckets, start the RustFS-backed app, then remove the MinIO container while retaining its volume. See [Object storage migration](docs/MINIO_INITIALIZATION.md).
**Bare-metal** — run once after MinIO is up: **Bare-metal** — run once after MinIO is up:
```bash ```bash
mc alias set local http://localhost:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD mc alias set local http://localhost:9010 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD
mc mb local/mineseeker mc mb local/mineseeker
echo '' | mc pipe local/mineseeker/media/.keep echo '' | mc pipe local/mineseeker/media/.keep
echo '' | mc pipe local/mineseeker/cache/.keep echo '' | mc pipe local/mineseeker/cache/.keep
@@ -216,14 +217,14 @@ POSTGRES_VERSION=18
MINIO_ROOT_USER=mineseeker MINIO_ROOT_USER=mineseeker
MINIO_ROOT_PASSWORD="<strong password>" MINIO_ROOT_PASSWORD="<strong password>"
MINIO_ENDPOINT=http://minio:9000 MINIO_ENDPOINT=http://rustfs:9000
MINIO_PUBLIC_URL=https://aws.mineseeker.hu MINIO_PUBLIC_URL=https://aws.mineseeker.hu
MAILER_DSN=smtp://mail:25?verify_peer=0 MAILER_DSN=smtp://mail:25?verify_peer=0
MAIL_DOMAIN=mineseeker.hu MAIL_DOMAIN=mineseeker.hu
RECAPTCHA_SITE_KEY="<your reCAPTCHA v3 site key>" CAP_API_ENDPOINT="https://cap.example.com/your-site-key/"
RECAPTCHA_SECRET_KEY="<your reCAPTCHA v3 secret key>" CAP_SECRET_KEY="<secret key from your Cap site dashboard>"
MERCURE_URL=https://mineseeker.hu/.well-known/mercure MERCURE_URL=https://mineseeker.hu/.well-known/mercure
MERCURE_PUBLIC_URL=https://mineseeker.hu/.well-known/mercure MERCURE_PUBLIC_URL=https://mineseeker.hu/.well-known/mercure
@@ -269,7 +270,7 @@ git tag v2026.01
git push origin v2026.01 git push origin v2026.01
``` ```
This writes `.env`, builds the Docker image, starts all services, runs migrations, and initialises the MinIO buckets automatically via `minio_init`. This writes `.env`, builds the Docker images, runs database migrations, and—if the legacy MinIO container exists—pauses app writers, copies and verifies all its buckets in RustFS, then removes the MinIO container after the app is running on RustFS. The MinIO data volume is retained.
#### 4. Verify #### 4. Verify
@@ -287,11 +288,15 @@ git push origin v2026.01
--- ---
## Game Documentation ## Documentation
For detailed information about game mechanics, bonus systems, and scoring rules, see the [docs](./docs/) directory: 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 - **[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
--- ---
@@ -300,3 +305,76 @@ For detailed information about game mechanics, bonus systems, and scoring rules,
LGPL-3.0 — see [LICENSE](LICENSE) for details. LGPL-3.0 — see [LICENSE](LICENSE) for details.
&copy; 2026 [SplendidBear](https://www.splendidbear.org) &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
---
+60 -1
View File
@@ -75,7 +75,7 @@
border-radius: 10px; border-radius: 10px;
padding: 44px 48px 40px; padding: 44px 48px 40px;
width: 100%; width: 100%;
max-width: 420px; max-width: 520px;
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
box-shadow: 0 8px 48px rgba(0, 0, 0, 0.5); box-shadow: 0 8px 48px rgba(0, 0, 0, 0.5);
} }
@@ -87,6 +87,65 @@
margin-bottom: 6px; margin-bottom: 6px;
} }
.auth-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
.auth-title {
margin-bottom: 0;
}
}
.auth-cap-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid rgba(149, 207, 245, 0.35);
background:
linear-gradient(115deg, transparent 0 38%, rgba(255, 255, 255, 0.18) 48%, transparent 58% 100%),
rgba(35, 111, 135, 0.2);
background-position: 120% 50%, 0 0;
background-size: 260% 100%, auto;
color: #95cff5;
font: 600 11px 'Rajdhani', sans-serif;
letter-spacing: 0.8px;
text-decoration: none;
text-transform: uppercase;
transition:
background-position 650ms ease,
border-color 200ms ease,
box-shadow 200ms ease,
color 200ms ease,
transform 200ms ease;
white-space: nowrap;
&:hover,
&:focus-visible {
background-position: -60% 50%, 0 0;
border-color: rgba(149, 207, 245, 0.65);
box-shadow: 0 0 18px rgba(35, 111, 135, 0.28);
color: #d5f1ff;
transform: translateY(-1px);
}
&:hover,
&:focus,
&:active,
&:visited {
text-decoration: none;
}
&:focus-visible {
outline: 2px solid rgba(149, 207, 245, 0.75);
outline-offset: 2px;
}
}
.auth-sub { .auth-sub {
font: 400 14px 'Rajdhani', sans-serif; font: 400 14px 'Rajdhani', sans-serif;
color: rgba(149, 207, 245, 0.6); color: rgba(149, 207, 245, 0.6);
+23
View File
@@ -119,3 +119,26 @@ footer {
&:hover { color: #95cff5; } &:hover { color: #95cff5; }
} }
} }
.footer-version {
display: inline-flex;
align-items: center;
gap: 5px;
margin: 0 7px;
padding: 3px 8px 2px;
border: 1px solid rgba(149, 207, 245, 0.24);
border-radius: 999px;
background: linear-gradient(135deg, rgba(35, 111, 135, 0.22), rgba(149, 207, 245, 0.08));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 0 12px rgba(35, 111, 135, 0.12);
color: rgba(180, 224, 248, 0.9);
font: 700 10px 'Rajdhani', sans-serif;
letter-spacing: 1px;
line-height: 1;
text-transform: uppercase;
vertical-align: middle;
i {
color: #95cff5;
font-size: 9px;
}
}
+15
View File
@@ -28,6 +28,21 @@
box-shadow: 0 8px 48px rgba(0, 0, 0, 0.4); 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 { .profile-avatar {
position: relative; position: relative;
width: 80px; width: 80px;
+74 -2
View File
@@ -7,12 +7,17 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
.back-from-game { .game-brand-bar {
display: inline-block; display: flex;
align-items: center;
position: fixed; position: fixed;
top: 20px; top: 20px;
left: 20px; left: 20px;
z-index: 20;
}
.back-from-game {
display: inline-block;
-ms-transform: scale(1); -ms-transform: scale(1);
-webkit-transform: scale(1); -webkit-transform: scale(1);
transform: scale(1); transform: scale(1);
@@ -31,3 +36,70 @@
-webkit-transition: all 250ms cubic-bezier(.17, .67, .83, .67); -webkit-transition: all 250ms cubic-bezier(.17, .67, .83, .67);
transition: all 250ms cubic-bezier(.17, .67, .83, .67); transition: all 250ms cubic-bezier(.17, .67, .83, .67);
} }
.game-cap-badge {
display: inline-flex;
align-items: center;
gap: 6px;
position: fixed;
bottom: 20px;
left: 50%;
z-index: 20;
padding: 5px 10px;
border: 1px solid rgba(255, 216, 92, 0.42);
-webkit-border-radius: 999px;
border-radius: 999px;
background:
linear-gradient(115deg, transparent 0 38%, rgba(255, 255, 255, 0.22) 48%, transparent 58% 100%),
rgba(14, 16, 18, 0.72);
background-position: 120% 50%, 0 0;
background-size: 260% 100%, auto;
-webkit-box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.32), 0 8px 22px rgba(0, 0, 0, 0.28);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.32), 0 8px 22px rgba(0, 0, 0, 0.28);
color: #ffe58a;
font: 700 11px 'Rajdhani', sans-serif;
letter-spacing: 0.9px;
line-height: 1;
text-decoration: none;
text-transform: uppercase;
-ms-transform: translateX(-50%);
-webkit-transform: translateX(-50%);
transform: translateX(-50%);
white-space: nowrap;
-webkit-transition:
background-position 650ms ease,
border-color 200ms ease,
box-shadow 200ms ease,
color 200ms ease,
transform 200ms ease;
transition:
background-position 650ms ease,
border-color 200ms ease,
box-shadow 200ms ease,
color 200ms ease,
transform 200ms ease;
}
.game-cap-badge:hover,
.game-cap-badge:focus-visible {
background-position: -60% 50%, 0 0;
border-color: rgba(255, 229, 138, 0.78);
-webkit-box-shadow: 0 0 0 1px rgba(255, 229, 138, 0.18), 0 0 24px rgba(255, 195, 50, 0.28);
box-shadow: 0 0 0 1px rgba(255, 229, 138, 0.18), 0 0 24px rgba(255, 195, 50, 0.28);
color: #fff4bd;
text-decoration: none;
-ms-transform: translateX(-50%) translateY(-1px);
-webkit-transform: translateX(-50%) translateY(-1px);
transform: translateX(-50%) translateY(-1px);
}
.game-cap-badge:focus,
.game-cap-badge:active,
.game-cap-badge:visited {
text-decoration: none;
}
.game-cap-badge:focus-visible {
outline: 2px solid rgba(255, 229, 138, 0.75);
outline-offset: 2px;
}
Binary file not shown.
+87
View File
@@ -0,0 +1,87 @@
/**
* 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 'cap-widget';
const FORMS_SELECTOR = '.auth-form';
const SUBMIT_SELECTOR = 'button[type="submit"], input[type="submit"]';
const createInvisibleCap = apiEndpoint => {
const host = document.createElement('cap-widget');
host.style.display = 'none';
const cap = new window.Cap({ apiEndpoint }, host);
return { cap, host };
};
const lockSubmit = (form, locked) => {
const submit = form.querySelector(SUBMIT_SELECTOR);
if (!submit) return;
submit.disabled = locked;
};
const bindInvisibleCap = (form, apiEndpoint) => {
const { cap, host } = createInvisibleCap(apiEndpoint);
form.appendChild(host);
let solving = null;
const solveToken = async () => {
if (!solving) {
solving = cap.solve()
.finally(() => {
solving = null;
});
}
const result = await solving;
return result?.token ?? cap.token ?? '';
};
lockSubmit(form, true);
solveToken()
.catch(() => '')
.finally(() => lockSubmit(form, false));
form.addEventListener('submit', async e => {
if (cap.token) {
return;
}
e.preventDefault();
lockSubmit(form, true);
try {
const token = await solveToken();
if (!token) {
lockSubmit(form, false);
return;
}
form.requestSubmit();
} catch (_) {
lockSubmit(form, false);
}
});
};
const initInvisibleCap = () => {
const endpointSource = document.querySelector('[data-cap-api-endpoint]')
|| document.getElementById('mine-wrapper');
const apiEndpoint = endpointSource?.dataset.capApiEndpoint;
if ('function' !== typeof window.Cap || !apiEndpoint) {
return;
}
document.querySelectorAll(FORMS_SELECTOR).forEach(form => bindInvisibleCap(form, apiEndpoint));
};
initInvisibleCap();
+7 -5
View File
@@ -7,7 +7,7 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
import React, { useMemo, useRef } from 'react'; import React, { Fragment, useMemo, useRef } from 'react';
import { string } from 'prop-types'; import { string } from 'prop-types';
import { useProfileDataProvider } from '@mine-hooks/useGameDataProvider'; import { useProfileDataProvider } from '@mine-hooks/useGameDataProvider';
@@ -16,6 +16,8 @@ export const AvatarUpload = ({ uploadUrl, initialThumbUrl, initials }) => {
const [thumbUrl, setThumbUrl] = React.useState(initialThumbUrl || null); const [thumbUrl, setThumbUrl] = React.useState(initialThumbUrl || null);
const { uploadAvatarMutation: { isPending, error, mutate } } = useProfileDataProvider(); const { uploadAvatarMutation: { isPending, error, mutate } } = useProfileDataProvider();
const errorMessage = useMemo(() => error?.message ?? null, [error]);
const handleChange = e => { const handleChange = e => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
@@ -40,9 +42,8 @@ export const AvatarUpload = ({ uploadUrl, initialThumbUrl, initials }) => {
}); });
}; };
const errorMessage = useMemo(() => error?.message ?? null, [error]);
return ( return (
<Fragment>
<div <div
className={`profile-avatar${isPending ? ' profile-avatar--loading' : ''}`} className={`profile-avatar${isPending ? ' profile-avatar--loading' : ''}`}
title="Click to change profile picture" title="Click to change profile picture"
@@ -62,10 +63,11 @@ export const AvatarUpload = ({ uploadUrl, initialThumbUrl, initials }) => {
style={{ display: 'none' }} style={{ display: 'none' }}
onChange={handleChange} onChange={handleChange}
/> />
{errorMessage && <div className="profile-avatar__error">{errorMessage}</div>}
</div> </div>
{errorMessage && <div className="profile-avatar__error">{errorMessage}</div>}
</Fragment>
); );
} };
AvatarUpload.propTypes = { AvatarUpload.propTypes = {
uploadUrl: string.isRequired, uploadUrl: string.isRequired,
+8 -4
View File
@@ -12,7 +12,9 @@ import { array } from 'prop-types';
import { formatDuration } from '@global-utils/format'; import { formatDuration } from '@global-utils/format';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import { createTheme, styled, ThemeProvider } from '@mui/material/styles'; import { createTheme, styled, ThemeProvider } from '@mui/material/styles';
import { Avatar, BonusPoints, StatRow } from '@global-components'; 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 darkTheme = createTheme({ palette: { mode: 'dark' } });
@@ -69,8 +71,10 @@ export const BattleDialog = ({ games }) => {
const endReason = resign const endReason = resign
? `${resign.charAt(0).toUpperCase() + resign.slice(1)} resigned` ? `${resign.charAt(0).toUpperCase() + resign.slice(1)} resigned`
: 26 <= maxPoints ? 'Points' : 'Abandoned'; : 26 <= maxPoints ? 'Points' : 'Abandoned';
const bothRegistered = game.bothRegistered;
const shareUrl = `${window.location.origin}/battle/${game.uuid}`; const shareUrl = `${window.location.origin}/battle/${game.uuid}`;
const canContinue = !resign && 26 > maxPoints; const canContinue = bothRegistered && !resign && 26 > maxPoints;
const canShare = !canContinue;
const playUrl = `${window.location.origin}/play/${game.uuid}`; const playUrl = `${window.location.origin}/play/${game.uuid}`;
const duration = formatDuration(game.created, game.date); const duration = formatDuration(game.created, game.date);
@@ -108,7 +112,7 @@ export const BattleDialog = ({ games }) => {
<i className="fa fa-play" /> <i className="fa fa-play" />
Continue Continue
</a> </a>
) : ( ) : canShare ? (
<button <button
className={`bd-share${copied ? ' bd-share--copied' : ''}`} className={`bd-share${copied ? ' bd-share--copied' : ''}`}
onClick={handleShare} onClick={handleShare}
@@ -118,7 +122,7 @@ export const BattleDialog = ({ games }) => {
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} /> <i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} />
{copied ? 'Copied!' : 'Share'} {copied ? 'Copied!' : 'Share'}
</button> </button>
)} ) : null}
<button className="bd-close" onClick={() => setOpen(false)} aria-label="Close"> <button className="bd-close" onClick={() => setOpen(false)} aria-label="Close">
<i className="fa fa-times" /> <i className="fa fa-times" />
</button> </button>
-89
View File
@@ -1,89 +0,0 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import { useEffect, useRef } from 'react';
import { string } from 'prop-types';
/**
* ContactForm Component
*
* Handles reCAPTCHA v3 integration for the contact form.
* Intercepts form submission, executes reCAPTCHA, and submits the form with the token.
*
* @param {string} siteKey - Google reCAPTCHA site key
* @param {string} recaptchaFieldId - ID of the hidden recaptcha input field
*/
const ContactForm = ({ siteKey, recaptchaFieldId }) => {
const formRef = useRef(null);
const isSubmittingRef = useRef(false);
useEffect(() => {
const form = document.querySelector('.auth-form');
if (!form) {
console.warn('ContactForm: No .auth-form found');
return;
}
formRef.current = form;
const handleSubmit = e => {
e.preventDefault();
if (isSubmittingRef.current) {
return;
}
isSubmittingRef.current = true;
if ('undefined' !== typeof grecaptcha) {
grecaptcha.ready(() => {
grecaptcha
.execute(siteKey, { action: 'contact' })
.then(token => {
const recaptchaField = document.getElementById(recaptchaFieldId);
if (recaptchaField) {
recaptchaField.value = token;
} else {
console.error(`ContactForm: Recaptcha field with ID "${recaptchaFieldId}" not found`);
}
isSubmittingRef.current = false;
form.submit();
})
.catch(error => {
console.error('ContactForm: reCAPTCHA execution failed', error);
isSubmittingRef.current = false;
});
});
} else {
console.error('ContactForm: grecaptcha is not loaded');
isSubmittingRef.current = false;
}
};
form.addEventListener('submit', handleSubmit);
return () => {
if (formRef.current) {
formRef.current.removeEventListener('submit', handleSubmit);
}
};
}, [siteKey, recaptchaFieldId]);
return null;
};
ContactForm.propTypes = {
siteKey: string.isRequired,
recaptchaFieldId: string.isRequired,
};
export default ContactForm;
-1
View File
@@ -9,7 +9,6 @@
export { AvatarUpload } from './AvatarUpload'; export { AvatarUpload } from './AvatarUpload';
export { BattleDialog } from './BattleDialog'; export { BattleDialog } from './BattleDialog';
export { default as ContactForm } from './ContactForm';
export { default as PasskeyLogin } from './PasskeyLogin'; export { default as PasskeyLogin } from './PasskeyLogin';
export { default as PasskeyManager } from './PasskeyManager'; export { default as PasskeyManager } from './PasskeyManager';
export { default as ProfileCharts } from './ProfileCharts'; export { default as ProfileCharts } from './ProfileCharts';
-30
View File
@@ -1,30 +0,0 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React from 'react';
import { createRoot } from 'react-dom/client';
import { ContactForm } from '@global-components';
const wrapper = document.getElementById('contact-form-wrapper');
if (wrapper) {
const siteKey = wrapper.dataset.siteKey;
const recaptchaFieldId = wrapper.dataset.recaptchaFieldId;
if (siteKey && recaptchaFieldId) {
createRoot(wrapper).render(
<ContactForm
siteKey={siteKey}
recaptchaFieldId={recaptchaFieldId}
/>
);
} else {
console.error('ContactForm: Missing siteKey or recaptchaFieldId in data attributes');
}
}
@@ -7,17 +7,16 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; import 'cap-widget';
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react';
import { func, node, string } from 'prop-types'; import { func, node, string } from 'prop-types';
const CAPTCHA_STORAGE_KEY = 'mineseeker_captcha_verified'; const CAPTCHA_STORAGE_KEY = 'mineseeker_captcha_verified';
const CAPTCHA_TOKEN_KEY = 'mineseeker_captcha_token'; const CAPTCHA_TOKEN_KEY = 'mineseeker_captcha_token';
const RECAPTCHA_ACTION = 'mineseeker_play';
const CaptchaOverlay = ({ siteKey, onVerified, children }) => { const CaptchaOverlay = ({ apiEndpoint, onVerified, children }) => {
const [verified, setVerified] = useState(false); const [verified, setVerified] = useState(false);
const [loading, setLoading] = useState(false); const capRef = useRef(null);
const [error, setError] = useState(false);
const handleToken = useCallback(token => { const handleToken = useCallback(token => {
const wrapper = document.getElementById('mine-wrapper'); const wrapper = document.getElementById('mine-wrapper');
@@ -30,12 +29,6 @@ const CaptchaOverlay = ({ siteKey, onVerified, children }) => {
onVerified?.(); onVerified?.();
}, [onVerified]); }, [onVerified]);
const buttonClasses = useMemo(() => [
'captcha-button',
error && 'captcha-button--error',
loading && 'captcha-button--loading',
].filter(Boolean).join(' '), [error, loading]);
useEffect(() => { useEffect(() => {
const storedToken = sessionStorage.getItem(CAPTCHA_TOKEN_KEY); const storedToken = sessionStorage.getItem(CAPTCHA_TOKEN_KEY);
const storedTime = sessionStorage.getItem(CAPTCHA_STORAGE_KEY); const storedTime = sessionStorage.getItem(CAPTCHA_STORAGE_KEY);
@@ -52,41 +45,43 @@ const CaptchaOverlay = ({ siteKey, onVerified, children }) => {
return; return;
} }
} }
}, [onVerified]);
if (window.grecaptcha) { useEffect(() => {
window.grecaptcha.ready(() => { const widget = document.createElement('cap-widget');
window.grecaptcha widget.style.display = 'none';
.execute(siteKey, { action: RECAPTCHA_ACTION }) capRef.current = widget;
.then(token => { document.body.appendChild(widget);
handleToken(token);
}) const cap = new window.Cap({ apiEndpoint }, widget);
.catch(() => { let cancelled = false;
setError(true);
}); const run = async () => {
}); try {
const result = await cap.solve();
if (!cancelled && result?.token) {
handleToken(result.token);
}
} catch (_) {
if (!cancelled) {
setTimeout(() => {
if (!cancelled) {
run();
}
}, 1200);
}
} }
}, [siteKey, onVerified, handleToken]);
const handleClick = () => {
setLoading(true);
setError(false);
window.grecaptcha.ready(() => {
window.grecaptcha
.execute(siteKey, { action: RECAPTCHA_ACTION })
.then(token => {
handleToken(token);
setLoading(false);
})
.catch(() => {
setLoading(false);
setError(true);
setTimeout(() => setError(false), 2000);
});
});
}; };
run();
return () => {
cancelled = true;
widget.remove();
capRef.current = null;
};
}, [apiEndpoint, handleToken]);
if (verified) { if (verified) {
return <Fragment>{children}</Fragment>; return <Fragment>{children}</Fragment>;
} }
@@ -99,16 +94,8 @@ const CaptchaOverlay = ({ siteKey, onVerified, children }) => {
</div> </div>
<h1 className="captcha-title">Ready to Play?</h1> <h1 className="captcha-title">Ready to Play?</h1>
<p className="captcha-description"> <p className="captcha-description">
Click below to verify you&apos;re human and start playing. Verifying your session...
</p> </p>
<button
className={buttonClasses}
onClick={handleClick}
disabled={loading}
>
<i className={`fa ${loading ? 'fa-spinner fa-spin' : error ? 'fa-exclamation-circle' : 'fa-play'}`} />
{loading ? 'Verifying...' : error ? 'Try Again' : 'Start Playing'}
</button>
</div> </div>
</div> </div>
); );
@@ -117,7 +104,7 @@ const CaptchaOverlay = ({ siteKey, onVerified, children }) => {
export default CaptchaOverlay; export default CaptchaOverlay;
CaptchaOverlay.propTypes = { CaptchaOverlay.propTypes = {
siteKey: string.isRequired, apiEndpoint: string.isRequired,
onVerified: func, onVerified: func,
children: node, children: node,
}; };
@@ -19,7 +19,7 @@ export const GameBoard = ({ gameAssoc, gameInherited, opponentName = '', isEnvDe
const { onClick, resign } = useServerCommunication(gameAssoc, gameInherited, opponentName, isEnvDev); const { onClick, resign } = useServerCommunication(gameAssoc, gameInherited, opponentName, isEnvDev);
const [captchaVerified, setCaptchaVerified] = useState(false); const [captchaVerified, setCaptchaVerified] = useState(false);
const siteKey = document.getElementById('mine-wrapper')?.dataset.recaptchaSiteKey; const apiEndpoint = document.getElementById('mine-wrapper')?.dataset.capApiEndpoint;
if (!gridReady) { if (!gridReady) {
return ( return (
@@ -29,9 +29,9 @@ export const GameBoard = ({ gameAssoc, gameInherited, opponentName = '', isEnvDe
); );
} }
if (!captchaVerified && siteKey) { if (!captchaVerified && apiEndpoint) {
return ( return (
<CaptchaOverlay siteKey={siteKey} onVerified={() => setCaptchaVerified(true)} /> <CaptchaOverlay apiEndpoint={apiEndpoint} onVerified={() => setCaptchaVerified(true)} />
); );
} }
@@ -49,6 +49,7 @@ export const GameProvider = ({ children }) => {
mine: new Howl({ src: ['/sound/mine.mp3'] }), mine: new Howl({ src: ['/sound/mine.mp3'] }),
warning: new Howl({ src: ['/sound/warning.mp3'] }), warning: new Howl({ src: ['/sound/warning.mp3'] }),
won: new Howl({ src: ['/sound/won.mp3'] }), won: new Howl({ src: ['/sound/won.mp3'] }),
starting: new Howl({ src: ['/sound/starting.mp3'] }),
}); });
/** Sync helpers (keep ref + state in lockstep) */ /** Sync helpers (keep ref + state in lockstep) */
@@ -269,6 +270,8 @@ export const GameProvider = ({ children }) => {
makeGameEndIfItEnds, resignProcess, makeGameEndIfItEnds, resignProcess,
/** UI action */ /** UI action */
onBombToggle, onBombToggle,
/** Sounds */
sounds,
}} }}
> >
{children} {children}
@@ -22,7 +22,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
/** Sync helpers */ /** Sync helpers */
syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue, syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue,
/** Game logic */ /** Game logic */
showOverlay, hideOverlay, applyStep, makeGameEndIfItEnds, resignProcess, showOverlay, hideOverlay, applyStep, makeGameEndIfItEnds, resignProcess, sounds,
/** Current cells snapshot (for active-check in onClick) */ /** Current cells snapshot (for active-check in onClick) */
cells, cells,
} = useGame(); } = useGame();
@@ -212,6 +212,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
*/ */
if (!endRef.current && (!isTrueRestoredRef.current || 0 !== opponentLastSeenRef.current)) { if (!endRef.current && (!isTrueRestoredRef.current || 0 !== opponentLastSeenRef.current)) {
hideOverlay(); hideOverlay();
sounds.current.starting.play();
} }
}; };
+42
View File
@@ -0,0 +1,42 @@
#!/bin/sh
set -eu
PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$PROJECT_DIR"
CONTAINER_ID=$(docker compose ps -q minio)
if [ -z "$CONTAINER_ID" ]; then
echo "Error: The MineSeeker MinIO service is not running."
exit 1
fi
VOLUME_NAME=$(docker inspect "$CONTAINER_ID" --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Name}}{{end}}{{end}}')
if [ -z "$VOLUME_NAME" ]; then
echo "Error: Could not determine the MinIO data volume."
exit 1
fi
BACKUP_DIR=${1:-"$PROJECT_DIR/backups"}
mkdir -p "$BACKUP_DIR"
BACKUP_DIR=$(CDPATH= cd -- "$BACKUP_DIR" && pwd)
TIMESTAMP=$(date +%Y-%m-%d.%H%M%S)
BACKUP_FILE="mineseeker-minio.${TIMESTAMP}.tar.gz"
BACKUP_PATH="$BACKUP_DIR/$BACKUP_FILE"
umask 077
restart_minio() {
docker compose start minio || echo "Warning: Failed to restart MinIO."
}
trap restart_minio EXIT
echo "Stopping MinIO for a consistent backup..."
docker compose stop minio
echo "Creating MinIO backup: $BACKUP_PATH"
docker run --rm \
--volume "$VOLUME_NAME:/data:ro" \
--volume "$BACKUP_DIR:/backup" \
busybox tar czf "/backup/$BACKUP_FILE" -C /data .
echo "Backup complete: $BACKUP_PATH"
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
set -eu
PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$PROJECT_DIR"
CONTAINER_ID=$(docker compose ps -q db)
if [ -z "$CONTAINER_ID" ]; then
echo "Error: The MineSeeker PostgreSQL service is not running."
exit 1
fi
DB_USER=$(docker inspect "$CONTAINER_ID" --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^POSTGRES_USER=//p')
DB_NAME=$(docker inspect "$CONTAINER_ID" --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^POSTGRES_DB=//p')
if [ -z "$DB_USER" ] || [ -z "$DB_NAME" ]; then
echo "Error: Could not read PostgreSQL credentials from the db service."
exit 1
fi
BACKUP_DIR=${1:-"$PROJECT_DIR/backups"}
mkdir -p "$BACKUP_DIR"
BACKUP_DIR=$(CDPATH= cd -- "$BACKUP_DIR" && pwd)
TIMESTAMP=$(date +%Y-%m-%d.%H%M%S)
BACKUP_FILE="mineseeker-postgres.${TIMESTAMP}.dump"
BACKUP_PATH="$BACKUP_DIR/$BACKUP_FILE"
TEMP_PATH="$BACKUP_PATH.tmp"
umask 077
trap 'rm -f "$TEMP_PATH"' EXIT
echo "Creating PostgreSQL backup: $BACKUP_PATH"
docker exec "$CONTAINER_ID" pg_dump \
--username="$DB_USER" \
--format=custom \
--create \
"$DB_NAME" > "$TEMP_PATH"
mv "$TEMP_PATH" "$BACKUP_PATH"
trap - EXIT
echo "Backup complete: $BACKUP_PATH"
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env php
<?php
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
+61
View File
@@ -0,0 +1,61 @@
#!/bin/sh
set -eu
PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$PROJECT_DIR"
if [ "$#" -ne 1 ]; then
echo "Usage: $0 <backup-file>"
echo "Example: $0 backups/mineseeker-minio.2026-08-18.120000.tar.gz"
exit 1
fi
BACKUP_FILE=$1
if [ ! -f "$BACKUP_FILE" ]; then
echo "Error: Backup file not found: $BACKUP_FILE"
exit 1
fi
BACKUP_DIR=$(CDPATH= cd -- "$(dirname -- "$BACKUP_FILE")" && pwd)
BACKUP_NAME=$(basename "$BACKUP_FILE")
CONTAINER_ID=$(docker compose ps -q minio)
if [ -z "$CONTAINER_ID" ]; then
echo "Error: The MineSeeker MinIO service is not running."
exit 1
fi
VOLUME_NAME=$(docker inspect "$CONTAINER_ID" --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Name}}{{end}}{{end}}')
if [ -z "$VOLUME_NAME" ]; then
echo "Error: Could not determine the MinIO data volume."
exit 1
fi
echo "Verifying MinIO archive..."
docker run --rm \
--volume "$BACKUP_DIR:/backup:ro" \
busybox tar -tzf "/backup/$BACKUP_NAME" > /dev/null
echo "Archive OK."
printf 'WARNING: This erases all current MineSeeker MinIO data. Type yes to continue: '
read -r CONFIRM
if [ "$CONFIRM" != 'yes' ]; then
echo "Aborted."
exit 1
fi
restart_minio() {
docker compose start minio || echo "Warning: Failed to restart MinIO."
}
trap restart_minio EXIT
echo "Stopping MinIO before restore..."
docker compose stop minio
echo "Restoring MinIO backup: $BACKUP_FILE"
docker run --rm \
--volume "$VOLUME_NAME:/data" \
--volume "$BACKUP_DIR:/backup:ro" \
busybox sh -c 'find /data -mindepth 1 -maxdepth 1 -exec rm -rf {} + && tar xzf "/backup/$1" -C /data' sh "$BACKUP_NAME"
echo "Restore complete."
+84
View File
@@ -0,0 +1,84 @@
#!/bin/sh
set -eu
PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$PROJECT_DIR"
if [ "$#" -ne 1 ]; then
echo "Usage: $0 <backup-file>"
echo "Example: $0 backups/mineseeker-postgres.2026-08-18.120000.dump"
exit 1
fi
BACKUP_FILE=$1
if [ ! -f "$BACKUP_FILE" ]; then
echo "Error: Backup file not found: $BACKUP_FILE"
exit 1
fi
CONTAINER_ID=$(docker compose ps -q db)
if [ -z "$CONTAINER_ID" ]; then
echo "Error: The MineSeeker PostgreSQL service is not running."
exit 1
fi
DB_USER=$(docker inspect "$CONTAINER_ID" --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^POSTGRES_USER=//p')
if [ -z "$DB_USER" ]; then
echo "Error: Could not read POSTGRES_USER from the db service."
exit 1
fi
DB_NAME=$(docker inspect "$CONTAINER_ID" --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^POSTGRES_DB=//p')
if [ -z "$DB_NAME" ]; then
echo "Error: Could not read POSTGRES_DB from the db service."
exit 1
fi
echo "Verifying PostgreSQL archive..."
docker exec -i "$CONTAINER_ID" pg_restore --list < "$BACKUP_FILE" > /dev/null
echo "Archive OK."
printf 'WARNING: This replaces the current MineSeeker database. Type yes to continue: '
read -r CONFIRM
if [ "$CONFIRM" != 'yes' ]; then
echo "Aborted."
exit 1
fi
APP_WAS_RUNNING=0
SCHEDULER_WAS_RUNNING=0
[ -n "$(docker compose ps -q app)" ] && APP_WAS_RUNNING=1
[ -n "$(docker compose ps -q scheduler)" ] && SCHEDULER_WAS_RUNNING=1
restart_services() {
[ "$APP_WAS_RUNNING" -eq 1 ] && docker compose start app || true
[ "$SCHEDULER_WAS_RUNNING" -eq 1 ] && docker compose start scheduler || true
}
trap restart_services EXIT
echo "Stopping MineSeeker services that use PostgreSQL..."
docker compose stop app scheduler
echo "Dropping configured database '$DB_NAME'..."
docker exec "$CONTAINER_ID" dropdb \
--username="$DB_USER" \
--if-exists \
--force \
"$DB_NAME"
echo "Creating '$DB_NAME' owned by '$DB_USER'..."
docker exec "$CONTAINER_ID" createdb \
--username="$DB_USER" \
--owner="$DB_USER" \
--template=template0 \
"$DB_NAME"
echo "Restoring PostgreSQL backup without source ownership/ACLs: $BACKUP_FILE"
docker exec -i "$CONTAINER_ID" pg_restore \
--username="$DB_USER" \
--dbname="$DB_NAME" \
--no-owner \
--no-acl \
--exit-on-error < "$BACKUP_FILE"
echo "Restore complete."
+148 -127
View File
@@ -7,30 +7,31 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@fontsource/changa-one": "^5.2.8", "@fontsource/changa-one": "^5.3.0",
"@fontsource/open-sans": "^5.2.7", "@fontsource/open-sans": "^5.3.0",
"@fontsource/rajdhani": "^5.2.7", "@fontsource/rajdhani": "^5.3.0",
"@fortawesome/fontawesome-free": "^7.2.0", "@fortawesome/fontawesome-free": "^7.3.1",
"@mui/material": "^9.0.0", "@mui/material": "^9.2.0",
"@mui/x-charts": "^9.0.2", "@mui/x-charts": "^9.10.1",
"@tanstack/react-query": "^5.99.2", "@tanstack/react-query": "^5.101.4",
"cap-widget": "^0.1.56",
"howler": "^2.2.4", "howler": "^2.2.4",
"lodash": "^4.18.1", "lodash": "^4.18.1",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react": "^19.2.5", "react": "^19.2.8",
"react-dom": "^19.2.5", "react-dom": "^19.2.8",
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.5", "@eslint/eslintrc": "^3.3.6",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@stylistic/eslint-plugin": "5.10.0", "@stylistic/eslint-plugin": "5.10.0",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.4",
"eslint": "10.2.1", "eslint": "^10.8.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-hooks": "7.1.1",
"globals": "17.5.0", "globals": "^17.8.0",
"sass": "^1.99.0", "sass": "^1.102.0",
"vite": "^8.0.8", "vite": "^8.1.5",
"vite-plugin-symfony": "^8.2.4", "vite-plugin-symfony": "^8.2.4",
}, },
}, },
@@ -38,27 +39,27 @@
"packages": { "packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.0", "http://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/code-frame": ["@babel/code-frame@7.29.0", "http://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], "@babel/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", "", { "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/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/generator": ["@babel/generator@7.29.1", "http://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], "@babel/helper-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-globals": ["@babel/helper-globals@7.28.0", "http://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "http://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "http://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], "@babel/helper-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-string-parser": ["@babel/helper-string-parser@7.27.1", "http://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "http://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "http://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], "@babel/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", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], "@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/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=="],
@@ -70,11 +71,7 @@
"@babel/types": ["@babel/types@7.29.0", "http://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], "@babel/types": ["@babel/types@7.29.0", "http://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@emnapi/core": ["@emnapi/core@1.9.2", "http://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], "@base-ui/utils": ["@base-ui/utils@0.3.1", "http://registry.npmjs.org/@base-ui/utils/-/utils-0.3.1.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "http://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "http://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@emotion/babel-plugin": ["@emotion/babel-plugin@11.13.5", "http://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", { "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/serialize": "^1.3.3", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", "find-root": "^1.1.0", "source-map": "^0.5.7", "stylis": "4.2.0" } }, "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ=="], "@emotion/babel-plugin": ["@emotion/babel-plugin@11.13.5", "http://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", { "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/serialize": "^1.3.3", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", "find-root": "^1.1.0", "source-map": "^0.5.7", "stylis": "4.2.0" } }, "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ=="],
@@ -106,27 +103,29 @@
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "http://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "http://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], "@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.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="], "@eslint/config-helpers": ["@eslint/config-helpers@0.7.0", "http://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw=="],
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], "@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/eslintrc": ["@eslint/eslintrc@3.3.6", "http://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.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.3.0", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA=="],
"@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], "@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@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], "@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.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "http://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="],
"@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=="], "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "http://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
"@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/changa-one": ["@fontsource/changa-one@5.3.0", "http://registry.npmjs.org/@fontsource/changa-one/-/changa-one-5.3.0.tgz", {}, "sha512-ACriykYOe12aupip0QfAaNo9/XNEt0WD1U1CiL/ZUGnrzIXFizJEkUzjnXWg+M4GXkY4n0o/W6TUtcNqMn0GrA=="],
"@fontsource/rajdhani": ["@fontsource/rajdhani@5.2.7", "http://registry.npmjs.org/@fontsource/rajdhani/-/rajdhani-5.2.7.tgz", {}, "sha512-7Gy10U688fCdeFfYKebUF2TZotdgH/ghKyMsseXPmB60lpaUHC8aoCSJl5/OpZ+KHKSU2TqBfKfteVkcIXxTAQ=="], "@fontsource/open-sans": ["@fontsource/open-sans@5.3.0", "http://registry.npmjs.org/@fontsource/open-sans/-/open-sans-5.3.0.tgz", {}, "sha512-V1bdMlGNyZrJwzrdusshMOw2YR0aqYDL7kOo9dkxUEKl8dFv66A9HLckOOXl572sG/7ohhDQrCrTFGSn9LZcSw=="],
"@fortawesome/fontawesome-free": ["@fortawesome/fontawesome-free@7.2.0", "http://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.2.0.tgz", {}, "sha512-3DguDv/oUE+7vjMeTSOjCSG+KeawgVQOHrKRnvUuqYh1mfArrh7s+s8hXW3e4RerBA1+Wh+hBqf8sJNpqNrBWg=="], "@fontsource/rajdhani": ["@fontsource/rajdhani@5.3.0", "http://registry.npmjs.org/@fontsource/rajdhani/-/rajdhani-5.3.0.tgz", {}, "sha512-oBJgSIibWJvVBKseme1JczqPLoDHN97LHti4uh0LjfcJqQoVmnQQlRKIpEjTJV559rmVQ6qbVZZwKwk2wkBTtw=="],
"@fortawesome/fontawesome-free": ["@fortawesome/fontawesome-free@7.3.1", "http://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.3.1.tgz", {}, "sha512-wmglKKPDIkgV3aWlZzWECCPoGIkYCulzBwxG9+w7rc5BGapZ6cPMpoPOT8k36J0Ni7PPX6c/rsoMWfS4d1MUMg=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "http://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/core": ["@humanfs/core@0.19.1", "http://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
@@ -138,7 +137,7 @@
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "http://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "http://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], "@jridgewell/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/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=="],
@@ -146,29 +145,27 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "http://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "http://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@mui/core-downloads-tracker": ["@mui/core-downloads-tracker@9.0.0", "http://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.0.0.tgz", {}, "sha512-uwQNGkhv0lf7ufxw6QXev77BW6pWbW+7uxYjU5+rfp4lBkFtMEgJCsarTM3Tn+i0lGx6+Ol2u88JdGXr0GDskA=="], "@mui/core-downloads-tracker": ["@mui/core-downloads-tracker@9.3.1", "", {}, "sha512-IAyAFNQbT7hysJ9HXphiOmWJF7G1OglzHanqCgvQgH9LA2ydxtmaTBDbcBqw6euZesyShiwvpvbnYO1GY1AyXQ=="],
"@mui/material": ["@mui/material@9.0.0", "http://registry.npmjs.org/@mui/material/-/material-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@mui/core-downloads-tracker": "^9.0.0", "@mui/system": "^9.0.0", "@mui/types": "^9.0.0", "@mui/utils": "^9.0.0", "@popperjs/core": "^2.11.8", "@types/react-transition-group": "^4.4.12", "clsx": "^2.1.1", "csstype": "^3.2.3", "prop-types": "^15.8.1", "react-is": "^19.2.4", "react-transition-group": "^4.4.5" }, "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", "@mui/material-pigment-css": "^9.0.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.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", "@mui/material-pigment-css", "@types/react"] }, "sha512-+VP/oQCDhDR87NQQgXnNBG8dwy6GNuQLnenS1pZvkbn2dKFSxRSRMybTpH9xUxXP+316mlYDy5CSbYtusnCWtw=="], "@mui/material": ["@mui/material@9.3.1", "", { "dependencies": { "@babel/runtime": "^7.29.7", "@mui/core-downloads-tracker": "^9.3.1", "@mui/system": "^9.3.0", "@mui/types": "^9.3.0", "@mui/utils": "^9.3.0", "@popperjs/core": "^2.11.8", "@types/react-transition-group": "^4.4.12", "clsx": "^2.1.1", "csstype": "^3.2.3", "prop-types": "^15.8.1", "react-is": "^19.2.8", "react-transition-group": "^4.4.5" }, "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", "@mui/material-pigment-css": "^9.3.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.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", "@mui/material-pigment-css", "@types/react"] }, "sha512-NahAEGIXqS1K0bA4th1jeFxBguS59NOcLbMA0vU+fSaPWKjtwGBGGHeTwlc9PSmzMjOZKceeFWESB8fVHr31hA=="],
"@mui/private-theming": ["@mui/private-theming@9.0.0", "http://registry.npmjs.org/@mui/private-theming/-/private-theming-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@mui/utils": "^9.0.0", "prop-types": "^15.8.1" }, "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-JtuZoaiCqwD6vjgYu6Xp3T7DZkrxJlgtDz5yESzhI34fEX5hHMh2VJUbuL9UOg8xrfIFMrq6dcYoH/7Zi4G0RA=="], "@mui/private-theming": ["@mui/private-theming@9.3.0", "", { "dependencies": { "@babel/runtime": "^7.29.7", "@mui/utils": "^9.3.0", "prop-types": "^15.8.1" }, "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-ERvqk5pejf9aRnQcDILSWGtFmsEMiVxlQ4+xsVCjsEvmK0fV9BiVP/cQwAF5dwyDFbve4lTrlcgeFEecVzTNiA=="],
"@mui/styled-engine": ["@mui/styled-engine@9.0.0", "http://registry.npmjs.org/@mui/styled-engine/-/styled-engine-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@emotion/cache": "^11.14.0", "@emotion/serialize": "^1.3.3", "@emotion/sheet": "^1.4.0", "csstype": "^3.2.3", "prop-types": "^15.8.1" }, "peerDependencies": { "@emotion/react": "^11.4.1", "@emotion/styled": "^11.3.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled"] }, "sha512-9RLGdX4Jg0aQPRuvqh/OLzYSPlgd5zyEw5/1HIRfdavSiOd03WtUaGZH9/w1RoTYuRKwpgy0hpIFaMHIqPVIWg=="], "@mui/styled-engine": ["@mui/styled-engine@9.3.0", "", { "dependencies": { "@babel/runtime": "^7.29.7", "@emotion/cache": "^11.14.0", "@emotion/serialize": "^1.3.3", "@emotion/sheet": "^1.4.0", "csstype": "^3.2.3", "prop-types": "^15.8.1" }, "peerDependencies": { "@emotion/react": "^11.4.1", "@emotion/styled": "^11.3.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled"] }, "sha512-x9+KYxhjoHYZ4nioxdKnvQWdw0RScbhoZfQ4tv3Db742683U3wlYSp6zV6Us78dMFnKnyTrPTkQKyutp14gnKA=="],
"@mui/system": ["@mui/system@9.0.0", "http://registry.npmjs.org/@mui/system/-/system-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@mui/private-theming": "^9.0.0", "@mui/styled-engine": "^9.0.0", "@mui/types": "^9.0.0", "@mui/utils": "^9.0.0", "clsx": "^2.1.1", "csstype": "^3.2.3", "prop-types": "^15.8.1" }, "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled", "@types/react"] }, "sha512-YnC5Zg6j04IxiLc/boAKs0464jfZlLFVa7mf5E8lF0XOtZVUvG6R6gJK50lgUYdaaLdyLfxF6xR7LaPuEpeT/g=="], "@mui/system": ["@mui/system@9.3.0", "", { "dependencies": { "@babel/runtime": "^7.29.7", "@mui/private-theming": "^9.3.0", "@mui/styled-engine": "^9.3.0", "@mui/types": "^9.3.0", "@mui/utils": "^9.3.0", "clsx": "^2.1.1", "csstype": "^3.2.3", "prop-types": "^15.8.1" }, "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled", "@types/react"] }, "sha512-0l4LqHJxZj65xSrioniGsxm7VNoGXonPo203oZjhBUvIDPeBqRTb7Mqc45Qxs6sO6WGR7WE/9cJ6lb4lkvHkjg=="],
"@mui/types": ["@mui/types@9.0.0", "http://registry.npmjs.org/@mui/types/-/types-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.29.2" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-i1cuFCAWN44b3AJWO7mh7tuh1sqbQSeVr/94oG0TX5uXivac8XalgE4/6fQZcmGZigzbQ35IXxj/4jLpRIBYZg=="], "@mui/types": ["@mui/types@9.3.0", "", { "dependencies": { "@babel/runtime": "^7.29.7" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-2JSxyfpEFWNUB2vKs/T1BvkfyNisMHWph8bLMj8T0uHwmLl/0qfAwQkfwMT6kxLXN9uIum9AEbECXU8er3amIg=="],
"@mui/utils": ["@mui/utils@9.0.0", "http://registry.npmjs.org/@mui/utils/-/utils-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@mui/types": "^9.0.0", "@types/prop-types": "^15.7.15", "clsx": "^2.1.1", "prop-types": "^15.8.1", "react-is": "^19.2.4" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-bQcqyg/gjULUqTuyUjSAFr6LQGLvtkNtDbJerAtoUn9kGZ0hg5QJiN1PLHMLbeFpe3te1831uq7GFl2ITokGdg=="], "@mui/utils": ["@mui/utils@9.3.0", "", { "dependencies": { "@babel/runtime": "^7.29.7", "@mui/types": "^9.3.0", "@types/prop-types": "^15.7.15", "clsx": "^2.1.1", "prop-types": "^15.8.1", "react-is": "^19.2.8" }, "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-2HZdHwWJ6eB+7lVGSOHsByGw8jeRulT4g0NZ608Wb8Q57DE2jbNqrWPFuJsvkQQiBiTmlpvQL3i+/62zsiPrkw=="],
"@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": ["@mui/x-charts@9.11.1", "", { "dependencies": { "@babel/runtime": "^7.29.7", "@mui/utils": "^9.3.0", "@mui/x-charts-vendor": "^9.11.1", "@mui/x-internal-gestures": "^9.10.0", "@mui/x-internals": "^9.11.0", "bezier-easing": "^3.0.1", "clsx": "^2.1.1", "prop-types": "^15.8.1", "reselect": "^5.2.0", "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-5Z6fzVm0NvD4auoyGZopp1wwg1mMVDXjbPEi4jahdxn14EzIAB99kKw4NUkKtmSaZJ6DiBU7zJXQAec1IZTaLA=="],
"@mui/x-charts-vendor": ["@mui/x-charts-vendor@9.0.0", "http://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@types/d3-array": "^3.2.2", "@types/d3-color": "^3.1.3", "@types/d3-format": "^3.0.4", "@types/d3-interpolate": "^3.0.4", "@types/d3-path": "^3.1.1", "@types/d3-scale": "^4.0.9", "@types/d3-shape": "^3.1.8", "@types/d3-time": "^3.0.4", "@types/d3-time-format": "^4.0.3", "@types/d3-timer": "^3.0.2", "d3-array": "^3.2.4", "d3-color": "^3.1.0", "d3-format": "^3.1.2", "d3-interpolate": "^3.0.1", "d3-path": "^3.1.0", "d3-scale": "^4.0.2", "d3-shape": "^3.2.0", "d3-time": "^3.1.0", "d3-time-format": "^4.1.0", "d3-timer": "^3.0.1", "flatqueue": "^3.0.0", "internmap": "^2.0.3" } }, "sha512-Do91i+fZiNj/4LN5oaGpJoutolzDBDwdfw6tHrx2LKXDMCRlaImCfreLbdbkk7dFsi9fuIP7hWiMV4vDJKPJTA=="], "@mui/x-charts-vendor": ["@mui/x-charts-vendor@9.11.1", "", { "dependencies": { "@babel/runtime": "^7.29.7", "@types/d3-array": "^3.2.2", "@types/d3-color": "^3.1.3", "@types/d3-format": "^3.0.4", "@types/d3-geo": "^3.1.0", "@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-geo": "^3.1.1", "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.1.0", "internmap": "^2.0.3" } }, "sha512-Q7cuPaKZLLsyvtEiP+ENG7xLlPatXyNQ7u5WkbAcAIZwyMK8MJw4MPgJiyWyfy9eVQO/IkRLIupabvu8cA2jPA=="],
"@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-internal-gestures": ["@mui/x-internal-gestures@9.10.0", "http://registry.npmjs.org/@mui/x-internal-gestures/-/x-internal-gestures-9.10.0.tgz", { "dependencies": { "@babel/runtime": "^7.29.7", "@base-ui/utils": "^0.3.1" } }, "sha512-Fgmh+vJlOjLst238/J5SlEOZLdnSe0yWjrYctYVWJr3pKbtmYSbDkjYvWjG97PeDvq5Wa1+W1qagiBoJgkUIng=="],
"@mui/x-internals": ["@mui/x-internals@9.0.0", "http://registry.npmjs.org/@mui/x-internals/-/x-internals-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@mui/utils": "9.0.0", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-E/4rdg69JjhyybpPGypCjAKSKLLnSdCFM+O6P/nkUg47+qt3uftxQEhjQO53rcn6ahHl6du/uNZ9BLgeY6kYxQ=="], "@mui/x-internals": ["@mui/x-internals@9.11.0", "", { "dependencies": { "@babel/runtime": "^7.29.7", "@base-ui/utils": "^0.3.1", "@mui/utils": "^9.3.0", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JjKe9k1+gVWNPwMTZLNSc92eVmoZqP5Xq3Ui6mJkstotUrbWIZC0o9+4AfTR1lYqWwnKmqm+VtLLVCa7MEBXWQ=="],
"@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=="],
"@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=="], "@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=="],
@@ -176,7 +173,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=="], "@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.144.0", "", {}, "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg=="],
"@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=="], "@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=="],
@@ -210,45 +207,41 @@
"@popperjs/core": ["@popperjs/core@2.11.8", "http://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="], "@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.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA=="],
"@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.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ=="],
"@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.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg=="],
"@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.2.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A=="],
"@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.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ=="],
"@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.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng=="],
"@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.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w=="],
"@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.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ=="],
"@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.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw=="],
"@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.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ=="],
"@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.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ=="],
"@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.2.4", "", { "os": "none", "cpu": "arm64" }, "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg=="],
"@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-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw=="],
"@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-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ=="],
"@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/pluginutils": ["@rolldown/pluginutils@1.0.1", "http://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@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@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=="],
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.10.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.56.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": "^9.0.0 || ^10.0.0" } }, "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ=="], "@tanstack/query-core": ["@tanstack/query-core@5.101.4", "http://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", {}, "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw=="],
"@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.101.4", "http://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", { "dependencies": { "@tanstack/query-core": "5.101.4" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA=="],
"@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=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "http://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], "@types/d3-array": ["@types/d3-array@3.2.2", "http://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
@@ -256,6 +249,8 @@
"@types/d3-format": ["@types/d3-format@3.0.4", "http://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="], "@types/d3-format": ["@types/d3-format@3.0.4", "http://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="],
"@types/d3-geo": ["@types/d3-geo@3.1.0", "http://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "http://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "http://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "http://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], "@types/d3-path": ["@types/d3-path@3.1.1", "http://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
@@ -270,10 +265,12 @@
"@types/d3-timer": ["@types/d3-timer@3.0.2", "http://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], "@types/d3-timer": ["@types/d3-timer@3.0.2", "http://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], "@types/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/estree": ["@types/estree@1.0.8", "http://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/geojson": ["@types/geojson@7946.0.16", "http://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "http://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/json-schema": ["@types/json-schema@7.0.15", "http://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/parse-json": ["@types/parse-json@4.0.2", "http://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="], "@types/parse-json": ["@types/parse-json@4.0.2", "http://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="],
@@ -286,7 +283,7 @@
"@typescript-eslint/types": ["@typescript-eslint/types@8.58.1", "http://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", {}, "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw=="], "@typescript-eslint/types": ["@typescript-eslint/types@8.58.1", "http://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", {}, "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "http://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.5", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "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-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA=="],
"acorn": ["acorn@8.16.0", "http://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn": ["acorn@8.16.0", "http://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
@@ -318,15 +315,15 @@
"balanced-match": ["balanced-match@1.0.0", "http://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", {}, "sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg=="], "balanced-match": ["balanced-match@1.0.0", "http://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", {}, "sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.20", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ=="], "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=="], "bezier-easing": ["bezier-easing@3.0.1", "http://registry.npmjs.org/bezier-easing/-/bezier-easing-3.0.1.tgz", {}, "sha512-vvvg10Anq7py0+KkIqlwJpUVdde9Z8kSzeUzAkQNYAJdA3mZnLG9r9ExdwtjcASCYER6UkNHdBNHHMnVimnU7A=="],
"brace-expansion": ["brace-expansion@1.1.11", "http://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], "brace-expansion": ["brace-expansion@1.1.11", "http://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"braces": ["braces@3.0.3", "http://registry.npmjs.org/braces/-/braces-3.0.3.tgz", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "braces": ["braces@3.0.3", "http://registry.npmjs.org/braces/-/braces-3.0.3.tgz", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], "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": ["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=="],
@@ -336,15 +333,17 @@
"callsites": ["callsites@3.1.0", "http://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "callsites": ["callsites@3.1.0", "http://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001788", "", {}, "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ=="], "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=="], "cap-widget": ["cap-widget@0.1.57", "", {}, "sha512-SyIK5XgYIFSDgH683M9KpfBmDSDB0ZfPZQBxL6MggNrjjnbIQYpbsNpj0S7PC/wXp7oJgAuBD2vtJnN9jJghBg=="],
"chokidar": ["chokidar@5.0.0", "http://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"clsx": ["clsx@2.1.1", "http://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "clsx": ["clsx@2.1.1", "http://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"concat-map": ["concat-map@0.0.1", "http://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "concat-map": ["concat-map@0.0.1", "http://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "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=="], "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=="],
@@ -358,6 +357,8 @@
"d3-format": ["d3-format@3.1.2", "http://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], "d3-format": ["d3-format@3.1.2", "http://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
"d3-geo": ["d3-geo@3.1.1", "http://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "http://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], "d3-interpolate": ["d3-interpolate@3.0.1", "http://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "http://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], "d3-path": ["d3-path@3.1.0", "http://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
@@ -394,7 +395,7 @@
"dunder-proto": ["dunder-proto@1.0.1", "http://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "dunder-proto": ["dunder-proto@1.0.1", "http://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.340", "", {}, "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA=="], "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=="], "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=="],
@@ -414,17 +415,17 @@
"es-to-primitive": ["es-to-primitive@1.3.0", "http://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], "es-to-primitive": ["es-to-primitive@1.3.0", "http://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "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=="], "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@10.2.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q=="], "eslint": ["eslint@10.8.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@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.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ=="],
"eslint-plugin-react": ["eslint-plugin-react@7.37.5", "http://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "http://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="], "eslint-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@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], "eslint-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=="], "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=="],
@@ -460,7 +461,7 @@
"flat-cache": ["flat-cache@4.0.1", "http://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], "flat-cache": ["flat-cache@4.0.1", "http://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatqueue": ["flatqueue@3.0.0", "http://registry.npmjs.org/flatqueue/-/flatqueue-3.0.0.tgz", {}, "sha512-y1deYaVt+lIc/d2uIcWDNd0CrdQTO5xoCjeFdhX0kSXvm2Acm0o+3bAOiYklTEoRyzwio3sv3/IiBZdusbAe2Q=="], "flatqueue": ["flatqueue@3.1.0", "", {}, "sha512-Ia4qIYrrsEqIRx3c3XhkT+QDLQuUV5ovsr6ah1rIgKT5wclhoGK3lAMS1bWRAWxlx7wtlTBpV7QXB5d9fOSRxA=="],
"flatted": ["flatted@3.4.2", "http://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], "flatted": ["flatted@3.4.2", "http://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
@@ -476,7 +477,7 @@
"generator-function": ["generator-function@2.0.1", "http://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], "generator-function": ["generator-function@2.0.1", "http://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "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-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=="],
@@ -486,7 +487,7 @@
"glob-parent": ["glob-parent@6.0.2", "http://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "glob-parent": ["glob-parent@6.0.2", "http://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@17.5.0", "", {}, "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g=="], "globals": ["globals@17.11.0", "", {}, "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw=="],
"globalthis": ["globalthis@1.0.4", "http://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], "globalthis": ["globalthis@1.0.4", "http://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
@@ -504,9 +505,9 @@
"hasown": ["hasown@2.0.2", "http://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "hasown": ["hasown@2.0.2", "http://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], "hermes-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", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], "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=="], "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=="],
@@ -584,7 +585,7 @@
"js-tokens": ["js-tokens@4.0.0", "http://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-tokens": ["js-tokens@4.0.0", "http://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "http://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "js-yaml": ["js-yaml@4.3.0", "http://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
"jsesc": ["jsesc@3.1.0", "http://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "jsesc": ["jsesc@3.1.0", "http://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
@@ -596,7 +597,7 @@
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "http://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "http://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "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=="], "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=="],
@@ -604,29 +605,29 @@
"levn": ["levn@0.4.1", "http://registry.npmjs.org/levn/-/levn-0.4.1.tgz", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "levn": ["levn@0.4.1", "http://registry.npmjs.org/levn/-/levn-0.4.1.tgz", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lightningcss": ["lightningcss@1.32.0", "http://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "http://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "http://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "http://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "http://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "http://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "http://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "http://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "http://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "http://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "http://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "http://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "http://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "lines-and-columns": ["lines-and-columns@1.2.4", "http://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
@@ -636,7 +637,7 @@
"loose-envify": ["loose-envify@1.4.0", "http://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "loose-envify": ["loose-envify@1.4.0", "http://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "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=="], "math-intrinsics": ["math-intrinsics@1.1.0", "http://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@@ -650,7 +651,7 @@
"ms": ["ms@2.1.3", "http://registry.npmjs.org/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "http://registry.npmjs.org/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "http://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
"natural-compare": ["natural-compare@1.4.0", "http://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "natural-compare": ["natural-compare@1.4.0", "http://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
@@ -658,7 +659,7 @@
"node-exports-info": ["node-exports-info@1.6.0", "http://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="], "node-exports-info": ["node-exports-info@1.6.0", "http://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="],
"node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], "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-assign": ["object-assign@4.1.1", "http://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@@ -700,7 +701,7 @@
"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=="], "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.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
"prelude-ls": ["prelude-ls@1.2.1", "http://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prelude-ls": ["prelude-ls@1.2.1", "http://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
@@ -710,21 +711,21 @@
"queue-microtask": ["queue-microtask@1.2.3", "http://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "queue-microtask": ["queue-microtask@1.2.3", "http://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"react": ["react@19.2.5", "http://registry.npmjs.org/react/-/react-19.2.5.tgz", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], "react": ["react@19.2.8", "http://registry.npmjs.org/react/-/react-19.2.8.tgz", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
"react-dom": ["react-dom@19.2.5", "http://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], "react-dom": ["react-dom@19.2.8", "http://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
"react-is": ["react-is@19.2.5", "http://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", {}, "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ=="], "react-is": ["react-is@19.2.8", "http://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", {}, "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ=="],
"react-transition-group": ["react-transition-group@4.4.5", "http://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], "react-transition-group": ["react-transition-group@4.4.5", "http://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
"readdirp": ["readdirp@4.1.2", "http://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "readdirp": ["readdirp@5.0.0", "http://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "http://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "http://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "http://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "http://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
"reselect": ["reselect@5.1.1", "http://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], "reselect": ["reselect@5.2.0", "http://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="],
"resolve": ["resolve@2.0.0-next.6", "http://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA=="], "resolve": ["resolve@2.0.0-next.6", "http://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA=="],
@@ -732,7 +733,7 @@
"reusify": ["reusify@1.1.0", "http://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "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.2.4", "", { "dependencies": { "@oxc-project/types": "=0.144.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.4", "@rolldown/binding-darwin-arm64": "1.2.4", "@rolldown/binding-darwin-x64": "1.2.4", "@rolldown/binding-freebsd-x64": "1.2.4", "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", "@rolldown/binding-linux-arm64-gnu": "1.2.4", "@rolldown/binding-linux-arm64-musl": "1.2.4", "@rolldown/binding-linux-ppc64-gnu": "1.2.4", "@rolldown/binding-linux-s390x-gnu": "1.2.4", "@rolldown/binding-linux-x64-gnu": "1.2.4", "@rolldown/binding-linux-x64-musl": "1.2.4", "@rolldown/binding-openharmony-arm64": "1.2.4", "@rolldown/binding-win32-arm64-msvc": "1.2.4", "@rolldown/binding-win32-x64-msvc": "1.2.4" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w=="],
"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=="], "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=="],
@@ -742,7 +743,7 @@
"safe-regex-test": ["safe-regex-test@1.1.0", "http://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], "safe-regex-test": ["safe-regex-test@1.1.0", "http://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
"sass": ["sass@1.99.0", "http://registry.npmjs.org/sass/-/sass-1.99.0.tgz", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": "sass.js" }, "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q=="], "sass": ["sass@1.102.0", "http://registry.npmjs.org/sass/-/sass-1.102.0.tgz", { "dependencies": { "chokidar": "^5.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-NSOyTnaQF7rTAEOtI2fwb386vL+akyiQLBZu8Na7hXCb+umJy0GAqlcMIaqACZ6Z1VgTBS4K9PG6B3IdjHGJsw=="],
"scheduler": ["scheduler@0.27.0", "http://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "scheduler": ["scheduler@0.27.0", "http://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
@@ -790,14 +791,12 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "http://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "http://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tinyglobby": ["tinyglobby@0.2.16", "http://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], "tinyglobby": ["tinyglobby@0.2.17", "http://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"to-regex-range": ["to-regex-range@5.0.1", "http://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "to-regex-range": ["to-regex-range@5.0.1", "http://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"totalist": ["totalist@3.0.1", "http://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], "totalist": ["totalist@3.0.1", "http://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
"tslib": ["tslib@2.8.1", "http://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-check": ["type-check@0.4.0", "http://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "type-check": ["type-check@0.4.0", "http://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typed-array-buffer": ["typed-array-buffer@1.0.3", "http://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], "typed-array-buffer": ["typed-array-buffer@1.0.3", "http://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
@@ -810,13 +809,13 @@
"unbox-primitive": ["unbox-primitive@1.1.0", "http://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], "unbox-primitive": ["unbox-primitive@1.1.0", "http://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "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=="], "uri-js": ["uri-js@4.4.1", "http://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "http://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "use-sync-external-store": ["use-sync-external-store@1.6.0", "http://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"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.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.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-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="],
"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=="], "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=="],
@@ -832,15 +831,17 @@
"word-wrap": ["word-wrap@1.2.5", "http://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "word-wrap": ["word-wrap@1.2.5", "http://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "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=="], "yaml": ["yaml@1.10.3", "http://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
"yocto-queue": ["yocto-queue@0.1.0", "http://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "yocto-queue": ["yocto-queue@0.1.0", "http://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod": ["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", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], "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=="],
"@base-ui/utils/@babel/runtime": ["@babel/runtime@7.29.7", "http://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@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=="], "@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=="],
@@ -850,11 +851,31 @@
"@eslint/eslintrc/globals": ["globals@14.0.0", "http://registry.npmjs.org/globals/-/globals-14.0.0.tgz", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "http://registry.npmjs.org/globals/-/globals-14.0.0.tgz", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@mui/material/@babel/runtime": ["@babel/runtime@7.29.7", "http://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@mui/private-theming/@babel/runtime": ["@babel/runtime@7.29.7", "http://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@mui/styled-engine/@babel/runtime": ["@babel/runtime@7.29.7", "http://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@mui/system/@babel/runtime": ["@babel/runtime@7.29.7", "http://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@mui/types/@babel/runtime": ["@babel/runtime@7.29.7", "http://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@mui/utils/@babel/runtime": ["@babel/runtime@7.29.7", "http://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@mui/x-charts/@babel/runtime": ["@babel/runtime@7.29.7", "http://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@mui/x-charts-vendor/@babel/runtime": ["@babel/runtime@7.29.7", "http://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@mui/x-internal-gestures/@babel/runtime": ["@babel/runtime@7.29.7", "http://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@mui/x-internals/@babel/runtime": ["@babel/runtime@7.29.7", "http://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"babel-plugin-macros/resolve": ["resolve@1.22.12", "http://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], "babel-plugin-macros/resolve": ["resolve@1.22.12", "http://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
"eslint/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/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "http://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"eslint/espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], "eslint/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=="], "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=="],
@@ -866,7 +887,7 @@
"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=="], "prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="], "vite/picomatch": ["picomatch@4.0.5", "http://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
"@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=="], "@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=="],
+120 -19
View File
@@ -6,10 +6,11 @@ services:
restart: unless-stopped restart: unless-stopped
container_name: '${APP_NAME}-app' container_name: '${APP_NAME}-app'
ports: ports:
- "10080:80" - "${APP_PUBLISHED_PORT:-10080}:80"
environment: environment:
SERVER_NAME: ${SERVER_NAME:-:80} SERVER_NAME: ${SERVER_NAME:-:80}
APP_ENV: prod APP_ENV: prod
APP_VERSION: ${APP_VERSION:-1.0.0}
APP_SECRET: ${APP_SECRET} APP_SECRET: ${APP_SECRET}
APP_PUBLIC_HOSTNAME: ${APP_PUBLIC_HOSTNAME:-localhost} APP_PUBLIC_HOSTNAME: ${APP_PUBLIC_HOSTNAME:-localhost}
APP_CONTACT_MAIL_ADDRESS: ${APP_CONTACT_MAIL_ADDRESS:-7system7@gmail.com} APP_CONTACT_MAIL_ADDRESS: ${APP_CONTACT_MAIL_ADDRESS:-7system7@gmail.com}
@@ -25,14 +26,14 @@ services:
MERCURE_JWT_TOKEN: ${MERCURE_JWT_TOKEN} MERCURE_JWT_TOKEN: ${MERCURE_JWT_TOKEN}
MERCURE_SUBSCRIBER_JWT: ${MERCURE_SUBSCRIBER_JWT} MERCURE_SUBSCRIBER_JWT: ${MERCURE_SUBSCRIBER_JWT}
MAILER_DSN: smtp://mail:25?verify_peer=0 MAILER_DSN: smtp://mail:25?verify_peer=0
RECAPTCHA_SITE_KEY: ${RECAPTCHA_SITE_KEY} CAP_API_ENDPOINT: ${CAP_API_ENDPOINT}
RECAPTCHA_SECRET_KEY: ${RECAPTCHA_SECRET_KEY} CAP_SECRET_KEY: ${CAP_SECRET_KEY}
WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:-localhost} WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:-localhost}
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-https://localhost} WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-https://localhost}
MINIO_ROOT_USER: ${MINIO_ROOT_USER} MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
MINIO_ENDPOINT: http://minio:9000 MINIO_ENDPOINT: http://rustfs:9000
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9000} MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9010}
# IMPORTANT: Set TRUSTED_PROXIES to your reverse proxy IP in production. # IMPORTANT: Set TRUSTED_PROXIES to your reverse proxy IP in production.
# For Docker on same host, use: 172.17.0.1 (default bridge) or 172.16.0.0/12 (overlay network) # For Docker on same host, use: 172.17.0.1 (default bridge) or 172.16.0.0/12 (overlay network)
# For Kubernetes or external proxy, use the proxy's IP address. # For Kubernetes or external proxy, use the proxy's IP address.
@@ -42,19 +43,113 @@ services:
- app_var:/app/var - app_var:/app/var
- caddy_data:/data - caddy_data:/data
- caddy_config:/config - caddy_config:/config
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/ -o /dev/null"]
interval: 10s
timeout: 5s
retries: 12
start_period: 60s
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
mail: mail:
condition: service_started condition: service_started
rustfs:
condition: service_healthy
storage_init:
condition: service_completed_successfully
scheduler:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
container_name: '${APP_NAME}-scheduler'
command: ['php', 'bin/console', 'messenger:consume', 'scheduler_default', '--env=prod', '--no-interaction', '--memory-limit=128M']
environment:
APP_ENV: prod
APP_SECRET: ${APP_SECRET}
APP_PUBLIC_HOSTNAME: ${APP_PUBLIC_HOSTNAME:-localhost}
DATABASE_URL: >-
postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?serverVersion=${POSTGRES_VERSION}&charset=utf8
POSTGRES_URL: db
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
MERCURE_URL: http://localhost/.well-known/mercure
MERCURE_PUBLIC_URL: https://${APP_PUBLIC_HOSTNAME:-localhost}/.well-known/mercure
MERCURE_JWT_SECRET: ${MERCURE_JWT_SECRET}
MERCURE_JWT_TOKEN: ${MERCURE_JWT_TOKEN}
MERCURE_SUBSCRIBER_JWT: ${MERCURE_SUBSCRIBER_JWT}
MAILER_DSN: smtp://mail:25?verify_peer=0
CAP_API_ENDPOINT: ${CAP_API_ENDPOINT}
CAP_SECRET_KEY: ${CAP_SECRET_KEY}
WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:-localhost}
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-https://localhost}
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
MINIO_ENDPOINT: http://rustfs:9000
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9010}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1}
volumes:
- app_var:/app/var
depends_on:
db:
condition: service_healthy
mail:
condition: service_started
rustfs:
condition: service_healthy
storage_init:
condition: service_completed_successfully
healthcheck:
disable: true
rustfs:
image: rustfs/rustfs:1.0.3
restart: unless-stopped
container_name: '${APP_NAME}-rustfs'
environment:
RUSTFS_ACCESS_KEY: ${MINIO_ROOT_USER}
RUSTFS_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
RUSTFS_ADDRESS: ':9000'
RUSTFS_CONSOLE_ADDRESS: ':9001'
RUSTFS_CONSOLE_ENABLE: 'true'
volumes:
- rustfs_data:/data
ports:
- "${RUSTFS_PUBLISHED_API_PORT:-9010}:9000"
- "${RUSTFS_PUBLISHED_CONSOLE_PORT:-9011}:9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/health"]
interval: 5s
timeout: 5s
retries: 20
start_period: 15s
storage_init:
build:
context: ./docker/storage-migrate
entrypoint: ["python"]
command: ["/usr/local/bin/storage-init.py"]
restart: "no"
container_name: '${APP_NAME}-storage-init'
depends_on:
rustfs:
condition: service_healthy
environment:
TARGET_ENDPOINT: http://rustfs:9000
STORAGE_ACCESS_KEY: ${MINIO_ROOT_USER}
STORAGE_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
STORAGE_BUCKET: mineseeker
minio: minio:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z-cpuv1 profiles: ["storage-migration"]
image: ${MINIO_IMAGE:-minio/minio:RELEASE.2025-09-07T16-13-09Z-cpuv1}
pull_policy: never
restart: unless-stopped restart: unless-stopped
container_name: '${APP_NAME}-minio' container_name: '${APP_NAME}-minio'
command: server /data --console-address ":9001" command: server /data --console-address ":9001"
ports: ports:
- "9000:9000" - "${MINIO_PUBLISHED_API_PORT:-9012}:9000"
- "9001:9001" - "${MINIO_PUBLISHED_CONSOLE_PORT:-9013}:9001"
environment: environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER} MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
@@ -66,19 +161,25 @@ services:
timeout: 5s timeout: 5s
retries: 10 retries: 10
start_period: 10s start_period: 10s
minio_init: storage_migrate:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z-cpuv1 profiles: ["storage-migration"]
build:
context: ./docker/storage-migrate
restart: "no" restart: "no"
container_name: '${APP_NAME}-minio-init' container_name: '${APP_NAME}-storage-migrate'
depends_on: depends_on:
minio: minio:
condition: service_healthy condition: service_healthy
rustfs:
condition: service_healthy
environment: environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER} SOURCE_ENDPOINT: http://minio:9000
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} TARGET_ENDPOINT: http://rustfs:9000
STORAGE_ACCESS_KEY: ${MINIO_ROOT_USER}
STORAGE_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
STORAGE_BUCKET: mineseeker
volumes: volumes:
- ./docker/minio-init.sh:/minio-init.sh:ro - storage_migration_state:/state
entrypoint: ["/bin/sh", "/minio-init.sh"]
mail: mail:
image: boky/postfix:latest image: boky/postfix:latest
restart: unless-stopped restart: unless-stopped
@@ -105,7 +206,7 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB} POSTGRES_DB: ${POSTGRES_DB}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql
healthcheck: healthcheck:
test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}" ] test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}" ]
interval: 5s interval: 5s
@@ -113,7 +214,7 @@ services:
retries: 10 retries: 10
start_period: 10s start_period: 10s
ports: ports:
- "15432:5432" - "${POSTGRES_PUBLISHED_PORT:-5452}:5432"
volumes: volumes:
app_var: app_var:
postgres_data: postgres_data:
@@ -121,5 +222,5 @@ volumes:
caddy_config: caddy_config:
postfix_spool: postfix_spool:
minio_data: minio_data:
rustfs_data:
storage_migration_state:
+26 -19
View File
@@ -1,6 +1,6 @@
{ {
"name": "splendid-bear/mineseeker", "name": "splendid-bear/mineseeker",
"version": "2026.2.1", "version": "2026.3.0",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"author": "https://www.splendidbear.org", "author": "https://www.splendidbear.org",
"bugs": "https://source.splendidbear.org", "bugs": "https://source.splendidbear.org",
@@ -10,7 +10,7 @@
"prefer-stable": true, "prefer-stable": true,
"private": true, "private": true,
"require": { "require": {
"php": ">=8.5", "php": ">=8.5.12",
"ext-gd": "*", "ext-gd": "*",
"ext-iconv": "*", "ext-iconv": "*",
"ext-json": "*", "ext-json": "*",
@@ -27,28 +27,35 @@
"scheb/2fa-backup-code": "^8.5", "scheb/2fa-backup-code": "^8.5",
"scheb/2fa-bundle": "^8.5", "scheb/2fa-bundle": "^8.5",
"scheb/2fa-totp": "^8.5", "scheb/2fa-totp": "^8.5",
"symfony/console": "7.4.*", "symfony/console": "8.1.*",
"symfony/flex": "^2.10.0", "symfony/flex": "^2.10.0",
"symfony/form": "7.4.*", "symfony/form": "8.1.*",
"symfony/framework-bundle": "7.4.*", "symfony/framework-bundle": "8.1.*",
"symfony/http-client": "7.4.*", "symfony/http-client": "8.1.*",
"symfony/mailer": "7.4.*", "symfony/mailer": "8.1.*",
"symfony/mercure": "^0.6", "symfony/mercure": "^0.9",
"symfony/mercure-bundle": "*", "symfony/mercure-bundle": "^0.6",
"symfony/monolog-bundle": "^3.8", "symfony/messenger": "8.1.*",
"symfony/security-bundle": "7.4.*", "symfony/monolog-bundle": "^4.0",
"symfony/translation": "7.4.*", "symfony/scheduler": "8.1.*",
"symfony/twig-bundle": "7.4.*", "symfony/security-bundle": "8.1.*",
"symfony/validator": "7.4.*", "symfony/translation": "8.1.*",
"symfony/yaml": "7.4.*", "symfony/twig-bundle": "8.1.*",
"symfony/validator": "8.1.*",
"symfony/yaml": "8.1.*",
"web-auth/webauthn-framework": "^5.2" "web-auth/webauthn-framework": "^5.2"
}, },
"require-dev": { "require-dev": {
"dama/doctrine-test-bundle": "^8.6",
"phpunit/phpunit": "^13.1",
"roave/security-advisories": "dev-master", "roave/security-advisories": "dev-master",
"symfony/dotenv": "7.4.*", "symfony/browser-kit": "8.1.*",
"symfony/css-selector": "8.1.*",
"symfony/dotenv": "8.1.*",
"symfony/maker-bundle": "^1.5", "symfony/maker-bundle": "^1.5",
"symfony/stopwatch": "7.4.*", "symfony/stopwatch": "8.1.*",
"symfony/web-profiler-bundle": "7.4.*" "symfony/web-profiler-bundle": "8.1.*",
"zenstruck/foundry": "^2.9"
}, },
"config": { "config": {
"preferred-install": { "preferred-install": {
@@ -97,7 +104,7 @@
"extra": { "extra": {
"symfony": { "symfony": {
"allow-contrib": false, "allow-contrib": false,
"require": "7.4.*" "require": "8.1.*"
} }
} }
} }
Generated
+4454 -1674
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -16,4 +16,6 @@ return [
Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true], Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true],
League\FlysystemBundle\FlysystemBundle::class => ['all' => true], League\FlysystemBundle\FlysystemBundle::class => ['all' => true],
Liip\ImagineBundle\LiipImagineBundle::class => ['all' => true], Liip\ImagineBundle\LiipImagineBundle::class => ['all' => true],
Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['dev' => true, 'test' => true],
DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
]; ];
+1
View File
@@ -3,6 +3,7 @@ mercure:
default: default:
url: '%env(MERCURE_URL)%' url: '%env(MERCURE_URL)%'
public_url: '%env(MERCURE_PUBLIC_URL)%' public_url: '%env(MERCURE_PUBLIC_URL)%'
protocol_version: '0.x'
jwt: jwt:
secret: '%env(MERCURE_JWT_SECRET)%' secret: '%env(MERCURE_JWT_SECRET)%'
publish: '*' publish: '*'
+22
View File
@@ -0,0 +1,22 @@
framework:
messenger:
# Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
# failure_transport: failed
transports:
# https://symfony.com/doc/current/messenger.html#transport-configuration
# async: '%env(MESSENGER_TRANSPORT_DSN)%'
# failed: 'doctrine://default?queue_name=failed'
sync: 'sync://'
routing:
# Route your messages to the transports
# 'App\Message\YourMessage': async
# when@test:
# framework:
# messenger:
# transports:
# # replace with your transport name here (e.g., my_transport: 'in-memory://')
# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test
# async: 'in-memory://'
+1 -1
View File
@@ -1,4 +1,4 @@
framework: framework:
test: true test: true
session: session:
storage_id: session.storage.mock_file storage_factory_id: session.storage.factory.mock_file
+1 -1
View File
@@ -4,4 +4,4 @@ twig:
strict_variables: '%kernel.debug%' strict_variables: '%kernel.debug%'
globals: globals:
version: "%jotunheimr.version%" version: "%jotunheimr.version%"
recaptcha_site_key: "%env(RECAPTCHA_SITE_KEY)%" cap_api_endpoint: "%env(CAP_API_ENDPOINT)%"
+17
View File
@@ -0,0 +1,17 @@
when@dev: &dev
# See full configuration: https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#full-default-bundle-configuration
zenstruck_foundry:
enable_auto_refresh_with_lazy_objects: true
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
+1 -1
View File
@@ -1,3 +1,3 @@
_errors: _errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.xml' resource: '@FrameworkBundle/Resources/config/routing/errors.php'
prefix: /_error prefix: /_error
+2 -2
View File
@@ -1,7 +1,7 @@
web_profiler_wdt: web_profiler_wdt:
resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml' resource: '@WebProfilerBundle/Resources/config/routing/wdt.php'
prefix: /_wdt prefix: /_wdt
web_profiler_profiler: web_profiler_profiler:
resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml' resource: '@WebProfilerBundle/Resources/config/routing/profiler.php'
prefix: /_profiler prefix: /_profiler
+2 -1
View File
@@ -2,7 +2,7 @@
# https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration # https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration
parameters: parameters:
locale: 'en' locale: 'en'
jotunheimr.version: 1.1.0-20191026 jotunheimr.version: '%env(APP_VERSION)%'
services: services:
# default configuration for services in *this* file # default configuration for services in *this* file
@@ -28,6 +28,7 @@ services:
App\Service\BattleCardGenerator: App\Service\BattleCardGenerator:
arguments: arguments:
$cacheDir: '%kernel.project_dir%/var/og-cache' $cacheDir: '%kernel.project_dir%/var/og-cache'
$fontPath: '%kernel.project_dir%/assets/fonts/Carlito-Bold.ttf'
$minioMediaStorage: '@mineseeker.media.storage' $minioMediaStorage: '@mineseeker.media.storage'
Aws\S3\S3Client: Aws\S3\S3Client:
+5
View File
@@ -8,6 +8,11 @@ until php -r "new PDO('pgsql:host=db;port=5432;dbname=${POSTGRES_DB}', '${POSTGR
done done
echo "[entrypoint] Database is ready." echo "[entrypoint] Database is ready."
if [ "$#" -gt 0 ]; then
echo "[entrypoint] Starting worker command: $*"
exec "$@"
fi
echo "[entrypoint] Clearing and warming Symfony cache..." echo "[entrypoint] Clearing and warming Symfony cache..."
php bin/console cache:clear php bin/console cache:clear
+6 -2
View File
@@ -1,8 +1,12 @@
#!/bin/sh #!/bin/sh
set -e set -e
echo "[minio-init] Configuring alias..." echo "[minio-init] Waiting for MinIO..."
mc alias set local http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" until mc alias set local http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" > /dev/null 2>&1; do
sleep 2
done
echo "[minio-init] Configuring bucket..."
if mc ls local/mineseeker > /dev/null 2>&1; then if mc ls local/mineseeker > /dev/null 2>&1; then
echo "[minio-init] Already initialized, skipping." echo "[minio-init] Already initialized, skipping."
+8
View File
@@ -0,0 +1,8 @@
FROM python:3.13-slim
RUN pip install --no-cache-dir boto3==1.43.118
COPY migrate.py /usr/local/bin/storage-migrate.py
COPY init.py /usr/local/bin/storage-init.py
ENTRYPOINT ["python", "/usr/local/bin/storage-migrate.py"]
+45
View File
@@ -0,0 +1,45 @@
"""Create the application bucket and public object-read policy on RustFS."""
import json
import os
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
BUCKET = os.environ.get("STORAGE_BUCKET", "mineseeker")
s3 = boto3.client(
"s3",
endpoint_url=os.environ["TARGET_ENDPOINT"],
aws_access_key_id=os.environ["STORAGE_ACCESS_KEY"],
aws_secret_access_key=os.environ["STORAGE_SECRET_KEY"],
region_name="us-east-1",
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
)
try:
s3.head_bucket(Bucket=BUCKET)
except ClientError as error:
status = error.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
code = error.response.get("Error", {}).get("Code", "")
if status != 404 and code not in {"NoSuchBucket", "404", "NotFound"}:
raise
s3.create_bucket(Bucket=BUCKET)
policy = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject"],
"Resource": [
f"arn:aws:s3:::{BUCKET}/media/*",
f"arn:aws:s3:::{BUCKET}/cache/*",
],
}
],
}
s3.put_bucket_policy(Bucket=BUCKET, Policy=json.dumps(policy))
print(f"RustFS bucket {BUCKET!r} is initialized", flush=True)
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""Copy the legacy MinIO bucket into RustFS exactly once, without deleting source data."""
import json
import itertools
import os
import sys
import tempfile
import time
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError, EndpointConnectionError
MARKER = "/state/minio-to-rustfs-v2.json"
BUCKET = os.environ.get("STORAGE_BUCKET", "mineseeker")
ACCESS_KEY = os.environ["STORAGE_ACCESS_KEY"]
SECRET_KEY = os.environ["STORAGE_SECRET_KEY"]
def client(endpoint):
return boto3.client(
"s3",
endpoint_url=endpoint,
aws_access_key_id=ACCESS_KEY,
aws_secret_access_key=SECRET_KEY,
region_name="us-east-1",
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
)
def wait_for_service(s3, label):
for attempt in range(60):
try:
s3.list_buckets()
print(f"{label} is ready", flush=True)
return
except (EndpointConnectionError, ClientError) as error:
if attempt == 59:
raise RuntimeError(f"{label} did not become ready: {error}") from error
time.sleep(2)
def bucket_exists(s3, bucket):
try:
s3.head_bucket(Bucket=bucket)
return True
except ClientError as error:
code = error.response.get("Error", {}).get("Code", "")
status = error.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
if code in {"NoSuchBucket", "404", "NotFound"} or status == 404:
return False
raise
def ensure_bucket(s3, bucket):
if not bucket_exists(s3, bucket):
s3.create_bucket(Bucket=bucket)
def reject_versioned_bucket(source, bucket):
versioning = source.get_bucket_versioning(Bucket=bucket).get("Status")
if versioning in {"Enabled", "Suspended"}:
raise RuntimeError(
f"Bucket {bucket!r} has versioning status {versioning}; "
"refusing to omit historical object versions or delete markers"
)
def copy_bucket(source, target, bucket):
copied = 0
paginator = source.get_paginator("list_objects_v2")
for page in paginator.paginate(Bucket=bucket):
for item in page.get("Contents", []):
key = item["Key"]
response = source.get_object(Bucket=bucket, Key=key)
try:
# Botocore's request checksum calculation needs a seekable body.
# Spool to disk beyond 16 MiB so large objects don't consume RAM.
with tempfile.SpooledTemporaryFile(max_size=16 * 1024 * 1024, mode="w+b") as body:
for chunk in response["Body"].iter_chunks(chunk_size=1024 * 1024):
if chunk:
body.write(chunk)
body.seek(0)
args = {
"Bucket": bucket,
"Key": key,
"Body": body,
"ContentLength": response["ContentLength"],
"Metadata": response.get("Metadata", {}),
}
for field in (
"ContentType",
"ContentEncoding",
"CacheControl",
"ContentDisposition",
"ContentLanguage",
"Expires",
):
if response.get(field) is not None:
args[field] = response[field]
if response.get("WebsiteRedirectLocation") is not None:
args["WebsiteRedirectLocation"] = response["WebsiteRedirectLocation"]
target.put_object(**args)
finally:
response["Body"].close()
copied += 1
if copied % 100 == 0:
print(f"Copied {copied} objects", flush=True)
return copied
def inventory(s3, bucket):
paginator = s3.get_paginator("list_objects_v2")
for page in paginator.paginate(Bucket=bucket):
for item in page.get("Contents", []):
yield item["Key"], item["Size"]
def verify_copy(source, target, bucket):
for source_item, target_item in itertools.zip_longest(
inventory(source, bucket), inventory(target, bucket)
):
if source_item != target_item:
raise RuntimeError(
f"Object inventory mismatch in bucket {bucket!r}: "
f"source={source_item!r}, target={target_item!r}"
)
def apply_bucket_policy(source, target, bucket):
if bucket != BUCKET:
try:
response = source.get_bucket_policy(Bucket=bucket)
except ClientError as error:
code = error.response.get("Error", {}).get("Code", "")
status = error.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
if code in {"NoSuchBucketPolicy", "NoSuchKey", "404", "NotFound"} or status == 404:
return
raise
target.put_bucket_policy(Bucket=bucket, Policy=response["Policy"])
return
policy = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject"],
"Resource": [
f"arn:aws:s3:::{bucket}/media/*",
f"arn:aws:s3:::{bucket}/cache/*",
],
}
],
}
target.put_bucket_policy(Bucket=bucket, Policy=json.dumps(policy))
def migrate_all_buckets(source, target):
buckets = sorted(bucket["Name"] for bucket in source.list_buckets().get("Buckets", []))
if BUCKET not in buckets:
raise RuntimeError(f"Required source bucket {BUCKET!r} does not exist; refusing to mark migration complete")
totals = {}
for bucket in buckets:
reject_versioned_bucket(source, bucket)
ensure_bucket(target, bucket)
totals[bucket] = copy_bucket(source, target, bucket)
verify_copy(source, target, bucket)
apply_bucket_policy(source, target, bucket)
print(f"Migrated bucket {bucket!r}: {totals[bucket]} objects", flush=True)
return totals
def main():
if os.path.exists(MARKER):
print("Storage migration already completed; skipping", flush=True)
return
source = client(os.environ["SOURCE_ENDPOINT"])
target = client(os.environ["TARGET_ENDPOINT"])
wait_for_service(source, "MinIO")
wait_for_service(target, "RustFS")
totals = migrate_all_buckets(source, target)
# Publish the completion marker only after the data and policy are confirmed.
temporary_marker = f"{MARKER}.tmp"
with open(temporary_marker, "w", encoding="utf-8") as marker:
json.dump({"buckets": totals, "objects_copied": sum(totals.values())}, marker)
marker.write("\n")
marker.flush()
os.fsync(marker.fileno())
os.replace(temporary_marker, MARKER)
print(
f"Migration complete: {sum(totals.values())} objects copied across "
f"{len(totals)} buckets; source data was left untouched",
flush=True,
)
if __name__ == "__main__":
try:
main()
except Exception as error: # Keep the failed container's logs useful to operators.
print(f"Storage migration failed: {error}", file=sys.stderr, flush=True)
raise
+443
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
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/*
```
+67
View File
@@ -0,0 +1,67 @@
# Object storage migration
RustFS is the normal S3-compatible store. MinIO and `storage_migrate` are gated
behind the `storage-migration` Compose profile and are not started by a normal
`docker compose up -d`.
On the first production release with a legacy MinIO container, the deployment
workflow pauses app/scheduler writers, starts both stores, copies every bucket
and its current objects (including `mineseeker` media and LiipImagine cache
thumbnails), verifies each source/target key-and-size inventory, then starts
the app on RustFS. It also carries over bucket policies, while enforcing the
existing public-read policy for `mineseeker/media/*` and `mineseeker/cache/*`.
Versioned or suspended-versioning buckets fail safely instead of silently
losing historical versions/delete markers. Only after successful migration
and app startup does it remove the
MinIO and migration containers. It never removes the named `minio_data` volume;
keep that volume as the rollback/backup until RustFS has been verified and a
separate retirement decision is made. A missing source bucket or inventory
mismatch fails the deployment rather than recording an empty/partial migration.
RustFS retains the public host ports `9010` (S3 API) and `9011` (console); the
legacy MinIO temporarily uses `9012` and `9013`. Confirm the public storage
hostname configured by `MINIO_PUBLIC_URL` (for example, `d.aws...`) proxies to
the RustFS API port before serving the new app. The legacy MinIO uses the local
image named by `MINIO_IMAGE` (defaulting to the previously configured release
tag) and does not try to pull it. If production has another local image tag,
set `MINIO_IMAGE` to that exact reference before deployment.
For future releases, the workflow sees no MinIO container and deploys only the
RustFS stack. Review the release logs and `docker compose logs rustfs` after
rollout; never use `docker compose down -v` as part of migration/retirement.
## Testing a backup in isolation
`bin/restore-minio.sh` erases the MinIO volume belonging to the selected Compose
project before extracting the archive. Test with a separate project name and
`APP_NAME`; Compose then uses separate containers, network, and named volumes.
Use alternate published ports so the test services can run beside the regular
development stack:
```bash
export COMPOSE_PROJECT_NAME=mine-minio-test
export APP_NAME=mine-minio-test
export MINIO_PUBLISHED_API_PORT=19102
export MINIO_PUBLISHED_CONSOLE_PORT=19103
export RUSTFS_PUBLISHED_API_PORT=19100
export RUSTFS_PUBLISHED_CONSOLE_PORT=19101
export APP_PUBLISHED_PORT=19180
export POSTGRES_PUBLISHED_PORT=15452
export MAILHOG_SMTP_PUBLISHED_PORT=19125
export MAILHOG_UI_PUBLISHED_PORT=19126
docker compose --profile storage-migration up -d minio rustfs
bin/restore-minio.sh bazmeg/mineseeker-minio.2026-10-24.162934.tar.gz
docker compose --profile storage-migration up --build --abort-on-container-exit --exit-code-from storage_migrate storage_migrate
docker compose logs storage_migrate
```
The restore confirmation names the test MinIO volume; verify it says
`mine-minio-test_minio_data` before typing `yes`. The migration's state volume
is also isolated. This starts only MinIO and RustFS before the explicit
migration—not the app, scheduler, or database. To start the test app only after
the migration succeeds, restore its PostgreSQL backup, then run
`MINIO_PUBLIC_URL=http://localhost:19100 docker compose up -d app scheduler`
so browser-generated cache URLs reach the isolated RustFS. `docker compose down`
does not delete the test volumes; avoid `down -v` until you intentionally want
to discard the test copy.
+160 -6
View File
@@ -1,6 +1,6 @@
# Mine-Seeker Game Documentation # Mine-Seeker Game Documentation
This directory contains comprehensive documentation about the Mine-Seeker game mechanics and implementation. This directory contains comprehensive documentation about the Mine-Seeker game mechanics, testing, and deployment.
## Game Mechanics ## Game Mechanics
@@ -18,8 +18,100 @@ Complete reference for the bonus points system including:
--- ---
## 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
### [Scheduled Tasks](./SCHEDULER.md)
Symfony Scheduler tasks and the Docker worker that runs them.
### [Object storage migration](./MINIO_INITIALIZATION.md)
Automatic, one-shot migration from legacy MinIO to RustFS.
### [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 ## 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 Points at a Glance
| Bonus Type | Points | Condition | | Bonus Type | Points | Condition |
|-----------|--------|-----------| |-----------|--------|-----------|
@@ -30,18 +122,80 @@ Complete reference for the bonus points system including:
| Chain Combo | Tracked | Consecutive mine clicks (no safe clicks) | | Chain Combo | Tracked | Consecutive mine clicks (no safe clicks) |
| Biggest Reveal | Tracked | Largest number of safe cells revealed | | Biggest Reveal | Tracked | Largest number of safe cells revealed |
### Key Rules ### Available Test Factories
- Safe cell bonus only awarded for ≥2 cells minimum | Factory | Entity | Purpose |
- Chain counter resets on any safe cell click |---------|--------|---------|
- Endgame threshold: 51 - (redPoints + bluePoints) ≤ 10 | `UserFactory` | `User` | Registered users |
- Bonus stats are per-player and persist in database | `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 ## Files Using This Information
### Game Mechanics
- Backend: `/src/Util/TopicManager.php` - Backend: `/src/Util/TopicManager.php`
- Frontend: `/assets/js/mine-seeker/contexts/GameProvider.jsx` - Frontend: `/assets/js/mine-seeker/contexts/GameProvider.jsx`
- UI: `/assets/js/mine-seeker/components/BonusStatsDialog.jsx` - UI: `/assets/js/mine-seeker/components/BonusStatsDialog.jsx`
- Constants: `/assets/js/mine-seeker/utils/constants.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
+31
View File
@@ -0,0 +1,31 @@
# Scheduled Tasks
MineSeeker uses Symfony Scheduler for recurring maintenance work. The Docker
Compose `scheduler` service runs the Scheduler worker continuously and is
restarted automatically if it exits.
## Pending-account cleanup
`PurgeExpiredPendingUsersCommand` is scheduled every hour. It deletes only
unverified users whose activation-token expiry has passed.
To inspect the registered schedule locally:
```bash
php bin/console debug:scheduler
```
To run the cleanup manually without deleting data:
```bash
php bin/console app:users:purge-expired-pending --dry-run
```
The production worker is started by Docker Compose:
```bash
docker compose up -d scheduler
docker compose logs -f scheduler
```
No host-level cron entry is required.
+265
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
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
```
+17 -16
View File
@@ -1,6 +1,6 @@
{ {
"name": "mine-seeker", "name": "mine-seeker",
"version": "2026.2.1", "version": "2026.3.0",
"author": "https://www.splendidbear.org", "author": "https://www.splendidbear.org",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"bugs": "https://source.splendidbear.org", "bugs": "https://source.splendidbear.org",
@@ -16,30 +16,31 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@fontsource/changa-one": "^5.2.8", "@fontsource/changa-one": "^5.3.0",
"@fontsource/open-sans": "^5.2.7", "@fontsource/open-sans": "^5.3.0",
"@fontsource/rajdhani": "^5.2.7", "@fontsource/rajdhani": "^5.3.0",
"@fortawesome/fontawesome-free": "^7.2.0", "@fortawesome/fontawesome-free": "^7.3.1",
"@mui/material": "^9.0.0", "@mui/material": "^9.3.1",
"@mui/x-charts": "^9.0.2", "@mui/x-charts": "^9.11.1",
"@tanstack/react-query": "^5.99.2", "@tanstack/react-query": "^5.101.4",
"cap-widget": "^0.1.57",
"howler": "^2.2.4", "howler": "^2.2.4",
"lodash": "^4.18.1", "lodash": "^4.18.1",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react": "^19.2.5", "react": "^19.2.8",
"react-dom": "^19.2.5" "react-dom": "^19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.5", "@eslint/eslintrc": "^3.3.6",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@stylistic/eslint-plugin": "5.10.0", "@stylistic/eslint-plugin": "5.10.0",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.5",
"eslint": "10.2.1", "eslint": "^10.8.1",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-hooks": "7.1.1",
"globals": "17.5.0", "globals": "^17.11.0",
"sass": "^1.99.0", "sass": "^1.102.0",
"vite": "^8.0.8", "vite": "^8.2.1",
"vite-plugin-symfony": "^8.2.4" "vite-plugin-symfony": "^8.2.4"
}, },
"scripts": { "scripts": {
+47
View File
@@ -0,0 +1,47 @@
<?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="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.
@@ -0,0 +1,78 @@
<?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\Command;
use App\Repository\UserRepository;
use DateTime;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Scheduler\Attribute\AsPeriodicTask;
/**
* Class PurgeExpiredPendingUsersCommand
*
* @package App\Command
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 07. 27.
*/
#[AsCommand(
name: 'app:users:purge-expired-pending',
description: 'Delete unverified accounts whose activation token has expired.',
)]
#[AsPeriodicTask(frequency: '1 hour')]
final class PurgeExpiredPendingUsersCommand extends Command
{
public function __construct(private readonly UserRepository $userRepository)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$now = new DateTime();
$count = $this->userRepository->countExpiredPendingUsers($now);
if ($input->getOption('dry-run')) {
$io->note(sprintf('%d expired pending account(s) would be deleted.', $count));
return Command::SUCCESS;
}
if ($count === 0) {
$io->success('No expired pending accounts found.');
return Command::SUCCESS;
}
$deleted = $this->userRepository->deleteExpiredPendingUsers($now);
$io->success(sprintf('Deleted %d expired pending account(s).', $deleted));
return Command::SUCCESS;
}
protected function configure(): void
{
$this->addOption(
'dry-run',
null,
InputOption::VALUE_NONE,
'Report how many accounts would be deleted without deleting them.',
);
}
}
+106
View File
@@ -0,0 +1,106 @@
<?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\Controller;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
/**
* Class ApiAuthController
*
* Provides a JSON login endpoint for native desktop clients.
* This endpoint is intentionally exempt from the CAPTCHA listener
* because desktop clients cannot display or solve the Cap widget.
*
* After a successful password login, if the user has TOTP enabled the response
* returns { requiresTwoFactor: true }. The client must then POST the 6-digit
* code to the standard /2fa_check endpoint (which is already exempt from
* the CAPTCHA listener via LoginCaptchaListener).
*
* @package App\Controller
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 26.
*/
#[AsController]
class ApiAuthController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly Security $security,
) {
}
/**
* POST /api/auth/login
*
* Request body (JSON): { "username": "...", "password": "..." }
*
* Responses:
* 200 { "success": true, "requiresTwoFactor": false }
* 200 { "success": true, "requiresTwoFactor": true }
* 400 { "success": false, "error": "..." }
* 401 { "success": false, "error": "..." }
*/
#[Route('/api/auth/login', name: 'MineSeekerBundle_api_auth_login', methods: ['POST'])]
public function login(Request $request): JsonResponse
{
$data = $request->toArray();
$username = trim($data['username'] ?? '');
$password = $data['password'] ?? '';
if ($username === '' || $password === '') {
return $this->json(
['success' => false, 'error' => 'Username and password are required.'],
Response::HTTP_BAD_REQUEST
);
}
/** @var User|null $user */
$user = $this->em->getRepository(User::class)->findOneBy(['username' => $username]);
if ($user === null || !$this->passwordHasher->isPasswordValid($user, $password)) {
return $this->json(
['success' => false, 'error' => 'Invalid username or password.'],
Response::HTTP_UNAUTHORIZED
);
}
if (!$user->isVerified) {
return $this->json(
['success' => false, 'error' => 'Account not yet activated. Check your email.'],
Response::HTTP_UNAUTHORIZED
);
}
// Log the user in via the Symfony security system.
// If TOTP is enabled, scheb/2fa will place the session into
// IS_AUTHENTICATED_2FA_IN_PROGRESS state, and the client must
// complete 2FA by POSTing the code to /2fa_check.
$this->security->login($user, 'form_login');
return $this->json([
'success' => true,
'requiresTwoFactor' => $user->isTotpAuthenticationEnabled(),
]);
}
}
+5 -2
View File
@@ -21,7 +21,6 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
/** /**
@@ -60,6 +59,7 @@ class GameController extends AbstractController
{ {
return $this->render('Game/play.html.twig', [ return $this->render('Game/play.html.twig', [
'env' => $this->env, 'env' => $this->env,
'game_assoc' => $gameAssoc,
'mercure_hub_url' => $this->mercurePublicUrl, 'mercure_hub_url' => $this->mercurePublicUrl,
'mercure_subscriber_jwt' => $this->mercureJwtService->mintSubscriberToken( 'mercure_subscriber_jwt' => $this->mercureJwtService->mintSubscriberToken(
$gameAssoc ?? '', $this->opponentNameService->resolveUserName(), $gameAssoc ?? '', $this->opponentNameService->resolveUserName(),
@@ -84,8 +84,11 @@ class GameController extends AbstractController
public function contact( public function contact(
Request $request, Request $request,
EntityManagerInterface $em, EntityManagerInterface $em,
MailerInterface $mailer,
): Response { ): Response {
// Keep the contact form implementation available, but disable public
// access while automated abuse protection is insufficient.
throw $this->createNotFoundException();
$contactMessage = new ContactMessage(); $contactMessage = new ContactMessage();
$form = $this->createForm(ContactFormType::class, $contactMessage); $form = $this->createForm(ContactFormType::class, $contactMessage);
$form->handleRequest($request); $form->handleRequest($request);
+6 -3
View File
@@ -27,6 +27,7 @@ use Doctrine\ORM\EntityManagerInterface;
use League\Flysystem\FilesystemException; use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator; use League\Flysystem\FilesystemOperator;
use Liip\ImagineBundle\Imagine\Cache\CacheManager; use Liip\ImagineBundle\Imagine\Cache\CacheManager;
use Liip\ImagineBundle\Service\FilterService;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use RuntimeException; use RuntimeException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -140,6 +141,7 @@ class ProfileController extends AbstractController
Request $request, Request $request,
EntityManagerInterface $em, EntityManagerInterface $em,
CacheManager $cacheManager, CacheManager $cacheManager,
FilterService $filterService,
#[Autowire(service: 'mineseeker.media.storage')] FilesystemOperator $mediaStorage, #[Autowire(service: 'mineseeker.media.storage')] FilesystemOperator $mediaStorage,
): JsonResponse { ): JsonResponse {
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
@@ -153,8 +155,8 @@ class ProfileController extends AbstractController
return $this->json(['error' => 'No file uploaded.'], 400); return $this->json(['error' => 'No file uploaded.'], 400);
} }
if ($file->getSize() > 2 * 1024 * 1024) { if ($file->getSize() > 10 * 1024 * 1024) {
return $this->json(['error' => 'File is too large. Maximum 2 MB.'], 400); return $this->json(['error' => 'File is too large. Maximum 10 MB.'], 400);
} }
$allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; $allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
@@ -182,6 +184,7 @@ class ProfileController extends AbstractController
$mediaStorage->writeStream($newPath, $stream); $mediaStorage->writeStream($newPath, $stream);
} catch (FilesystemException $e) { } catch (FilesystemException $e) {
$this->logger->error('Unable to write new avatar: ' . $e->getMessage()); $this->logger->error('Unable to write new avatar: ' . $e->getMessage());
fclose($stream);
throw new RuntimeException('Unable to write new avatar: ' . $e->getMessage()); throw new RuntimeException('Unable to write new avatar: ' . $e->getMessage());
} }
fclose($stream); fclose($stream);
@@ -190,7 +193,7 @@ class ProfileController extends AbstractController
$em->flush(); $em->flush();
return $this->json([ return $this->json([
'thumbUrl' => $cacheManager->generateUrl($newPath, 'avatar_thumb'), 'thumbUrl' => $filterService->getUrlOfFilteredImage($newPath, 'avatar_thumb'),
]); ]);
} }
+6 -5
View File
@@ -18,7 +18,6 @@ use App\Repository\UserRepository;
use App\Service\Email\SendActivationEmailService; use App\Service\Email\SendActivationEmailService;
use App\Service\Email\SendPasswordResetEmailService; use App\Service\Email\SendPasswordResetEmailService;
use App\Service\Email\SendUserActivationNotificationService; use App\Service\Email\SendUserActivationNotificationService;
use App\Service\Email\SendUserRegistrationNotificationService;
use DateTime; use DateTime;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use LogicException; use LogicException;
@@ -44,6 +43,8 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
#[AsController] #[AsController]
class SecurityController extends AbstractController class SecurityController extends AbstractController
{ {
private const string ACTIVATION_TOKEN_TTL = '+24 hours';
public function __construct( public function __construct(
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $em,
private readonly RequestStack $requestStack, private readonly RequestStack $requestStack,
@@ -53,7 +54,6 @@ class SecurityController extends AbstractController
private readonly SendActivationEmailService $activationEmail, private readonly SendActivationEmailService $activationEmail,
private readonly SendPasswordResetEmailService $passwordResetEmail, private readonly SendPasswordResetEmailService $passwordResetEmail,
private readonly SendUserActivationNotificationService $activationNotificationEmail, private readonly SendUserActivationNotificationService $activationNotificationEmail,
private readonly SendUserRegistrationNotificationService $registrationNotificationEmail,
) { ) {
} }
@@ -92,6 +92,7 @@ class SecurityController extends AbstractController
$user->isVerified = false; $user->isVerified = false;
$user->verificationToken = $token; $user->verificationToken = $token;
$user->verificationTokenExpiresAt = new DateTime(self::ACTIVATION_TOKEN_TTL);
$user->password = $this->passwordHasher->hashPassword($user, $form->get('plainPassword')->getData()); $user->password = $this->passwordHasher->hashPassword($user, $form->get('plainPassword')->getData());
$this->em->persist($user); $this->em->persist($user);
@@ -109,7 +110,6 @@ class SecurityController extends AbstractController
} }
$this->activationEmail->send($user, $activationUrl); $this->activationEmail->send($user, $activationUrl);
$this->registrationNotificationEmail->send($user, new DateTime());
$this->addFlash('verify_email', $user->email); $this->addFlash('verify_email', $user->email);
@@ -194,13 +194,14 @@ class SecurityController extends AbstractController
{ {
$user = $this->em->getRepository(User::class)->findOneBy(['verificationToken' => $token]); $user = $this->em->getRepository(User::class)->findOneBy(['verificationToken' => $token]);
if (!$user) { if (!$user || $user->verificationTokenExpiresAt === null || $user->verificationTokenExpiresAt <= new DateTime()) {
$this->addFlash('error', 'This activation link is invalid or has already been used.'); $this->addFlash('error', 'This activation link is invalid, expired, or has already been used.');
return $this->redirectToRoute('MineSeekerBundle_login'); return $this->redirectToRoute('MineSeekerBundle_login');
} }
$user->isVerified = true; $user->isVerified = true;
$user->verificationToken = null; $user->verificationToken = null;
$user->verificationTokenExpiresAt = null;
$this->em->flush(); $this->em->flush();
$this->activationNotificationEmail->send($user, new DateTime()); $this->activationNotificationEmail->send($user, new DateTime());
+1
View File
@@ -46,6 +46,7 @@ final readonly class ProfileGameDto implements JsonSerializable
public float $blueBonusPoints, public float $blueBonusPoints,
public array $redBonusStats, public array $redBonusStats,
public array $blueBonusStats, public array $blueBonusStats,
public bool $bothRegistered,
) { ) {
} }
+2
View File
@@ -62,6 +62,7 @@ final readonly class ProfileGameDtoFactory
blueBonusPoints: $game->blueBonusPoints ?? 0.0, blueBonusPoints: $game->blueBonusPoints ?? 0.0,
redBonusStats: $game->redBonusStats ?? [], redBonusStats: $game->redBonusStats ?? [],
blueBonusStats: $game->blueBonusStats ?? [], blueBonusStats: $game->blueBonusStats ?? [],
bothRegistered: $game->red !== null && $game->blue !== null,
); );
} }
@@ -128,6 +129,7 @@ final readonly class ProfileGameDtoFactory
blueBonusPoints: $battle->blueBonusPoints, blueBonusPoints: $battle->blueBonusPoints,
redBonusStats: $battle->redBonusStats, redBonusStats: $battle->redBonusStats,
blueBonusStats: $battle->blueBonusStats, blueBonusStats: $battle->blueBonusStats,
bothRegistered: !$battle->oppIsGuest,
); );
} }
+3
View File
@@ -62,6 +62,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, TotpTwo
#[Column(length: 64, nullable: true)] #[Column(length: 64, nullable: true)]
public ?string $verificationToken = null; public ?string $verificationToken = null;
#[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
public ?DateTime $verificationTokenExpiresAt = null;
#[Column(length: 64, nullable: true)] #[Column(length: 64, nullable: true)]
public ?string $resetToken = null; public ?string $resetToken = null;
+4 -4
View File
@@ -19,7 +19,7 @@ use Symfony\Component\Security\Http\Event\CheckPassportEvent;
/** /**
* Class LoginCaptchaListener * Class LoginCaptchaListener
* *
* Validates the Google reCAPTCHA v3 token during form-login authentication. * Validates the Cap CAPTCHA token during form-login authentication.
* Fires on CheckPassportEvent, which is dispatched after credentials are * Fires on CheckPassportEvent, which is dispatched after credentials are
* collected but before the user is authenticated. * collected but before the user is authenticated.
* *
@@ -53,12 +53,12 @@ readonly class LoginCaptchaListener
return; return;
} }
$token = $request->request->getString('g-recaptcha-response'); $token = $request->request->getString('cap-token');
if ($this->recaptcha->verify($token, $request->getClientIp())) { if ($this->recaptcha->verify($token)) {
return; return;
} }
throw new CustomUserMessageAuthenticationException('reCAPTCHA verification failed. Please try again.'); throw new CustomUserMessageAuthenticationException('CAPTCHA verification failed. Please try again.');
} }
} }
+4 -5
View File
@@ -22,8 +22,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
/** /**
* Class RecaptchaType * Class RecaptchaType
* *
* Reads the Google reCAPTCHA v3 token from the raw POST field * Reads the Cap CAPTCHA token from the raw POST field
* `g-recaptcha-response` (populated by JS before form submit) and injects * `cap-token` (auto-injected by the cap-widget web component) and injects
* it as this field's value before validation runs. * it as this field's value before validation runs.
* *
* @package App\Form * @package App\Form
@@ -41,9 +41,8 @@ class RecaptchaType extends AbstractType
{ {
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (PreSubmitEvent $event): void { $builder->addEventListener(FormEvents::PRE_SUBMIT, function (PreSubmitEvent $event): void {
$request = $this->requestStack->getCurrentRequest(); $request = $this->requestStack->getCurrentRequest();
$token = $request?->request->getString('g-recaptcha-response') ?? ''; $token = $request?->request->getString('cap-token') ?? '';
// For forms that set the token directly on the field (e.g. registration_form[recaptcha]) // For forms that set the token directly on the field, fall back to the submitted value.
// rather than via a standalone g-recaptcha-response input, fall back to the submitted value.
if ($token === '') { if ($token === '') {
$token = (string) ($event->getData() ?? ''); $token = (string) ($event->getData() ?? '');
} }
@@ -0,0 +1,45 @@
<?php declare(strict_types=1);
/*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20260727120000
*
* @package App\Migrations
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 07. 27.
*/
final class Version20260727120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add expiry timestamps for account activation tokens.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE app_user ADD verification_token_expires_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql("UPDATE app_user SET verification_token_expires_at = CURRENT_TIMESTAMP + INTERVAL '24 hours' WHERE is_verified = FALSE AND verification_token IS NOT NULL");
$this->addSql('CREATE INDEX IDX_APP_USER_PENDING_ACTIVATION_EXPIRY ON app_user (verification_token_expires_at) WHERE is_verified = FALSE AND verification_token_expires_at IS NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX IDX_APP_USER_PENDING_ACTIVATION_EXPIRY');
$this->addSql('ALTER TABLE app_user DROP verification_token_expires_at');
}
}
+12 -2
View File
@@ -40,9 +40,19 @@ class RecentBattleRepository extends ServiceEntityRepository
public function findRecentForUser(int $userId, int $limit = 30): array public function findRecentForUser(int $userId, int $limit = 30): array
{ {
return $this->createQueryBuilder('rb') $qb = $this->createQueryBuilder('rb');
->where('rb.userId = :uid')
return $qb
->where($qb->expr()->eq('rb.userId', ':uid'))
->andWhere(
$qb->expr()->orX(
$qb->expr()->eq('rb.oppIsGuest', ':false'),
$qb->expr()->isNotNull('rb.redPoints'),
$qb->expr()->isNotNull('rb.bluePoints')
),
)
->setParameter('uid', $userId) ->setParameter('uid', $userId)
->setParameter('false', false)
->orderBy('rb.updated', 'DESC') ->orderBy('rb.updated', 'DESC')
->setMaxResults($limit) ->setMaxResults($limit)
->getQuery() ->getQuery()
+29
View File
@@ -11,6 +11,7 @@
namespace App\Repository; namespace App\Repository;
use App\Entity\User; use App\Entity\User;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\NonUniqueResultException; use Doctrine\ORM\NonUniqueResultException;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
@@ -87,6 +88,34 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader
} }
} }
public function countExpiredPendingUsers(DateTimeInterface $now): int
{
$qb = $this->createQueryBuilder('u');
return (int) $qb
->select($qb->expr()->count('u.id'))
->where($qb->expr()->eq('u.isVerified', ':isVerified'))
->andWhere($qb->expr()->lte('u.verificationTokenExpiresAt', ':now'))
->setParameter('isVerified', false)
->setParameter('now', $now)
->getQuery()
->getSingleScalarResult();
}
public function deleteExpiredPendingUsers(DateTimeInterface $now): int
{
$qb = $this->createQueryBuilder('u');
return $qb
->delete()
->where($qb->expr()->eq('u.isVerified', ':isVerified'))
->andWhere($qb->expr()->lte('u.verificationTokenExpiresAt', ':now'))
->setParameter('isVerified', false)
->setParameter('now', $now)
->getQuery()
->execute();
}
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{ {
if (!$user instanceof User) { if (!$user instanceof User) {
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\Schedule as SymfonySchedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Symfony\Contracts\Cache\CacheInterface;
/**
* Class Schedule
*
* @package App
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 07. 27.
*/
#[AsSchedule]
class Schedule implements ScheduleProviderInterface
{
public function __construct(private CacheInterface $cache) { }
public function getSchedule(): SymfonySchedule
{
return (new SymfonySchedule())
->stateful($this->cache) // ensure missed tasks are executed
->processOnlyLastMissedRun(true) // ensure only last missed task is run
// add your own tasks here
// see https://symfony.com/doc/current/scheduler.html#attaching-recurring-messages-to-a-schedule
;
}
}
+2 -1
View File
@@ -11,6 +11,7 @@
namespace App\Security; namespace App\Security;
use App\Entity\User; use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\User\UserCheckerInterface; use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
@@ -40,5 +41,5 @@ final class UserChecker implements UserCheckerInterface
} }
} }
public function checkPostAuth(UserInterface $user): void { } public function checkPostAuth(UserInterface $user, ?TokenInterface $token = null): void { }
} }
+9 -13
View File
@@ -34,11 +34,11 @@ final class BattleCardGenerator
{ {
private const int WIDTH = 1200; private const int WIDTH = 1200;
private const int HEIGHT = 630; private const int HEIGHT = 630;
private const string FONT = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
private const int AVATAR_SIZE = 120; private const int AVATAR_SIZE = 120;
public function __construct( public function __construct(
private readonly string $cacheDir, private readonly string $cacheDir,
private readonly string $fontPath,
private readonly FilesystemOperator $minioMediaStorage, private readonly FilesystemOperator $minioMediaStorage,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
) { ) {
@@ -159,7 +159,7 @@ final class BattleCardGenerator
$redBonusPoints = $game->redBonusPoints ?? 0; $redBonusPoints = $game->redBonusPoints ?? 0;
$blueBonusPoints = $game->blueBonusPoints ?? 0; $blueBonusPoints = $game->blueBonusPoints ?? 0;
$bonusText = number_format((float)$redBonusPoints, 1, '.', '') . ' * : * ' . number_format((float)$blueBonusPoints, 1, '.', ''); $bonusText = number_format((float)$redBonusPoints, 1, '.', '') . ' * : * ' . number_format((float)$blueBonusPoints, 1, '.', '');
$this->centeredText($im, $bonusText, 24, self::WIDTH / 2, 425, $gold); $this->centeredText($im, $bonusText, 24, self::WIDTH / 2, 445, $gold);
if ($winner === 'red') { if ($winner === 'red') {
$resultText = $redName . ' wins'; $resultText = $redName . ' wins';
@@ -176,17 +176,16 @@ final class BattleCardGenerator
} }
if ($resultText !== '') { if ($resultText !== '') {
$this->centeredText($im, $resultText, 30, self::WIDTH / 2, 475, $resultColor); $this->centeredText($im, $resultText, 30, self::WIDTH / 2, 495, $resultColor);
} }
if ($resign) { if ($resign) {
$this->centeredText($im, ucfirst($resign) . ' resigned', 18, self::WIDTH / 2, 508, $muted); $this->centeredText($im, ucfirst($resign) . ' resigned', 18, self::WIDTH / 2, 528, $muted);
} }
$this->centeredText($im, 'mineseeker.hu', 16, self::WIDTH / 2, self::HEIGHT - 20, $muted); $this->centeredText($im, 'mineseeker.hu', 16, self::WIDTH / 2, self::HEIGHT - 20, $muted);
imagepng($im, $dest); imagepng($im, $dest);
imagedestroy($im);
} }
/** Draw avatar or initials centered on $cx, $cy. */ /** Draw avatar or initials centered on $cx, $cy. */
@@ -263,9 +262,6 @@ final class BattleCardGenerator
} }
} }
imagedestroy($avatarImg);
imagedestroy($resized);
imagedestroy($mask);
} else { } else {
/** Draw circular background with initials*/ /** Draw circular background with initials*/
imagefilledellipse($im, (int)$cx, (int)$cy, self::AVATAR_SIZE, self::AVATAR_SIZE, $color); imagefilledellipse($im, (int)$cx, (int)$cy, self::AVATAR_SIZE, self::AVATAR_SIZE, $color);
@@ -273,23 +269,23 @@ final class BattleCardGenerator
/** Draw initials */ /** Draw initials */
$initials = mb_strtoupper(mb_substr($name, 0, 2)); $initials = mb_strtoupper(mb_substr($name, 0, 2));
$fontSize = 48; $fontSize = 48;
$bbox = imagettfbbox($fontSize, 0, self::FONT, $initials); $bbox = imagettfbbox($fontSize, 0, $this->fontPath, $initials);
$textW = $bbox[2] - $bbox[0]; $textW = $bbox[2] - $bbox[0];
$textH = $bbox[1] - $bbox[7]; $textH = $bbox[1] - $bbox[7];
$textX = $cx - $textW / 2; $textX = $cx - $textW / 2;
$textY = $cy + $textH / 2; $textY = $cy + $textH / 2;
$white = imagecolorallocate($im, 255, 255, 255); $white = imagecolorallocate($im, 255, 255, 255);
imagettftext($im, $fontSize, 0, (int)$textX, (int)$textY, $white, self::FONT, $initials); imagettftext($im, $fontSize, 0, (int)$textX, (int)$textY, $white, $this->fontPath, $initials);
} }
} }
/** Render text centered on $cx. */ /** Render text centered on $cx. */
private function centeredText(GdImage $im, string $text, int $size, int $cx, int $y, int $color): void private function centeredText(GdImage $im, string $text, int $size, int $cx, int $y, int $color): void
{ {
$bbox = imagettfbbox($size, 0, self::FONT, $text); $bbox = imagettfbbox($size, 0, $this->fontPath, $text);
$w = $bbox[2] - $bbox[0]; $w = $bbox[2] - $bbox[0];
imagettftext($im, $size, 0, (int)($cx - $w / 2), $y, $color, self::FONT, $text); imagettftext($im, $size, 0, (int)($cx - $w / 2), $y, $color, $this->fontPath, $text);
} }
/** Render text centered on $cx, shrinking font size to fit $maxWidth. */ /** Render text centered on $cx, shrinking font size to fit $maxWidth. */
@@ -302,7 +298,7 @@ final class BattleCardGenerator
int $color, int $color,
int $maxWidth int $maxWidth
): void { ): void {
$bbox = imagettfbbox($size, 0, self::FONT, $text); $bbox = imagettfbbox($size, 0, $this->fontPath, $text);
$w = $bbox[2] - $bbox[0]; $w = $bbox[2] - $bbox[0];
if ($w > $maxWidth) { if ($w > $maxWidth) {
$size = (int)($size * $maxWidth / $w); $size = (int)($size * $maxWidth / $w);
@@ -1,75 +0,0 @@
<?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.
*/
/*
* 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\Service\Email;
use App\Entity\User;
use DateTime;
use Exception;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
/**
* Class SendUserRegistrationNotificationService
*
* @package App\Service\Email
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 20.
*/
readonly final class SendUserRegistrationNotificationService
{
public function __construct(
#[Autowire(env: 'APP_CONTACT_MAIL_ADDRESS')]
private string $appContactMailAddress,
private LoggerInterface $logger,
private MailerInterface $mailer,
) {
}
public function send(User $user, DateTime $registeredAt): void
{
try {
$this->mailer->send(
new TemplatedEmail()
->from('noreply@mineseeker.hu')
->to($this->appContactMailAddress)
->subject("🎉 New User Registration: {$user->getUsername()}")
->htmlTemplate('emails/user_registration_notification.html.twig')
->context([
'user' => $user,
'registeredAt' => $registeredAt,
])
);
} catch (TransportExceptionInterface|Exception $e) {
$this->logger->error("Failed to send user registration notification: {$e->getMessage()}", [
'exception' => $e,
'user' => $user->getUsername(),
]);
throw new RuntimeException("Failed to send user registration notification: {$e->getMessage()}");
}
}
}
+11 -23
View File
@@ -26,50 +26,38 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
*/ */
readonly final class RecaptchaService readonly final class RecaptchaService
{ {
private const string SITEVERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
/**
* Minimum score to accept a request (0.0 = bot, 1.0 = human).
* 0.5 is Google's recommended default threshold.
*/
private const float SCORE_THRESHOLD = 0.5;
public function __construct( public function __construct(
private HttpClientInterface $httpClient, private HttpClientInterface $httpClient,
private LoggerInterface $logger, private LoggerInterface $logger,
#[Autowire(env: 'RECAPTCHA_SECRET_KEY')] #[Autowire(env: 'CAP_API_ENDPOINT')]
private string $apiEndpoint,
#[Autowire(env: 'CAP_SECRET_KEY')]
private string $secretKey, private string $secretKey,
) {} ) {}
public function verify(string $token, string $remoteIp = ''): bool public function verify(string $token): bool
{ {
if ($token === '') { if ($token === '') {
return false; return false;
} }
try { try {
$body = ['secret' => $this->secretKey, 'response' => $token]; $siteverifyUrl = rtrim($this->apiEndpoint, '/') . '/siteverify';
if ($remoteIp !== '') {
$body['remoteip'] = $remoteIp;
}
$data = $this->httpClient $data = $this->httpClient
->request('POST', self::SITEVERIFY_URL, ['body' => $body]) ->request('POST', $siteverifyUrl, [
'body' => ['secret' => $this->secretKey, 'response' => $token],
])
->toArray(); ->toArray();
$this->logger->info('reCAPTCHA verify response', [ $this->logger->info('Cap verify response', [
'success' => $data['success'] ?? null, 'success' => $data['success'] ?? null,
'score' => $data['score'] ?? null,
'hostname' => $data['hostname'] ?? null,
'error-codes' => $data['error-codes'] ?? [],
'token_length' => strlen($token), 'token_length' => strlen($token),
]); ]);
return ($data['success'] ?? false) === true return ($data['success'] ?? false) === true;
&& ($data['score'] ?? 0.0) >= self::SCORE_THRESHOLD;
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->logger->error('reCAPTCHA verification failed: ' . $e->getMessage()); $this->logger->error('Cap verification failed: ' . $e->getMessage());
return false; return false;
} }
+33
View File
@@ -0,0 +1,33 @@
<?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\Story;
use Zenstruck\Foundry\Attribute\AsFixture;
use Zenstruck\Foundry\Story;
/**
* Class AppStory
*
* @package App\Story
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 21.
*/
#[AsFixture(name: 'main')]
final class AppStory extends Story
{
public function build(): void
{
// SomeFactory::createOne();
}
}
+4 -8
View File
@@ -11,7 +11,6 @@
namespace App\Validator; namespace App\Validator;
use App\Service\RecaptchaService; use App\Service\RecaptchaService;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator; use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException; use Symfony\Component\Validator\Exception\UnexpectedTypeException;
@@ -26,12 +25,12 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
* @link www.splendidbear.org * @link www.splendidbear.org
* @since 2026. 04. 12. * @since 2026. 04. 12.
*/ */
class RecaptchaValidator extends ConstraintValidator final class RecaptchaValidator extends ConstraintValidator
{ {
public function __construct( public function __construct(
private readonly RecaptchaService $recaptcha, private readonly RecaptchaService $recaptcha,
private readonly RequestStack $requestStack, ) {
) {} }
public function validate(mixed $value, Constraint $constraint): void public function validate(mixed $value, Constraint $constraint): void
{ {
@@ -39,10 +38,7 @@ class RecaptchaValidator extends ConstraintValidator
throw new UnexpectedTypeException($constraint, Recaptcha::class); throw new UnexpectedTypeException($constraint, Recaptcha::class);
} }
$request = $this->requestStack->getCurrentRequest(); if ($this->recaptcha->verify((string)$value)) {
$remoteIp = $request !== null ? ((string) $request->getClientIp()) : '';
if ($this->recaptcha->verify((string) $value, $remoteIp)) {
return; return;
} }
+61
View File
@@ -2,6 +2,15 @@
"cocur/slugify": { "cocur/slugify": {
"version": "v3.1" "version": "v3.1"
}, },
"dama/doctrine-test-bundle": {
"version": "8.6",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "8.3",
"ref": "dfc51177476fb39d014ed89944cde53dc3326d23"
}
},
"doctrine/collections": { "doctrine/collections": {
"version": "v1.5.0" "version": "v1.5.0"
}, },
@@ -120,6 +129,21 @@
"ref": "3a6673f248f8fc1dd364dadfef4c5b381d1efab6" "ref": "3a6673f248f8fc1dd364dadfef4c5b381d1efab6"
} }
}, },
"phpunit/phpunit": {
"version": "13.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "11.1",
"ref": "ca0bc067abfb40a8de1b2561b96cbfc2b833c314"
},
"files": [
".env.test",
"phpunit.dist.xml",
"tests/bootstrap.php",
"bin/phpunit"
]
},
"psr/cache": { "psr/cache": {
"version": "1.0.1" "version": "1.0.1"
}, },
@@ -292,6 +316,18 @@
"config/packages/mercure.yaml" "config/packages/mercure.yaml"
] ]
}, },
"symfony/messenger": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.0",
"ref": "d8936e2e2230637ef97e5eecc0eea074eecae58b"
},
"files": [
"config/packages/messenger.yaml"
]
},
"symfony/mime": { "symfony/mime": {
"version": "v4.3.5" "version": "v4.3.5"
}, },
@@ -351,6 +387,18 @@
"ref": "cda8b550123383d25827705d05a42acf6819fe4e" "ref": "cda8b550123383d25827705d05a42acf6819fe4e"
} }
}, },
"symfony/scheduler": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.2",
"ref": "caea3c928ee9e1b21288fd76aef36f16ea355515"
},
"files": [
"src/Schedule.php"
]
},
"symfony/security": { "symfony/security": {
"version": "v4.0.9" "version": "v4.0.9"
}, },
@@ -469,5 +517,18 @@
}, },
"zendframework/zend-eventmanager": { "zendframework/zend-eventmanager": {
"version": "3.2.1" "version": "3.2.1"
},
"zenstruck/foundry": {
"version": "2.9",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.7",
"ref": "9bd824886195ebe64a24438db1b6d17eb833ac56"
},
"files": [
"config/packages/zenstruck_foundry.yaml",
"src/Story/AppStory.php"
]
} }
} }
+6 -2
View File
@@ -260,12 +260,16 @@
<li><a href="{{ path('MineSeekerBundle_rules') }}">Game Rules</a></li> <li><a href="{{ path('MineSeekerBundle_rules') }}">Game Rules</a></li>
<li><a href="{{ path('MineSeekerBundle_terms') }}">Terms of Use</a></li> <li><a href="{{ path('MineSeekerBundle_terms') }}">Terms of Use</a></li>
<li><a href="{{ path('MineSeekerBundle_privacy') }}">Privacy Policy</a></li> <li><a href="{{ path('MineSeekerBundle_privacy') }}">Privacy Policy</a></li>
<li><a href="{{ path('MineSeekerBundle_contact') }}">Contact</a></li>
</ul> </ul>
</nav> </nav>
</div> </div>
<div class="footer-copy"> <div class="footer-copy">
<p>&copy; {{ "now"|date("Y") }} MineSeeker &nbsp;&bull;&nbsp; All rights reserved &nbsp;&bull;&nbsp; <p>&copy; {{ "now"|date("Y") }} MineSeeker
<span class="footer-version" title="MineSeeker version {{ version }}">
<i class="fas fa-code-branch" aria-hidden="true"></i>
<span>v{{ version }}</span>
</span>
&nbsp;&bull;&nbsp; All rights reserved &nbsp;&bull;&nbsp;
<a href="https://www.splendidbear.org">www.splendidbear.org</a> <a href="https://www.splendidbear.org">www.splendidbear.org</a>
</p> </p>
</div> </div>
+7 -3
View File
@@ -3,18 +3,23 @@
{% block title %} - Play!{% endblock %} {% block title %} - Play!{% endblock %}
{% block body %} {% block body %}
<div class="game-brand-bar">
<a class="back-from-game" href="{{ path('MineSeekerBundle_homepage') }}"> <a class="back-from-game" href="{{ path('MineSeekerBundle_homepage') }}">
<img src="{{ asset('/images/mine-logo-txt.png') }}" alt="Mineseeker Logo"> <img src="{{ asset('/images/mine-logo-txt.png') }}" alt="Mineseeker Logo">
</a> </a>
<a class="game-cap-badge" href="https://trycap.dev" target="_blank" rel="noopener noreferrer">
<i class="fas fa-shield-halved"></i> Protected by Cap
</a>
</div>
<div class="mine-container"> <div class="mine-container">
<div id="mine-wrapper" <div id="mine-wrapper"
data-env="{{ env }}" data-env="{{ env }}"
data-game-id="{{ app.request.get('gameAssoc') }}" data-game-id="{{ game_assoc }}"
data-opponent-name="{{ opponent_name }}" data-opponent-name="{{ opponent_name }}"
data-is-authenticated="{{ app.user ? '1' : '0' }}" data-is-authenticated="{{ app.user ? '1' : '0' }}"
data-mercure-hub-url="{{ mercure_hub_url }}" data-mercure-hub-url="{{ mercure_hub_url }}"
data-mercure-subscriber-jwt="{{ mercure_subscriber_jwt }}" data-mercure-subscriber-jwt="{{ mercure_subscriber_jwt }}"
data-recaptcha-site-key="{{ recaptcha_site_key }}"> data-cap-api-endpoint="{{ cap_api_endpoint }}">
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
@@ -39,6 +44,5 @@
{% block javascripts %} {% block javascripts %}
{{ parent() }} {{ parent() }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
{{ vite_entry_script_tags('mineseeker', { dependency: 'react' }) }} {{ vite_entry_script_tags('mineseeker', { dependency: 'react' }) }}
{% endblock %} {% endblock %}
+9 -6
View File
@@ -37,6 +37,14 @@
</p> </p>
<div class="auth-card" style="max-width: 600px; margin: 0 auto;"> <div class="auth-card" style="max-width: 600px; margin: 0 auto;">
<div class="auth-title-row">
<h2 class="auth-title">Contact Form</h2>
<a class="auth-cap-badge" href="https://trycap.dev" target="_blank" rel="noopener noreferrer">
<i class="fas fa-shield-halved"></i> Protected by Cap
</a>
</div>
<p class="auth-sub">Your message is protected by automated abuse checks.</p>
<div data-cap-api-endpoint="{{ cap_api_endpoint }}" style="display: none;" aria-hidden="true"></div>
{{ form_start(form, {attr: {class: 'auth-form'}}) }} {{ form_start(form, {attr: {class: 'auth-form'}}) }}
<div class="auth-field"> <div class="auth-field">
@@ -127,10 +135,5 @@
{% block javascripts %} {% block javascripts %}
{{ parent() }} {{ parent() }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script> {{ vite_entry_script_tags('cap') }}
{{ vite_entry_script_tags('contact', { dependency: 'react' }) }}
<div id="contact-form-wrapper"
data-site-key="{{ recaptcha_site_key }}"
data-recaptcha-field-id="{{ form.recaptcha.vars.id }}">
</div>
{% endblock %} {% endblock %}
+7 -16
View File
@@ -34,8 +34,14 @@
</div> </div>
{% else %} {% else %}
<div class="auth-card"> <div class="auth-card">
<div class="auth-title-row">
<h2 class="auth-title">Forgot Password</h2> <h2 class="auth-title">Forgot Password</h2>
<a class="auth-cap-badge" href="https://trycap.dev" target="_blank" rel="noopener noreferrer">
<i class="fas fa-shield-halved"></i> Protected by Cap
</a>
</div>
<p class="auth-sub">Enter your email and we'll send you a reset link</p> <p class="auth-sub">Enter your email and we'll send you a reset link</p>
<div data-cap-api-endpoint="{{ cap_api_endpoint }}" style="display: none;" aria-hidden="true"></div>
{{ form_start(form, {attr: {class: 'auth-form'}}) }} {{ form_start(form, {attr: {class: 'auth-form'}}) }}
@@ -75,20 +81,5 @@
{% block javascripts %} {% block javascripts %}
{{ parent() }} {{ parent() }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script> {{ vite_entry_script_tags('cap') }}
<script>
(function () {
const form = document.querySelector('.auth-form');
if (!form) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
grecaptcha.ready(function () {
grecaptcha.execute('{{ recaptcha_site_key }}', {action: 'forgot_password'}).then(function (token) {
document.getElementById('{{ form.recaptcha.vars.id }}').value = token;
form.submit();
});
});
});
}());
</script>
{% endblock %} {% endblock %}
+8 -17
View File
@@ -36,7 +36,12 @@
{% endfor %} {% endfor %}
<div class="auth-card"> <div class="auth-card">
<div class="auth-title-row">
<h2 class="auth-title">Sign In</h2> <h2 class="auth-title">Sign In</h2>
<a class="auth-cap-badge" href="https://trycap.dev" target="_blank" rel="noopener noreferrer">
<i class="fas fa-shield-halved"></i> Protected by Cap
</a>
</div>
<p class="auth-sub">Welcome back, commander</p> <p class="auth-sub">Welcome back, commander</p>
{% if error %} {% if error %}
@@ -46,6 +51,8 @@
</div> </div>
{% endif %} {% endif %}
<div data-cap-api-endpoint="{{ cap_api_endpoint }}" style="display: none;" aria-hidden="true"></div>
<form class="auth-form" method="post" action="{{ path('MineSeekerBundle_login') }}"> <form class="auth-form" method="post" action="{{ path('MineSeekerBundle_login') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}"/> <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}"/>
@@ -92,8 +99,6 @@
</p> </p>
</div> </div>
<input type="hidden" id="g-recaptcha-response" name="g-recaptcha-response"/>
<button type="submit" class="auth-submit"> <button type="submit" class="auth-submit">
<i class="fas fa-right-to-bracket"></i> Sign In <i class="fas fa-right-to-bracket"></i> Sign In
</button> </button>
@@ -121,20 +126,6 @@
{% block javascripts %} {% block javascripts %}
{{ parent() }} {{ parent() }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script> {{ vite_entry_script_tags('cap') }}
<script>
document.querySelector('.auth-form').addEventListener('submit', function (e) {
e.preventDefault();
const form = this;
grecaptcha.ready(function () {
grecaptcha.execute('{{ recaptcha_site_key }}', {action: 'login'}).then(function (token) {
document.getElementById('g-recaptcha-response').value = token;
form.submit();
});
});
});
</script>
{{ vite_entry_script_tags('passkey', { dependency: 'react' }) }} {{ vite_entry_script_tags('passkey', { dependency: 'react' }) }}
{% endblock %} {% endblock %}
+7 -3
View File
@@ -2,7 +2,9 @@
{% macro stat_val(value, suffix) %} {% macro stat_val(value, suffix) %}
{%- set abbr = value >= 1000 -%} {%- set abbr = value >= 1000 -%}
<span class="profile-stat__value"{% if abbr %} title="{{ value }}"{% endif %}>{% if abbr %}{{ (value / 1000)|round(1, 'floor') }}k{% else %}{{ value }}{% endif %}{% if suffix %}<small>{{ suffix }}</small>{% endif %}</span> <span
class="profile-stat__value"{% if abbr %} title="{{ value }}"{% endif %}>{% if abbr %}{{ (value / 1000)|round(1, 'floor') }}k{% else %}{{ value }}{% endif %}{% if suffix %}
<small>{{ suffix }}</small>{% endif %}</span>
{% endmacro %} {% endmacro %}
{% block title %} - Profile{% endblock %} {% block title %} - Profile{% endblock %}
@@ -31,7 +33,7 @@
<div class="profile-header"> <div class="profile-header">
<div id="profile-avatar-root" <div id="profile-avatar-root"
data-upload-url="{{ path('MineSeekerBundle_profile_avatar') }}" data-upload-url="{{ path('MineSeekerBundle_profile_avatar') }}"
data-thumb-url="{{ app.user.avatarPath ? app.user.avatarPath|imagine_filter('avatar_thumb') : '' }}" data-thumb-url="{{ app.user.avatarPath ? path('liip_imagine_filter', {path: app.user.avatarPath, filter: 'avatar_thumb'}) : '' }}"
data-initials="{{ app.user.username|slice(0, 2)|upper }}"> data-initials="{{ app.user.username|slice(0, 2)|upper }}">
</div> </div>
<div class="profile-info"> <div class="profile-info">
@@ -138,7 +140,9 @@
and (my_points > 25 or opp_points > 25)) %} and (my_points > 25 or opp_points > 25)) %}
{% set is_anonymous = game.oppIsGuest %} {% set is_anonymous = game.oppIsGuest %}
<div class="profile-game profile-game--{{ result }}{% if not is_finished and not is_anonymous %} profile-game--ongoing{% elseif is_anonymous %} profile-game--abandoned{% endif %}{% if loop.index0 >= 5 %} profile-game--hidden{% endif %}" data-game-index="{{ loop.index0 }}"> <div
class="profile-game profile-game--{{ result }}{% if not is_finished and not is_anonymous %} profile-game--ongoing{% elseif is_anonymous %} profile-game--abandoned{% endif %}{% if loop.index0 >= 5 %} profile-game--hidden{% endif %}"
data-game-index="{{ loop.index0 }}">
<span class="profile-game__badge"> <span class="profile-game__badge">
{% if is_finished %} {% if is_finished %}
{{ result == 'win' ? 'Win' : (result == 'loss' ? 'Loss' : 'Draw') }} {{ result == 'win' ? 'Win' : (result == 'loss' ? 'Loss' : 'Draw') }}
+7 -16
View File
@@ -42,8 +42,14 @@
</div> </div>
{% else %} {% else %}
<div class="auth-card"> <div class="auth-card">
<div class="auth-title-row">
<h2 class="auth-title">Create Account</h2> <h2 class="auth-title">Create Account</h2>
<a class="auth-cap-badge" href="https://trycap.dev" target="_blank" rel="noopener noreferrer">
<i class="fas fa-shield-halved"></i> Protected by Cap
</a>
</div>
<p class="auth-sub">Join the battle — no subscription required</p> <p class="auth-sub">Join the battle — no subscription required</p>
<div data-cap-api-endpoint="{{ cap_api_endpoint }}" style="display: none;" aria-hidden="true"></div>
{{ form_start(form, {attr: {class: 'auth-form'}}) }} {{ form_start(form, {attr: {class: 'auth-form'}}) }}
@@ -153,20 +159,5 @@
{% block javascripts %} {% block javascripts %}
{{ parent() }} {{ parent() }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script> {{ vite_entry_script_tags('cap') }}
<script>
(function () {
const form = document.querySelector('.auth-form');
if (!form) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
grecaptcha.ready(function () {
grecaptcha.execute('{{ recaptcha_site_key }}', {action: 'register'}).then(function (token) {
document.getElementById('{{ form.recaptcha.vars.id }}').value = token;
form.submit();
});
});
});
}());
</script>
{% endblock %} {% endblock %}
+7 -15
View File
@@ -16,8 +16,14 @@
{% block body %} {% block body %}
<div class="auth-page"> <div class="auth-page">
<div class="auth-card"> <div class="auth-card">
<div class="auth-title-row">
<h2 class="auth-title">Reset Password</h2> <h2 class="auth-title">Reset Password</h2>
<a class="auth-cap-badge" href="https://trycap.dev" target="_blank" rel="noopener noreferrer">
<i class="fas fa-shield-halved"></i> Protected by Cap
</a>
</div>
<p class="auth-sub">Choose a new password for your account</p> <p class="auth-sub">Choose a new password for your account</p>
<div data-cap-api-endpoint="{{ cap_api_endpoint }}" style="display: none;" aria-hidden="true"></div>
{{ form_start(form, {attr: {class: 'auth-form'}}) }} {{ form_start(form, {attr: {class: 'auth-form'}}) }}
@@ -65,19 +71,5 @@
{% block javascripts %} {% block javascripts %}
{{ parent() }} {{ parent() }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script> {{ vite_entry_script_tags('cap') }}
<script>
(function () {
document.querySelector('.auth-form').addEventListener('submit', function (e) {
e.preventDefault();
const form = this;
grecaptcha.ready(function () {
grecaptcha.execute('{{ recaptcha_site_key }}', {action: 'reset_password'}).then(function (token) {
document.getElementById('{{ form.recaptcha.vars.id }}').value = token;
form.submit();
});
});
});
}());
</script>
{% endblock %} {% endblock %}
+14
View File
@@ -28,6 +28,20 @@
<link rel="icon" href="{{ asset('/images/favicon/favicon.ico') }}" type="image/x-icon"> <link rel="icon" href="{{ asset('/images/favicon/favicon.ico') }}" type="image/x-icon">
{% block metas %}{% endblock %} {% block metas %}{% endblock %}
<title>MineSeeker{% block title %}{% endblock %}</title> <title>MineSeeker{% block title %}{% endblock %}</title>
<script
defer src="https://umami.splendidbear.org/script.js"
data-website-id="825e02a9-d675-4cbd-9e68-72b98de2e4e9"
>
</script>
<script
defer
src="https://umami.splendidbear.org/recorder.js"
data-website-id="825e02a9-d675-4cbd-9e68-72b98de2e4e9"
data-sample-rate="0.15"
data-mask-level="moderate"
data-max-duration="300000"
>
</script>
{% block stylesheets %}{% endblock %} {% block stylesheets %}{% endblock %}
</head> </head>
@@ -1,92 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>New User Registration</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
border-radius: 8px 8px 0 0;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 24px;
}
.content {
background: #f9f9f9;
padding: 30px;
border-radius: 0 0 8px 8px;
}
.field {
margin-bottom: 20px;
}
.field-label {
font-weight: 600;
color: #666;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 5px;
}
.field-value {
background: white;
padding: 12px;
border-radius: 4px;
border-left: 3px solid #667eea;
}
.footer {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #ddd;
font-size: 12px;
color: #999;
text-align: center;
}
</style>
</head>
<body>
<div class="header">
<h1>👤 New User Registration</h1>
</div>
<div class="content">
<div class="field">
<div class="field-label">Username</div>
<div class="field-value">
<strong>{{ user.username }}</strong>
</div>
</div>
<div class="field">
<div class="field-label">Email</div>
<div class="field-value">
<a href="mailto:{{ user.email }}">{{ user.email }}</a>
</div>
</div>
<div class="field">
<div class="field-label">Details</div>
<div class="field-value">
<strong>Registered:</strong> {{ registeredAt|date('Y-m-d H:i:s') }}<br>
<strong>Status:</strong> {% if user.isVerified %}✓ Verified{% else %}⏳ Awaiting Email Verification{% endif %}<br>
<strong>Email Verified:</strong> {% if user.isVerified %}Yes{% else %}No - activation link sent{% endif %}
</div>
</div>
<div class="footer">
A new user has registered on MineSeeker.<br>
User must verify their email before account is fully activated.
</div>
</div>
</body>
</html>
@@ -0,0 +1,74 @@
<?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\Command;
use App\Command\PurgeExpiredPendingUsersCommand;
use App\Entity\User;
use App\Tests\Factory\UserFactory;
use App\Tests\WebTestCase;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use Symfony\Component\Console\Tester\CommandTester;
#[TestDox('Purge Expired Pending Users Command')]
class PurgeExpiredPendingUsersCommandTest extends WebTestCase
{
#[Test]
#[TestDox('Deletes expired unverified users only')]
public function deletesOnlyExpiredUnverifiedUsers(): void
{
$expiredUser = UserFactory::createOne([
'isVerified' => false,
'verificationToken' => 'expired-token',
'verificationTokenExpiresAt' => new DateTime('-1 minute'),
]);
$pendingUser = UserFactory::createOne([
'isVerified' => false,
'verificationToken' => 'valid-token',
'verificationTokenExpiresAt' => new DateTime('+1 day'),
]);
$verifiedUser = UserFactory::createOne([
'isVerified' => true,
'verificationToken' => 'verified-token',
'verificationTokenExpiresAt' => new DateTime('-1 minute'),
]);
$command = static::getContainer()->get(PurgeExpiredPendingUsersCommand::class);
$tester = new CommandTester($command);
$tester->execute([]);
$em = static::getContainer()->get(EntityManagerInterface::class);
$em->clear();
self::assertNull($em->find(User::class, $expiredUser->id));
self::assertNotNull($em->find(User::class, $pendingUser->id));
self::assertNotNull($em->find(User::class, $verifiedUser->id));
}
#[Test]
#[TestDox('Dry run keeps expired unverified users')]
public function dryRunDoesNotDeleteExpiredUsers(): void
{
$expiredUser = UserFactory::createOne([
'isVerified' => false,
'verificationToken' => 'expired-token',
'verificationTokenExpiresAt' => new DateTime('-1 minute'),
]);
$command = static::getContainer()->get(PurgeExpiredPendingUsersCommand::class);
$tester = new CommandTester($command);
$tester->execute(['--dry-run' => true]);
$em = static::getContainer()->get(EntityManagerInterface::class);
self::assertNotNull($em->find(User::class, $expiredUser->id));
}
}
+143
View File
@@ -0,0 +1,143 @@
<?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 PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use App\Tests\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* Class GameControllerTest
*
* @package App\Tests\Controller
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 21.
*/
#[TestDox('Game Controller')]
class GameControllerTest extends WebTestCase
{
private KernelBrowser $client;
protected function setUp(): void
{
parent::setUp();
$this->client = static::createClient();
}
#[Test]
#[TestDox('Homepage loads successfully with navigation links')]
public function homepageLoadsSuccessfully(): void
{
$crawler = $this->client->request('GET', '/');
self::assertResponseStatusCodeSame(Response::HTTP_OK);
self::assertSelectorExists('h1');
self::assertSelectorExists('a[href="/play"]');
}
#[Test]
#[TestDox('Play page loads successfully')]
public function playPageLoadsSuccessfully(): void
{
$crawler = $this->client->request('GET', '/play');
self::assertResponseStatusCodeSame(Response::HTTP_OK);
self::assertResponseHasHeader('Content-Type');
}
#[Test]
#[TestDox('Play page with game association loads successfully')]
public function playPageWithGameAssocLoadsSuccessfully(): void
{
$crawler = $this->client->request('GET', '/play/testgame123');
self::assertResponseStatusCodeSame(Response::HTTP_OK);
self::assertResponseHasHeader('Content-Type');
}
#[Test]
#[TestDox('Privacy policy page loads successfully')]
public function privacyPolicyPageLoadsSuccessfully(): void
{
$crawler = $this->client->request('GET', '/privacy-policy');
self::assertResponseStatusCodeSame(Response::HTTP_OK);
self::assertSelectorExists('h1');
}
#[Test]
#[TestDox('Terms of service page loads successfully')]
public function termsOfServicePageLoadsSuccessfully(): void
{
$crawler = $this->client->request('GET', '/terms-of-service');
self::assertResponseStatusCodeSame(Response::HTTP_OK);
self::assertSelectorExists('h1');
}
#[Test]
#[TestDox('Contact page is not publicly accessible')]
public function contactPageIsNotPubliclyAccessible(): void
{
$crawler = $this->client->request('GET', '/contact');
self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
}
#[Test]
#[TestDox('Landing page loads successfully with play link')]
public function landingPageLoadsSuccessfully(): void
{
$crawler = $this->client->request('GET', '/landing-page');
self::assertResponseStatusCodeSame(Response::HTTP_OK);
self::assertSelectorExists('h1');
self::assertSelectorExists('a[href="/play"]');
}
#[Test]
#[TestDox('Rules page loads successfully')]
public function rulesPageLoadsSuccessfully(): void
{
$crawler = $this->client->request('GET', '/rules');
self::assertResponseStatusCodeSame(Response::HTTP_OK);
self::assertSelectorExists('h1');
}
#[Test]
#[TestDox('Homepage contains navigation links to play, login, and register')]
public function homepageContainsNavigationLinks(): void
{
$crawler = $this->client->request('GET', '/');
self::assertResponseIsSuccessful();
self::assertSelectorExists('a[href="/play"]');
self::assertSelectorExists('a[href="/login"]');
self::assertSelectorExists('a[href="/register"]');
}
#[Test]
#[TestDox('Play page has correct meta tags for SEO and social sharing')]
public function playPageHasCorrectMetaTags(): void
{
$this->client->request('GET', '/play');
self::assertResponseIsSuccessful();
self::assertSelectorExists('meta[name="description"]');
self::assertSelectorExists('meta[property="og:title"]');
}
}
+146
View File
@@ -0,0 +1,146 @@
<?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\PlayedGameFactory;
use App\Tests\Factory\UserFactory;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use App\Tests\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* Class ProfileControllerTest
*
* @package App\Tests\Controller
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 21.
*/
#[TestDox('Profile Controller')]
class ProfileControllerTest extends WebTestCase
{
private KernelBrowser $client;
protected function setUp(): void
{
parent::setUp();
$this->client = static::createClient();
}
#[Test]
#[TestDox('Profile page requires authentication')]
public function profilePageRequiresAuthentication(): void
{
$this->client->request('GET', '/profile');
self::assertResponseRedirects('/login');
}
#[Test]
#[TestDox('Profile security page requires authentication')]
public function profileSecurityPageRequiresAuthentication(): void
{
$this->client->request('GET', '/profile/security');
self::assertResponseRedirects('/login');
}
#[Test]
#[TestDox('Profile avatar upload requires authentication')]
public function profileAvatarPageRequiresAuthentication(): void
{
$this->client->request('POST', '/profile/avatar');
self::assertResponseRedirects('/login');
}
#[Test]
#[TestDox('Battle share page returns 404 for non-existent game')]
public function battleSharePageReturns404ForNonExistentGame(): void
{
$uuid = '00000000-0000-4000-a000-000000000000';
$this->client->request('GET', '/battle/' . $uuid);
self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
}
#[Test]
#[TestDox('Battle share page displays valid game details')]
public function battleSharePageShowsValidGame(): void
{
$game = PlayedGameFactory::new()
->withRegisteredPlayers()
->redWins()
->create();
$crawler = $this->client->request('GET', '/battle/' . $game->uuid);
self::assertResponseIsSuccessful();
self::assertSelectorExists('h1');
self::assertGreaterThan(0, $crawler->filter('body')->count());
}
#[Test]
#[TestDox('Battle share page has Open Graph meta tags for social sharing')]
public function battleSharePageHasOgMetaTags(): void
{
$game = PlayedGameFactory::new()
->withRegisteredPlayers()
->redWins()
->create();
$this->client->request('GET', '/battle/' . $game->uuid);
self::assertResponseIsSuccessful();
self::assertSelectorExists('meta[property="og:title"]');
self::assertSelectorExists('meta[property="og:image"]');
self::assertSelectorExists('meta[property="og:description"]');
self::assertSelectorExists('meta[property="og:type"]');
}
#[Test]
#[TestDox('OG battle image returns 404 for non-existent game')]
public function ogBattleImageReturns404ForNonExistentGame(): void
{
$uuid = '00000000-0000-4000-a000-000000000000';
$this->client->request('GET', '/og/battle/' . $uuid . '.png');
self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
}
#[Test]
#[TestDox('OG battle image generates valid PNG for existing game')]
public function ogBattleImageReturnsImageForValidGame(): void
{
$game = PlayedGameFactory::new()
->withRegisteredPlayers()
->redWins()
->create();
$this->client->request('GET', '/og/battle/' . $game->uuid . '.png');
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('Content-Type', 'image/png');
}
#[Test]
#[TestDox('Battle share page returns 404 for invalid UUID format')]
public function battleSharePageWithInvalidUuidFormat(): void
{
$this->client->request('GET', '/battle/invalid-uuid');
self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
}
}
+169
View File
@@ -0,0 +1,169 @@
<?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 DateTime;
use Doctrine\ORM\EntityManagerInterface;
use App\Tests\Factory\UserFactory;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use App\Tests\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* Class SecurityControllerTest
*
* @package App\Tests\Controller
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 21.
*/
#[TestDox('Security Controller')]
class SecurityControllerTest extends WebTestCase
{
private KernelBrowser $client;
protected function setUp(): void
{
parent::setUp();
$this->client = static::createClient();
}
#[Test]
#[TestDox('Login page loads successfully with form fields')]
public function loginPageLoadsSuccessfully(): void
{
$crawler = $this->client->request('GET', '/login');
self::assertResponseStatusCodeSame(Response::HTTP_OK);
self::assertSelectorExists('form');
self::assertSelectorExists('input[name="_username"]');
self::assertSelectorExists('input[name="_password"]');
self::assertSelectorExists('button[type="submit"]');
}
#[Test]
#[TestDox('Login page has links to register and forgot password')]
public function loginPageHasRegisterLink(): void
{
$crawler = $this->client->request('GET', '/login');
self::assertResponseIsSuccessful();
self::assertSelectorExists('a[href="/register"]');
self::assertSelectorExists('a[href="/forgot-password"]');
}
#[Test]
#[TestDox('Register page loads successfully with form')]
public function registerPageLoadsSuccessfully(): void
{
$crawler = $this->client->request('GET', '/register');
self::assertResponseStatusCodeSame(Response::HTTP_OK);
self::assertSelectorExists('form');
self::assertSelectorExists('button[type="submit"]');
}
#[Test]
#[TestDox('Register page has link to login')]
public function registerPageHasLoginLink(): void
{
$crawler = $this->client->request('GET', '/register');
self::assertResponseIsSuccessful();
self::assertSelectorExists('a[href="/login"]');
}
#[Test]
#[TestDox('Forgot password page loads successfully with form')]
public function forgotPasswordPageLoadsSuccessfully(): void
{
$crawler = $this->client->request('GET', '/forgot-password');
self::assertResponseStatusCodeSame(Response::HTTP_OK);
self::assertSelectorExists('form');
self::assertSelectorExists('button[type="submit"]');
}
#[Test]
#[TestDox('Account activation with invalid token redirects to login')]
public function activateWithInvalidTokenShowsError(): void
{
$this->client->request('GET', '/activate/invalid-token');
self::assertResponseRedirects('/login');
}
#[Test]
#[TestDox('Expired account activation token does not activate the account')]
public function expiredActivationTokenDoesNotActivateAccount(): void
{
$user = UserFactory::createOne([
'isVerified' => false,
'verificationToken' => 'expired-activation-token',
'verificationTokenExpiresAt' => new DateTime('-1 minute'),
]);
$this->client->request('GET', '/activate/expired-activation-token');
self::assertResponseRedirects('/login');
static::getContainer()->get(EntityManagerInterface::class)->refresh($user);
self::assertFalse($user->isVerified);
self::assertSame('expired-activation-token', $user->verificationToken);
}
#[Test]
#[TestDox('Password reset with invalid token redirects to forgot password')]
public function resetPasswordWithInvalidTokenShowsError(): void
{
$this->client->request('GET', '/reset-password/invalid-token');
self::assertResponseRedirects('/forgot-password');
}
#[Test]
#[TestDox('Authenticated user is redirected from login page to homepage')]
public function authenticatedUserRedirectsFromLogin(): void
{
$user = UserFactory::createOne();
$this->client->loginUser($user);
$this->client->request('GET', '/login');
self::assertResponseRedirects('/');
}
#[Test]
#[TestDox('Authenticated user is redirected from register page to homepage')]
public function authenticatedUserRedirectsFromRegister(): void
{
$user = UserFactory::createOne();
$this->client->loginUser($user);
$this->client->request('GET', '/register');
self::assertResponseRedirects('/');
}
#[Test]
#[TestDox('Login form has remember me checkbox')]
public function loginFormHasRememberMeCheckbox(): void
{
$crawler = $this->client->request('GET', '/login');
self::assertResponseIsSuccessful();
self::assertSelectorExists('input[name="_remember_me"]');
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Dto;
use App\Dto\ProfileChartDataDto;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
/**
* Class ProfileChartDataDtoTest
*
* @package App\Tests\Dto
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 21.
*/
#[TestDox('Profile Chart Data Dto')]
class ProfileChartDataDtoTest extends TestCase
{
#[Test]
#[TestDox('Json serialize returns all properties')]
public function jsonSerializeReturnsAllProperties(): void
{
$months = ['Jan', 'Feb', 'Mar'];
$wins = [5, 8, 10];
$losses = [2, 3, 4];
$draws = [1, 0, 1];
$recentGames = [
['id' => 1, 'result' => 'win'],
['id' => 2, 'result' => 'loss'],
];
$dto = new ProfileChartDataDto(
months: $months,
wins: $wins,
losses: $losses,
draws: $draws,
pieWins: 23,
pieLosses: 9,
pieDraws: 2,
recentGames: $recentGames,
);
$json = $dto->jsonSerialize();
$this->assertSame($months, $json['months']);
$this->assertSame($wins, $json['wins']);
$this->assertSame($losses, $json['losses']);
$this->assertSame($draws, $json['draws']);
$this->assertSame(23, $json['pieWins']);
$this->assertSame(9, $json['pieLosses']);
$this->assertSame(2, $json['pieDraws']);
$this->assertSame($recentGames, $json['recentGames']);
}
#[Test]
#[TestDox('Constructor with empty arrays')]
public function constructorWithEmptyArrays(): void
{
$dto = new ProfileChartDataDto(
months: [],
wins: [],
losses: [],
draws: [],
pieWins: 0,
pieLosses: 0,
pieDraws: 0,
recentGames: [],
);
$json = $dto->jsonSerialize();
$this->assertSame([], $json['months']);
$this->assertSame([], $json['wins']);
$this->assertSame([], $json['recentGames']);
$this->assertSame(0, $json['pieWins']);
}
}
+126
View File
@@ -0,0 +1,126 @@
<?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\Dto;
use App\Dto\ProfileGameDto;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
/**
* Class ProfileGameDtoTest
*
* @package App\Tests\Dto
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 21.
*/
#[TestDox('Profile Game Dto')]
class ProfileGameDtoTest extends TestCase
{
#[Test]
#[TestDox('Json serialize returns all properties')]
public function jsonSerializeReturnsAllProperties(): void
{
$dto = new ProfileGameDto(
id: 1,
uuid: '550e8400-e29b-41d4-a716-446655440000',
redName: 'RedPlayer',
blueName: 'BluePlayer',
redAvatar: '/uploads/avatars/red.png',
blueAvatar: '/uploads/avatars/blue.png',
redPoints: 10,
bluePoints: 8,
redExplodedBomb: false,
blueExplodedBomb: true,
resign: null,
created: '2026-04-20 10:00',
date: '2026-04-20 10:30',
isRed: true,
result: 'win',
myPoints: 10,
oppPoints: 8,
redBonusPoints: 5.5,
blueBonusPoints: 2.0,
redBonusStats: ['blindHits' => 2, 'chainBest' => 3],
blueBonusStats: ['blindHits' => 1, 'chainBest' => 2],
bothRegistered: true,
);
$json = $dto->jsonSerialize();
$this->assertSame(1, $json['id']);
$this->assertSame('550e8400-e29b-41d4-a716-446655440000', $json['uuid']);
$this->assertSame('RedPlayer', $json['redName']);
$this->assertSame('BluePlayer', $json['blueName']);
$this->assertSame('/uploads/avatars/red.png', $json['redAvatar']);
$this->assertSame('/uploads/avatars/blue.png', $json['blueAvatar']);
$this->assertSame(10, $json['redPoints']);
$this->assertSame(8, $json['bluePoints']);
$this->assertFalse($json['redExplodedBomb']);
$this->assertTrue($json['blueExplodedBomb']);
$this->assertNull($json['resign']);
$this->assertSame('2026-04-20 10:00', $json['created']);
$this->assertSame('2026-04-20 10:30', $json['date']);
$this->assertTrue($json['isRed']);
$this->assertSame('win', $json['result']);
$this->assertSame(10, $json['myPoints']);
$this->assertSame(8, $json['oppPoints']);
$this->assertSame(5.5, $json['redBonusPoints']);
$this->assertSame(2.0, $json['blueBonusPoints']);
$this->assertSame(['blindHits' => 2, 'chainBest' => 3], $json['redBonusStats']);
$this->assertSame(['blindHits' => 1, 'chainBest' => 2], $json['blueBonusStats']);
$this->assertTrue($json['bothRegistered']);
}
#[Test]
#[TestDox('Json serialize with null values')]
public function jsonSerializeWithNullValues(): void
{
$dto = new ProfileGameDto(
id: null,
uuid: null,
redName: 'Guest',
blueName: 'Guest',
redAvatar: null,
blueAvatar: null,
redPoints: null,
bluePoints: null,
redExplodedBomb: null,
blueExplodedBomb: null,
resign: null,
created: null,
date: null,
isRed: false,
result: 'draw',
myPoints: null,
oppPoints: null,
redBonusPoints: 0.0,
blueBonusPoints: 0.0,
redBonusStats: [],
blueBonusStats: [],
bothRegistered: false,
);
$json = $dto->jsonSerialize();
$this->assertNull($json['id']);
$this->assertNull($json['uuid']);
$this->assertNull($json['redAvatar']);
$this->assertNull($json['blueAvatar']);
$this->assertNull($json['redPoints']);
$this->assertNull($json['bluePoints']);
$this->assertSame('draw', $json['result']);
$this->assertFalse($json['bothRegistered']);
}
}
+134
View File
@@ -0,0 +1,134 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Dto;
use App\Dto\ProfileStatsDto;
use App\Entity\UserStats;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
/**
* Class ProfileStatsDtoTest
*
* @package App\Tests\Dto
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 21.
*/
#[TestDox('Profile Stats Dto')]
class ProfileStatsDtoTest extends TestCase
{
#[Test]
#[TestDox('From user stats with valid stats')]
public function fromUserStatsWithValidStats(): void
{
$userStats = new UserStats();
$userStats->userId = 1;
$userStats->totalGames = 100;
$userStats->wins = 60;
$userStats->losses = 30;
$userStats->draws = 10;
$userStats->totalMines = 500;
$userStats->totalBonusPoints = '150.5';
$userStats->avgBonus = '2.5';
$userStats->bestChain = 5;
$userStats->blindHits = 20;
$userStats->edgeMines = 15;
$userStats->gamesWithScores = 90;
$dto = ProfileStatsDto::fromUserStats($userStats);
$this->assertSame(100, $dto->total);
$this->assertSame(60, $dto->wins);
$this->assertSame(30, $dto->losses);
$this->assertSame(10, $dto->draws);
$this->assertSame(500, $dto->minesHit);
$this->assertSame(67, $dto->winRate); // 60/90 * 100 = 67
$this->assertSame(6, $dto->avgScore); // 500/90 = 5.56 -> 6
$this->assertSame(150.5, $dto->bonusPoints);
$this->assertSame(2.5, $dto->avgBonus);
$this->assertSame(5, $dto->bestChain);
$this->assertSame(20, $dto->blindHits);
$this->assertSame(15, $dto->edgeMines);
}
#[Test]
#[TestDox('From user stats with null returns empty')]
public function fromUserStatsWithNullReturnsEmpty(): void
{
$dto = ProfileStatsDto::fromUserStats(null);
$this->assertSame(0, $dto->total);
$this->assertSame(0, $dto->wins);
$this->assertSame(0, $dto->losses);
$this->assertSame(0, $dto->draws);
$this->assertSame(0, $dto->minesHit);
$this->assertSame(0, $dto->winRate);
$this->assertSame(0, $dto->avgScore);
$this->assertSame(0.0, $dto->bonusPoints);
$this->assertSame(0.0, $dto->avgBonus);
$this->assertSame(0, $dto->bestChain);
$this->assertSame(0, $dto->blindHits);
$this->assertSame(0, $dto->edgeMines);
}
#[Test]
#[TestDox('Empty returns default values')]
public function emptyReturnsDefaultValues(): void
{
$dto = ProfileStatsDto::empty();
$this->assertSame(0, $dto->total);
$this->assertSame(0, $dto->wins);
$this->assertSame(0, $dto->losses);
$this->assertSame(0, $dto->draws);
$this->assertSame(0, $dto->minesHit);
$this->assertSame(0, $dto->winRate);
$this->assertSame(0, $dto->avgScore);
$this->assertSame(0.0, $dto->bonusPoints);
$this->assertSame(0.0, $dto->avgBonus);
$this->assertSame(0, $dto->bestChain);
$this->assertSame(0, $dto->blindHits);
$this->assertSame(0, $dto->edgeMines);
}
#[Test]
#[TestDox('Win rate calculation with no games with scores')]
public function winRateCalculationWithNoGamesWithScores(): void
{
$userStats = new UserStats();
$userStats->totalGames = 10;
$userStats->wins = 5;
$userStats->losses = 5;
$userStats->draws = 0;
$userStats->gamesWithScores = 0;
$dto = ProfileStatsDto::fromUserStats($userStats);
$this->assertSame(0, $dto->winRate);
}
#[Test]
#[TestDox('Avg score calculation with no games with scores')]
public function avgScoreCalculationWithNoGamesWithScores(): void
{
$userStats = new UserStats();
$userStats->totalMines = 100;
$userStats->gamesWithScores = 0;
$dto = ProfileStatsDto::fromUserStats($userStats);
$this->assertSame(0, $dto->avgScore);
}
}
+142
View File
@@ -0,0 +1,142 @@
<?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\Entity;
use App\Entity\UserStats;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
/**
* Class UserStatsTest
*
* @package App\Tests\Entity
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 21.
*/
#[TestDox('User Stats')]
class UserStatsTest extends TestCase
{
#[Test]
#[TestDox('Get win rate calculates correctly')]
public function getWinRateCalculatesCorrectly(): void
{
$stats = new UserStats();
$stats->wins = 60;
$stats->gamesWithScores = 100;
$result = $stats->getWinRate();
$this->assertSame(60, $result);
}
#[Test]
#[TestDox('Get win rate with zero games returns zero')]
public function getWinRateWithZeroGamesReturnsZero(): void
{
$stats = new UserStats();
$stats->wins = 10;
$stats->gamesWithScores = 0;
$result = $stats->getWinRate();
$this->assertSame(0, $result);
}
#[Test]
#[TestDox('Get win rate rounds correctly')]
public function getWinRateRoundsCorrectly(): void
{
$stats = new UserStats();
$stats->wins = 33;
$stats->gamesWithScores = 100;
$result = $stats->getWinRate();
$this->assertSame(33, $result);
}
#[Test]
#[TestDox('Get avg score calculates correctly')]
public function getAvgScoreCalculatesCorrectly(): void
{
$stats = new UserStats();
$stats->totalMines = 550;
$stats->gamesWithScores = 100;
$result = $stats->getAvgScore();
$this->assertSame(6, $result); // 550/100 = 5.5 -> 6
}
#[Test]
#[TestDox('Get avg score with zero games returns zero')]
public function getAvgScoreWithZeroGamesReturnsZero(): void
{
$stats = new UserStats();
$stats->totalMines = 100;
$stats->gamesWithScores = 0;
$result = $stats->getAvgScore();
$this->assertSame(0, $result);
}
#[Test]
#[TestDox('Get avg score rounds down')]
public function getAvgScoreRoundsDown(): void
{
$stats = new UserStats();
$stats->totalMines = 101;
$stats->gamesWithScores = 100;
$result = $stats->getAvgScore();
$this->assertSame(1, $result); // 101/100 = 1.01 -> 1
}
#[Test]
#[TestDox('Get avg score rounds up')]
public function getAvgScoreRoundsUp(): void
{
$stats = new UserStats();
$stats->totalMines = 151;
$stats->gamesWithScores = 100;
$result = $stats->getAvgScore();
$this->assertSame(2, $result); // 151/100 = 1.51 -> 2
}
#[Test]
#[TestDox('Default values')]
public function defaultValues(): void
{
$stats = new UserStats();
$this->assertSame(0, $stats->userId);
$this->assertSame(0, $stats->totalGames);
$this->assertSame(0, $stats->wins);
$this->assertSame(0, $stats->losses);
$this->assertSame(0, $stats->draws);
$this->assertSame(0, $stats->totalMines);
$this->assertSame('0.0', $stats->totalBonusPoints);
$this->assertSame('0.0', $stats->avgBonus);
$this->assertSame(0, $stats->bestChain);
$this->assertSame(0, $stats->blindHits);
$this->assertSame(0, $stats->edgeMines);
$this->assertSame(0, $stats->gamesWithScores);
$this->assertNull($stats->lastGameAt);
}
}

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