Compare commits

..
Author SHA1 Message Date
lang 8b428260d7 chg: pkg: new version release !skipChangelog
Deploy to Production / deploy (push) Successful in 27s
2026-07-27 19:30:53 +02:00
lang 306add033a chg: pkg: new version release !skipChangelog 2026-07-27 19:02:31 +02:00
lang f451df283c chg: usr: add current version of the app in the footer #14
Deploy to Production / deploy (push) Successful in 3m42s
2026-07-27 19:01:42 +02:00
lang 8d542e683e chg: pkg: upgrade FE to the latest possible version #14 2026-07-27 18:52:14 +02:00
lang dff46cdc6f chg: test: all tests passed and migrated to Symfony 8 #14 2026-07-27 18:39:17 +02:00
lang 82fff0cdf0 chg: pkg: upgrade the BE deps to the latest possible version #14 2026-07-27 18:35:42 +02:00
lang 4bf7ac358d chg: dev: remove registration notification #14 2026-07-27 18:16:08 +02:00
lang 1a97c2c4b4 chg: pkg: add a process that removes the unnecessary minio-init when it does not needed anymore #14 2026-07-27 18:09:33 +02:00
lang 9ea83d7e6e new: dev: hardening the registration process with removing the not activated registrations #14 2026-07-27 18:08:52 +02:00
lang f3e4ff211d chg: dev: removed the obsolete contact form entry - legacy code from Google ReCapthca #14 2026-07-27 14:57:33 +02:00
lang 7a86767db1 chg: pkg: new version release !skipChangelog
Deploy to Production / deploy (push) Successful in 2m18s
2026-06-08 12:59:29 +02:00
lang 2b1689b321 chg: usr: add clickable badges for Cap app-wide #13
Deploy to Production / deploy (push) Successful in 2m43s
2026-06-02 09:48:23 +02:00
lang cf56b10aba chg: pkg: upgrade both backend and frontend to the latest available version #13 2026-06-01 22:36:19 +02:00
lang 7063704539 chg: usr: replace Google ReCaptcha with Cap instance #13 2026-06-01 22:24:34 +02:00
98 changed files with 2980 additions and 5060 deletions
+18 -6
View File
@@ -6,6 +6,9 @@
APP_ENV=dev APP_ENV=dev
APP_SECRET=changethis APP_SECRET=changethis
APP_NAME=mineseeker APP_NAME=mineseeker
# Public application release identifier, e.g. 2026.3.0. In Gitea production
# deployments this is supplied by the APP_VERSION repository variable.
APP_VERSION=1.0.0
# APP_PUBLIC_HOSTNAME: The public hostname for your application (used for generating absolute URLs in emails) # APP_PUBLIC_HOSTNAME: The public hostname for your application (used for generating absolute URLs in emails)
# For production, set this to your domain (e.g., mineseeker.com) # For production, set this to your domain (e.g., mineseeker.com)
APP_PUBLIC_HOSTNAME=localhost APP_PUBLIC_HOSTNAME=localhost
@@ -17,15 +20,17 @@ 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
@@ -57,3 +62,10 @@ MERCURE_SUBSCRIBER_JWT=changethis
WEBAUTHN_RP_ID=mine.local WEBAUTHN_RP_ID=mine.local
WEBAUTHN_ORIGIN=https://mine.local WEBAUTHN_ORIGIN=https://mine.local
###< 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
View File
@@ -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"
+5
View File
@@ -34,6 +34,11 @@ jobs:
docker compose build docker compose build
- name: Start services - name: Start services
env:
APP_VERSION: ${{ vars.APP_VERSION }}
run: | run: |
cd "${{ vars.PROD_APP_DIR }}" cd "${{ vars.PROD_APP_DIR }}"
docker compose up -d minio
docker compose rm -sf minio_init
docker compose run --rm minio_init
docker compose up -d docker compose up -d
-1
View File
@@ -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/
+36
View File
@@ -1,6 +1,42 @@
# Changelog # Changelog
## 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
+8 -2
View File
@@ -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 and initialize MinIO (uses existing images)"
@echo " make start-build - Start services and build images if needed" @echo " make start-build - Build, start services, and initialize MinIO"
@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,11 +19,17 @@ help:
@echo " make test - Run PHPUnit tests" @echo " make test - Run PHPUnit tests"
start: start:
docker compose up -d minio
docker compose rm -sf minio_init
docker compose run --rm minio_init
docker compose up -d docker compose up -d
start-build: start-build:
composer i composer i
bun run build bun run build
docker compose up -d --build minio
docker compose rm -sf minio_init
docker compose run --rm minio_init
docker compose up -d --build docker compose up -d --build
stop: stop:
+4 -3
View File
@@ -78,7 +78,8 @@ Edit `.env` and fill in every value. Key ones:
| `MINIO_ROOT_USER/PASSWORD` | MinIO admin credentials | | `MINIO_ROOT_USER/PASSWORD` | MinIO admin credentials |
| `MINIO_ENDPOINT` | `http://localhost:9000` (bare-metal) | | `MINIO_ENDPOINT` | `http://localhost:9000` (bare-metal) |
| `MINIO_PUBLIC_URL` | Public URL browsers use to reach MinIO | | `MINIO_PUBLIC_URL` | Public URL browsers use to reach MinIO |
| `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) |
@@ -222,8 +223,8 @@ 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
+60 -1
View File
@@ -75,7 +75,7 @@
border-radius: 10px; border-radius: 10px;
padding: 44px 48px 40px; padding: 44px 48px 40px;
width: 100%; width: 100%;
max-width: 420px; max-width: 520px;
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
box-shadow: 0 8px 48px rgba(0, 0, 0, 0.5); box-shadow: 0 8px 48px rgba(0, 0, 0, 0.5);
} }
@@ -87,6 +87,65 @@
margin-bottom: 6px; margin-bottom: 6px;
} }
.auth-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
.auth-title {
margin-bottom: 0;
}
}
.auth-cap-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid rgba(149, 207, 245, 0.35);
background:
linear-gradient(115deg, transparent 0 38%, rgba(255, 255, 255, 0.18) 48%, transparent 58% 100%),
rgba(35, 111, 135, 0.2);
background-position: 120% 50%, 0 0;
background-size: 260% 100%, auto;
color: #95cff5;
font: 600 11px 'Rajdhani', sans-serif;
letter-spacing: 0.8px;
text-decoration: none;
text-transform: uppercase;
transition:
background-position 650ms ease,
border-color 200ms ease,
box-shadow 200ms ease,
color 200ms ease,
transform 200ms ease;
white-space: nowrap;
&:hover,
&:focus-visible {
background-position: -60% 50%, 0 0;
border-color: rgba(149, 207, 245, 0.65);
box-shadow: 0 0 18px rgba(35, 111, 135, 0.28);
color: #d5f1ff;
transform: translateY(-1px);
}
&:hover,
&:focus,
&:active,
&:visited {
text-decoration: none;
}
&:focus-visible {
outline: 2px solid rgba(149, 207, 245, 0.75);
outline-offset: 2px;
}
}
.auth-sub { .auth-sub {
font: 400 14px 'Rajdhani', sans-serif; font: 400 14px 'Rajdhani', sans-serif;
color: rgba(149, 207, 245, 0.6); color: rgba(149, 207, 245, 0.6);
+23
View File
@@ -119,3 +119,26 @@ footer {
&:hover { color: #95cff5; } &:hover { color: #95cff5; }
} }
} }
.footer-version {
display: inline-flex;
align-items: center;
gap: 5px;
margin: 0 7px;
padding: 3px 8px 2px;
border: 1px solid rgba(149, 207, 245, 0.24);
border-radius: 999px;
background: linear-gradient(135deg, rgba(35, 111, 135, 0.22), rgba(149, 207, 245, 0.08));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 0 12px rgba(35, 111, 135, 0.12);
color: rgba(180, 224, 248, 0.9);
font: 700 10px 'Rajdhani', sans-serif;
letter-spacing: 1px;
line-height: 1;
text-transform: uppercase;
vertical-align: middle;
i {
color: #95cff5;
font-size: 9px;
}
}
+74 -2
View File
@@ -7,12 +7,17 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
.back-from-game { .game-brand-bar {
display: inline-block; display: flex;
align-items: center;
position: fixed; position: fixed;
top: 20px; top: 20px;
left: 20px; left: 20px;
z-index: 20;
}
.back-from-game {
display: inline-block;
-ms-transform: scale(1); -ms-transform: scale(1);
-webkit-transform: scale(1); -webkit-transform: scale(1);
transform: scale(1); transform: scale(1);
@@ -31,3 +36,70 @@
-webkit-transition: all 250ms cubic-bezier(.17, .67, .83, .67); -webkit-transition: all 250ms cubic-bezier(.17, .67, .83, .67);
transition: all 250ms cubic-bezier(.17, .67, .83, .67); transition: all 250ms cubic-bezier(.17, .67, .83, .67);
} }
.game-cap-badge {
display: inline-flex;
align-items: center;
gap: 6px;
position: fixed;
bottom: 20px;
left: 50%;
z-index: 20;
padding: 5px 10px;
border: 1px solid rgba(255, 216, 92, 0.42);
-webkit-border-radius: 999px;
border-radius: 999px;
background:
linear-gradient(115deg, transparent 0 38%, rgba(255, 255, 255, 0.22) 48%, transparent 58% 100%),
rgba(14, 16, 18, 0.72);
background-position: 120% 50%, 0 0;
background-size: 260% 100%, auto;
-webkit-box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.32), 0 8px 22px rgba(0, 0, 0, 0.28);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.32), 0 8px 22px rgba(0, 0, 0, 0.28);
color: #ffe58a;
font: 700 11px 'Rajdhani', sans-serif;
letter-spacing: 0.9px;
line-height: 1;
text-decoration: none;
text-transform: uppercase;
-ms-transform: translateX(-50%);
-webkit-transform: translateX(-50%);
transform: translateX(-50%);
white-space: nowrap;
-webkit-transition:
background-position 650ms ease,
border-color 200ms ease,
box-shadow 200ms ease,
color 200ms ease,
transform 200ms ease;
transition:
background-position 650ms ease,
border-color 200ms ease,
box-shadow 200ms ease,
color 200ms ease,
transform 200ms ease;
}
.game-cap-badge:hover,
.game-cap-badge:focus-visible {
background-position: -60% 50%, 0 0;
border-color: rgba(255, 229, 138, 0.78);
-webkit-box-shadow: 0 0 0 1px rgba(255, 229, 138, 0.18), 0 0 24px rgba(255, 195, 50, 0.28);
box-shadow: 0 0 0 1px rgba(255, 229, 138, 0.18), 0 0 24px rgba(255, 195, 50, 0.28);
color: #fff4bd;
text-decoration: none;
-ms-transform: translateX(-50%) translateY(-1px);
-webkit-transform: translateX(-50%) translateY(-1px);
transform: translateX(-50%) translateY(-1px);
}
.game-cap-badge:focus,
.game-cap-badge:active,
.game-cap-badge:visited {
text-decoration: none;
}
.game-cap-badge:focus-visible {
outline: 2px solid rgba(255, 229, 138, 0.75);
outline-offset: 2px;
}
+87
View File
@@ -0,0 +1,87 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import 'cap-widget';
const FORMS_SELECTOR = '.auth-form';
const SUBMIT_SELECTOR = 'button[type="submit"], input[type="submit"]';
const createInvisibleCap = apiEndpoint => {
const host = document.createElement('cap-widget');
host.style.display = 'none';
const cap = new window.Cap({ apiEndpoint }, host);
return { cap, host };
};
const lockSubmit = (form, locked) => {
const submit = form.querySelector(SUBMIT_SELECTOR);
if (!submit) return;
submit.disabled = locked;
};
const bindInvisibleCap = (form, apiEndpoint) => {
const { cap, host } = createInvisibleCap(apiEndpoint);
form.appendChild(host);
let solving = null;
const solveToken = async () => {
if (!solving) {
solving = cap.solve()
.finally(() => {
solving = null;
});
}
const result = await solving;
return result?.token ?? cap.token ?? '';
};
lockSubmit(form, true);
solveToken()
.catch(() => '')
.finally(() => lockSubmit(form, false));
form.addEventListener('submit', async e => {
if (cap.token) {
return;
}
e.preventDefault();
lockSubmit(form, true);
try {
const token = await solveToken();
if (!token) {
lockSubmit(form, false);
return;
}
form.requestSubmit();
} catch (_) {
lockSubmit(form, false);
}
});
};
const initInvisibleCap = () => {
const endpointSource = document.querySelector('[data-cap-api-endpoint]')
|| document.getElementById('mine-wrapper');
const apiEndpoint = endpointSource?.dataset.capApiEndpoint;
if ('function' !== typeof window.Cap || !apiEndpoint) {
return;
}
document.querySelectorAll(FORMS_SELECTOR).forEach(form => bindInvisibleCap(form, apiEndpoint));
};
initInvisibleCap();
-89
View File
@@ -1,89 +0,0 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import { useEffect, useRef } from 'react';
import { string } from 'prop-types';
/**
* ContactForm Component
*
* Handles reCAPTCHA v3 integration for the contact form.
* Intercepts form submission, executes reCAPTCHA, and submits the form with the token.
*
* @param {string} siteKey - Google reCAPTCHA site key
* @param {string} recaptchaFieldId - ID of the hidden recaptcha input field
*/
const ContactForm = ({ siteKey, recaptchaFieldId }) => {
const formRef = useRef(null);
const isSubmittingRef = useRef(false);
useEffect(() => {
const form = document.querySelector('.auth-form');
if (!form) {
console.warn('ContactForm: No .auth-form found');
return;
}
formRef.current = form;
const handleSubmit = e => {
e.preventDefault();
if (isSubmittingRef.current) {
return;
}
isSubmittingRef.current = true;
if ('undefined' !== typeof grecaptcha) {
grecaptcha.ready(() => {
grecaptcha
.execute(siteKey, { action: 'contact' })
.then(token => {
const recaptchaField = document.getElementById(recaptchaFieldId);
if (recaptchaField) {
recaptchaField.value = token;
} else {
console.error(`ContactForm: Recaptcha field with ID "${recaptchaFieldId}" not found`);
}
isSubmittingRef.current = false;
form.submit();
})
.catch(error => {
console.error('ContactForm: reCAPTCHA execution failed', error);
isSubmittingRef.current = false;
});
});
} else {
console.error('ContactForm: grecaptcha is not loaded');
isSubmittingRef.current = false;
}
};
form.addEventListener('submit', handleSubmit);
return () => {
if (formRef.current) {
formRef.current.removeEventListener('submit', handleSubmit);
}
};
}, [siteKey, recaptchaFieldId]);
return null;
};
ContactForm.propTypes = {
siteKey: string.isRequired,
recaptchaFieldId: string.isRequired,
};
export default ContactForm;
-1
View File
@@ -9,7 +9,6 @@
export { AvatarUpload } from './AvatarUpload'; export { AvatarUpload } from './AvatarUpload';
export { BattleDialog } from './BattleDialog'; export { BattleDialog } from './BattleDialog';
export { default as ContactForm } from './ContactForm';
export { default as PasskeyLogin } from './PasskeyLogin'; export { default as PasskeyLogin } from './PasskeyLogin';
export { default as PasskeyManager } from './PasskeyManager'; export { default as PasskeyManager } from './PasskeyManager';
export { default as ProfileCharts } from './ProfileCharts'; export { default as ProfileCharts } from './ProfileCharts';
-30
View File
@@ -1,30 +0,0 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React from 'react';
import { createRoot } from 'react-dom/client';
import { ContactForm } from '@global-components';
const wrapper = document.getElementById('contact-form-wrapper');
if (wrapper) {
const siteKey = wrapper.dataset.siteKey;
const recaptchaFieldId = wrapper.dataset.recaptchaFieldId;
if (siteKey && recaptchaFieldId) {
createRoot(wrapper).render(
<ContactForm
siteKey={siteKey}
recaptchaFieldId={recaptchaFieldId}
/>
);
} else {
console.error('ContactForm: Missing siteKey or recaptchaFieldId in data attributes');
}
}
@@ -7,17 +7,16 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; import 'cap-widget';
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react';
import { func, node, string } from 'prop-types'; import { func, node, string } from 'prop-types';
const CAPTCHA_STORAGE_KEY = 'mineseeker_captcha_verified'; const CAPTCHA_STORAGE_KEY = 'mineseeker_captcha_verified';
const CAPTCHA_TOKEN_KEY = 'mineseeker_captcha_token'; const CAPTCHA_TOKEN_KEY = 'mineseeker_captcha_token';
const RECAPTCHA_ACTION = 'mineseeker_play';
const CaptchaOverlay = ({ siteKey, onVerified, children }) => { const CaptchaOverlay = ({ apiEndpoint, onVerified, children }) => {
const [verified, setVerified] = useState(false); const [verified, setVerified] = useState(false);
const [loading, setLoading] = useState(false); const capRef = useRef(null);
const [error, setError] = useState(false);
const handleToken = useCallback(token => { const handleToken = useCallback(token => {
const wrapper = document.getElementById('mine-wrapper'); const wrapper = document.getElementById('mine-wrapper');
@@ -30,12 +29,6 @@ const CaptchaOverlay = ({ siteKey, onVerified, children }) => {
onVerified?.(); onVerified?.();
}, [onVerified]); }, [onVerified]);
const buttonClasses = useMemo(() => [
'captcha-button',
error && 'captcha-button--error',
loading && 'captcha-button--loading',
].filter(Boolean).join(' '), [error, loading]);
useEffect(() => { useEffect(() => {
const storedToken = sessionStorage.getItem(CAPTCHA_TOKEN_KEY); const storedToken = sessionStorage.getItem(CAPTCHA_TOKEN_KEY);
const storedTime = sessionStorage.getItem(CAPTCHA_STORAGE_KEY); const storedTime = sessionStorage.getItem(CAPTCHA_STORAGE_KEY);
@@ -52,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&apos;re human and start playing. Verifying your session...
</p> </p>
<button
className={buttonClasses}
onClick={handleClick}
disabled={loading}
>
<i className={`fa ${loading ? 'fa-spinner fa-spin' : error ? 'fa-exclamation-circle' : 'fa-play'}`} />
{loading ? 'Verifying...' : error ? 'Try Again' : 'Start Playing'}
</button>
</div> </div>
</div> </div>
); );
@@ -117,7 +104,7 @@ const CaptchaOverlay = ({ siteKey, onVerified, children }) => {
export default CaptchaOverlay; export default CaptchaOverlay;
CaptchaOverlay.propTypes = { CaptchaOverlay.propTypes = {
siteKey: string.isRequired, apiEndpoint: string.isRequired,
onVerified: func, onVerified: func,
children: node, children: node,
}; };
@@ -19,7 +19,7 @@ export const GameBoard = ({ gameAssoc, gameInherited, opponentName = '', isEnvDe
const { onClick, resign } = useServerCommunication(gameAssoc, gameInherited, opponentName, isEnvDev); const { onClick, resign } = useServerCommunication(gameAssoc, gameInherited, opponentName, isEnvDev);
const [captchaVerified, setCaptchaVerified] = useState(false); const [captchaVerified, setCaptchaVerified] = useState(false);
const siteKey = document.getElementById('mine-wrapper')?.dataset.recaptchaSiteKey; const apiEndpoint = document.getElementById('mine-wrapper')?.dataset.capApiEndpoint;
if (!gridReady) { if (!gridReady) {
return ( return (
@@ -29,9 +29,9 @@ export const GameBoard = ({ gameAssoc, gameInherited, opponentName = '', isEnvDe
); );
} }
if (!captchaVerified && siteKey) { if (!captchaVerified && apiEndpoint) {
return ( return (
<CaptchaOverlay siteKey={siteKey} onVerified={() => setCaptchaVerified(true)} /> <CaptchaOverlay apiEndpoint={apiEndpoint} onVerified={() => setCaptchaVerified(true)} />
); );
} }
+105 -70
View File
@@ -11,26 +11,27 @@
"@fontsource/open-sans": "^5.2.7", "@fontsource/open-sans": "^5.2.7",
"@fontsource/rajdhani": "^5.2.7", "@fontsource/rajdhani": "^5.2.7",
"@fortawesome/fontawesome-free": "^7.2.0", "@fortawesome/fontawesome-free": "^7.2.0",
"@mui/material": "^9.0.0", "@mui/material": "^9.0.1",
"@mui/x-charts": "^9.0.2", "@mui/x-charts": "^9.3.0",
"@tanstack/react-query": "^5.99.2", "@tanstack/react-query": "^5.100.14",
"cap-widget": "^0.1.53",
"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.7",
"react-dom": "^19.2.5", "react-dom": "^19.2.7",
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.5", "@eslint/eslintrc": "^3.3.5",
"@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.2",
"eslint": "10.2.1", "eslint": "^10.4.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.6.0",
"sass": "^1.99.0", "sass": "^1.100.0",
"vite": "^8.0.8", "vite": "^8.0.16",
"vite-plugin-symfony": "^8.2.4", "vite-plugin-symfony": "^8.2.4",
}, },
}, },
@@ -70,11 +71,13 @@
"@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", "", { "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/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
"@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=="], "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@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 +111,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", "", { "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", "", { "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", "", {}, "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", "", {}, "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", "", {}, "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", "", {}, "sha512-oBJgSIibWJvVBKseme1JczqPLoDHN97LHti4uh0LjfcJqQoVmnQQlRKIpEjTJV559rmVQ6qbVZZwKwk2wkBTtw=="],
"@fortawesome/fontawesome-free": ["@fortawesome/fontawesome-free@7.3.1", "", {}, "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 +151,29 @@
"@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.2.0", "", {}, "sha512-+XMav+ZaXkZKUFUgzjrfMEedfyJKxxviAske2q8N8CWDMeqZdDU2lWMkiUPiB388hGaDqhwvOAwkrsc/pUyp8g=="],
"@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.2.0", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@mui/core-downloads-tracker": "^9.2.0", "@mui/system": "^9.2.0", "@mui/types": "^9.1.1", "@mui/utils": "^9.2.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.6", "react-transition-group": "^4.4.5" }, "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", "@mui/material-pigment-css": "^9.2.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-+YTRSgGKGrrRo2XJZXs7JRA6qHoHWvNtxyqxnrRJTBmIuLOUpxxh7m4G9lF4tWberxGFY+EqkkRPgJCl+fSMJg=="],
"@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.2.0", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@mui/utils": "^9.2.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-w9wpyDxGPGnAACPB2hKhCDmILJIAvQxrfjUbIAEa0AznX1rOjaz5N+yB1uuw8ixnJcpEh/tPbD9oEe19wcWPHw=="],
"@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.1.1", "", { "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-neaYKdJfvEG54q8efHLJR7swpHG/gfSv9xGqW5iTSMsubD7yPCPFrhVBt284j1DOF3uZaaDJSHQL7gz6jGF21Q=="],
"@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.2.0", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@mui/private-theming": "^9.2.0", "@mui/styled-engine": "^9.1.1", "@mui/types": "^9.1.1", "@mui/utils": "^9.2.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-YvUJwKoGVtbnOm2PyPi5TvX2d1rOA6sqSpEWVs4WmXNIaFTuYmNUaVdU2o1NKUEe31URnD3E8ZVUMcsLQXwcYg=="],
"@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.1.1", "", { "dependencies": { "@babel/runtime": "^7.29.2" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-Zjt7u8wNvDg40rPTGoL+TnfkpuSKjwubsNSFRH1KAVZLcaV4I3AFNHIFbvH7p4F3alEibSbdd90xAgn5Rnfndg=="],
"@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.2.0", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@mui/types": "^9.1.1", "@types/prop-types": "^15.7.15", "clsx": "^2.1.1", "prop-types": "^15.8.1", "react-is": "^19.2.6" }, "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-OsUH5zhlSOM4xmLl53+agug1M1UyWb4zxFxWQCqwKTKUeQPvTENtg3JhrroBD2qpCLKsX5W/DYGERJ4mBUbc8g=="],
"@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.10.1", "", { "dependencies": { "@babel/runtime": "^7.29.7", "@mui/utils": "^9.2.0", "@mui/x-charts-vendor": "^9.4.0", "@mui/x-internal-gestures": "^9.10.0", "@mui/x-internals": "^9.10.1", "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-GVVyuCllFkega4yJEIxDLcJCoFuvU9rkjt4PMcWrAWEQxKsaN2TNTY5CmcJ3NJBRSoe0oN3/dBeWBS2ul4t7FA=="],
"@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.4.0", "", { "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.0.0", "internmap": "^2.0.3" } }, "sha512-1/p3RmRPGlzCtvpWwjwBCRoxJW1eDoQdml8MWq71Q1Oq0n36mTkFjzPTbcaJ52jigIIIQrMloqTUkhmEXwwBMg=="],
"@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", "", { "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.10.1", "", { "dependencies": { "@babel/runtime": "^7.29.7", "@base-ui/utils": "^0.3.1", "@mui/utils": "^9.2.0", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-pKFVzFQHB34TuMG3gOVx+LfIR8VBsi/ZhX/erRX2WOXu+ewEYGvdeK1mW9hLe7e2v3uyUbgT8RNeN0cy10cDjg=="],
"@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=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
"@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 +181,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.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="],
"@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 +215,45 @@
"@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.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="],
"@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.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="],
"@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.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g=="],
"@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.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA=="],
"@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.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw=="],
"@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.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q=="],
"@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.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA=="],
"@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.1.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg=="],
"@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.1.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA=="],
"@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.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ=="],
"@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.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg=="],
"@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.1.5", "", { "os": "none", "cpu": "arm64" }, "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw=="],
"@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-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA=="],
"@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-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw=="],
"@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/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="],
"@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=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "http://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@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", "", {}, "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", "", { "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=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@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 +261,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", "", { "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 +281,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", "", {}, "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 +295,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.4", "", { "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-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg=="],
"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 +329,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", "", {}, "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 +347,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.56", "", {}, "sha512-mAoFWzouxCjvi0B0Y0s7cRGGaLvJxifMVeNIHiFCkZme/9BOASWliN0aqz9wTUdlrt3Awm0UEA8h8DV1IDUuug=="],
"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 +369,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", "", { "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 +431,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.0", "", { "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-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ=="],
"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=="],
@@ -486,7 +499,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.8.0", "", {}, "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ=="],
"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 +597,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", "", { "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=="],
@@ -650,7 +663,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.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
"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 +713,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.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
"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 +723,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", "", {}, "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", "", { "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", "", {}, "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", "", {}, "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 +745,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.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="],
"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 +755,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", "", { "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,7 +803,7 @@
"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=="],
@@ -816,7 +829,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.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.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-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="],
"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 +855,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", "", {}, "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 +865,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", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@mui/private-theming/@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@mui/styled-engine/@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@mui/system/@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@mui/types/@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@mui/utils/@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@mui/x-charts/@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@mui/x-charts-vendor/@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@mui/x-internal-gestures/@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@mui/x-internals/@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "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 +901,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", "", {}, "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=="],
+45 -6
View File
@@ -10,6 +10,7 @@ services:
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,8 +26,8 @@ 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}
@@ -47,6 +48,45 @@ services:
condition: service_healthy condition: service_healthy
mail: mail:
condition: service_started condition: service_started
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://minio:9000
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9000}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1}
volumes:
- app_var:/app/var
depends_on:
db:
condition: service_healthy
mail:
condition: service_started
minio: minio:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z-cpuv1 image: minio/minio:RELEASE.2025-09-07T16-13-09Z-cpuv1
restart: unless-stopped restart: unless-stopped
@@ -68,6 +108,7 @@ services:
start_period: 10s start_period: 10s
minio_init: minio_init:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z-cpuv1 image: minio/minio:RELEASE.2025-09-07T16-13-09Z-cpuv1
profiles: ['bootstrap']
restart: "no" restart: "no"
container_name: '${APP_NAME}-minio-init' container_name: '${APP_NAME}-minio-init'
depends_on: depends_on:
@@ -105,7 +146,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 +154,7 @@ services:
retries: 10 retries: 10
start_period: 10s start_period: 10s
ports: ports:
- "15432:5432" - "5452:5432"
volumes: volumes:
app_var: app_var:
postgres_data: postgres_data:
@@ -121,5 +162,3 @@ volumes:
caddy_config: caddy_config:
postfix_spool: postfix_spool:
minio_data: minio_data:
+19 -17
View File
@@ -1,6 +1,6 @@
{ {
"name": "splendid-bear/mineseeker", "name": "splendid-bear/mineseeker",
"version": "2026.2.1", "version": "2026.3.0",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"author": "https://www.splendidbear.org", "author": "https://www.splendidbear.org",
"bugs": "https://source.splendidbear.org", "bugs": "https://source.splendidbear.org",
@@ -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.7",
"symfony/mercure-bundle": "^0.4", "symfony/mercure-bundle": "^0.4",
"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
+1881 -1648
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
framework:
messenger:
# Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
# failure_transport: failed
transports:
# https://symfony.com/doc/current/messenger.html#transport-configuration
# async: '%env(MESSENGER_TRANSPORT_DSN)%'
# failed: 'doctrine://default?queue_name=failed'
sync: 'sync://'
routing:
# Route your messages to the transports
# 'App\Message\YourMessage': async
# when@test:
# framework:
# messenger:
# transports:
# # replace with your transport name here (e.g., my_transport: 'in-memory://')
# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test
# async: 'in-memory://'
+1 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
_errors: _errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.xml' resource: '@FrameworkBundle/Resources/config/routing/errors.php'
prefix: /_error prefix: /_error
+2 -2
View File
@@ -1,7 +1,7 @@
web_profiler_wdt: web_profiler_wdt:
resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml' resource: '@WebProfilerBundle/Resources/config/routing/wdt.php'
prefix: /_wdt prefix: /_wdt
web_profiler_profiler: web_profiler_profiler:
resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml' resource: '@WebProfilerBundle/Resources/config/routing/profiler.php'
prefix: /_profiler prefix: /_profiler
+1 -1
View File
@@ -2,7 +2,7 @@
# https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration # https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration
parameters: parameters:
locale: 'en' locale: 'en'
jotunheimr.version: 1.1.0-20191026 jotunheimr.version: '%env(APP_VERSION)%'
services: services:
# default configuration for services in *this* file # default configuration for services in *this* file
+5
View File
@@ -8,6 +8,11 @@ until php -r "new PDO('pgsql:host=db;port=5432;dbname=${POSTGRES_DB}', '${POSTGR
done done
echo "[entrypoint] Database is ready." echo "[entrypoint] Database is ready."
if [ "$#" -gt 0 ]; then
echo "[entrypoint] Starting worker command: $*"
exec "$@"
fi
echo "[entrypoint] Clearing and warming Symfony cache..." echo "[entrypoint] Clearing and warming Symfony cache..."
php bin/console cache:clear php bin/console cache:clear
+6 -2
View File
@@ -1,8 +1,12 @@
#!/bin/sh #!/bin/sh
set -e set -e
echo "[minio-init] Configuring alias..." echo "[minio-init] Waiting for MinIO..."
mc alias set local http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" until mc alias set local http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" > /dev/null 2>&1; do
sleep 2
done
echo "[minio-init] Configuring bucket..."
if mc ls local/mineseeker > /dev/null 2>&1; then if mc ls local/mineseeker > /dev/null 2>&1; then
echo "[minio-init] Already initialized, skipping." echo "[minio-init] Already initialized, skipping."
+19
View File
@@ -0,0 +1,19 @@
# MinIO Initialization
The `minio_init` Compose service creates the `mineseeker` bucket and applies
its public read policy. It is an idempotent bootstrap task: after the bucket
exists, it exits without changing it.
It belongs to the `bootstrap` Compose profile, so normal `docker compose up`
does not create a stopped `mineseeker-minio-init` container.
`make start`, `make start-build`, and the production deployment workflow run
the bootstrap task automatically with `docker compose run --rm minio_init`.
They also remove any stopped legacy initializer container first. The temporary
container is removed as soon as the task succeeds.
To run it manually when needed:
```bash
docker compose run --rm minio_init
```
+6
View File
@@ -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.
### [MinIO Initialization](./MINIO_INITIALIZATION.md)
Automatic, one-shot setup of the MinIO bucket and its access policy.
### [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)
+31
View File
@@ -0,0 +1,31 @@
# Scheduled Tasks
MineSeeker uses Symfony Scheduler for recurring maintenance work. The Docker
Compose `scheduler` service runs the Scheduler worker continuously and is
restarted automatically if it exits.
## Pending-account cleanup
`PurgeExpiredPendingUsersCommand` is scheduled every hour. It deletes only
unverified users whose activation-token expiry has passed.
To inspect the registered schedule locally:
```bash
php bin/console debug:scheduler
```
To run the cleanup manually without deleting data:
```bash
php bin/console app:users:purge-expired-pending --dry-run
```
The production worker is started by Docker Compose:
```bash
docker compose up -d scheduler
docker compose logs -f scheduler
```
No host-level cron entry is required.
-8
View File
@@ -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
-11
View File
@@ -1,11 +0,0 @@
.env
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.egg-info/
dist/
build/
.venv/
venv/
-47
View File
@@ -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())
View File
-93
View File
@@ -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()
-52
View File
@@ -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
-117
View File
@@ -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,
},
)
-163
View File
@@ -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)
-108
View File
@@ -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)
-30
View File
@@ -1,30 +0,0 @@
"""
This file is part of the SplendidBear Websites' projects.
Copyright (c) 2026 @ www.splendidbear.org
For the full copyright and license information, please view the LICENSE
file that was distributed with this source code.
"""
import 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"
-90
View File
@@ -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"
-267
View File
@@ -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))
-45
View File
@@ -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()
-109
View File
@@ -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()
-96
View File
@@ -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)
-477
View File
@@ -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
-239
View File
@@ -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()
-185
View File
@@ -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
-146
View File
@@ -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}")
-116
View File
@@ -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)
-102
View File
@@ -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)
-112
View File
@@ -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
-17
View File
@@ -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
-31
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{ {
"name": "mine-seeker", "name": "mine-seeker",
"version": "2026.2.1", "version": "2026.3.0",
"author": "https://www.splendidbear.org", "author": "https://www.splendidbear.org",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"bugs": "https://source.splendidbear.org", "bugs": "https://source.splendidbear.org",
@@ -16,30 +16,31 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@fontsource/changa-one": "^5.2.8", "@fontsource/changa-one": "^5.3.0",
"@fontsource/open-sans": "^5.2.7", "@fontsource/open-sans": "^5.3.0",
"@fontsource/rajdhani": "^5.2.7", "@fontsource/rajdhani": "^5.3.0",
"@fortawesome/fontawesome-free": "^7.2.0", "@fortawesome/fontawesome-free": "^7.3.1",
"@mui/material": "^9.0.0", "@mui/material": "^9.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.9", "vite": "^8.1.5",
"vite-plugin-symfony": "^8.2.4" "vite-plugin-symfony": "^8.2.4"
}, },
"scripts": { "scripts": {
-1
View File
@@ -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&amp;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.',
);
}
}
+3 -3
View File
@@ -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>
+1
View File
@@ -60,6 +60,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(),
-21
View File
@@ -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
{ {
+6 -5
View File
@@ -18,7 +18,6 @@ use App\Repository\UserRepository;
use App\Service\Email\SendActivationEmailService; use App\Service\Email\SendActivationEmailService;
use App\Service\Email\SendPasswordResetEmailService; use App\Service\Email\SendPasswordResetEmailService;
use App\Service\Email\SendUserActivationNotificationService; use App\Service\Email\SendUserActivationNotificationService;
use App\Service\Email\SendUserRegistrationNotificationService;
use DateTime; use DateTime;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use LogicException; use LogicException;
@@ -44,6 +43,8 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
#[AsController] #[AsController]
class SecurityController extends AbstractController class SecurityController extends AbstractController
{ {
private const string ACTIVATION_TOKEN_TTL = '+24 hours';
public function __construct( public function __construct(
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $em,
private readonly RequestStack $requestStack, private readonly RequestStack $requestStack,
@@ -53,7 +54,6 @@ class SecurityController extends AbstractController
private readonly SendActivationEmailService $activationEmail, private readonly SendActivationEmailService $activationEmail,
private readonly SendPasswordResetEmailService $passwordResetEmail, private readonly SendPasswordResetEmailService $passwordResetEmail,
private readonly SendUserActivationNotificationService $activationNotificationEmail, private readonly SendUserActivationNotificationService $activationNotificationEmail,
private readonly SendUserRegistrationNotificationService $registrationNotificationEmail,
) { ) {
} }
@@ -92,6 +92,7 @@ class SecurityController extends AbstractController
$user->isVerified = false; $user->isVerified = false;
$user->verificationToken = $token; $user->verificationToken = $token;
$user->verificationTokenExpiresAt = new DateTime(self::ACTIVATION_TOKEN_TTL);
$user->password = $this->passwordHasher->hashPassword($user, $form->get('plainPassword')->getData()); $user->password = $this->passwordHasher->hashPassword($user, $form->get('plainPassword')->getData());
$this->em->persist($user); $this->em->persist($user);
@@ -109,7 +110,6 @@ class SecurityController extends AbstractController
} }
$this->activationEmail->send($user, $activationUrl); $this->activationEmail->send($user, $activationUrl);
$this->registrationNotificationEmail->send($user, new DateTime());
$this->addFlash('verify_email', $user->email); $this->addFlash('verify_email', $user->email);
@@ -194,13 +194,14 @@ class SecurityController extends AbstractController
{ {
$user = $this->em->getRepository(User::class)->findOneBy(['verificationToken' => $token]); $user = $this->em->getRepository(User::class)->findOneBy(['verificationToken' => $token]);
if (!$user) { if (!$user || $user->verificationTokenExpiresAt === null || $user->verificationTokenExpiresAt <= new DateTime()) {
$this->addFlash('error', 'This activation link is invalid or has already been used.'); $this->addFlash('error', 'This activation link is invalid, expired, or has already been used.');
return $this->redirectToRoute('MineSeekerBundle_login'); return $this->redirectToRoute('MineSeekerBundle_login');
} }
$user->isVerified = true; $user->isVerified = true;
$user->verificationToken = null; $user->verificationToken = null;
$user->verificationTokenExpiresAt = null;
$this->em->flush(); $this->em->flush();
$this->activationNotificationEmail->send($user, new DateTime()); $this->activationNotificationEmail->send($user, new DateTime());
+3
View File
@@ -62,6 +62,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, TotpTwo
#[Column(length: 64, nullable: true)] #[Column(length: 64, nullable: true)]
public ?string $verificationToken = null; public ?string $verificationToken = null;
#[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
public ?DateTime $verificationTokenExpiresAt = null;
#[Column(length: 64, nullable: true)] #[Column(length: 64, nullable: true)]
public ?string $resetToken = null; public ?string $resetToken = null;
+4 -4
View File
@@ -19,7 +19,7 @@ use Symfony\Component\Security\Http\Event\CheckPassportEvent;
/** /**
* Class LoginCaptchaListener * Class LoginCaptchaListener
* *
* Validates the Google reCAPTCHA v3 token during form-login authentication. * Validates the Cap CAPTCHA token during form-login authentication.
* Fires on CheckPassportEvent, which is dispatched after credentials are * Fires on CheckPassportEvent, which is dispatched after credentials are
* collected but before the user is authenticated. * collected but before the user is authenticated.
* *
@@ -53,12 +53,12 @@ readonly class LoginCaptchaListener
return; return;
} }
$token = $request->request->getString('g-recaptcha-response'); $token = $request->request->getString('cap-token');
if ($this->recaptcha->verify($token, $request->getClientIp())) { if ($this->recaptcha->verify($token)) {
return; return;
} }
throw new CustomUserMessageAuthenticationException('reCAPTCHA verification failed. Please try again.'); throw new CustomUserMessageAuthenticationException('CAPTCHA verification failed. Please try again.');
} }
} }
+4 -5
View File
@@ -22,8 +22,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
/** /**
* Class RecaptchaType * Class RecaptchaType
* *
* Reads the Google reCAPTCHA v3 token from the raw POST field * Reads the Cap CAPTCHA token from the raw POST field
* `g-recaptcha-response` (populated by JS before form submit) and injects * `cap-token` (auto-injected by the cap-widget web component) and injects
* it as this field's value before validation runs. * it as this field's value before validation runs.
* *
* @package App\Form * @package App\Form
@@ -41,9 +41,8 @@ class RecaptchaType extends AbstractType
{ {
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (PreSubmitEvent $event): void { $builder->addEventListener(FormEvents::PRE_SUBMIT, function (PreSubmitEvent $event): void {
$request = $this->requestStack->getCurrentRequest(); $request = $this->requestStack->getCurrentRequest();
$token = $request?->request->getString('g-recaptcha-response') ?? ''; $token = $request?->request->getString('cap-token') ?? '';
// For forms that set the token directly on the field (e.g. registration_form[recaptcha]) // For forms that set the token directly on the field, fall back to the submitted value.
// rather than via a standalone g-recaptcha-response input, fall back to the submitted value.
if ($token === '') { if ($token === '') {
$token = (string) ($event->getData() ?? ''); $token = (string) ($event->getData() ?? '');
} }
@@ -0,0 +1,45 @@
<?php declare(strict_types=1);
/*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20260727120000
*
* @package App\Migrations
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 07. 27.
*/
final class Version20260727120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add expiry timestamps for account activation tokens.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE app_user ADD verification_token_expires_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql("UPDATE app_user SET verification_token_expires_at = CURRENT_TIMESTAMP + INTERVAL '24 hours' WHERE is_verified = FALSE AND verification_token IS NOT NULL");
$this->addSql('CREATE INDEX IDX_APP_USER_PENDING_ACTIVATION_EXPIRY ON app_user (verification_token_expires_at) WHERE is_verified = FALSE AND verification_token_expires_at IS NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX IDX_APP_USER_PENDING_ACTIVATION_EXPIRY');
$this->addSql('ALTER TABLE app_user DROP verification_token_expires_at');
}
}
+29
View File
@@ -11,6 +11,7 @@
namespace App\Repository; namespace App\Repository;
use App\Entity\User; use App\Entity\User;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\NonUniqueResultException; use Doctrine\ORM\NonUniqueResultException;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
@@ -87,6 +88,34 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader
} }
} }
public function countExpiredPendingUsers(DateTimeInterface $now): int
{
$qb = $this->createQueryBuilder('u');
return (int) $qb
->select($qb->expr()->count('u.id'))
->where($qb->expr()->eq('u.isVerified', ':isVerified'))
->andWhere($qb->expr()->lte('u.verificationTokenExpiresAt', ':now'))
->setParameter('isVerified', false)
->setParameter('now', $now)
->getQuery()
->getSingleScalarResult();
}
public function deleteExpiredPendingUsers(DateTimeInterface $now): int
{
$qb = $this->createQueryBuilder('u');
return $qb
->delete()
->where($qb->expr()->eq('u.isVerified', ':isVerified'))
->andWhere($qb->expr()->lte('u.verificationTokenExpiresAt', ':now'))
->setParameter('isVerified', false)
->setParameter('now', $now)
->getQuery()
->execute();
}
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{ {
if (!$user instanceof User) { if (!$user instanceof User) {
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\Schedule as SymfonySchedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Symfony\Contracts\Cache\CacheInterface;
/**
* Class Schedule
*
* @package App
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 07. 27.
*/
#[AsSchedule]
class Schedule implements ScheduleProviderInterface
{
public function __construct(private CacheInterface $cache) { }
public function getSchedule(): SymfonySchedule
{
return (new SymfonySchedule())
->stateful($this->cache) // ensure missed tasks are executed
->processOnlyLastMissedRun(true) // ensure only last missed task is run
// add your own tasks here
// see https://symfony.com/doc/current/scheduler.html#attaching-recurring-messages-to-a-schedule
;
}
}
+2 -1
View File
@@ -11,6 +11,7 @@
namespace App\Security; namespace App\Security;
use App\Entity\User; use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\User\UserCheckerInterface; use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
@@ -40,5 +41,5 @@ final class UserChecker implements UserCheckerInterface
} }
} }
public function checkPostAuth(UserInterface $user): void { } public function checkPostAuth(UserInterface $user, ?TokenInterface $token = null): void { }
} }
-4
View File
@@ -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()}");
}
}
}
+13 -25
View File
@@ -26,50 +26,38 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
*/ */
readonly final class RecaptchaService readonly final class RecaptchaService
{ {
private const string SITEVERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
/**
* Minimum score to accept a request (0.0 = bot, 1.0 = human).
* 0.5 is Google's recommended default threshold.
*/
private const float SCORE_THRESHOLD = 0.5;
public function __construct( public function __construct(
private HttpClientInterface $httpClient, private HttpClientInterface $httpClient,
private LoggerInterface $logger, private LoggerInterface $logger,
#[Autowire(env: 'RECAPTCHA_SECRET_KEY')] #[Autowire(env: 'CAP_API_ENDPOINT')]
private string $apiEndpoint,
#[Autowire(env: 'CAP_SECRET_KEY')]
private string $secretKey, private string $secretKey,
) {} ) {}
public function verify(string $token, string $remoteIp = ''): bool public function verify(string $token): bool
{ {
if ($token === '') { if ($token === '') {
return false; return false;
} }
try { try {
$body = ['secret' => $this->secretKey, 'response' => $token]; $siteverifyUrl = rtrim($this->apiEndpoint, '/') . '/siteverify';
if ($remoteIp !== '') {
$body['remoteip'] = $remoteIp;
}
$data = $this->httpClient $data = $this->httpClient
->request('POST', self::SITEVERIFY_URL, ['body' => $body]) ->request('POST', $siteverifyUrl, [
'body' => ['secret' => $this->secretKey, 'response' => $token],
])
->toArray(); ->toArray();
$this->logger->info('reCAPTCHA verify response', [ $this->logger->info('Cap verify response', [
'success' => $data['success'] ?? null, 'success' => $data['success'] ?? null,
'score' => $data['score'] ?? null, '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;
} }
+1 -6
View File
@@ -11,7 +11,6 @@
namespace App\Validator; namespace App\Validator;
use App\Service\RecaptchaService; use App\Service\RecaptchaService;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator; use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException; use Symfony\Component\Validator\Exception\UnexpectedTypeException;
@@ -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;
} }
+24
View File
@@ -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"
}, },
+6 -1
View File
@@ -265,7 +265,12 @@
</nav> </nav>
</div> </div>
<div class="footer-copy"> <div class="footer-copy">
<p>&copy; {{ "now"|date("Y") }} MineSeeker &nbsp;&bull;&nbsp; All rights reserved &nbsp;&bull;&nbsp; <p>&copy; {{ "now"|date("Y") }} MineSeeker
<span class="footer-version" title="MineSeeker version {{ version }}">
<i class="fas fa-code-branch" aria-hidden="true"></i>
<span>v{{ version }}</span>
</span>
&nbsp;&bull;&nbsp; All rights reserved &nbsp;&bull;&nbsp;
<a href="https://www.splendidbear.org">www.splendidbear.org</a> <a href="https://www.splendidbear.org">www.splendidbear.org</a>
</p> </p>
</div> </div>
+10 -6
View File
@@ -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 %}
+9 -6
View File
@@ -37,6 +37,14 @@
</p> </p>
<div class="auth-card" style="max-width: 600px; margin: 0 auto;"> <div class="auth-card" style="max-width: 600px; margin: 0 auto;">
<div class="auth-title-row">
<h2 class="auth-title">Contact Form</h2>
<a class="auth-cap-badge" href="https://trycap.dev" target="_blank" rel="noopener noreferrer">
<i class="fas fa-shield-halved"></i> Protected by Cap
</a>
</div>
<p class="auth-sub">Your message is protected by automated abuse checks.</p>
<div data-cap-api-endpoint="{{ cap_api_endpoint }}" style="display: none;" aria-hidden="true"></div>
{{ form_start(form, {attr: {class: 'auth-form'}}) }} {{ form_start(form, {attr: {class: 'auth-form'}}) }}
<div class="auth-field"> <div class="auth-field">
@@ -127,10 +135,5 @@
{% block javascripts %} {% block javascripts %}
{{ parent() }} {{ parent() }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script> {{ vite_entry_script_tags('cap') }}
{{ vite_entry_script_tags('contact', { dependency: 'react' }) }}
<div id="contact-form-wrapper"
data-site-key="{{ recaptcha_site_key }}"
data-recaptcha-field-id="{{ form.recaptcha.vars.id }}">
</div>
{% endblock %} {% endblock %}
+8 -17
View File
@@ -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 %}
+9 -18
View File
@@ -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 %}
+8 -17
View File
@@ -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 %}
+8 -16
View File
@@ -16,8 +16,14 @@
{% block body %} {% block body %}
<div class="auth-page"> <div class="auth-page">
<div class="auth-card"> <div class="auth-card">
<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));
}
}
+23 -2
View File
@@ -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');
+3 -3
View File
@@ -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
{ {
+3 -3
View File
@@ -12,7 +12,7 @@ namespace App\Tests\Factory;
use App\Entity\Gamer; use App\Entity\Gamer;
use DateTime; use DateTime;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
/** /**
* Class GamerFactory * Class GamerFactory
@@ -24,9 +24,9 @@ use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
* @link www.splendidbear.org * @link www.splendidbear.org
* @since 2026. 04. 21. * @since 2026. 04. 21.
* *
* @extends PersistentProxyObjectFactory<Gamer> * @extends PersistentObjectFactory<Gamer>
*/ */
class GamerFactory extends PersistentProxyObjectFactory class GamerFactory extends PersistentObjectFactory
{ {
protected function defaults(): array protected function defaults(): array
{ {
+4 -4
View File
@@ -12,7 +12,7 @@ namespace App\Tests\Factory;
use App\Entity\Grid; use App\Entity\Grid;
use App\Entity\GridRow; use App\Entity\GridRow;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
/** /**
* Class GridFactory * Class GridFactory
@@ -24,9 +24,9 @@ use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
* @link www.splendidbear.org * @link www.splendidbear.org
* @since 2026. 04. 21. * @since 2026. 04. 21.
* *
* @extends PersistentProxyObjectFactory<Grid> * @extends PersistentObjectFactory<Grid>
*/ */
class GridFactory extends PersistentProxyObjectFactory class GridFactory extends PersistentObjectFactory
{ {
protected function defaults(): array protected function defaults(): array
{ {
@@ -45,7 +45,7 @@ class GridFactory extends PersistentProxyObjectFactory
return $this->afterInstantiate(function (Grid $grid): void { return $this->afterInstantiate(function (Grid $grid): void {
for ($i = 0; $i < 16; $i++) { for ($i = 0; $i < 16; $i++) {
/** @var GridRow $factory */ /** @var GridRow $factory */
$factory = GridRowFactory::new()->create()->_real(); $factory = GridRowFactory::new()->create();
$grid->addGridRow($factory); $grid->addGridRow($factory);
} }
}); });
+3 -3
View File
@@ -11,7 +11,7 @@
namespace App\Tests\Factory; namespace App\Tests\Factory;
use App\Entity\GridRow; use App\Entity\GridRow;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
/** /**
* Class GridRowFactory * Class GridRowFactory
@@ -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<GridRow> * @extends PersistentObjectFactory<GridRow>
*/ */
class GridRowFactory extends PersistentProxyObjectFactory class GridRowFactory extends PersistentObjectFactory
{ {
protected function defaults(): array protected function defaults(): array
{ {
+3 -3
View File
@@ -13,7 +13,7 @@ namespace App\Tests\Factory;
use App\Entity\PlayedGame; use App\Entity\PlayedGame;
use DateTime; use DateTime;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
/** /**
* Class PlayedGameFactory * Class PlayedGameFactory
@@ -25,9 +25,9 @@ use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
* @link www.splendidbear.org * @link www.splendidbear.org
* @since 2026. 04. 21. * @since 2026. 04. 21.
* *
* @extends PersistentProxyObjectFactory<PlayedGame> * @extends PersistentObjectFactory<PlayedGame>
*/ */
class PlayedGameFactory extends PersistentProxyObjectFactory class PlayedGameFactory extends PersistentObjectFactory
{ {
protected function defaults(): array protected function defaults(): array
{ {
+3 -3
View File
@@ -12,7 +12,7 @@ namespace App\Tests\Factory;
use App\Entity\Step; use App\Entity\Step;
use DateTime; use DateTime;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
/** /**
* Class StepFactory * Class StepFactory
@@ -24,9 +24,9 @@ use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
* @link www.splendidbear.org * @link www.splendidbear.org
* @since 2026. 04. 21. * @since 2026. 04. 21.
* *
* @extends PersistentProxyObjectFactory<Step> * @extends PersistentObjectFactory<Step>
*/ */
class StepFactory extends PersistentProxyObjectFactory class StepFactory extends PersistentObjectFactory
{ {
protected function defaults(): array protected function defaults(): array
{ {
+3 -3
View File
@@ -11,7 +11,7 @@
namespace App\Tests\Factory; namespace App\Tests\Factory;
use App\Entity\User; use App\Entity\User;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
/** /**
* Class UserFactory * Class UserFactory
@@ -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<User> * @extends PersistentObjectFactory<User>
*/ */
class UserFactory extends PersistentProxyObjectFactory class UserFactory extends PersistentObjectFactory
{ {
protected function defaults(): array protected function defaults(): array
{ {
+3 -3
View File
@@ -12,7 +12,7 @@ namespace App\Tests\Factory;
use App\Entity\WebAuthnCredential; use App\Entity\WebAuthnCredential;
use DateTime; use DateTime;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
/** /**
* Class WebAuthnCredentialFactory * Class WebAuthnCredentialFactory
@@ -24,9 +24,9 @@ use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
* @link www.splendidbear.org * @link www.splendidbear.org
* @since 2026. 04. 21. * @since 2026. 04. 21.
* *
* @extends PersistentProxyObjectFactory<WebAuthnCredential> * @extends PersistentObjectFactory<WebAuthnCredential>
*/ */
class WebAuthnCredentialFactory extends PersistentProxyObjectFactory class WebAuthnCredentialFactory extends PersistentObjectFactory
{ {
protected function defaults(): array protected function defaults(): array
{ {
+3 -3
View File
@@ -44,7 +44,7 @@ class FactoryExampleTest extends WebTestCase
{ {
$user = UserFactory::createOne(); $user = UserFactory::createOne();
self::assertInstanceOf(User::class, $user->_real()); self::assertInstanceOf(User::class, $user);
self::assertNotNull($user->username); self::assertNotNull($user->username);
self::assertNotNull($user->email); self::assertNotNull($user->email);
self::assertTrue($user->isVerified); self::assertTrue($user->isVerified);
@@ -87,7 +87,7 @@ class FactoryExampleTest extends WebTestCase
->withRegisteredPlayers() ->withRegisteredPlayers()
->create(); ->create();
self::assertInstanceOf(PlayedGame::class, $game->_real()); self::assertInstanceOf(PlayedGame::class, $game);
self::assertNotNull($game->red); self::assertNotNull($game->red);
self::assertNotNull($game->blue); self::assertNotNull($game->blue);
self::assertNull($game->redAnon); self::assertNull($game->redAnon);
@@ -181,7 +181,7 @@ class FactoryExampleTest extends WebTestCase
->recentlyUsed() ->recentlyUsed()
->create(['user' => $user]); ->create(['user' => $user]);
self::assertEquals($user->_real(), $credential->user); self::assertEquals($user, $credential->user);
self::assertEquals('YubiKey 5C', $credential->credentialName); self::assertEquals('YubiKey 5C', $credential->credentialName);
self::assertNotNull($credential->lastUsedAt); self::assertNotNull($credential->lastUsedAt);
} }
+24 -87
View File
@@ -34,16 +34,17 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
#[TestDox('Recaptcha Service')] #[TestDox('Recaptcha Service')]
class RecaptchaServiceTest extends TestCase class RecaptchaServiceTest extends TestCase
{ {
private const SECRET_KEY = 'test-secret-key'; private const API_ENDPOINT = 'http://localhost:3000/test-site-key/';
private const SECRET_KEY = 'test-secret-key';
#[Test] #[Test]
#[TestDox('Verify returns false for empty token')] #[TestDox('Verify returns false for empty token')]
public function verifyReturnsFalseForEmptyToken(): void public function verifyReturnsFalseForEmptyToken(): void
{ {
$httpClient = $this->createMock(HttpClientInterface::class); $httpClient = $this->createMock(HttpClientInterface::class);
$logger = $this->createMock(LoggerInterface::class); $logger = $this->createMock(LoggerInterface::class);
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY); $service = new RecaptchaService($httpClient, $logger, self::API_ENDPOINT, self::SECRET_KEY);
$result = $service->verify(''); $result = $service->verify('');
@@ -55,15 +56,14 @@ class RecaptchaServiceTest extends TestCase
public function verifyReturnsFalseWhenApiReturnsFailure(): void public function verifyReturnsFalseWhenApiReturnsFailure(): void
{ {
$mockResponse = new MockResponse(json_encode([ $mockResponse = new MockResponse(json_encode([
'success' => false, 'success' => false,
'error-codes' => ['invalid-input-secret'],
], JSON_THROW_ON_ERROR)); ], JSON_THROW_ON_ERROR));
$httpClient = new MockHttpClient($mockResponse); $httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class); $logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('info'); $logger->expects($this->once())->method('info');
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY); $service = new RecaptchaService($httpClient, $logger, self::API_ENDPOINT, self::SECRET_KEY);
$result = $service->verify('invalid-token'); $result = $service->verify('invalid-token');
@@ -71,47 +71,24 @@ class RecaptchaServiceTest extends TestCase
} }
#[Test] #[Test]
#[TestDox('Verify returns true when api returns success and high score')] #[TestDox('Verify returns true when api returns success')]
public function verifyReturnsTrueWhenApiReturnsSuccessAndHighScore(): void public function verifyReturnsTrueWhenApiReturnsSuccess(): void
{ {
$mockResponse = new MockResponse(json_encode([ $mockResponse = new MockResponse(json_encode([
'success' => true, 'success' => true,
'score' => 0.8,
'hostname' => 'test.com',
], JSON_THROW_ON_ERROR)); ], JSON_THROW_ON_ERROR));
$httpClient = new MockHttpClient($mockResponse); $httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class); $logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('info'); $logger->expects($this->once())->method('info');
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY); $service = new RecaptchaService($httpClient, $logger, self::API_ENDPOINT, self::SECRET_KEY);
$result = $service->verify('valid-token'); $result = $service->verify('valid-token');
$this->assertTrue($result); $this->assertTrue($result);
} }
#[Test]
#[TestDox('Verify returns false when score below threshold')]
public function verifyReturnsFalseWhenScoreBelowThreshold(): void
{
$mockResponse = new MockResponse(json_encode([
'success' => true,
'score' => 0.3,
'hostname' => 'test.com',
], JSON_THROW_ON_ERROR));
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('info');
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$result = $service->verify('low-score-token');
$this->assertFalse($result);
}
#[Test] #[Test]
#[TestDox('Verify returns false when api throws exception')] #[TestDox('Verify returns false when api throws exception')]
public function verifyReturnsFalseWhenApiThrowsException(): void public function verifyReturnsFalseWhenApiThrowsException(): void
@@ -119,10 +96,10 @@ class RecaptchaServiceTest extends TestCase
$mockResponse = new MockResponse('', ['http_code' => 500]); $mockResponse = new MockResponse('', ['http_code' => 500]);
$httpClient = new MockHttpClient($mockResponse); $httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class); $logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('error'); $logger->expects($this->once())->method('error');
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY); $service = new RecaptchaService($httpClient, $logger, self::API_ENDPOINT, self::SECRET_KEY);
$result = $service->verify('test-token'); $result = $service->verify('test-token');
@@ -130,59 +107,19 @@ class RecaptchaServiceTest extends TestCase
} }
#[Test] #[Test]
#[TestDox('Verify includes remote ip when provided')] #[TestDox('Siteverify URL is derived from api endpoint')]
public function verifyIncludesRemoteIpWhenProvided(): void public function siteverifyUrlIsDerivedFromApiEndpoint(): void
{ {
$mockResponse = new MockResponse(json_encode([ $mockResponse = new MockResponse(json_encode(['success' => true], JSON_THROW_ON_ERROR));
'success' => true,
'score' => 0.9,
], JSON_THROW_ON_ERROR));
$httpClient = new MockHttpClient($mockResponse); $httpClient = new MockHttpClient(function (string $method, string $url) use ($mockResponse): MockResponse {
$logger = $this->createMock(LoggerInterface::class); $this->assertSame('http://localhost:3000/test-site-key/siteverify', $url);
$logger->expects($this->once())->method('info'); return $mockResponse;
});
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY); $logger = $this->createMock(LoggerInterface::class);
$service = new RecaptchaService($httpClient, $logger, self::API_ENDPOINT, self::SECRET_KEY);
$result = $service->verify('test-token', '192.168.1.1'); $service->verify('test-token');
$this->assertTrue($result);
}
#[Test]
#[TestDox('Verify with score at threshold')]
public function verifyWithScoreAtThreshold(): void
{
$mockResponse = new MockResponse(json_encode([
'success' => true,
'score' => 0.5,
], JSON_THROW_ON_ERROR));
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$result = $service->verify('threshold-token');
$this->assertTrue($result);
}
#[Test]
#[TestDox('Verify with no score fails')]
public function verifyWithNoScoreFails(): void
{
$mockResponse = new MockResponse(json_encode([
'success' => true,
], JSON_THROW_ON_ERROR));
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$result = $service->verify('no-score-token');
$this->assertFalse($result);
} }
} }
+1 -1
View File
@@ -27,7 +27,7 @@ export default defineConfig({
mineseeker: './assets/js/app.jsx', mineseeker: './assets/js/app.jsx',
passkey: './assets/js/passkey.jsx', passkey: './assets/js/passkey.jsx',
profile: './assets/js/profile.jsx', profile: './assets/js/profile.jsx',
contact: './assets/js/contact.jsx', cap: './assets/js/cap.js',
mineseekerStyle: './assets/css/style.mineseeker.scss', mineseekerStyle: './assets/css/style.mineseeker.scss',
homeStyle: './assets/css/style.layout.scss', homeStyle: './assets/css/style.layout.scss',
passkeyStyle: './assets/css/passkey.scss', passkeyStyle: './assets/css/passkey.scss',