Private
Public Access
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e90bbe5cf | ||
|
|
1d8d0ac110 | ||
|
|
007c91f7e9 | ||
|
|
e70cb57176 | ||
|
|
db54899ed4 | ||
|
|
558c8a3bad | ||
|
|
2be49ebdf1 | ||
|
|
8b428260d7 | ||
|
|
306add033a | ||
|
|
f451df283c | ||
|
|
8d542e683e | ||
|
|
dff46cdc6f | ||
|
|
82fff0cdf0 | ||
|
|
4bf7ac358d | ||
|
|
1a97c2c4b4 | ||
|
|
9ea83d7e6e | ||
|
|
f3e4ff211d | ||
|
|
7a86767db1 | ||
|
|
2b1689b321 | ||
|
|
cf56b10aba | ||
|
|
7063704539 |
@@ -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 ###
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
# define your env variables for the test env here
|
# define your env variables for the test env here
|
||||||
KERNEL_CLASS='App\Kernel'
|
KERNEL_CLASS='App\Kernel'
|
||||||
APP_SECRET='$ecretf0rt3st'
|
APP_SECRET='$ecretf0rt3st'
|
||||||
|
DATABASE_URL="postgresql://system7:bazmeg@127.0.0.1:5452/mineseeker_test?serverVersion=18&charset=utf8"
|
||||||
|
|||||||
@@ -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 }}"
|
||||||
docker compose up -d
|
|
||||||
|
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
|
||||||
|
fi
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "/phpactor.schema.json",
|
||||||
|
"symfony.enabled": true,
|
||||||
|
"indexer.exclude_patterns": [
|
||||||
|
"/vendor/**/Tests/**/*",
|
||||||
|
"/vendor/**/tests/**/*",
|
||||||
|
"/var/cache/**/*",
|
||||||
|
"/vendor/composer/**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -94,7 +94,6 @@ assets/
|
|||||||
│ ├── utils/ # Shared utilities
|
│ ├── utils/ # Shared utilities
|
||||||
│ ├── profile.jsx # Profile page entry
|
│ ├── profile.jsx # Profile page entry
|
||||||
│ ├── passkey.jsx # Passkey management entry
|
│ ├── passkey.jsx # Passkey management entry
|
||||||
│ └── contact.jsx # Contact form entry
|
|
||||||
├── css/
|
├── css/
|
||||||
│ └── homepage/ # SCSS partials (imported by style.homepage.scss)
|
│ └── homepage/ # SCSS partials (imported by style.homepage.scss)
|
||||||
└── fonts/
|
└── fonts/
|
||||||
|
|||||||
@@ -1,6 +1,58 @@
|
|||||||
# 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)
|
## v2026.2.9-0 (2026-04-23)
|
||||||
|
|
||||||
### New
|
### New
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
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"
|
||||||
@@ -19,21 +19,41 @@ help:
|
|||||||
@echo " make test - Run PHPUnit tests"
|
@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!"
|
||||||
@@ -42,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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;
|
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,40 +45,42 @@ 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);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setError(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [siteKey, onVerified, handleToken]);
|
|
||||||
|
|
||||||
|
const cap = new window.Cap({ apiEndpoint }, widget);
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
const handleClick = () => {
|
const run = async () => {
|
||||||
setLoading(true);
|
try {
|
||||||
setError(false);
|
const result = await cap.solve();
|
||||||
|
if (!cancelled && result?.token) {
|
||||||
|
handleToken(result.token);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
run();
|
||||||
|
}
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
window.grecaptcha.ready(() => {
|
run();
|
||||||
window.grecaptcha
|
|
||||||
.execute(siteKey, { action: RECAPTCHA_ACTION })
|
return () => {
|
||||||
.then(token => {
|
cancelled = true;
|
||||||
handleToken(token);
|
widget.remove();
|
||||||
setLoading(false);
|
capRef.current = null;
|
||||||
})
|
};
|
||||||
.catch(() => {
|
}, [apiEndpoint, handleToken]);
|
||||||
setLoading(false);
|
|
||||||
setError(true);
|
|
||||||
setTimeout(() => setError(false), 2000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
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'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)} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Executable
+42
@@ -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"
|
||||||
Executable
+41
@@ -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
+61
@@ -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."
|
||||||
Executable
+84
@@ -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."
|
||||||
@@ -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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -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=="],
|
||||||
|
|
||||||
@@ -108,25 +105,27 @@
|
|||||||
|
|
||||||
"@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-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", "http://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", { "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", "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/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", "http://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", { "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", "http://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", {}, "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", "http://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
|
"@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=="],
|
||||||
|
|
||||||
@@ -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.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
|
||||||
|
|
||||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "http://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
"@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.126.0", "", {}, "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ=="],
|
"@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.16", "", { "os": "android", "cpu": "arm64" }, "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA=="],
|
"@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.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ=="],
|
"@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.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ=="],
|
"@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.16", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g=="],
|
"@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.16", "", { "os": "linux", "cpu": "arm" }, "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg=="],
|
"@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.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg=="],
|
"@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.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg=="],
|
"@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.16", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ=="],
|
"@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.16", "", { "os": "linux", "cpu": "s390x" }, "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ=="],
|
"@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.16", "", { "os": "linux", "cpu": "x64" }, "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg=="],
|
"@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.16", "", { "os": "linux", "cpu": "x64" }, "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w=="],
|
"@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.16", "", { "os": "none", "cpu": "arm64" }, "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA=="],
|
"@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.16", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ=="],
|
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw=="],
|
||||||
|
|
||||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q=="],
|
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ=="],
|
||||||
|
|
||||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "x64" }, "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g=="],
|
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.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", "http://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.56.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": "^9.0.0 || ^10.0.0" } }, "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ=="],
|
||||||
|
|
||||||
"@tanstack/query-core": ["@tanstack/query-core@5.99.2", "http://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.2.tgz", {}, "sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA=="],
|
"@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/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=="],
|
"@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=="],
|
||||||
|
|
||||||
"@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=="],
|
||||||
@@ -274,6 +269,8 @@
|
|||||||
|
|
||||||
"@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=="],
|
||||||
|
|
||||||
@@ -320,7 +317,7 @@
|
|||||||
|
|
||||||
"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=="],
|
"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=="],
|
||||||
|
|
||||||
@@ -338,7 +335,9 @@
|
|||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001788", "http://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", {}, "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=="],
|
||||||
|
|
||||||
@@ -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=="],
|
||||||
@@ -418,7 +419,7 @@
|
|||||||
|
|
||||||
"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", "http://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q=="],
|
"eslint": ["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=="],
|
||||||
|
|
||||||
@@ -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=="],
|
||||||
|
|
||||||
@@ -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", "http://registry.npmjs.org/globals/-/globals-17.5.0.tgz", {}, "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=="],
|
||||||
|
|
||||||
@@ -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=="],
|
||||||
|
|
||||||
@@ -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=="],
|
||||||
|
|
||||||
@@ -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=="],
|
||||||
|
|
||||||
@@ -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.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
|
"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.16", "", { "dependencies": { "@oxc-project/types": "=0.126.0", "@rolldown/pluginutils": "1.0.0-rc.16" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-x64": "1.0.0-rc.16", "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g=="],
|
"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=="],
|
||||||
@@ -816,7 +815,7 @@
|
|||||||
|
|
||||||
"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.9", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.16", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw=="],
|
"vite": ["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=="],
|
||||||
|
|
||||||
@@ -842,6 +841,8 @@
|
|||||||
|
|
||||||
"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=="],
|
"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=="],
|
||||||
|
|
||||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "http://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "http://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||||
@@ -850,6 +851,26 @@
|
|||||||
|
|
||||||
"@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=="],
|
||||||
@@ -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.16", "", {}, "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA=="],
|
"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
@@ -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:
|
||||||
|
|||||||
+22
-20
@@ -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,32 +27,34 @@
|
|||||||
"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.7",
|
"symfony/mercure": "^0.9",
|
||||||
"symfony/mercure-bundle": "^0.4",
|
"symfony/mercure-bundle": "^0.6",
|
||||||
|
"symfony/messenger": "8.1.*",
|
||||||
"symfony/monolog-bundle": "^4.0",
|
"symfony/monolog-bundle": "^4.0",
|
||||||
"symfony/security-bundle": "7.4.*",
|
"symfony/scheduler": "8.1.*",
|
||||||
"symfony/translation": "7.4.*",
|
"symfony/security-bundle": "8.1.*",
|
||||||
"symfony/twig-bundle": "7.4.*",
|
"symfony/translation": "8.1.*",
|
||||||
"symfony/validator": "7.4.*",
|
"symfony/twig-bundle": "8.1.*",
|
||||||
"symfony/yaml": "7.4.*",
|
"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",
|
"dama/doctrine-test-bundle": "^8.6",
|
||||||
"phpunit/phpunit": "^13.1",
|
"phpunit/phpunit": "^13.1",
|
||||||
"roave/security-advisories": "dev-master",
|
"roave/security-advisories": "dev-master",
|
||||||
"symfony/browser-kit": "7.4.*",
|
"symfony/browser-kit": "8.1.*",
|
||||||
"symfony/css-selector": "7.4.*",
|
"symfony/css-selector": "8.1.*",
|
||||||
"symfony/dotenv": "7.4.*",
|
"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"
|
"zenstruck/foundry": "^2.9"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
@@ -102,7 +104,7 @@
|
|||||||
"extra": {
|
"extra": {
|
||||||
"symfony": {
|
"symfony": {
|
||||||
"allow-contrib": false,
|
"allow-contrib": false,
|
||||||
"require": "7.4.*"
|
"require": "8.1.*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2396
-2001
File diff suppressed because it is too large
Load Diff
@@ -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: '*'
|
||||||
|
|||||||
@@ -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://'
|
||||||
@@ -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)%"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
when@dev: &dev
|
when@dev: &dev
|
||||||
# See full configuration: https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#full-default-bundle-configuration
|
# See full configuration: https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#full-default-bundle-configuration
|
||||||
zenstruck_foundry:
|
zenstruck_foundry:
|
||||||
|
enable_auto_refresh_with_lazy_objects: true
|
||||||
persistence:
|
persistence:
|
||||||
# Flush only once per call of `PersistentObjectFactory::create()`
|
# Flush only once per call of `PersistentObjectFactory::create()`
|
||||||
flush_once: true
|
flush_once: true
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
_errors:
|
_errors:
|
||||||
resource: '@FrameworkBundle/Resources/config/routing/errors.xml'
|
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
|
||||||
prefix: /_error
|
prefix: /_error
|
||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -57,6 +57,12 @@ Quick command reference for testing:
|
|||||||
|
|
||||||
## CI/CD & Deployment
|
## 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)
|
### [CI/CD Integration Guide](./CI_CD.md)
|
||||||
Comprehensive guide for continuous integration and deployment:
|
Comprehensive guide for continuous integration and deployment:
|
||||||
- Gitea Actions workflows (CI and CD pipelines)
|
- Gitea Actions workflows (CI and CD pipelines)
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
# MineSeeker GTK4 Desktop Client — configuration
|
|
||||||
# Copy this file to .env and fill in your values.
|
|
||||||
|
|
||||||
# Base URL of the MineSeeker server (no trailing slash)
|
|
||||||
MINESEEKER_BASE_URL=https://mineseeker.example.com
|
|
||||||
|
|
||||||
# Public Mercure hub URL (SSE endpoint)
|
|
||||||
MINESEEKER_MERCURE_URL=https://mineseeker.example.com/.well-known/mercure
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
.env
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
*.pyo
|
|
||||||
*.pyd
|
|
||||||
.Python
|
|
||||||
*.egg-info/
|
|
||||||
dist/
|
|
||||||
build/
|
|
||||||
.venv/
|
|
||||||
venv/
|
|
||||||
@@ -1,47 +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 sys
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import gi
|
|
||||||
gi.require_version("Gtk", "4.0")
|
|
||||||
gi.require_version("Adw", "1")
|
|
||||||
gi.require_version("Gst", "1.0")
|
|
||||||
from gi.repository import Gtk, Adw, Gst, GLib
|
|
||||||
|
|
||||||
# Validate config early (raises EnvironmentError if .env is missing)
|
|
||||||
from mineseeker import config # noqa: F401
|
|
||||||
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MineSeekerApp(Adw.Application):
|
|
||||||
def __init__(self) -> None:
|
|
||||||
super().__init__(application_id="org.splendidbear.mineseeker")
|
|
||||||
self.connect("activate", self._on_activate)
|
|
||||||
|
|
||||||
def _on_activate(self, app: Adw.Application) -> None:
|
|
||||||
# Import here so GTK/Adw is already initialised before building widgets
|
|
||||||
from mineseeker.ui.app_window import AppWindow
|
|
||||||
window = AppWindow(application=app)
|
|
||||||
window.present()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
Gst.init(None)
|
|
||||||
app = MineSeekerApp()
|
|
||||||
return app.run(sys.argv)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,93 +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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from mineseeker.api import client
|
|
||||||
|
|
||||||
|
|
||||||
class AuthError(Exception):
|
|
||||||
"""Raised on authentication failure."""
|
|
||||||
|
|
||||||
|
|
||||||
class TotpRequired(Exception):
|
|
||||||
"""Raised when the server requires a TOTP code after password login."""
|
|
||||||
|
|
||||||
|
|
||||||
def login(username: str, password: str) -> None:
|
|
||||||
"""
|
|
||||||
Authenticate with username + password via the dedicated JSON endpoint
|
|
||||||
POST /api/auth/login, which bypasses the reCAPTCHA gate.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TotpRequired – server confirmed credentials but TOTP is required next.
|
|
||||||
AuthError – credentials wrong, account inactive, or server error.
|
|
||||||
"""
|
|
||||||
session = client.get_session()
|
|
||||||
resp = session.post(
|
|
||||||
client.url("/api/auth/login"),
|
|
||||||
json={"username": username, "password": password},
|
|
||||||
# The endpoint sets a session cookie; follow any redirects
|
|
||||||
allow_redirects=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Non-2xx means a hard server error (500 etc.) — let it propagate
|
|
||||||
if resp.status_code >= 500:
|
|
||||||
resp.raise_for_status()
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
|
|
||||||
if not data.get("success"):
|
|
||||||
raise AuthError(data.get("error", "Login failed."))
|
|
||||||
|
|
||||||
if data.get("requiresTwoFactor"):
|
|
||||||
raise TotpRequired()
|
|
||||||
|
|
||||||
|
|
||||||
def submit_totp(code: str) -> None:
|
|
||||||
"""
|
|
||||||
Submit the 6-digit TOTP code after login() raises TotpRequired.
|
|
||||||
|
|
||||||
The scheb/2fa bundle processes POST /2fa_check directly as a firewall
|
|
||||||
listener — no CSRF token required, no JSON body. The code goes as a
|
|
||||||
form-encoded field named _auth_code, same as the browser form.
|
|
||||||
The LoginCaptchaListener already skips /2fa_check paths.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
AuthError – if the code is wrong or the session is no longer in
|
|
||||||
IS_AUTHENTICATED_2FA_IN_PROGRESS state.
|
|
||||||
"""
|
|
||||||
session = client.get_session()
|
|
||||||
resp = session.post(
|
|
||||||
client.url("/2fa_check"),
|
|
||||||
data={"_auth_code": code},
|
|
||||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
||||||
allow_redirects=True,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
|
|
||||||
# If we land back on /2fa the code was wrong
|
|
||||||
if "/2fa" in resp.url:
|
|
||||||
raise AuthError("Invalid authentication code.")
|
|
||||||
|
|
||||||
|
|
||||||
def login_as_guest() -> None:
|
|
||||||
"""
|
|
||||||
Start an anonymous session.
|
|
||||||
A GET to the homepage is enough for Symfony to create a session and
|
|
||||||
assign the anon_<session_id> identity used by ResolveUserNamesService.
|
|
||||||
"""
|
|
||||||
session = client.get_session()
|
|
||||||
resp = session.get(client.url("/"), headers={"Accept": "text/html"})
|
|
||||||
resp.raise_for_status()
|
|
||||||
|
|
||||||
|
|
||||||
def logout() -> None:
|
|
||||||
"""Discard the local session (client-side only)."""
|
|
||||||
client.reset_session()
|
|
||||||
@@ -1,52 +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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from mineseeker import config
|
|
||||||
|
|
||||||
# Module-level singleton session shared by all API modules.
|
|
||||||
# Holds cookies (Symfony session cookie after login) across all requests.
|
|
||||||
_session: requests.Session | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_session() -> requests.Session:
|
|
||||||
global _session
|
|
||||||
if _session is None:
|
|
||||||
_session = requests.Session()
|
|
||||||
_session.headers.update({
|
|
||||||
"Accept": "application/json",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
})
|
|
||||||
return _session
|
|
||||||
|
|
||||||
|
|
||||||
def reset_session() -> None:
|
|
||||||
"""Discard the current session (logout / new guest session)."""
|
|
||||||
global _session
|
|
||||||
_session = None
|
|
||||||
|
|
||||||
|
|
||||||
def url(path: str) -> str:
|
|
||||||
"""Build an absolute URL from a server-relative path."""
|
|
||||||
return f"{config.BASE_URL}/{path.lstrip('/')}"
|
|
||||||
|
|
||||||
|
|
||||||
def get(path: str, **kwargs) -> requests.Response:
|
|
||||||
resp = get_session().get(url(path), **kwargs)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
def post(path: str, json: dict | None = None, **kwargs) -> requests.Response:
|
|
||||||
resp = get_session().post(url(path), json=json, **kwargs)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp
|
|
||||||
@@ -1,117 +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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
|
|
||||||
from mineseeker.api import client
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_token() -> dict:
|
|
||||||
"""
|
|
||||||
GET /api/game/token
|
|
||||||
Returns { "mercureJwt": str, "gameAssoc": str }
|
|
||||||
"""
|
|
||||||
resp = client.get("/api/game/token")
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
def connect(game_assoc: str) -> dict:
|
|
||||||
"""
|
|
||||||
GET /api/game/connect/{gameAssoc}
|
|
||||||
Returns the decoded connect-information dict.
|
|
||||||
"""
|
|
||||||
resp = client.get(f"/api/game/connect/{game_assoc}")
|
|
||||||
raw = resp.text.strip()
|
|
||||||
decoded = base64.b64decode(raw).decode("utf-8")
|
|
||||||
return json.loads(decoded)
|
|
||||||
|
|
||||||
|
|
||||||
def start(game_assoc: str) -> bool:
|
|
||||||
"""POST /api/game/start — initialise the grid for a new game."""
|
|
||||||
resp = client.post("/api/game/start", json={"gameAssoc": game_assoc})
|
|
||||||
return bool(resp.json().get("success", False))
|
|
||||||
|
|
||||||
|
|
||||||
def join(game_assoc: str) -> bool:
|
|
||||||
"""POST /api/game/join/{gameAssoc} — announce this player's presence."""
|
|
||||||
resp = client.post(f"/api/game/join/{game_assoc}", json={})
|
|
||||||
return bool(resp.json().get("success", False))
|
|
||||||
|
|
||||||
|
|
||||||
def step(
|
|
||||||
game_assoc: str,
|
|
||||||
coords: list[int],
|
|
||||||
player: str,
|
|
||||||
bomb: bool,
|
|
||||||
resign: str | None,
|
|
||||||
step_elapsed: float,
|
|
||||||
) -> dict:
|
|
||||||
"""
|
|
||||||
POST /api/game/step/{gameAssoc}
|
|
||||||
Returns the full step result dict published by TopicManager::publish().
|
|
||||||
"""
|
|
||||||
resp = client.post(
|
|
||||||
f"/api/game/step/{game_assoc}",
|
|
||||||
json={
|
|
||||||
"coords": coords,
|
|
||||||
"player": player,
|
|
||||||
"bomb": bomb,
|
|
||||||
"resign": resign,
|
|
||||||
"stepElapsed": step_elapsed,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
def leave(game_assoc: str) -> None:
|
|
||||||
"""POST /api/game/leave/{gameAssoc} — fire-and-forget on window close."""
|
|
||||||
try:
|
|
||||||
client.post(f"/api/game/leave/{game_assoc}", json={})
|
|
||||||
except Exception:
|
|
||||||
pass # best-effort
|
|
||||||
|
|
||||||
|
|
||||||
def heartbeat(game_assoc: str, color: str) -> None:
|
|
||||||
"""POST /api/game/heartbeat/{gameAssoc} — keep-alive ping."""
|
|
||||||
try:
|
|
||||||
client.post(f"/api/game/heartbeat/{game_assoc}", json={"color": color})
|
|
||||||
except Exception:
|
|
||||||
pass # best-effort
|
|
||||||
|
|
||||||
|
|
||||||
def waiting() -> list[dict]:
|
|
||||||
"""GET /api/game/waiting — list of waiting players in the lobby."""
|
|
||||||
resp = client.get("/api/game/waiting")
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
def challenge(target_game_assoc: str, challenger_game_assoc: str) -> None:
|
|
||||||
"""POST /api/game/challenge/{targetGameAssoc}"""
|
|
||||||
client.post(
|
|
||||||
f"/api/game/challenge/{target_game_assoc}",
|
|
||||||
json={"challengerGameAssoc": challenger_game_assoc},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def challenge_respond(
|
|
||||||
challenger_game_assoc: str,
|
|
||||||
accepted: bool,
|
|
||||||
target_game_assoc: str,
|
|
||||||
) -> None:
|
|
||||||
"""POST /api/game/challenge/respond/{challengerGameAssoc}"""
|
|
||||||
client.post(
|
|
||||||
f"/api/game/challenge/respond/{challenger_game_assoc}",
|
|
||||||
json={
|
|
||||||
"accepted": accepted,
|
|
||||||
"targetGameAssoc": target_game_assoc,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -1,163 +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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from gi.repository import GLib
|
|
||||||
|
|
||||||
from mineseeker import config
|
|
||||||
from mineseeker.api import client
|
|
||||||
from mineseeker.constants import SSE_RECONNECT_INITIAL, SSE_RECONNECT_MAX
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class SseListener:
|
|
||||||
"""
|
|
||||||
Opens a Mercure SSE connection in a daemon thread and dispatches
|
|
||||||
parsed JSON messages back to the GTK main thread via GLib.idle_add().
|
|
||||||
|
|
||||||
Message routing mirrors useServerCommunication.jsx handleMercureMessage():
|
|
||||||
|
|
||||||
payload.type == "challenge" → on_challenge(payload)
|
|
||||||
payload.type == "challenge-response" → on_challenge_response(payload)
|
|
||||||
payload.type == "heartbeat" → on_heartbeat(payload)
|
|
||||||
"data" key present → on_topic(payload)
|
|
||||||
"msg" key present → on_unsubscribe(payload)
|
|
||||||
(none of the above) → on_subscribe(payload)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
game_assoc: str,
|
|
||||||
mercure_jwt: str,
|
|
||||||
*,
|
|
||||||
on_subscribe: Callable[[dict], Any] | None = None,
|
|
||||||
on_unsubscribe: Callable[[dict], Any] | None = None,
|
|
||||||
on_topic: Callable[[dict], Any] | None = None,
|
|
||||||
on_challenge: Callable[[dict], Any] | None = None,
|
|
||||||
on_challenge_response: Callable[[dict], Any] | None = None,
|
|
||||||
on_heartbeat: Callable[[dict], Any] | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._game_assoc = game_assoc
|
|
||||||
self._mercure_jwt = mercure_jwt
|
|
||||||
self._handlers = {
|
|
||||||
"subscribe": on_subscribe,
|
|
||||||
"unsubscribe": on_unsubscribe,
|
|
||||||
"topic": on_topic,
|
|
||||||
"challenge": on_challenge,
|
|
||||||
"challenge-response": on_challenge_response,
|
|
||||||
"heartbeat": on_heartbeat,
|
|
||||||
}
|
|
||||||
self._stop_event = threading.Event()
|
|
||||||
self._thread: threading.Thread | None = None
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Public control
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def start(self) -> None:
|
|
||||||
"""Start the background SSE listener thread."""
|
|
||||||
self._stop_event.clear()
|
|
||||||
self._thread = threading.Thread(
|
|
||||||
target=self._run, daemon=True, name="sse-listener"
|
|
||||||
)
|
|
||||||
self._thread.start()
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
"""Signal the background thread to stop."""
|
|
||||||
self._stop_event.set()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Background thread
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _build_url(self) -> str:
|
|
||||||
topic = f"mineseeker/channel/{self._game_assoc}"
|
|
||||||
return f"{config.MERCURE_URL}?topic={topic}"
|
|
||||||
|
|
||||||
def _run(self) -> None:
|
|
||||||
backoff = SSE_RECONNECT_INITIAL
|
|
||||||
while not self._stop_event.is_set():
|
|
||||||
try:
|
|
||||||
self._stream()
|
|
||||||
backoff = SSE_RECONNECT_INITIAL # reset on clean disconnect
|
|
||||||
except Exception as exc:
|
|
||||||
if self._stop_event.is_set():
|
|
||||||
break
|
|
||||||
log.warning("SSE connection lost (%s), reconnecting in %.1fs", exc, backoff)
|
|
||||||
time.sleep(backoff)
|
|
||||||
backoff = min(backoff * 2, SSE_RECONNECT_MAX)
|
|
||||||
|
|
||||||
def _stream(self) -> None:
|
|
||||||
"""Open the SSE stream and process events until stopped or error."""
|
|
||||||
url = self._build_url()
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Bearer {self._mercure_jwt}",
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
}
|
|
||||||
# Use the requests session from client.py so cookies are included
|
|
||||||
resp = client.get_session().get(
|
|
||||||
url, headers=headers, stream=True, timeout=(10, None)
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
|
|
||||||
# Parse the raw SSE stream manually (sseclient-py would also work
|
|
||||||
# but avoids an extra dependency on GLib-aware loops)
|
|
||||||
data_lines: list[str] = []
|
|
||||||
for raw_line in resp.iter_lines(decode_unicode=True):
|
|
||||||
if self._stop_event.is_set():
|
|
||||||
break
|
|
||||||
if raw_line.startswith("data:"):
|
|
||||||
data_lines.append(raw_line[5:].lstrip(" "))
|
|
||||||
elif raw_line == "" and data_lines:
|
|
||||||
# Empty line signals end of event — dispatch it
|
|
||||||
payload_str = "\n".join(data_lines)
|
|
||||||
data_lines = []
|
|
||||||
try:
|
|
||||||
payload = json.loads(payload_str)
|
|
||||||
GLib.idle_add(self._dispatch, payload)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
log.debug("Non-JSON SSE data ignored: %s", payload_str)
|
|
||||||
|
|
||||||
def _dispatch(self, payload: dict) -> bool:
|
|
||||||
"""Called on the GTK main thread via GLib.idle_add."""
|
|
||||||
msg_type = payload.get("type")
|
|
||||||
|
|
||||||
if msg_type == "challenge":
|
|
||||||
self._call("challenge", payload)
|
|
||||||
elif msg_type == "challenge-response":
|
|
||||||
self._call("challenge-response", payload)
|
|
||||||
elif msg_type == "heartbeat":
|
|
||||||
self._call("heartbeat", payload)
|
|
||||||
elif "data" in payload:
|
|
||||||
self._call("topic", payload)
|
|
||||||
elif "msg" in payload:
|
|
||||||
self._call("unsubscribe", payload)
|
|
||||||
else:
|
|
||||||
self._call("subscribe", payload)
|
|
||||||
|
|
||||||
return GLib.SOURCE_REMOVE # run once only
|
|
||||||
|
|
||||||
def _call(self, key: str, payload: dict) -> None:
|
|
||||||
handler = self._handlers.get(key)
|
|
||||||
if handler is not None:
|
|
||||||
try:
|
|
||||||
handler(payload)
|
|
||||||
except Exception:
|
|
||||||
log.exception("Error in SSE handler '%s'", key)
|
|
||||||
@@ -1,108 +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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import io
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import gi
|
|
||||||
gi.require_version("GdkPixbuf", "2.0")
|
|
||||||
gi.require_version("Gst", "1.0")
|
|
||||||
from gi.repository import GdkPixbuf, Gst
|
|
||||||
|
|
||||||
from mineseeker import config
|
|
||||||
from mineseeker.api import client
|
|
||||||
from mineseeker.constants import IMAGE_NAMES, SOUND_NAMES
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Image cache { filename: GdkPixbuf.Pixbuf }
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_images: dict[str, GdkPixbuf.Pixbuf] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def load_images(cell_size: int = 40) -> None:
|
|
||||||
"""
|
|
||||||
Fetch all game images from the server and cache them as GdkPixbuf.Pixbuf.
|
|
||||||
Call once at startup (blocking; run in a thread if you want a splash screen).
|
|
||||||
"""
|
|
||||||
for name in IMAGE_NAMES:
|
|
||||||
url = f"{config.BASE_URL}/images/{name}"
|
|
||||||
try:
|
|
||||||
resp = client.get_session().get(url, timeout=10)
|
|
||||||
resp.raise_for_status()
|
|
||||||
loader = GdkPixbuf.PixbufLoader()
|
|
||||||
loader.write(resp.content)
|
|
||||||
loader.close()
|
|
||||||
pixbuf = loader.get_pixbuf()
|
|
||||||
# Scale to the cell size used by the grid widget
|
|
||||||
pixbuf = pixbuf.scale_simple(cell_size, cell_size, GdkPixbuf.InterpType.BILINEAR)
|
|
||||||
_images[name] = pixbuf
|
|
||||||
except Exception as exc:
|
|
||||||
log.warning("Could not load image %s: %s", name, exc)
|
|
||||||
|
|
||||||
|
|
||||||
def get_image(name: str) -> GdkPixbuf.Pixbuf | None:
|
|
||||||
"""Return a cached Pixbuf by filename, or None if not loaded."""
|
|
||||||
return _images.get(name)
|
|
||||||
|
|
||||||
|
|
||||||
def get_image_or_fallback(name: str, fallback: str) -> GdkPixbuf.Pixbuf | None:
|
|
||||||
return _images.get(name) or _images.get(fallback)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Sound — via GStreamer playbin
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_sounds: dict[str, str] = {} # { key: URI }
|
|
||||||
|
|
||||||
|
|
||||||
def load_sounds() -> None:
|
|
||||||
"""
|
|
||||||
Build the URI map for the six game sound effects.
|
|
||||||
GStreamer will stream them on-demand from the server.
|
|
||||||
"""
|
|
||||||
Gst.init(None)
|
|
||||||
for filename in SOUND_NAMES:
|
|
||||||
key = filename.split(".")[0] # "click", "bomb", etc.
|
|
||||||
_sounds[key] = f"{config.BASE_URL}/sound/{filename}"
|
|
||||||
|
|
||||||
|
|
||||||
def play_sound(key: str) -> None:
|
|
||||||
"""
|
|
||||||
Play a sound by key ("click", "mine", "warning", "bomb", "won", "starting").
|
|
||||||
Each call spawns a fresh GStreamer playbin — fire-and-forget.
|
|
||||||
"""
|
|
||||||
uri = _sounds.get(key)
|
|
||||||
if not uri:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
player = Gst.ElementFactory.make("playbin", None)
|
|
||||||
if player is None:
|
|
||||||
return
|
|
||||||
player.set_property("uri", uri)
|
|
||||||
player.set_state(Gst.State.PLAYING)
|
|
||||||
|
|
||||||
# Connect to bus to clean up after playback
|
|
||||||
bus = player.get_bus()
|
|
||||||
bus.add_signal_watch()
|
|
||||||
|
|
||||||
def _on_message(bus, msg, player=player):
|
|
||||||
if msg.type in (Gst.MessageType.EOS, Gst.MessageType.ERROR):
|
|
||||||
player.set_state(Gst.State.NULL)
|
|
||||||
bus.remove_signal_watch()
|
|
||||||
return True
|
|
||||||
|
|
||||||
bus.connect("message", _on_message)
|
|
||||||
except Exception as exc:
|
|
||||||
log.debug("Sound play failed (%s): %s", key, exc)
|
|
||||||
@@ -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 os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
# Load .env from the gtk-client/ directory (parent of this package)
|
|
||||||
_env_path = Path(__file__).parent.parent / ".env"
|
|
||||||
load_dotenv(dotenv_path=_env_path)
|
|
||||||
|
|
||||||
BASE_URL: str = os.environ.get("MINESEEKER_BASE_URL", "").rstrip("/")
|
|
||||||
MERCURE_URL: str = os.environ.get("MINESEEKER_MERCURE_URL", "").rstrip("/")
|
|
||||||
|
|
||||||
if not BASE_URL:
|
|
||||||
raise EnvironmentError(
|
|
||||||
"MINESEEKER_BASE_URL is not set. "
|
|
||||||
"Copy gtk-client/.env.example to gtk-client/.env and fill in the values."
|
|
||||||
)
|
|
||||||
|
|
||||||
if not MERCURE_URL:
|
|
||||||
# Fall back to <BASE_URL>/.well-known/mercure if not explicitly set
|
|
||||||
MERCURE_URL = f"{BASE_URL}/.well-known/mercure"
|
|
||||||
@@ -1,90 +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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Grid dimensions
|
|
||||||
GRID_ROWS: int = 16
|
|
||||||
GRID_COLS: int = 16
|
|
||||||
GRID_SIZE: int = GRID_ROWS * GRID_COLS # 256
|
|
||||||
|
|
||||||
# Game rules
|
|
||||||
TOTAL_MINES: int = 51
|
|
||||||
WIN_THRESHOLD: int = 26 # first player to reach this wins
|
|
||||||
|
|
||||||
# Cell pixel size in the grid widget (each cell rendered as a square)
|
|
||||||
CELL_SIZE: int = 40
|
|
||||||
|
|
||||||
# Player colours (match backend "red" / "blue" strings)
|
|
||||||
PLAYER_RED: str = "red"
|
|
||||||
PLAYER_BLUE: str = "blue"
|
|
||||||
|
|
||||||
# Bomb reveal diamond half-width (matches PHP getBombRadius / JS bombRadius)
|
|
||||||
BOMB_RADIUS: int = 2
|
|
||||||
|
|
||||||
# Heartbeat interval in milliseconds (mirrors JS 1500 ms)
|
|
||||||
HEARTBEAT_INTERVAL_MS: int = 1500
|
|
||||||
|
|
||||||
# SSE reconnect back-off (seconds)
|
|
||||||
SSE_RECONNECT_INITIAL: float = 1.0
|
|
||||||
SSE_RECONNECT_MAX: float = 30.0
|
|
||||||
|
|
||||||
# Bonus stat display labels (mirrors JS BONUS_LABELS)
|
|
||||||
BONUS_LABELS: dict[str, str] = {
|
|
||||||
"blindHits": "Blind Hits",
|
|
||||||
"chainBest": "Best Chain",
|
|
||||||
"chainCurrent": "Current Chain",
|
|
||||||
"lastMineHits": "Endgame Mines",
|
|
||||||
"edgeMines": "Edge Mines",
|
|
||||||
"biggestReveal": "Biggest Reveal",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Image URL path fragments served from BASE_URL/images/
|
|
||||||
IMAGE_NAMES: list[str] = [
|
|
||||||
"bg-target-outbg.png",
|
|
||||||
"bg-bomb-outbg.png",
|
|
||||||
"bg-bomb-disabled-outbg.png",
|
|
||||||
"bg-bomb-exploded-outbg.png",
|
|
||||||
"bg-bomb-empty-outbg.png",
|
|
||||||
"bg-left-mine-outbg.png",
|
|
||||||
"bg-cursor-red-outbg.png",
|
|
||||||
"bg-cursor-blue-outbg.png",
|
|
||||||
"bg-figure-red-outbg.png",
|
|
||||||
"bg-figure-blue-outbg.png",
|
|
||||||
"bg-flag-red-outbg.png",
|
|
||||||
"bg-flag-blue-outbg.png",
|
|
||||||
"bg-last-red-outbg.png",
|
|
||||||
"bg-last-blue-outbg.png",
|
|
||||||
"bg-wave-1-outbg.png",
|
|
||||||
"bg-wave-2-outbg.png",
|
|
||||||
"bg-corner-outbg.png",
|
|
||||||
"bg-bomb-top-left-outbg.png",
|
|
||||||
"bg-bomb-top-center-outbg.png",
|
|
||||||
"bg-bomb-top-right-outbg.png",
|
|
||||||
"bg-bomb-middle-left-outbg.png",
|
|
||||||
"bg-bomb-middle-center-outbg.png",
|
|
||||||
"bg-bomb-middle-right-outbg.png",
|
|
||||||
"bg-bomb-bottom-left-outbg.png",
|
|
||||||
"bg-bomb-bottom-center-outbg.png",
|
|
||||||
"bg-bomb-bottom-right-outbg.png",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Sound file names served from BASE_URL/sound/
|
|
||||||
SOUND_NAMES: list[str] = [
|
|
||||||
"click.mp3",
|
|
||||||
"bomb.mp3",
|
|
||||||
"mine.mp3",
|
|
||||||
"warning.mp3",
|
|
||||||
"won.mp3",
|
|
||||||
"starting.mp3",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Bomb position image name helper
|
|
||||||
# horizontal: "top" | "middle" | "bottom"
|
|
||||||
# vertical: "left" | "center" | "right"
|
|
||||||
def bomb_pos_image(horizontal: str, vertical: str) -> str:
|
|
||||||
return f"bg-bomb-{horizontal}-{vertical}-outbg.png"
|
|
||||||
@@ -1,267 +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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import random
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from mineseeker.constants import GRID_ROWS, GRID_COLS, WIN_THRESHOLD
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Cell representation
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Cell:
|
|
||||||
"""Mirrors a single entry in the JS cells array."""
|
|
||||||
row: int
|
|
||||||
col: int
|
|
||||||
# "hidden" | "safe" | "mine"
|
|
||||||
state: str = "hidden"
|
|
||||||
# Numeric adjacent-mine count (0-8) or "m" for mine, None when unknown
|
|
||||||
value: Any = None
|
|
||||||
# "red", "blue", or None — which player claimed this mine
|
|
||||||
owner: str | None = None
|
|
||||||
# Whether this was the last cell clicked (for highlight)
|
|
||||||
is_last: bool = False
|
|
||||||
# Wave background variant (1 or 2) for unrevealed cells
|
|
||||||
wave: int = 1
|
|
||||||
# Bomb position overlay strings ("top"/"middle"/"bottom", "left"/"center"/"right") or None
|
|
||||||
bomb_h: str | None = None
|
|
||||||
bomb_v: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _init_cells() -> list[list[Cell]]:
|
|
||||||
"""Create the initial 16×16 grid of hidden cells (random wave image)."""
|
|
||||||
return [
|
|
||||||
[Cell(row=r, col=c, wave=random.choice([1, 1, 2]))
|
|
||||||
for c in range(GRID_COLS)]
|
|
||||||
for r in range(GRID_ROWS)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Bonus stats
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class BonusStats:
|
|
||||||
blind_hits: int = 0
|
|
||||||
chain_best: int = 0
|
|
||||||
chain_current: int = 0
|
|
||||||
last_mine_hits: int = 0
|
|
||||||
edge_mines: int = 0
|
|
||||||
biggest_reveal: int = 0
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, d: dict) -> "BonusStats":
|
|
||||||
return cls(
|
|
||||||
blind_hits=d.get("blindHits", 0),
|
|
||||||
chain_best=d.get("chainBest", 0),
|
|
||||||
chain_current=d.get("chainCurrent", 0),
|
|
||||||
last_mine_hits=d.get("lastMineHits", 0),
|
|
||||||
edge_mines=d.get("edgeMines", 0),
|
|
||||||
biggest_reveal=d.get("biggestReveal", 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Player snapshot
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PlayerState:
|
|
||||||
name: str = ""
|
|
||||||
anon_name: str = ""
|
|
||||||
avatar_url: str | None = None
|
|
||||||
mines: int = 0
|
|
||||||
bonus_points: float = 0.0
|
|
||||||
bonus_stats: BonusStats = field(default_factory=BonusStats)
|
|
||||||
have_bomb: bool = True
|
|
||||||
bomb_used: bool = False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def display_name(self) -> str:
|
|
||||||
return self.name or self.anon_name or "Guest"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def bomb_enabled(self) -> bool:
|
|
||||||
"""Mirrors JS: bomb only enabled when player is NOT ahead."""
|
|
||||||
return self.have_bomb and not self.bomb_used
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Game state
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class GameState:
|
|
||||||
cells: list[list[Cell]] = field(default_factory=_init_cells)
|
|
||||||
|
|
||||||
red: PlayerState = field(default_factory=PlayerState)
|
|
||||||
blue: PlayerState = field(default_factory=PlayerState)
|
|
||||||
|
|
||||||
# Whose turn it is ("red" or "blue") — blue always starts
|
|
||||||
turn: str = "blue"
|
|
||||||
|
|
||||||
# Has the game ended?
|
|
||||||
finished: bool = False
|
|
||||||
|
|
||||||
# Winner ("red", "blue", "draw", or None)
|
|
||||||
winner: str | None = None
|
|
||||||
|
|
||||||
# Resignation ("red" or "blue" resigned, or None)
|
|
||||||
resigned: str | None = None
|
|
||||||
|
|
||||||
# Shareable UUID assigned by the server after the first step
|
|
||||||
uuid: str | None = None
|
|
||||||
|
|
||||||
# The last step coordinates per player { "red": (r,c) | None, ... }
|
|
||||||
last_step: dict[str, tuple[int, int] | None] = field(
|
|
||||||
default_factory=lambda: {"red": None, "blue": None}
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Apply a step result from the server
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def apply_step(self, data: dict) -> None:
|
|
||||||
"""
|
|
||||||
Update state from a step payload (TopicManager::publish() result).
|
|
||||||
Mirrors JS applyStep() in useServerCommunication.jsx.
|
|
||||||
"""
|
|
||||||
if data.get("resign"):
|
|
||||||
self.resigned = data["resign"]
|
|
||||||
self.finished = True
|
|
||||||
self.winner = "blue" if data["resign"] == "red" else "red"
|
|
||||||
if data.get("uuid"):
|
|
||||||
self.uuid = data["uuid"]
|
|
||||||
return
|
|
||||||
|
|
||||||
player: str = data.get("player", "")
|
|
||||||
coords = data.get("coords")
|
|
||||||
if coords:
|
|
||||||
self.last_step[player] = (coords[0], coords[1])
|
|
||||||
# Clear previous last-step highlights for this player
|
|
||||||
for row in self.cells:
|
|
||||||
for cell in row:
|
|
||||||
if cell.is_last and cell.owner == player:
|
|
||||||
cell.is_last = False
|
|
||||||
self.cells[coords[0]][coords[1]].is_last = True
|
|
||||||
|
|
||||||
# Reveal cells
|
|
||||||
for rc in data.get("revealedCells", []):
|
|
||||||
r, c, v = rc["row"], rc["col"], rc["value"]
|
|
||||||
cell = self.cells[r][c]
|
|
||||||
if v == "m":
|
|
||||||
cell.state = "mine"
|
|
||||||
cell.value = "m"
|
|
||||||
cell.owner = player
|
|
||||||
else:
|
|
||||||
cell.state = "safe"
|
|
||||||
cell.value = v
|
|
||||||
|
|
||||||
# Reveal leftover mines at game end
|
|
||||||
for rc in data.get("leftMines", []):
|
|
||||||
r, c = rc["row"], rc["col"]
|
|
||||||
cell = self.cells[r][c]
|
|
||||||
cell.state = "mine"
|
|
||||||
cell.value = "m"
|
|
||||||
|
|
||||||
# Scores
|
|
||||||
self.red.mines = data.get("redPoints", self.red.mines)
|
|
||||||
self.blue.mines = data.get("bluePoints", self.blue.mines)
|
|
||||||
self.red.bonus_points = data.get("redBonusPoints", self.red.bonus_points)
|
|
||||||
self.blue.bonus_points = data.get("blueBonusPoints", self.blue.bonus_points)
|
|
||||||
|
|
||||||
if "redBonusStats" in data:
|
|
||||||
self.red.bonus_stats = BonusStats.from_dict(data["redBonusStats"])
|
|
||||||
if "blueBonusStats" in data:
|
|
||||||
self.blue.bonus_stats = BonusStats.from_dict(data["blueBonusStats"])
|
|
||||||
|
|
||||||
if data.get("bomb"):
|
|
||||||
if player == "red":
|
|
||||||
self.red.bomb_used = True
|
|
||||||
else:
|
|
||||||
self.blue.bomb_used = True
|
|
||||||
|
|
||||||
if data.get("uuid") and not self.finished:
|
|
||||||
self.uuid = data["uuid"]
|
|
||||||
|
|
||||||
# Win check
|
|
||||||
if self.red.mines >= WIN_THRESHOLD:
|
|
||||||
self.finished = True
|
|
||||||
self.winner = "red"
|
|
||||||
elif self.blue.mines >= WIN_THRESHOLD:
|
|
||||||
self.finished = True
|
|
||||||
self.winner = "blue"
|
|
||||||
|
|
||||||
# Advance turn (switches after every move)
|
|
||||||
if not self.finished:
|
|
||||||
self.turn = "blue" if player == "red" else "red"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Restore from server connect information
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def apply_connect(self, data: dict) -> None:
|
|
||||||
"""
|
|
||||||
Restore an existing game from the /api/game/connect payload.
|
|
||||||
Mirrors JS wInit() in useServerCommunication.jsx.
|
|
||||||
"""
|
|
||||||
if not data.get("users"):
|
|
||||||
return # fresh game, nothing to restore
|
|
||||||
|
|
||||||
users = data["users"]
|
|
||||||
self.red.name = users.get("red", "")
|
|
||||||
self.red.anon_name = users.get("redAnon", "")
|
|
||||||
self.blue.name = users.get("blue", "")
|
|
||||||
self.blue.anon_name = users.get("blueAnon", "")
|
|
||||||
|
|
||||||
self.red.mines = data.get("redPoints", 0)
|
|
||||||
self.blue.mines = data.get("bluePoints", 0)
|
|
||||||
self.red.bonus_points = data.get("redBonusPoints", 0.0)
|
|
||||||
self.blue.bonus_points = data.get("blueBonusPoints", 0.0)
|
|
||||||
|
|
||||||
if data.get("redBonusStats"):
|
|
||||||
self.red.bonus_stats = BonusStats.from_dict(data["redBonusStats"])
|
|
||||||
if data.get("blueBonusStats"):
|
|
||||||
self.blue.bonus_stats = BonusStats.from_dict(data["blueBonusStats"])
|
|
||||||
|
|
||||||
# Restore revealed cells (enriched with player colour)
|
|
||||||
for rc in data.get("revealedCells") or []:
|
|
||||||
r, c, v = rc["row"], rc["col"], rc["value"]
|
|
||||||
p = rc.get("player")
|
|
||||||
cell = self.cells[r][c]
|
|
||||||
if v == "m":
|
|
||||||
cell.state = "mine"
|
|
||||||
cell.value = "m"
|
|
||||||
cell.owner = p
|
|
||||||
else:
|
|
||||||
cell.state = "safe"
|
|
||||||
cell.value = v
|
|
||||||
|
|
||||||
# Restore last-step highlights
|
|
||||||
last = data.get("lastStep", {})
|
|
||||||
for color in ("red", "blue"):
|
|
||||||
ls = last.get(color)
|
|
||||||
if ls:
|
|
||||||
r, c = ls["row"], ls["col"]
|
|
||||||
self.last_step[color] = (r, c)
|
|
||||||
self.cells[r][c].is_last = True
|
|
||||||
|
|
||||||
# Determine whose turn it is from mostRecentStep
|
|
||||||
mrs = data.get("mostRecentStep")
|
|
||||||
if mrs:
|
|
||||||
self.turn = "blue" if mrs["player"] == "red" else "red"
|
|
||||||
|
|
||||||
self.finished = bool(data.get("gameFinished", False))
|
|
||||||
@@ -1,45 +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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Session:
|
|
||||||
"""Holds the current player's identity and game association."""
|
|
||||||
|
|
||||||
# Username (real user) or "anon_<session_id>" for guests
|
|
||||||
username: str = ""
|
|
||||||
|
|
||||||
# Whether this is an authenticated (non-guest) user
|
|
||||||
is_authenticated: bool = False
|
|
||||||
|
|
||||||
# Current game association UUID
|
|
||||||
game_assoc: str = ""
|
|
||||||
|
|
||||||
# "red" or "blue" — assigned when both players are subscribed
|
|
||||||
color: str = ""
|
|
||||||
|
|
||||||
# Mercure subscriber JWT for the current game
|
|
||||||
mercure_jwt: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
# Module-level singleton; reset on logout
|
|
||||||
_session: Session = Session()
|
|
||||||
|
|
||||||
|
|
||||||
def get() -> Session:
|
|
||||||
return _session
|
|
||||||
|
|
||||||
|
|
||||||
def reset() -> None:
|
|
||||||
global _session
|
|
||||||
_session = Session()
|
|
||||||
@@ -1,109 +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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import gi
|
|
||||||
gi.require_version("Gtk", "4.0")
|
|
||||||
gi.require_version("Adw", "1")
|
|
||||||
from gi.repository import Gtk, Adw
|
|
||||||
|
|
||||||
from mineseeker.ui.login_page import LoginPage
|
|
||||||
from mineseeker.ui.totp_page import TotpPage
|
|
||||||
from mineseeker.ui.lobby_page import LobbyPage
|
|
||||||
from mineseeker.ui.game_page import GamePage
|
|
||||||
|
|
||||||
|
|
||||||
class AppWindow(Adw.ApplicationWindow):
|
|
||||||
"""
|
|
||||||
Main application window containing a Gtk.Stack that navigates between:
|
|
||||||
- "login" : LoginPage
|
|
||||||
- "totp" : TotpPage
|
|
||||||
- "lobby" : LobbyPage
|
|
||||||
- "game" : GamePage (replaced on each new game)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, application: Adw.Application) -> None:
|
|
||||||
super().__init__(application=application)
|
|
||||||
self.set_title("MineSeeker")
|
|
||||||
self.set_default_size(980, 680)
|
|
||||||
|
|
||||||
# Stack — child names serve as page IDs
|
|
||||||
self._stack = Gtk.Stack()
|
|
||||||
self._stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
|
|
||||||
self._stack.set_transition_duration(200)
|
|
||||||
|
|
||||||
# Build pages
|
|
||||||
self._login_page = LoginPage(on_success=self._on_login_success, on_guest=self._on_guest)
|
|
||||||
self._totp_page = TotpPage(on_success=self._on_totp_success, on_back=self._show_login)
|
|
||||||
self._lobby_page = LobbyPage(on_game_start=self._on_game_start)
|
|
||||||
self._game_page: GamePage | None = None
|
|
||||||
|
|
||||||
self._stack.add_named(self._login_page, "login")
|
|
||||||
self._stack.add_named(self._totp_page, "totp")
|
|
||||||
self._stack.add_named(self._lobby_page, "lobby")
|
|
||||||
|
|
||||||
# Wrap in a NavigationView-style container using Adw.ToolbarView
|
|
||||||
toolbar_view = Adw.ToolbarView()
|
|
||||||
header = Adw.HeaderBar()
|
|
||||||
toolbar_view.add_top_bar(header)
|
|
||||||
toolbar_view.set_content(self._stack)
|
|
||||||
|
|
||||||
self.set_content(toolbar_view)
|
|
||||||
|
|
||||||
# Start on the login page
|
|
||||||
self._stack.set_visible_child_name("login")
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Navigation helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _show_login(self) -> None:
|
|
||||||
self._stack.set_visible_child_name("login")
|
|
||||||
|
|
||||||
def _show_totp(self) -> None:
|
|
||||||
self._stack.set_visible_child_name("totp")
|
|
||||||
|
|
||||||
def _show_lobby(self) -> None:
|
|
||||||
self._lobby_page.refresh()
|
|
||||||
self._stack.set_visible_child_name("lobby")
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Callbacks from child pages
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _on_login_success(self, needs_totp: bool) -> None:
|
|
||||||
if needs_totp:
|
|
||||||
self._show_totp()
|
|
||||||
else:
|
|
||||||
self._show_lobby()
|
|
||||||
|
|
||||||
def _on_guest(self) -> None:
|
|
||||||
self._show_lobby()
|
|
||||||
|
|
||||||
def _on_totp_success(self) -> None:
|
|
||||||
self._show_lobby()
|
|
||||||
|
|
||||||
def _on_game_start(self, game_assoc: str, mercure_jwt: str, color: str) -> None:
|
|
||||||
"""Replace or create the GamePage and switch to it."""
|
|
||||||
# Remove previous game page if present
|
|
||||||
if self._game_page is not None:
|
|
||||||
self._stack.remove(self._game_page)
|
|
||||||
|
|
||||||
self._game_page = GamePage(
|
|
||||||
game_assoc=game_assoc,
|
|
||||||
mercure_jwt=mercure_jwt,
|
|
||||||
color=color,
|
|
||||||
on_leave=self._on_game_leave,
|
|
||||||
)
|
|
||||||
self._stack.add_named(self._game_page, "game")
|
|
||||||
self._stack.set_visible_child_name("game")
|
|
||||||
|
|
||||||
def _on_game_leave(self) -> None:
|
|
||||||
self._show_lobby()
|
|
||||||
@@ -1,96 +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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import gi
|
|
||||||
gi.require_version("Gtk", "4.0")
|
|
||||||
gi.require_version("Adw", "1")
|
|
||||||
from gi.repository import Gtk, Adw
|
|
||||||
|
|
||||||
from mineseeker.state.game_state import BonusStats
|
|
||||||
from mineseeker.constants import BONUS_LABELS
|
|
||||||
|
|
||||||
|
|
||||||
class BonusDialog(Adw.Dialog):
|
|
||||||
"""Modal dialog displaying bonus stats for both players."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
parent: Gtk.Widget,
|
|
||||||
red_name: str,
|
|
||||||
blue_name: str,
|
|
||||||
red_points: float,
|
|
||||||
blue_points: float,
|
|
||||||
red_stats: BonusStats,
|
|
||||||
blue_stats: BonusStats,
|
|
||||||
) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self.set_title("Bonus Statistics")
|
|
||||||
self.set_content_width(480)
|
|
||||||
|
|
||||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
|
||||||
|
|
||||||
header = Adw.HeaderBar()
|
|
||||||
box.append(header)
|
|
||||||
|
|
||||||
content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
|
|
||||||
content.set_margin_top(16)
|
|
||||||
content.set_margin_bottom(16)
|
|
||||||
content.set_margin_start(16)
|
|
||||||
content.set_margin_end(16)
|
|
||||||
|
|
||||||
# Totals row
|
|
||||||
totals = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
|
|
||||||
totals.add_css_class("card")
|
|
||||||
|
|
||||||
red_total = Gtk.Label(label=f"{red_name}: {red_points:.1f} pts")
|
|
||||||
red_total.add_css_class("red-player")
|
|
||||||
red_total.set_hexpand(True)
|
|
||||||
red_total.set_xalign(0)
|
|
||||||
red_total.set_margin_start(12)
|
|
||||||
red_total.set_margin_top(8)
|
|
||||||
red_total.set_margin_bottom(8)
|
|
||||||
totals.append(red_total)
|
|
||||||
|
|
||||||
blue_total = Gtk.Label(label=f"{blue_name}: {blue_points:.1f} pts")
|
|
||||||
blue_total.add_css_class("blue-player")
|
|
||||||
blue_total.set_hexpand(True)
|
|
||||||
blue_total.set_xalign(1)
|
|
||||||
blue_total.set_margin_end(12)
|
|
||||||
totals.append(blue_total)
|
|
||||||
|
|
||||||
content.append(totals)
|
|
||||||
|
|
||||||
# Per-stat rows
|
|
||||||
group = Adw.PreferencesGroup(title="Breakdown")
|
|
||||||
stat_fields = [
|
|
||||||
("blind_hits", red_stats.blind_hits, blue_stats.blind_hits),
|
|
||||||
("chain_best", red_stats.chain_best, blue_stats.chain_best),
|
|
||||||
("last_mine_hits",red_stats.last_mine_hits,blue_stats.last_mine_hits),
|
|
||||||
("edge_mines", red_stats.edge_mines, blue_stats.edge_mines),
|
|
||||||
("biggest_reveal",red_stats.biggest_reveal,blue_stats.biggest_reveal),
|
|
||||||
]
|
|
||||||
key_map = {
|
|
||||||
"blind_hits": "blindHits",
|
|
||||||
"chain_best": "chainBest",
|
|
||||||
"last_mine_hits": "lastMineHits",
|
|
||||||
"edge_mines": "edgeMines",
|
|
||||||
"biggest_reveal": "biggestReveal",
|
|
||||||
}
|
|
||||||
for field_name, rv, bv in stat_fields:
|
|
||||||
label = BONUS_LABELS.get(key_map[field_name], field_name)
|
|
||||||
row = Adw.ActionRow(title=label)
|
|
||||||
row.set_subtitle(f"Red: {rv} Blue: {bv}")
|
|
||||||
group.add(row)
|
|
||||||
|
|
||||||
content.append(group)
|
|
||||||
box.append(content)
|
|
||||||
self.set_child(box)
|
|
||||||
self.present(parent)
|
|
||||||
@@ -1,477 +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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
import gi
|
|
||||||
gi.require_version("Gtk", "4.0")
|
|
||||||
gi.require_version("Adw", "1")
|
|
||||||
from gi.repository import Gtk, Adw, GLib
|
|
||||||
|
|
||||||
from mineseeker.api import game as game_api
|
|
||||||
from mineseeker.api.sse import SseListener
|
|
||||||
from mineseeker import assets
|
|
||||||
from mineseeker.constants import HEARTBEAT_INTERVAL_MS, WIN_THRESHOLD, PLAYER_RED, PLAYER_BLUE
|
|
||||||
from mineseeker.state.game_state import GameState
|
|
||||||
from mineseeker.state import session as session_mod
|
|
||||||
from mineseeker.ui.grid_widget import GridWidget
|
|
||||||
from mineseeker.ui.player_panel import PlayerPanel
|
|
||||||
from mineseeker.ui.bonus_dialog import BonusDialog
|
|
||||||
from mineseeker.ui.result_overlay import ResultOverlay
|
|
||||||
|
|
||||||
|
|
||||||
class GamePage(Gtk.Overlay):
|
|
||||||
"""
|
|
||||||
Full game screen.
|
|
||||||
|
|
||||||
Layout:
|
|
||||||
[RedPanel] [GridWidget] [BluePanel]
|
|
||||||
|
|
||||||
An Overlay places the ResultOverlay on top when the game ends.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
game_assoc: str,
|
|
||||||
mercure_jwt: str,
|
|
||||||
color: str,
|
|
||||||
on_leave: Callable[[], None],
|
|
||||||
) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self._game_assoc = game_assoc
|
|
||||||
self._mercure_jwt = mercure_jwt
|
|
||||||
self._color = color # "red" | "blue" | "" (determined by subscribe)
|
|
||||||
self._on_leave = on_leave
|
|
||||||
self._state = GameState()
|
|
||||||
self._bomb_mode = False
|
|
||||||
self._step_start: float = time.monotonic()
|
|
||||||
self._heartbeat_source: int | None = None
|
|
||||||
|
|
||||||
# --- Layout ---
|
|
||||||
main_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
|
|
||||||
main_box.set_hexpand(True)
|
|
||||||
main_box.set_vexpand(True)
|
|
||||||
|
|
||||||
# Red player panel (left)
|
|
||||||
self._red_panel = PlayerPanel(
|
|
||||||
color=PLAYER_RED,
|
|
||||||
is_local=(color == PLAYER_RED),
|
|
||||||
on_bomb_toggle=self._on_bomb_toggle,
|
|
||||||
on_resign=self._on_resign,
|
|
||||||
)
|
|
||||||
main_box.append(self._red_panel)
|
|
||||||
|
|
||||||
# Centre column: status bar + grid
|
|
||||||
centre = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
|
||||||
centre.set_hexpand(True)
|
|
||||||
centre.set_vexpand(True)
|
|
||||||
|
|
||||||
# Status / turn label
|
|
||||||
self._status_label = Gtk.Label(label="Connecting…")
|
|
||||||
self._status_label.add_css_class("dim-label")
|
|
||||||
self._status_label.set_margin_top(8)
|
|
||||||
self._status_label.set_margin_bottom(8)
|
|
||||||
centre.append(self._status_label)
|
|
||||||
|
|
||||||
# Grid in a scrolled window so it never clips on small screens
|
|
||||||
scrolled = Gtk.ScrolledWindow()
|
|
||||||
scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
|
||||||
scrolled.set_hexpand(True)
|
|
||||||
scrolled.set_vexpand(True)
|
|
||||||
|
|
||||||
self._grid = GridWidget(on_cell_click=self._on_cell_click)
|
|
||||||
scrolled.set_child(self._grid)
|
|
||||||
centre.append(scrolled)
|
|
||||||
|
|
||||||
# Bonus button row
|
|
||||||
bonus_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
|
||||||
bonus_box.set_halign(Gtk.Align.CENTER)
|
|
||||||
bonus_box.set_margin_top(6)
|
|
||||||
bonus_box.set_margin_bottom(6)
|
|
||||||
|
|
||||||
bonus_btn = Gtk.Button(label="Bonus Stats")
|
|
||||||
bonus_btn.add_css_class("flat")
|
|
||||||
bonus_btn.connect("clicked", self._show_bonus_dialog)
|
|
||||||
bonus_box.append(bonus_btn)
|
|
||||||
|
|
||||||
leave_btn = Gtk.Button(label="Leave")
|
|
||||||
leave_btn.add_css_class("flat")
|
|
||||||
leave_btn.connect("clicked", self._on_leave_clicked)
|
|
||||||
bonus_box.append(leave_btn)
|
|
||||||
|
|
||||||
centre.append(bonus_box)
|
|
||||||
main_box.append(centre)
|
|
||||||
|
|
||||||
# Blue player panel (right)
|
|
||||||
self._blue_panel = PlayerPanel(
|
|
||||||
color=PLAYER_BLUE,
|
|
||||||
is_local=(color == PLAYER_BLUE),
|
|
||||||
on_bomb_toggle=self._on_bomb_toggle,
|
|
||||||
on_resign=self._on_resign,
|
|
||||||
)
|
|
||||||
main_box.append(self._blue_panel)
|
|
||||||
|
|
||||||
# Result overlay
|
|
||||||
self._result_overlay = ResultOverlay(
|
|
||||||
on_play_again=self._on_play_again,
|
|
||||||
on_lobby=self._on_lobby,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.set_child(main_box)
|
|
||||||
self.add_overlay(self._result_overlay)
|
|
||||||
|
|
||||||
# Start async init
|
|
||||||
threading.Thread(target=self._init_game, daemon=True).start()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Game initialisation
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _init_game(self) -> None:
|
|
||||||
"""Connect, start SSE, join the channel, start/restore the game."""
|
|
||||||
try:
|
|
||||||
# 1. Fetch existing game state
|
|
||||||
connect_data = game_api.connect(self._game_assoc)
|
|
||||||
|
|
||||||
GLib.idle_add(self._apply_connect_data, connect_data)
|
|
||||||
|
|
||||||
# 2. Start the SSE listener
|
|
||||||
self._sse = SseListener(
|
|
||||||
game_assoc=self._game_assoc,
|
|
||||||
mercure_jwt=self._mercure_jwt,
|
|
||||||
on_subscribe=self._on_subscribe,
|
|
||||||
on_unsubscribe=self._on_unsubscribe,
|
|
||||||
on_topic=self._on_topic,
|
|
||||||
on_challenge=self._on_challenge,
|
|
||||||
on_challenge_response=self._on_challenge_response,
|
|
||||||
on_heartbeat=self._on_heartbeat,
|
|
||||||
)
|
|
||||||
self._sse.start()
|
|
||||||
|
|
||||||
# 3. Join (announces presence via Mercure)
|
|
||||||
game_api.join(self._game_assoc)
|
|
||||||
|
|
||||||
# 4. If no existing game, create the grid
|
|
||||||
if not connect_data.get("users"):
|
|
||||||
game_api.start(self._game_assoc)
|
|
||||||
|
|
||||||
# 5. Start heartbeat
|
|
||||||
GLib.idle_add(self._start_heartbeat)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
GLib.idle_add(self._set_status, f"Error: {e}")
|
|
||||||
|
|
||||||
def _apply_connect_data(self, data: dict) -> bool:
|
|
||||||
self._state.apply_connect(data)
|
|
||||||
self._refresh_panels()
|
|
||||||
self._grid.set_state(self._state)
|
|
||||||
return GLib.SOURCE_REMOVE
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# SSE handlers (called on GTK main thread via GLib.idle_add)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _on_subscribe(self, payload: dict) -> None:
|
|
||||||
"""Two players connected → start game."""
|
|
||||||
users = payload.get("users", {})
|
|
||||||
user_cnt = payload.get("userCnt", 0)
|
|
||||||
|
|
||||||
# Determine our colour if not yet assigned
|
|
||||||
if not self._color:
|
|
||||||
sess = session_mod.get()
|
|
||||||
my_name = sess.username
|
|
||||||
if my_name == users.get("blue") or my_name == users.get("blueAnon"):
|
|
||||||
self._color = PLAYER_BLUE
|
|
||||||
else:
|
|
||||||
self._color = PLAYER_RED
|
|
||||||
sess.color = self._color
|
|
||||||
|
|
||||||
# Update player names from subscribe payload
|
|
||||||
self._state.red.name = users.get("red", "")
|
|
||||||
self._state.red.anon_name = users.get("redAnon", "")
|
|
||||||
self._state.blue.name = users.get("blue", "")
|
|
||||||
self._state.blue.anon_name = users.get("blueAnon", "")
|
|
||||||
|
|
||||||
if user_cnt == 2:
|
|
||||||
self._set_status("Game started!")
|
|
||||||
assets.play_sound("starting")
|
|
||||||
else:
|
|
||||||
self._set_status("Waiting for opponent…")
|
|
||||||
|
|
||||||
self._refresh_panels()
|
|
||||||
|
|
||||||
def _on_unsubscribe(self, payload: dict) -> None:
|
|
||||||
self._set_status("Opponent left the game.")
|
|
||||||
|
|
||||||
def _on_topic(self, payload: dict) -> None:
|
|
||||||
"""A step was made — apply it and refresh."""
|
|
||||||
data = payload.get("data", {})
|
|
||||||
if not data:
|
|
||||||
return
|
|
||||||
|
|
||||||
player = data.get("player", "")
|
|
||||||
is_mine = data.get("revealedCells") and any(
|
|
||||||
rc.get("value") == "m" for rc in data["revealedCells"]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Play sounds
|
|
||||||
if data.get("resign"):
|
|
||||||
assets.play_sound("won")
|
|
||||||
elif is_mine:
|
|
||||||
my_state = self._state.red if player == PLAYER_RED else self._state.blue
|
|
||||||
if my_state.mines > 20:
|
|
||||||
assets.play_sound("warning")
|
|
||||||
else:
|
|
||||||
assets.play_sound("mine")
|
|
||||||
else:
|
|
||||||
assets.play_sound("click")
|
|
||||||
|
|
||||||
self._state.apply_step(data)
|
|
||||||
self._grid.refresh()
|
|
||||||
self._refresh_panels()
|
|
||||||
|
|
||||||
if self._state.finished:
|
|
||||||
self._show_result()
|
|
||||||
|
|
||||||
# uuid from server
|
|
||||||
if data.get("uuid"):
|
|
||||||
session_mod.get().game_assoc = data["uuid"]
|
|
||||||
|
|
||||||
def _on_challenge(self, payload: dict) -> None:
|
|
||||||
"""Incoming challenge — show accept/decline dialog."""
|
|
||||||
challenger_name = payload.get("challengerName", "Someone")
|
|
||||||
challenger_assoc = payload.get("challengerGameAssoc", "")
|
|
||||||
GLib.idle_add(self._show_challenge_dialog, challenger_name, challenger_assoc)
|
|
||||||
|
|
||||||
def _on_challenge_response(self, payload: dict) -> None:
|
|
||||||
if payload.get("accepted"):
|
|
||||||
# Switch to the new game assoc
|
|
||||||
new_assoc = payload.get("targetGameAssoc", "")
|
|
||||||
if new_assoc:
|
|
||||||
GLib.idle_add(self._redirect_to_game, new_assoc)
|
|
||||||
|
|
||||||
def _on_heartbeat(self, payload: dict) -> None:
|
|
||||||
# Heartbeat from opponent received — game is live
|
|
||||||
pass
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Cell click / resign
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _on_cell_click(self, row: int, col: int, bomb_mode: bool) -> None:
|
|
||||||
if self._state.finished:
|
|
||||||
return
|
|
||||||
if self._state.turn != self._color:
|
|
||||||
return # not our turn
|
|
||||||
|
|
||||||
elapsed = time.monotonic() - self._step_start
|
|
||||||
self._step_start = time.monotonic()
|
|
||||||
|
|
||||||
threading.Thread(
|
|
||||||
target=self._send_step,
|
|
||||||
args=(row, col, bomb_mode, elapsed),
|
|
||||||
daemon=True,
|
|
||||||
).start()
|
|
||||||
|
|
||||||
def _send_step(self, row: int, col: int, bomb: bool, elapsed: float) -> None:
|
|
||||||
try:
|
|
||||||
result = game_api.step(
|
|
||||||
game_assoc=self._game_assoc,
|
|
||||||
coords=[row, col],
|
|
||||||
player=self._color,
|
|
||||||
bomb=bomb,
|
|
||||||
resign=None,
|
|
||||||
step_elapsed=elapsed,
|
|
||||||
)
|
|
||||||
GLib.idle_add(self._apply_step_result, result)
|
|
||||||
except Exception as e:
|
|
||||||
GLib.idle_add(self._set_status, f"Step error: {e}")
|
|
||||||
|
|
||||||
def _apply_step_result(self, data: dict) -> bool:
|
|
||||||
self._state.apply_step(data)
|
|
||||||
self._grid.refresh()
|
|
||||||
self._refresh_panels()
|
|
||||||
if self._bomb_mode:
|
|
||||||
self._bomb_mode = False
|
|
||||||
self._grid.set_bomb_mode(False)
|
|
||||||
local_panel = self._red_panel if self._color == PLAYER_RED else self._blue_panel
|
|
||||||
local_panel.reset_bomb_toggle()
|
|
||||||
if self._state.finished:
|
|
||||||
self._show_result()
|
|
||||||
return GLib.SOURCE_REMOVE
|
|
||||||
|
|
||||||
def _on_resign(self) -> None:
|
|
||||||
threading.Thread(target=self._send_resign, daemon=True).start()
|
|
||||||
|
|
||||||
def _send_resign(self) -> None:
|
|
||||||
try:
|
|
||||||
result = game_api.step(
|
|
||||||
game_assoc=self._game_assoc,
|
|
||||||
coords=[0, 0],
|
|
||||||
player=self._color,
|
|
||||||
bomb=False,
|
|
||||||
resign=self._color,
|
|
||||||
step_elapsed=0,
|
|
||||||
)
|
|
||||||
GLib.idle_add(self._apply_step_result, result)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Bomb toggle
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _on_bomb_toggle(self, active: bool) -> None:
|
|
||||||
self._bomb_mode = active
|
|
||||||
self._grid.set_bomb_mode(active)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Heartbeat
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _start_heartbeat(self) -> bool:
|
|
||||||
interval_s = HEARTBEAT_INTERVAL_MS / 1000.0
|
|
||||||
self._heartbeat_source = GLib.timeout_add(
|
|
||||||
HEARTBEAT_INTERVAL_MS,
|
|
||||||
self._send_heartbeat,
|
|
||||||
)
|
|
||||||
return GLib.SOURCE_REMOVE
|
|
||||||
|
|
||||||
def _send_heartbeat(self) -> bool:
|
|
||||||
if self._color:
|
|
||||||
threading.Thread(
|
|
||||||
target=game_api.heartbeat,
|
|
||||||
args=(self._game_assoc, self._color),
|
|
||||||
daemon=True,
|
|
||||||
).start()
|
|
||||||
return GLib.SOURCE_CONTINUE # repeat
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Result / game over
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _show_result(self) -> None:
|
|
||||||
assets.play_sound("won")
|
|
||||||
self._result_overlay.show_result(
|
|
||||||
winner=self._state.winner,
|
|
||||||
resigned=self._state.resigned,
|
|
||||||
local_color=self._color,
|
|
||||||
red_mines=self._state.red.mines,
|
|
||||||
blue_mines=self._state.blue.mines,
|
|
||||||
red_name=self._state.red.display_name,
|
|
||||||
blue_name=self._state.blue.display_name,
|
|
||||||
)
|
|
||||||
self._stop_heartbeat()
|
|
||||||
|
|
||||||
def _stop_heartbeat(self) -> None:
|
|
||||||
if self._heartbeat_source is not None:
|
|
||||||
GLib.source_remove(self._heartbeat_source)
|
|
||||||
self._heartbeat_source = None
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Navigation callbacks
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _on_play_again(self) -> None:
|
|
||||||
self._leave_game()
|
|
||||||
self._on_leave()
|
|
||||||
|
|
||||||
def _on_lobby(self) -> None:
|
|
||||||
self._leave_game()
|
|
||||||
self._on_leave()
|
|
||||||
|
|
||||||
def _on_leave_clicked(self, *_) -> None:
|
|
||||||
self._leave_game()
|
|
||||||
self._on_leave()
|
|
||||||
|
|
||||||
def _leave_game(self) -> None:
|
|
||||||
self._stop_heartbeat()
|
|
||||||
if hasattr(self, "_sse"):
|
|
||||||
self._sse.stop()
|
|
||||||
threading.Thread(
|
|
||||||
target=game_api.leave, args=(self._game_assoc,), daemon=True
|
|
||||||
).start()
|
|
||||||
|
|
||||||
def _redirect_to_game(self, new_assoc: str) -> bool:
|
|
||||||
# Challenge accepted — leave current and open new game page
|
|
||||||
self._leave_game()
|
|
||||||
self._on_leave()
|
|
||||||
return GLib.SOURCE_REMOVE
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Challenge dialog
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _show_challenge_dialog(self, challenger_name: str, challenger_assoc: str) -> bool:
|
|
||||||
dialog = Adw.AlertDialog(
|
|
||||||
heading=f"Challenge from {challenger_name}",
|
|
||||||
body="Do you accept the challenge?",
|
|
||||||
)
|
|
||||||
dialog.add_response("decline", "Decline")
|
|
||||||
dialog.add_response("accept", "Accept")
|
|
||||||
dialog.set_response_appearance("accept", Adw.ResponseAppearance.SUGGESTED)
|
|
||||||
dialog.connect(
|
|
||||||
"response",
|
|
||||||
lambda d, resp: self._on_challenge_response_dialog(resp, challenger_assoc),
|
|
||||||
)
|
|
||||||
dialog.present(self)
|
|
||||||
return GLib.SOURCE_REMOVE
|
|
||||||
|
|
||||||
def _on_challenge_response_dialog(self, response: str, challenger_assoc: str) -> None:
|
|
||||||
accepted = response == "accept"
|
|
||||||
threading.Thread(
|
|
||||||
target=game_api.challenge_respond,
|
|
||||||
args=(challenger_assoc, accepted, self._game_assoc),
|
|
||||||
daemon=True,
|
|
||||||
).start()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Bonus dialog
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _show_bonus_dialog(self, *_) -> None:
|
|
||||||
BonusDialog(
|
|
||||||
parent=self,
|
|
||||||
red_name=self._state.red.display_name,
|
|
||||||
blue_name=self._state.blue.display_name,
|
|
||||||
red_points=self._state.red.bonus_points,
|
|
||||||
blue_points=self._state.blue.bonus_points,
|
|
||||||
red_stats=self._state.red.bonus_stats,
|
|
||||||
blue_stats=self._state.blue.bonus_stats,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _refresh_panels(self) -> None:
|
|
||||||
is_red_turn = self._state.turn == PLAYER_RED
|
|
||||||
self._red_panel.update(self._state.red, is_red_turn)
|
|
||||||
self._blue_panel.update(self._state.blue, not is_red_turn)
|
|
||||||
self._grid.set_state(self._state)
|
|
||||||
self._update_status_label()
|
|
||||||
|
|
||||||
def _update_status_label(self) -> None:
|
|
||||||
if self._state.finished:
|
|
||||||
self._status_label.set_label("Game over")
|
|
||||||
elif not self._color:
|
|
||||||
self._status_label.set_label("Connecting…")
|
|
||||||
elif self._state.turn == self._color:
|
|
||||||
self._status_label.set_label("Your turn")
|
|
||||||
else:
|
|
||||||
opponent = self._state.blue if self._color == PLAYER_RED else self._state.red
|
|
||||||
self._status_label.set_label(f"{opponent.display_name}'s turn")
|
|
||||||
|
|
||||||
def _set_status(self, message: str) -> bool:
|
|
||||||
self._status_label.set_label(message)
|
|
||||||
return GLib.SOURCE_REMOVE
|
|
||||||
@@ -1,239 +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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import gi
|
|
||||||
gi.require_version("Gtk", "4.0")
|
|
||||||
gi.require_version("Gdk", "4.0")
|
|
||||||
from gi.repository import Gtk, Gdk, GdkPixbuf
|
|
||||||
|
|
||||||
from mineseeker import assets
|
|
||||||
from mineseeker.constants import (
|
|
||||||
GRID_ROWS, GRID_COLS, CELL_SIZE, PLAYER_RED, PLAYER_BLUE, bomb_pos_image
|
|
||||||
)
|
|
||||||
from mineseeker.state.game_state import GameState, Cell
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Bomb diamond radius (mirrors JS bombRadius() / PHP getBombRadius())
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _bomb_cells(row: int, col: int) -> list[tuple[int, int]]:
|
|
||||||
"""Return all cells within the 5×5 diamond centred at (row, col)."""
|
|
||||||
result = []
|
|
||||||
for dr in range(-2, 3):
|
|
||||||
for dc in range(-2, 3):
|
|
||||||
if abs(dr) + abs(dc) <= 2:
|
|
||||||
r, c = row + dr, col + dc
|
|
||||||
if 0 <= r < GRID_ROWS and 0 <= c < GRID_COLS:
|
|
||||||
result.append((r, c))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _bomb_pos(dr: int, dc: int) -> tuple[str, str]:
|
|
||||||
"""Map (delta_row, delta_col) to (horizontal, vertical) overlay names."""
|
|
||||||
h = "top" if dr < 0 else ("bottom" if dr > 0 else "middle")
|
|
||||||
v = "left" if dc < 0 else ("right" if dc > 0 else "center")
|
|
||||||
return h, v
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# GridWidget
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class GridWidget(Gtk.DrawingArea):
|
|
||||||
"""
|
|
||||||
16×16 minesweeper grid rendered with Cairo + GdkPixbuf tile images.
|
|
||||||
|
|
||||||
Signals emitted (via callbacks, not GObject signals for simplicity):
|
|
||||||
on_cell_click(row, col, bomb_mode) — user clicked a cell
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, on_cell_click: Callable[[int, int, bool], None]) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self._on_cell_click = on_cell_click
|
|
||||||
self._state: GameState | None = None
|
|
||||||
self._bomb_mode: bool = False
|
|
||||||
self._hover: tuple[int, int] | None = None # (row, col) under cursor
|
|
||||||
|
|
||||||
width = CELL_SIZE * GRID_COLS
|
|
||||||
height = CELL_SIZE * GRID_ROWS
|
|
||||||
self.set_content_width(width)
|
|
||||||
self.set_content_height(height)
|
|
||||||
self.set_draw_func(self._draw)
|
|
||||||
|
|
||||||
# Click gesture
|
|
||||||
click = Gtk.GestureClick()
|
|
||||||
click.connect("pressed", self._on_pressed)
|
|
||||||
self.add_controller(click)
|
|
||||||
|
|
||||||
# Motion controller for bomb hover preview
|
|
||||||
motion = Gtk.EventControllerMotion()
|
|
||||||
motion.connect("motion", self._on_motion)
|
|
||||||
motion.connect("leave", self._on_leave)
|
|
||||||
self.add_controller(motion)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Public API
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def set_state(self, state: GameState) -> None:
|
|
||||||
self._state = state
|
|
||||||
self.queue_draw()
|
|
||||||
|
|
||||||
def set_bomb_mode(self, active: bool) -> None:
|
|
||||||
self._bomb_mode = active
|
|
||||||
self.queue_draw()
|
|
||||||
|
|
||||||
def refresh(self) -> None:
|
|
||||||
self.queue_draw()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Drawing
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _draw(self, area, cr, width, height) -> None:
|
|
||||||
if self._state is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
for r in range(GRID_ROWS):
|
|
||||||
for c in range(GRID_COLS):
|
|
||||||
cell = self._state.cells[r][c]
|
|
||||||
x = c * CELL_SIZE
|
|
||||||
y = r * CELL_SIZE
|
|
||||||
self._draw_cell(cr, x, y, cell, r, c)
|
|
||||||
|
|
||||||
# Bomb hover diamond overlay
|
|
||||||
if self._bomb_mode and self._hover:
|
|
||||||
hr, hc = self._hover
|
|
||||||
for (br, bc) in _bomb_cells(hr, hc):
|
|
||||||
dr, dc = br - hr, bc - hc
|
|
||||||
h_pos, v_pos = _bomb_pos(dr, dc)
|
|
||||||
img_name = bomb_pos_image(h_pos, v_pos)
|
|
||||||
pixbuf = assets.get_image(img_name)
|
|
||||||
if pixbuf:
|
|
||||||
self._paint_pixbuf(cr, bc * CELL_SIZE, br * CELL_SIZE, pixbuf)
|
|
||||||
|
|
||||||
def _draw_cell(self, cr, x: int, y: int, cell: Cell, row: int, col: int) -> None:
|
|
||||||
cs = CELL_SIZE
|
|
||||||
|
|
||||||
if cell.state == "hidden":
|
|
||||||
# Wave background
|
|
||||||
wave_name = f"bg-wave-{cell.wave}-outbg.png"
|
|
||||||
pixbuf = assets.get_image(wave_name) or assets.get_image("bg-wave-1-outbg.png")
|
|
||||||
if pixbuf:
|
|
||||||
self._paint_pixbuf(cr, x, y, pixbuf)
|
|
||||||
else:
|
|
||||||
# Fallback: solid dark tile
|
|
||||||
cr.set_source_rgb(0.15, 0.15, 0.25)
|
|
||||||
cr.rectangle(x, y, cs, cs)
|
|
||||||
cr.fill()
|
|
||||||
|
|
||||||
elif cell.state == "safe":
|
|
||||||
# Light tile with number
|
|
||||||
cr.set_source_rgb(0.85, 0.85, 0.85)
|
|
||||||
cr.rectangle(x, y, cs, cs)
|
|
||||||
cr.fill()
|
|
||||||
# Draw thin border
|
|
||||||
cr.set_source_rgb(0.6, 0.6, 0.6)
|
|
||||||
cr.set_line_width(0.5)
|
|
||||||
cr.rectangle(x + 0.5, y + 0.5, cs - 1, cs - 1)
|
|
||||||
cr.stroke()
|
|
||||||
if cell.value and cell.value != 0:
|
|
||||||
self._draw_number(cr, x, y, cs, int(cell.value))
|
|
||||||
|
|
||||||
elif cell.state == "mine":
|
|
||||||
# Mine flag — show the appropriate player flag
|
|
||||||
color = cell.owner or "red"
|
|
||||||
flag_name = f"bg-flag-{color}-outbg.png"
|
|
||||||
pixbuf = assets.get_image(flag_name)
|
|
||||||
if pixbuf:
|
|
||||||
self._paint_pixbuf(cr, x, y, pixbuf)
|
|
||||||
else:
|
|
||||||
# Fallback colour
|
|
||||||
if color == "red":
|
|
||||||
cr.set_source_rgb(0.8, 0.1, 0.1)
|
|
||||||
else:
|
|
||||||
cr.set_source_rgb(0.1, 0.3, 0.9)
|
|
||||||
cr.rectangle(x, y, cs, cs)
|
|
||||||
cr.fill()
|
|
||||||
|
|
||||||
# Last-step highlight overlay
|
|
||||||
if cell.is_last:
|
|
||||||
color = cell.owner or (self._state.turn if self._state else "red")
|
|
||||||
last_name = f"bg-last-{color}-outbg.png"
|
|
||||||
pixbuf = assets.get_image(last_name)
|
|
||||||
if pixbuf:
|
|
||||||
self._paint_pixbuf(cr, x, y, pixbuf)
|
|
||||||
|
|
||||||
# Target overlay on hover (non-bomb)
|
|
||||||
if not self._bomb_mode and self._hover == (row, col):
|
|
||||||
pixbuf = assets.get_image("bg-target-outbg.png")
|
|
||||||
if pixbuf:
|
|
||||||
self._paint_pixbuf(cr, x, y, pixbuf)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _paint_pixbuf(cr, x: int, y: int, pixbuf: GdkPixbuf.Pixbuf) -> None:
|
|
||||||
Gdk.cairo_set_source_pixbuf(cr, pixbuf, x, y)
|
|
||||||
cr.paint()
|
|
||||||
|
|
||||||
# Number colours matching standard minesweeper conventions
|
|
||||||
_NUM_COLOURS = {
|
|
||||||
1: (0.0, 0.0, 1.0),
|
|
||||||
2: (0.0, 0.5, 0.0),
|
|
||||||
3: (1.0, 0.0, 0.0),
|
|
||||||
4: (0.0, 0.0, 0.5),
|
|
||||||
5: (0.5, 0.0, 0.0),
|
|
||||||
6: (0.0, 0.5, 0.5),
|
|
||||||
7: (0.0, 0.0, 0.0),
|
|
||||||
8: (0.5, 0.5, 0.5),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _draw_number(self, cr, x: int, y: int, cs: int, value: int) -> None:
|
|
||||||
r, g, b = self._NUM_COLOURS.get(value, (0, 0, 0))
|
|
||||||
cr.set_source_rgb(r, g, b)
|
|
||||||
cr.select_font_face("Sans", 0, 1) # normal, bold
|
|
||||||
cr.set_font_size(cs * 0.55)
|
|
||||||
text = str(value)
|
|
||||||
ext = cr.text_extents(text)
|
|
||||||
tx = x + (cs - ext.width) / 2 - ext.x_bearing
|
|
||||||
ty = y + (cs + ext.height) / 2 - ext.y_bearing - ext.height
|
|
||||||
cr.move_to(tx, ty)
|
|
||||||
cr.show_text(text)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Input handlers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _cell_at(self, px: float, py: float) -> tuple[int, int] | None:
|
|
||||||
col = int(px // CELL_SIZE)
|
|
||||||
row = int(py // CELL_SIZE)
|
|
||||||
if 0 <= row < GRID_ROWS and 0 <= col < GRID_COLS:
|
|
||||||
return row, col
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _on_pressed(self, gesture, n_press, x, y) -> None:
|
|
||||||
pos = self._cell_at(x, y)
|
|
||||||
if pos and self._state and not self._state.finished:
|
|
||||||
self._on_cell_click(pos[0], pos[1], self._bomb_mode)
|
|
||||||
|
|
||||||
def _on_motion(self, controller, x, y) -> None:
|
|
||||||
pos = self._cell_at(x, y)
|
|
||||||
if pos != self._hover:
|
|
||||||
self._hover = pos
|
|
||||||
self.queue_draw()
|
|
||||||
|
|
||||||
def _on_leave(self, controller) -> None:
|
|
||||||
if self._hover is not None:
|
|
||||||
self._hover = None
|
|
||||||
self.queue_draw()
|
|
||||||
@@ -1,185 +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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import threading
|
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
import gi
|
|
||||||
gi.require_version("Gtk", "4.0")
|
|
||||||
gi.require_version("Adw", "1")
|
|
||||||
from gi.repository import Gtk, Adw, GLib
|
|
||||||
|
|
||||||
from mineseeker.api import game as game_api
|
|
||||||
from mineseeker import assets
|
|
||||||
from mineseeker.state import session as session_mod
|
|
||||||
|
|
||||||
|
|
||||||
class LobbyPage(Gtk.Box):
|
|
||||||
"""
|
|
||||||
Lobby screen — shows waiting players and a "New Game" button.
|
|
||||||
|
|
||||||
Flow:
|
|
||||||
- "New Game" → fetch token → start game → on_game_start()
|
|
||||||
- Click a waiting player → challenge them → on_game_start() when accepted
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, on_game_start: Callable[[str, str, str], None]) -> None:
|
|
||||||
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
|
||||||
self._on_game_start = on_game_start
|
|
||||||
self._waiting: list[dict] = []
|
|
||||||
|
|
||||||
# Header bar action area
|
|
||||||
header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
|
||||||
header_box.set_margin_top(12)
|
|
||||||
header_box.set_margin_bottom(12)
|
|
||||||
header_box.set_margin_start(16)
|
|
||||||
header_box.set_margin_end(16)
|
|
||||||
|
|
||||||
title = Gtk.Label(label="Lobby")
|
|
||||||
title.add_css_class("title-2")
|
|
||||||
title.set_hexpand(True)
|
|
||||||
title.set_xalign(0)
|
|
||||||
header_box.append(title)
|
|
||||||
|
|
||||||
self._refresh_btn = Gtk.Button(label="Refresh")
|
|
||||||
self._refresh_btn.connect("clicked", lambda *_: self.refresh())
|
|
||||||
header_box.append(self._refresh_btn)
|
|
||||||
|
|
||||||
new_game_btn = Gtk.Button(label="New Game")
|
|
||||||
new_game_btn.add_css_class("suggested-action")
|
|
||||||
new_game_btn.connect("clicked", self._on_new_game)
|
|
||||||
header_box.append(new_game_btn)
|
|
||||||
|
|
||||||
self.append(header_box)
|
|
||||||
self.append(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL))
|
|
||||||
|
|
||||||
# Waiting players list
|
|
||||||
scrolled = Gtk.ScrolledWindow()
|
|
||||||
scrolled.set_vexpand(True)
|
|
||||||
scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
|
||||||
|
|
||||||
self._list_box = Gtk.ListBox()
|
|
||||||
self._list_box.set_selection_mode(Gtk.SelectionMode.NONE)
|
|
||||||
self._list_box.add_css_class("boxed-list")
|
|
||||||
self._list_box.set_margin_top(12)
|
|
||||||
self._list_box.set_margin_bottom(12)
|
|
||||||
self._list_box.set_margin_start(16)
|
|
||||||
self._list_box.set_margin_end(16)
|
|
||||||
|
|
||||||
scrolled.set_child(self._list_box)
|
|
||||||
self.append(scrolled)
|
|
||||||
|
|
||||||
self._status_label = Gtk.Label(label="No players waiting.")
|
|
||||||
self._status_label.add_css_class("dim-label")
|
|
||||||
self._status_label.set_margin_top(24)
|
|
||||||
self._status_label.set_visible(True)
|
|
||||||
self.append(self._status_label)
|
|
||||||
|
|
||||||
def refresh(self) -> None:
|
|
||||||
"""Fetch waiting players list from the server."""
|
|
||||||
self._refresh_btn.set_sensitive(False)
|
|
||||||
threading.Thread(target=self._do_refresh, daemon=True).start()
|
|
||||||
|
|
||||||
def _do_refresh(self) -> None:
|
|
||||||
try:
|
|
||||||
waiting = game_api.waiting()
|
|
||||||
GLib.idle_add(self._update_list, waiting)
|
|
||||||
except Exception:
|
|
||||||
GLib.idle_add(self._refresh_btn.set_sensitive, True)
|
|
||||||
|
|
||||||
def _update_list(self, waiting: list[dict]) -> bool:
|
|
||||||
self._waiting = waiting
|
|
||||||
# Clear existing rows
|
|
||||||
while True:
|
|
||||||
row = self._list_box.get_first_child()
|
|
||||||
if row is None:
|
|
||||||
break
|
|
||||||
self._list_box.remove(row)
|
|
||||||
|
|
||||||
my_assoc = session_mod.get().game_assoc
|
|
||||||
|
|
||||||
for player in waiting:
|
|
||||||
if player["gameAssoc"] == my_assoc:
|
|
||||||
continue # don't show ourselves
|
|
||||||
row = self._make_player_row(player)
|
|
||||||
self._list_box.append(row)
|
|
||||||
|
|
||||||
has_players = bool([p for p in waiting if p.get("gameAssoc") != my_assoc])
|
|
||||||
self._status_label.set_visible(not has_players)
|
|
||||||
self._refresh_btn.set_sensitive(True)
|
|
||||||
return GLib.SOURCE_REMOVE
|
|
||||||
|
|
||||||
def _make_player_row(self, player: dict) -> Adw.ActionRow:
|
|
||||||
row = Adw.ActionRow()
|
|
||||||
row.set_title(player.get("name", "Guest"))
|
|
||||||
row.set_subtitle(f"Waiting since {player.get('since', '')[:19].replace('T', ' ')}")
|
|
||||||
|
|
||||||
challenge_btn = Gtk.Button(label="Challenge")
|
|
||||||
challenge_btn.add_css_class("flat")
|
|
||||||
challenge_btn.set_valign(Gtk.Align.CENTER)
|
|
||||||
challenge_btn.connect(
|
|
||||||
"clicked",
|
|
||||||
lambda _btn, p=player: self._on_challenge(p),
|
|
||||||
)
|
|
||||||
row.add_suffix(challenge_btn)
|
|
||||||
return row
|
|
||||||
|
|
||||||
def _on_new_game(self, *_) -> None:
|
|
||||||
threading.Thread(target=self._do_new_game, daemon=True).start()
|
|
||||||
|
|
||||||
def _do_new_game(self) -> None:
|
|
||||||
try:
|
|
||||||
token_data = game_api.fetch_token()
|
|
||||||
game_assoc = token_data["gameAssoc"]
|
|
||||||
mercure_jwt = token_data["mercureJwt"]
|
|
||||||
|
|
||||||
sess = session_mod.get()
|
|
||||||
sess.game_assoc = game_assoc
|
|
||||||
sess.mercure_jwt = mercure_jwt
|
|
||||||
sess.color = "red" # first player always red
|
|
||||||
|
|
||||||
# Load images while we wait for an opponent
|
|
||||||
assets.load_images()
|
|
||||||
|
|
||||||
GLib.idle_add(self._on_game_start, game_assoc, mercure_jwt, "red")
|
|
||||||
except Exception as e:
|
|
||||||
GLib.idle_add(self._show_error_toast, str(e))
|
|
||||||
|
|
||||||
def _on_challenge(self, player: dict) -> None:
|
|
||||||
threading.Thread(
|
|
||||||
target=self._do_challenge, args=(player,), daemon=True
|
|
||||||
).start()
|
|
||||||
|
|
||||||
def _do_challenge(self, player: dict) -> None:
|
|
||||||
try:
|
|
||||||
token_data = game_api.fetch_token()
|
|
||||||
game_assoc = token_data["gameAssoc"]
|
|
||||||
mercure_jwt = token_data["mercureJwt"]
|
|
||||||
|
|
||||||
sess = session_mod.get()
|
|
||||||
sess.game_assoc = game_assoc
|
|
||||||
sess.mercure_jwt = mercure_jwt
|
|
||||||
|
|
||||||
game_api.challenge(
|
|
||||||
target_game_assoc=player["gameAssoc"],
|
|
||||||
challenger_game_assoc=game_assoc,
|
|
||||||
)
|
|
||||||
|
|
||||||
assets.load_images()
|
|
||||||
# GamePage will determine color from subscribe payload
|
|
||||||
GLib.idle_add(self._on_game_start, game_assoc, mercure_jwt, "")
|
|
||||||
except Exception as e:
|
|
||||||
GLib.idle_add(self._show_error_toast, str(e))
|
|
||||||
|
|
||||||
def _show_error_toast(self, message: str) -> bool:
|
|
||||||
# Find the nearest Adw.ToastOverlay ancestor if available, otherwise print
|
|
||||||
print(f"[LobbyPage] Error: {message}")
|
|
||||||
return GLib.SOURCE_REMOVE
|
|
||||||
@@ -1,146 +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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import threading
|
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
import gi
|
|
||||||
gi.require_version("Gtk", "4.0")
|
|
||||||
gi.require_version("Adw", "1")
|
|
||||||
from gi.repository import Gtk, Adw, GLib
|
|
||||||
|
|
||||||
from mineseeker.api.auth import login, login_as_guest, TotpRequired, AuthError
|
|
||||||
from mineseeker import assets
|
|
||||||
|
|
||||||
|
|
||||||
class LoginPage(Gtk.Box):
|
|
||||||
"""Username + password login form with a 'Play as Guest' option."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
on_success: Callable[[bool], None],
|
|
||||||
on_guest: Callable[[], None],
|
|
||||||
) -> None:
|
|
||||||
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
|
||||||
self._on_success = on_success
|
|
||||||
self._on_guest = on_guest
|
|
||||||
|
|
||||||
self.set_valign(Gtk.Align.CENTER)
|
|
||||||
self.set_halign(Gtk.Align.CENTER)
|
|
||||||
|
|
||||||
clamp = Adw.Clamp()
|
|
||||||
clamp.set_maximum_size(360)
|
|
||||||
|
|
||||||
inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
|
|
||||||
inner.set_margin_top(32)
|
|
||||||
inner.set_margin_bottom(32)
|
|
||||||
inner.set_margin_start(16)
|
|
||||||
inner.set_margin_end(16)
|
|
||||||
|
|
||||||
# Title
|
|
||||||
title = Gtk.Label(label="MineSeeker")
|
|
||||||
title.add_css_class("title-1")
|
|
||||||
inner.append(title)
|
|
||||||
|
|
||||||
subtitle = Gtk.Label(label="Sign in to play")
|
|
||||||
subtitle.add_css_class("dim-label")
|
|
||||||
inner.append(subtitle)
|
|
||||||
|
|
||||||
# Credentials group
|
|
||||||
group = Adw.PreferencesGroup()
|
|
||||||
|
|
||||||
self._username_row = Adw.EntryRow(title="Username")
|
|
||||||
group.add(self._username_row)
|
|
||||||
|
|
||||||
self._password_row = Adw.PasswordEntryRow(title="Password")
|
|
||||||
self._password_row.connect("entry-activated", self._on_login_clicked)
|
|
||||||
group.add(self._password_row)
|
|
||||||
|
|
||||||
inner.append(group)
|
|
||||||
|
|
||||||
# Error label
|
|
||||||
self._error_label = Gtk.Label(label="")
|
|
||||||
self._error_label.add_css_class("error")
|
|
||||||
self._error_label.set_visible(False)
|
|
||||||
inner.append(self._error_label)
|
|
||||||
|
|
||||||
# Login button
|
|
||||||
self._login_btn = Gtk.Button(label="Sign In")
|
|
||||||
self._login_btn.add_css_class("suggested-action")
|
|
||||||
self._login_btn.add_css_class("pill")
|
|
||||||
self._login_btn.connect("clicked", self._on_login_clicked)
|
|
||||||
inner.append(self._login_btn)
|
|
||||||
|
|
||||||
# Separator
|
|
||||||
sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
|
|
||||||
inner.append(sep)
|
|
||||||
|
|
||||||
# Guest button
|
|
||||||
guest_btn = Gtk.Button(label="Play as Guest")
|
|
||||||
guest_btn.add_css_class("pill")
|
|
||||||
guest_btn.connect("clicked", self._on_guest_clicked)
|
|
||||||
inner.append(guest_btn)
|
|
||||||
|
|
||||||
clamp.set_child(inner)
|
|
||||||
self.append(clamp)
|
|
||||||
|
|
||||||
def _set_busy(self, busy: bool) -> None:
|
|
||||||
self._login_btn.set_sensitive(not busy)
|
|
||||||
self._username_row.set_sensitive(not busy)
|
|
||||||
self._password_row.set_sensitive(not busy)
|
|
||||||
if busy:
|
|
||||||
self._error_label.set_visible(False)
|
|
||||||
|
|
||||||
def _show_error(self, message: str) -> None:
|
|
||||||
self._error_label.set_label(message)
|
|
||||||
self._error_label.set_visible(True)
|
|
||||||
|
|
||||||
def _on_login_clicked(self, *_) -> None:
|
|
||||||
username = self._username_row.get_text().strip()
|
|
||||||
password = self._password_row.get_text()
|
|
||||||
if not username or not password:
|
|
||||||
self._show_error("Please enter username and password.")
|
|
||||||
return
|
|
||||||
self._set_busy(True)
|
|
||||||
threading.Thread(
|
|
||||||
target=self._do_login, args=(username, password), daemon=True
|
|
||||||
).start()
|
|
||||||
|
|
||||||
def _do_login(self, username: str, password: str) -> None:
|
|
||||||
try:
|
|
||||||
login(username, password)
|
|
||||||
# Load assets after successful authentication
|
|
||||||
assets.load_sounds()
|
|
||||||
GLib.idle_add(self._on_success, False)
|
|
||||||
except TotpRequired:
|
|
||||||
assets.load_sounds()
|
|
||||||
GLib.idle_add(self._on_success, True)
|
|
||||||
except AuthError as e:
|
|
||||||
GLib.idle_add(self._handle_auth_error, str(e))
|
|
||||||
except Exception as e:
|
|
||||||
GLib.idle_add(self._handle_auth_error, f"Connection error: {e}")
|
|
||||||
|
|
||||||
def _handle_auth_error(self, message: str) -> bool:
|
|
||||||
self._set_busy(False)
|
|
||||||
self._show_error(message)
|
|
||||||
return GLib.SOURCE_REMOVE
|
|
||||||
|
|
||||||
def _on_guest_clicked(self, *_) -> None:
|
|
||||||
self._set_busy(True)
|
|
||||||
threading.Thread(target=self._do_guest, daemon=True).start()
|
|
||||||
|
|
||||||
def _do_guest(self) -> None:
|
|
||||||
try:
|
|
||||||
login_as_guest()
|
|
||||||
assets.load_sounds()
|
|
||||||
GLib.idle_add(self._on_guest)
|
|
||||||
except Exception as e:
|
|
||||||
GLib.idle_add(self._handle_auth_error, f"Could not start guest session: {e}")
|
|
||||||
@@ -1,116 +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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
import gi
|
|
||||||
gi.require_version("Gtk", "4.0")
|
|
||||||
gi.require_version("Adw", "1")
|
|
||||||
from gi.repository import Gtk, Adw
|
|
||||||
|
|
||||||
from mineseeker.state.game_state import PlayerState
|
|
||||||
from mineseeker.constants import WIN_THRESHOLD
|
|
||||||
|
|
||||||
|
|
||||||
class PlayerPanel(Gtk.Box):
|
|
||||||
"""
|
|
||||||
Vertical sidebar panel showing one player's info:
|
|
||||||
- Name + colour indicator
|
|
||||||
- Mine count (e.g. "12 / 26")
|
|
||||||
- Bonus points
|
|
||||||
- Bomb toggle button
|
|
||||||
- Resign button (only for the local player)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
color: str,
|
|
||||||
is_local: bool,
|
|
||||||
on_bomb_toggle: Callable[[bool], None],
|
|
||||||
on_resign: Callable[[], None],
|
|
||||||
) -> None:
|
|
||||||
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
|
||||||
self._color = color
|
|
||||||
self._is_local = is_local
|
|
||||||
self._on_bomb_toggle = on_bomb_toggle
|
|
||||||
self._on_resign = on_resign
|
|
||||||
self._bomb_active = False
|
|
||||||
|
|
||||||
self.set_margin_top(12)
|
|
||||||
self.set_margin_bottom(12)
|
|
||||||
self.set_margin_start(12)
|
|
||||||
self.set_margin_end(12)
|
|
||||||
self.set_valign(Gtk.Align.START)
|
|
||||||
|
|
||||||
# Colour dot + name
|
|
||||||
name_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
|
||||||
dot = Gtk.Label(label="●")
|
|
||||||
dot.add_css_class("red-player" if color == "red" else "blue-player")
|
|
||||||
name_box.append(dot)
|
|
||||||
|
|
||||||
self._name_label = Gtk.Label(label="Waiting…")
|
|
||||||
self._name_label.add_css_class("title-4")
|
|
||||||
self._name_label.set_ellipsize(3) # PANGO_ELLIPSIZE_END
|
|
||||||
name_box.append(self._name_label)
|
|
||||||
self.append(name_box)
|
|
||||||
|
|
||||||
# Mine count
|
|
||||||
self._mine_label = Gtk.Label(label=f"0 / {WIN_THRESHOLD}")
|
|
||||||
self._mine_label.add_css_class("title-2")
|
|
||||||
self.append(self._mine_label)
|
|
||||||
|
|
||||||
# Bonus points
|
|
||||||
self._bonus_label = Gtk.Label(label="Bonus: 0")
|
|
||||||
self._bonus_label.add_css_class("dim-label")
|
|
||||||
self.append(self._bonus_label)
|
|
||||||
|
|
||||||
# Bomb button — only meaningful for local player
|
|
||||||
if is_local:
|
|
||||||
self._bomb_btn = Gtk.ToggleButton(label="Bomb")
|
|
||||||
self._bomb_btn.set_sensitive(False)
|
|
||||||
self._bomb_btn.connect("toggled", self._on_bomb_toggled)
|
|
||||||
self.append(self._bomb_btn)
|
|
||||||
|
|
||||||
sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
|
|
||||||
self.append(sep)
|
|
||||||
|
|
||||||
resign_btn = Gtk.Button(label="Resign")
|
|
||||||
resign_btn.add_css_class("destructive-action")
|
|
||||||
resign_btn.connect("clicked", lambda *_: self._on_resign())
|
|
||||||
self.append(resign_btn)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Update from state
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def update(self, player: PlayerState, is_turn: bool) -> None:
|
|
||||||
self._name_label.set_label(player.display_name)
|
|
||||||
self._mine_label.set_label(f"{player.mines} / {WIN_THRESHOLD}")
|
|
||||||
self._bonus_label.set_label(f"Bonus: {player.bonus_points:.1f}")
|
|
||||||
|
|
||||||
if self._is_local and hasattr(self, "_bomb_btn"):
|
|
||||||
can_use = player.bomb_enabled and not player.bomb_used and is_turn
|
|
||||||
self._bomb_btn.set_sensitive(can_use)
|
|
||||||
if player.bomb_used:
|
|
||||||
self._bomb_btn.set_label("Bomb Used")
|
|
||||||
|
|
||||||
def set_bomb_enabled(self, enabled: bool) -> None:
|
|
||||||
if self._is_local and hasattr(self, "_bomb_btn"):
|
|
||||||
self._bomb_btn.set_sensitive(enabled)
|
|
||||||
|
|
||||||
def reset_bomb_toggle(self) -> None:
|
|
||||||
"""Deactivate the bomb toggle (after a bomb move is sent)."""
|
|
||||||
if self._is_local and hasattr(self, "_bomb_btn"):
|
|
||||||
self._bomb_btn.set_active(False)
|
|
||||||
|
|
||||||
def _on_bomb_toggled(self, btn: Gtk.ToggleButton) -> None:
|
|
||||||
self._bomb_active = btn.get_active()
|
|
||||||
self._on_bomb_toggle(self._bomb_active)
|
|
||||||
@@ -1,102 +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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
import gi
|
|
||||||
gi.require_version("Gtk", "4.0")
|
|
||||||
gi.require_version("Adw", "1")
|
|
||||||
from gi.repository import Gtk, Adw
|
|
||||||
|
|
||||||
|
|
||||||
class ResultOverlay(Gtk.Box):
|
|
||||||
"""
|
|
||||||
Translucent overlay shown at game end.
|
|
||||||
Displays the winner, final scores, and action buttons.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
on_play_again: Callable[[], None],
|
|
||||||
on_lobby: Callable[[], None],
|
|
||||||
) -> None:
|
|
||||||
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=16)
|
|
||||||
self._on_play_again = on_play_again
|
|
||||||
self._on_lobby = on_lobby
|
|
||||||
|
|
||||||
self.set_visible(False)
|
|
||||||
self.set_valign(Gtk.Align.CENTER)
|
|
||||||
self.set_halign(Gtk.Align.CENTER)
|
|
||||||
self.set_margin_top(16)
|
|
||||||
self.set_margin_bottom(16)
|
|
||||||
self.set_margin_start(16)
|
|
||||||
self.set_margin_end(16)
|
|
||||||
self.add_css_class("card")
|
|
||||||
|
|
||||||
self._title_label = Gtk.Label(label="")
|
|
||||||
self._title_label.add_css_class("title-1")
|
|
||||||
self.append(self._title_label)
|
|
||||||
|
|
||||||
self._subtitle_label = Gtk.Label(label="")
|
|
||||||
self._subtitle_label.add_css_class("title-3")
|
|
||||||
self.append(self._subtitle_label)
|
|
||||||
|
|
||||||
self._score_label = Gtk.Label(label="")
|
|
||||||
self._score_label.add_css_class("dim-label")
|
|
||||||
self.append(self._score_label)
|
|
||||||
|
|
||||||
btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
|
||||||
btn_box.set_halign(Gtk.Align.CENTER)
|
|
||||||
|
|
||||||
play_again_btn = Gtk.Button(label="Play Again")
|
|
||||||
play_again_btn.add_css_class("suggested-action")
|
|
||||||
play_again_btn.add_css_class("pill")
|
|
||||||
play_again_btn.connect("clicked", lambda *_: self._on_play_again())
|
|
||||||
btn_box.append(play_again_btn)
|
|
||||||
|
|
||||||
lobby_btn = Gtk.Button(label="Back to Lobby")
|
|
||||||
lobby_btn.add_css_class("pill")
|
|
||||||
lobby_btn.connect("clicked", lambda *_: self._on_lobby())
|
|
||||||
btn_box.append(lobby_btn)
|
|
||||||
|
|
||||||
self.append(btn_box)
|
|
||||||
|
|
||||||
def show_result(
|
|
||||||
self,
|
|
||||||
winner: str | None,
|
|
||||||
resigned: str | None,
|
|
||||||
local_color: str,
|
|
||||||
red_mines: int,
|
|
||||||
blue_mines: int,
|
|
||||||
red_name: str,
|
|
||||||
blue_name: str,
|
|
||||||
) -> None:
|
|
||||||
if resigned:
|
|
||||||
loser_name = red_name if resigned == "red" else blue_name
|
|
||||||
self._title_label.set_label("Resignation")
|
|
||||||
self._subtitle_label.set_label(f"{loser_name} resigned.")
|
|
||||||
elif winner == "draw" or winner is None:
|
|
||||||
self._title_label.set_label("Draw!")
|
|
||||||
self._subtitle_label.set_label("Equal mines — it's a draw.")
|
|
||||||
elif winner == local_color:
|
|
||||||
self._title_label.set_label("You Win!")
|
|
||||||
self._subtitle_label.set_label("Congratulations!")
|
|
||||||
else:
|
|
||||||
self._title_label.set_label("You Lose")
|
|
||||||
self._subtitle_label.set_label("Better luck next time.")
|
|
||||||
|
|
||||||
self._score_label.set_label(
|
|
||||||
f"{red_name}: {red_mines} mines · {blue_name}: {blue_mines} mines"
|
|
||||||
)
|
|
||||||
self.set_visible(True)
|
|
||||||
|
|
||||||
def hide_result(self) -> None:
|
|
||||||
self.set_visible(False)
|
|
||||||
@@ -1,112 +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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import threading
|
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
import gi
|
|
||||||
gi.require_version("Gtk", "4.0")
|
|
||||||
gi.require_version("Adw", "1")
|
|
||||||
from gi.repository import Gtk, Adw, GLib
|
|
||||||
|
|
||||||
from mineseeker.api.auth import submit_totp, AuthError
|
|
||||||
|
|
||||||
|
|
||||||
class TotpPage(Gtk.Box):
|
|
||||||
"""6-digit TOTP code entry shown after a successful password login."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
on_success: Callable[[], None],
|
|
||||||
on_back: Callable[[], None],
|
|
||||||
) -> None:
|
|
||||||
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
|
||||||
self._on_success = on_success
|
|
||||||
self._on_back = on_back
|
|
||||||
|
|
||||||
self.set_valign(Gtk.Align.CENTER)
|
|
||||||
self.set_halign(Gtk.Align.CENTER)
|
|
||||||
|
|
||||||
clamp = Adw.Clamp()
|
|
||||||
clamp.set_maximum_size(360)
|
|
||||||
|
|
||||||
inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
|
|
||||||
inner.set_margin_top(32)
|
|
||||||
inner.set_margin_bottom(32)
|
|
||||||
inner.set_margin_start(16)
|
|
||||||
inner.set_margin_end(16)
|
|
||||||
|
|
||||||
title = Gtk.Label(label="Two-Factor Authentication")
|
|
||||||
title.add_css_class("title-2")
|
|
||||||
inner.append(title)
|
|
||||||
|
|
||||||
subtitle = Gtk.Label(label="Enter the 6-digit code from your authenticator app.")
|
|
||||||
subtitle.set_wrap(True)
|
|
||||||
subtitle.add_css_class("dim-label")
|
|
||||||
inner.append(subtitle)
|
|
||||||
|
|
||||||
group = Adw.PreferencesGroup()
|
|
||||||
self._code_row = Adw.EntryRow(title="Authentication Code")
|
|
||||||
self._code_row.set_input_purpose(Gtk.InputPurpose.DIGITS)
|
|
||||||
self._code_row.connect("entry-activated", self._on_verify_clicked)
|
|
||||||
group.add(self._code_row)
|
|
||||||
inner.append(group)
|
|
||||||
|
|
||||||
self._error_label = Gtk.Label(label="")
|
|
||||||
self._error_label.add_css_class("error")
|
|
||||||
self._error_label.set_visible(False)
|
|
||||||
inner.append(self._error_label)
|
|
||||||
|
|
||||||
self._verify_btn = Gtk.Button(label="Verify")
|
|
||||||
self._verify_btn.add_css_class("suggested-action")
|
|
||||||
self._verify_btn.add_css_class("pill")
|
|
||||||
self._verify_btn.connect("clicked", self._on_verify_clicked)
|
|
||||||
inner.append(self._verify_btn)
|
|
||||||
|
|
||||||
back_btn = Gtk.Button(label="Back to Login")
|
|
||||||
back_btn.add_css_class("pill")
|
|
||||||
back_btn.connect("clicked", lambda *_: self._on_back())
|
|
||||||
inner.append(back_btn)
|
|
||||||
|
|
||||||
clamp.set_child(inner)
|
|
||||||
self.append(clamp)
|
|
||||||
|
|
||||||
def _set_busy(self, busy: bool) -> None:
|
|
||||||
self._verify_btn.set_sensitive(not busy)
|
|
||||||
self._code_row.set_sensitive(not busy)
|
|
||||||
if busy:
|
|
||||||
self._error_label.set_visible(False)
|
|
||||||
|
|
||||||
def _show_error(self, message: str) -> None:
|
|
||||||
self._error_label.set_label(message)
|
|
||||||
self._error_label.set_visible(True)
|
|
||||||
|
|
||||||
def _on_verify_clicked(self, *_) -> None:
|
|
||||||
code = self._code_row.get_text().strip()
|
|
||||||
if len(code) != 6 or not code.isdigit():
|
|
||||||
self._show_error("Code must be exactly 6 digits.")
|
|
||||||
return
|
|
||||||
self._set_busy(True)
|
|
||||||
threading.Thread(target=self._do_verify, args=(code,), daemon=True).start()
|
|
||||||
|
|
||||||
def _do_verify(self, code: str) -> None:
|
|
||||||
try:
|
|
||||||
submit_totp(code)
|
|
||||||
GLib.idle_add(self._on_success)
|
|
||||||
except AuthError as e:
|
|
||||||
GLib.idle_add(self._handle_error, str(e))
|
|
||||||
except Exception as e:
|
|
||||||
GLib.idle_add(self._handle_error, f"Connection error: {e}")
|
|
||||||
|
|
||||||
def _handle_error(self, message: str) -> bool:
|
|
||||||
self._set_busy(False)
|
|
||||||
self._show_error(message)
|
|
||||||
return GLib.SOURCE_REMOVE
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# PyGObject is NOT installable from PyPI into a plain venv.
|
|
||||||
# It must come from your system package manager, e.g.:
|
|
||||||
# Debian/Ubuntu: sudo apt install python3-gi python3-gi-cairo gir1.2-gtk-4.0 gir1.2-adw-1
|
|
||||||
# Fedora: sudo dnf install python3-gobject gtk4 libadwaita
|
|
||||||
# Arch: sudo pacman -S python-gobject gtk4 libadwaita
|
|
||||||
#
|
|
||||||
# Create the venv with --system-site-packages so the system gi module is visible:
|
|
||||||
# python3 -m venv --system-site-packages .venv
|
|
||||||
#
|
|
||||||
# Then install only the pure-Python deps below:
|
|
||||||
requests>=2.31.0
|
|
||||||
sseclient-py>=1.8.0
|
|
||||||
python-dotenv>=1.0.0
|
|
||||||
|
|
||||||
# GStreamer for sound is also a system package:
|
|
||||||
# Debian/Ubuntu: sudo apt install gstreamer1.0-plugins-good gstreamer1.0-libav python3-gst-1.0
|
|
||||||
# Fedora: sudo dnf install gstreamer1-plugins-good python3-gstreamer1
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Convenience launcher for the MineSeeker GTK4 desktop client.
|
|
||||||
# Creates the venv on first run, then launches main.py.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
VENV="$SCRIPT_DIR/.venv"
|
|
||||||
PYTHON="$VENV/bin/python"
|
|
||||||
|
|
||||||
# ── Create venv if missing ──────────────────────────────────────────────────
|
|
||||||
if [ ! -f "$PYTHON" ]; then
|
|
||||||
echo "[run.sh] Creating venv with --system-site-packages…"
|
|
||||||
python3 -m venv --system-site-packages "$VENV"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Install / update pure-Python deps ──────────────────────────────────────
|
|
||||||
echo "[run.sh] Installing dependencies…"
|
|
||||||
"$VENV/bin/pip" install --quiet requests sseclient-py python-dotenv
|
|
||||||
|
|
||||||
# ── Check .env ──────────────────────────────────────────────────────────────
|
|
||||||
if [ ! -f "$SCRIPT_DIR/.env" ]; then
|
|
||||||
echo ""
|
|
||||||
echo "ERROR: $SCRIPT_DIR/.env not found."
|
|
||||||
echo "Copy .env.example to .env and set MINESEEKER_BASE_URL."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Launch ──────────────────────────────────────────────────────────────────
|
|
||||||
echo "[run.sh] Starting MineSeeker…"
|
|
||||||
exec "$PYTHON" "$SCRIPT_DIR/main.py" "$@"
|
|
||||||
+17
-16
@@ -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.9",
|
"vite": "^8.2.1",
|
||||||
"vite-plugin-symfony": "^8.2.4"
|
"vite-plugin-symfony": "^8.2.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
<server name="APP_ENV" value="test" force="true" />
|
<server name="APP_ENV" value="test" force="true" />
|
||||||
<server name="SHELL_VERBOSITY" value="-1" />
|
<server name="SHELL_VERBOSITY" value="-1" />
|
||||||
<env name="SYMFONY_DEPRECATIONS_HELPER" value="disabled" />
|
<env name="SYMFONY_DEPRECATIONS_HELPER" value="disabled" />
|
||||||
<env name="DATABASE_URL" value="postgresql://system7:bazmeg@127.0.0.1:15432/mineseeker_test?serverVersion=16&charset=utf8" />
|
|
||||||
<env name="DAMA_DISABLE_STATIC_CONNECTION" value="0" />
|
<env name="DAMA_DISABLE_STATIC_CONNECTION" value="0" />
|
||||||
</php>
|
</php>
|
||||||
|
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,13 +25,13 @@ use Symfony\Component\Routing\Attribute\Route;
|
|||||||
* Class ApiAuthController
|
* Class ApiAuthController
|
||||||
*
|
*
|
||||||
* Provides a JSON login endpoint for native desktop clients.
|
* Provides a JSON login endpoint for native desktop clients.
|
||||||
* This endpoint is intentionally exempt from the reCAPTCHA listener
|
* This endpoint is intentionally exempt from the CAPTCHA listener
|
||||||
* because desktop clients cannot execute reCAPTCHA v3.
|
* because desktop clients cannot display or solve the Cap widget.
|
||||||
*
|
*
|
||||||
* After a successful password login, if the user has TOTP enabled the response
|
* After a successful password login, if the user has TOTP enabled the response
|
||||||
* returns { requiresTwoFactor: true }. The client must then POST the 6-digit
|
* returns { requiresTwoFactor: true }. The client must then POST the 6-digit
|
||||||
* code to the standard /2fa_check endpoint (which is already exempt from
|
* code to the standard /2fa_check endpoint (which is already exempt from
|
||||||
* the reCAPTCHA listener via LoginCaptchaListener).
|
* the CAPTCHA listener via LoginCaptchaListener).
|
||||||
*
|
*
|
||||||
* @package App\Controller
|
* @package App\Controller
|
||||||
* @author Lang <https://www.splendidbear.org>
|
* @author Lang <https://www.splendidbear.org>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ namespace App\Controller;
|
|||||||
|
|
||||||
use App\Entity\PlayedGame;
|
use App\Entity\PlayedGame;
|
||||||
use App\Repository\PlayedGameRepository;
|
use App\Repository\PlayedGameRepository;
|
||||||
use App\Service\MercureJwtService;
|
|
||||||
use App\Service\ResolveUserNamesService;
|
use App\Service\ResolveUserNamesService;
|
||||||
use App\Util\RpcManager;
|
use App\Util\RpcManager;
|
||||||
use App\Util\TopicManager;
|
use App\Util\TopicManager;
|
||||||
@@ -24,7 +23,6 @@ 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\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Uid\Uuid;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class MercureController
|
* Class MercureController
|
||||||
@@ -47,28 +45,9 @@ class MercureController extends AbstractController
|
|||||||
private readonly TopicManager $topicManager,
|
private readonly TopicManager $topicManager,
|
||||||
private readonly RpcManager $rpcManager,
|
private readonly RpcManager $rpcManager,
|
||||||
private readonly ResolveUserNamesService $userNamesService,
|
private readonly ResolveUserNamesService $userNamesService,
|
||||||
private readonly MercureJwtService $mercureJwtService,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a fresh Mercure subscriber JWT and a new gameAssoc UUID.
|
|
||||||
* Intended for native desktop clients that cannot parse the JWT from HTML.
|
|
||||||
*
|
|
||||||
* Response: { "mercureJwt": "<jwt>", "gameAssoc": "<uuid>" }
|
|
||||||
*/
|
|
||||||
#[Route('/api/game/token', name: 'MineSeekerBundle_api_game_token', methods: ['GET'])]
|
|
||||||
public function token(): JsonResponse
|
|
||||||
{
|
|
||||||
$gameAssoc = Uuid::v4()->toRfc4122();
|
|
||||||
$userName = $this->userNamesService->resolveUserName();
|
|
||||||
|
|
||||||
return $this->json([
|
|
||||||
'mercureJwt' => $this->mercureJwtService->mintSubscriberToken($gameAssoc, $userName),
|
|
||||||
'gameAssoc' => $gameAssoc,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/api/game/start', name: 'MineSeekerBundle_api_game_start', methods: ['POST'])]
|
#[Route('/api/game/start', name: 'MineSeekerBundle_api_game_start', methods: ['POST'])]
|
||||||
public function start(Request $request): JsonResponse
|
public function start(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,7 +186,6 @@ final class BattleCardGenerator
|
|||||||
$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);
|
||||||
|
|||||||
@@ -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()}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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,
|
'token_length' => strlen($token),
|
||||||
'hostname' => $data['hostname'] ?? null,
|
|
||||||
'error-codes' => $data['error-codes'] ?? [],
|
|
||||||
'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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -30,7 +29,6 @@ final class RecaptchaValidator extends ConstraintValidator
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly RecaptchaService $recaptcha,
|
private readonly RecaptchaService $recaptcha,
|
||||||
private readonly RequestStack $requestStack,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,10 +38,7 @@ final 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -316,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"
|
||||||
},
|
},
|
||||||
@@ -375,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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>© {{ "now"|date("Y") }} MineSeeker • All rights reserved •
|
<p>© {{ "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>
|
||||||
|
• All rights reserved •
|
||||||
<a href="https://www.splendidbear.org">www.splendidbear.org</a>
|
<a href="https://www.splendidbear.org">www.splendidbear.org</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,18 +3,23 @@
|
|||||||
{% block title %} - Play!{% endblock %}
|
{% block title %} - Play!{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<a class="back-from-game" href="{{ path('MineSeekerBundle_homepage') }}">
|
<div class="game-brand-bar">
|
||||||
<img src="{{ asset('/images/mine-logo-txt.png') }}" alt="Mineseeker Logo">
|
<a class="back-from-game" href="{{ path('MineSeekerBundle_homepage') }}">
|
||||||
</a>
|
<img src="{{ asset('/images/mine-logo-txt.png') }}" alt="Mineseeker Logo">
|
||||||
|
</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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -34,8 +34,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<h2 class="auth-title">Forgot Password</h2>
|
<div class="auth-title-row">
|
||||||
|
<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 %}
|
||||||
|
|||||||
@@ -36,7 +36,12 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<h2 class="auth-title">Sign In</h2>
|
<div class="auth-title-row">
|
||||||
|
<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 %}
|
||||||
|
|||||||
@@ -42,8 +42,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<h2 class="auth-title">Create Account</h2>
|
<div class="auth-title-row">
|
||||||
|
<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 %}
|
||||||
|
|||||||
@@ -16,8 +16,14 @@
|
|||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="auth-page">
|
<div class="auth-page">
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<h2 class="auth-title">Reset Password</h2>
|
<div class="auth-title-row">
|
||||||
|
<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 %}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,14 +89,12 @@ class GameControllerTest extends WebTestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
#[TestDox('Contact page loads successfully with form')]
|
#[TestDox('Contact page is not publicly accessible')]
|
||||||
public function contactPageLoadsSuccessfully(): void
|
public function contactPageIsNotPubliclyAccessible(): void
|
||||||
{
|
{
|
||||||
$crawler = $this->client->request('GET', '/contact');
|
$crawler = $this->client->request('GET', '/contact');
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(Response::HTTP_OK);
|
self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
|
||||||
self::assertSelectorExists('form');
|
|
||||||
self::assertSelectorExists('button[type="submit"]');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
|
|
||||||
namespace App\Tests\Controller;
|
namespace App\Tests\Controller;
|
||||||
|
|
||||||
|
use DateTime;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use App\Tests\Factory\UserFactory;
|
use App\Tests\Factory\UserFactory;
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
@@ -103,6 +105,25 @@ class SecurityControllerTest extends WebTestCase
|
|||||||
self::assertResponseRedirects('/login');
|
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]
|
#[Test]
|
||||||
#[TestDox('Password reset with invalid token redirects to forgot password')]
|
#[TestDox('Password reset with invalid token redirects to forgot password')]
|
||||||
public function resetPasswordWithInvalidTokenShowsError(): void
|
public function resetPasswordWithInvalidTokenShowsError(): void
|
||||||
@@ -117,7 +138,7 @@ class SecurityControllerTest extends WebTestCase
|
|||||||
public function authenticatedUserRedirectsFromLogin(): void
|
public function authenticatedUserRedirectsFromLogin(): void
|
||||||
{
|
{
|
||||||
$user = UserFactory::createOne();
|
$user = UserFactory::createOne();
|
||||||
$this->client->loginUser($user->_real());
|
$this->client->loginUser($user);
|
||||||
|
|
||||||
$this->client->request('GET', '/login');
|
$this->client->request('GET', '/login');
|
||||||
|
|
||||||
@@ -129,7 +150,7 @@ class SecurityControllerTest extends WebTestCase
|
|||||||
public function authenticatedUserRedirectsFromRegister(): void
|
public function authenticatedUserRedirectsFromRegister(): void
|
||||||
{
|
{
|
||||||
$user = UserFactory::createOne();
|
$user = UserFactory::createOne();
|
||||||
$this->client->loginUser($user->_real());
|
$this->client->loginUser($user);
|
||||||
|
|
||||||
$this->client->request('GET', '/register');
|
$this->client->request('GET', '/register');
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
namespace App\Tests\Factory;
|
namespace App\Tests\Factory;
|
||||||
|
|
||||||
use App\Entity\ContactMessage;
|
use App\Entity\ContactMessage;
|
||||||
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
|
use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class ContactMessageFactory
|
* Class ContactMessageFactory
|
||||||
@@ -23,9 +23,9 @@ use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
|
|||||||
* @link www.splendidbear.org
|
* @link www.splendidbear.org
|
||||||
* @since 2026. 04. 21.
|
* @since 2026. 04. 21.
|
||||||
*
|
*
|
||||||
* @extends PersistentProxyObjectFactory<ContactMessage>
|
* @extends PersistentObjectFactory<ContactMessage>
|
||||||
*/
|
*/
|
||||||
class ContactMessageFactory extends PersistentProxyObjectFactory
|
class ContactMessageFactory extends PersistentObjectFactory
|
||||||
{
|
{
|
||||||
protected function defaults(): array
|
protected function defaults(): array
|
||||||
{
|
{
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user