Private
Public Access
1
0

Compare commits

..

1 Commits

Author SHA1 Message Date
lang 6484133199 new: dev: initialize the GTK client #11 2026-04-28 08:28:51 +02:00
54 changed files with 3735 additions and 1120 deletions
+4 -6
View File
@@ -22,12 +22,10 @@ APP_PUBLIC_HOSTNAME=localhost
DATABASE_URL=postgresql://POSTGRES_USER:POSTGRES_PASSWORD@127.0.0.1:15432/POSTGRES_DB?serverVersion=18&charset=utf8
###< doctrine/doctrine-bundle ###
###> trycap.dev/cap ###
# CAP_API_ENDPOINT: Full URL including site-key path, e.g. https://cap.example.com/my-site-key/
# After deploying the Cap service, create a site in the dashboard and paste the endpoint here.
CAP_API_ENDPOINT=http://localhost:3000/changethis/
CAP_SECRET_KEY=changethis
###< trycap.dev/cap ###
###> google/recaptcha ###
RECAPTCHA_SITE_KEY=changethis
RECAPTCHA_SECRET_KEY=changethis
###< google/recaptcha ###
###> minio/minio ###
MINIO_ROOT_USER=changethis
+3 -4
View File
@@ -78,8 +78,7 @@ Edit `.env` and fill in every value. Key ones:
| `MINIO_ROOT_USER/PASSWORD` | MinIO admin credentials |
| `MINIO_ENDPOINT` | `http://localhost:9000` (bare-metal) |
| `MINIO_PUBLIC_URL` | Public URL browsers use to reach MinIO |
| `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) |
| `RECAPTCHA_SITE_KEY/SECRET_KEY` | Google reCAPTCHA v3 keys for your domain |
| `MERCURE_JWT_SECRET` | Random secret (generated in step 3) |
| `MERCURE_JWT_TOKEN` | Signed publisher JWT (generated in step 3) |
| `MERCURE_SUBSCRIBER_JWT` | Signed subscriber JWT (generated in step 3) |
@@ -223,8 +222,8 @@ MINIO_PUBLIC_URL=https://aws.mineseeker.hu
MAILER_DSN=smtp://mail:25?verify_peer=0
MAIL_DOMAIN=mineseeker.hu
CAP_API_ENDPOINT="https://cap.example.com/your-site-key/"
CAP_SECRET_KEY="<secret key from your Cap site dashboard>"
RECAPTCHA_SITE_KEY="<your reCAPTCHA v3 site key>"
RECAPTCHA_SECRET_KEY="<your reCAPTCHA v3 secret key>"
MERCURE_URL=https://mineseeker.hu/.well-known/mercure
MERCURE_PUBLIC_URL=https://mineseeker.hu/.well-known/mercure
+1 -60
View File
@@ -75,7 +75,7 @@
border-radius: 10px;
padding: 44px 48px 40px;
width: 100%;
max-width: 520px;
max-width: 420px;
backdrop-filter: blur(4px);
box-shadow: 0 8px 48px rgba(0, 0, 0, 0.5);
}
@@ -87,65 +87,6 @@
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 {
font: 400 14px 'Rajdhani', sans-serif;
color: rgba(149, 207, 245, 0.6);
+3 -75
View File
@@ -7,17 +7,12 @@
* file that was distributed with this source code.
*/
.game-brand-bar {
display: flex;
align-items: center;
.back-from-game {
display: inline-block;
position: fixed;
top: 20px;
left: 20px;
z-index: 20;
}
.back-from-game {
display: inline-block;
-ms-transform: scale(1);
-webkit-transform: scale(1);
transform: scale(1);
@@ -35,71 +30,4 @@
transform: scale(1.2);
-webkit-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
@@ -1,87 +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 '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();
+79 -2
View File
@@ -7,6 +7,83 @@
* file that was distributed with this source code.
*/
const ContactForm = () => null;
import { useEffect, useRef } from 'react';
import { string } from 'prop-types';
export default ContactForm;
/**
* 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;
@@ -7,16 +7,17 @@
* file that was distributed with this source code.
*/
import 'cap-widget';
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react';
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import { func, node, string } from 'prop-types';
const CAPTCHA_STORAGE_KEY = 'mineseeker_captcha_verified';
const CAPTCHA_TOKEN_KEY = 'mineseeker_captcha_token';
const RECAPTCHA_ACTION = 'mineseeker_play';
const CaptchaOverlay = ({ apiEndpoint, onVerified, children }) => {
const CaptchaOverlay = ({ siteKey, onVerified, children }) => {
const [verified, setVerified] = useState(false);
const capRef = useRef(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const handleToken = useCallback(token => {
const wrapper = document.getElementById('mine-wrapper');
@@ -29,6 +30,12 @@ const CaptchaOverlay = ({ apiEndpoint, onVerified, children }) => {
onVerified?.();
}, [onVerified]);
const buttonClasses = useMemo(() => [
'captcha-button',
error && 'captcha-button--error',
loading && 'captcha-button--loading',
].filter(Boolean).join(' '), [error, loading]);
useEffect(() => {
const storedToken = sessionStorage.getItem(CAPTCHA_TOKEN_KEY);
const storedTime = sessionStorage.getItem(CAPTCHA_STORAGE_KEY);
@@ -45,42 +52,40 @@ const CaptchaOverlay = ({ apiEndpoint, onVerified, children }) => {
return;
}
}
}, [onVerified]);
useEffect(() => {
const widget = document.createElement('cap-widget');
widget.style.display = 'none';
capRef.current = widget;
document.body.appendChild(widget);
if (window.grecaptcha) {
window.grecaptcha.ready(() => {
window.grecaptcha
.execute(siteKey, { action: RECAPTCHA_ACTION })
.then(token => {
handleToken(token);
})
.catch(() => {
setError(true);
});
});
}
}, [siteKey, onVerified, handleToken]);
const cap = new window.Cap({ apiEndpoint }, widget);
let cancelled = false;
const run = async () => {
try {
const result = await cap.solve();
if (!cancelled && result?.token) {
handleToken(result.token);
}
} catch (_) {
if (!cancelled) {
setTimeout(() => {
if (!cancelled) {
run();
}
}, 1200);
}
}
};
const handleClick = () => {
setLoading(true);
setError(false);
run();
return () => {
cancelled = true;
widget.remove();
capRef.current = null;
};
}, [apiEndpoint, handleToken]);
window.grecaptcha.ready(() => {
window.grecaptcha
.execute(siteKey, { action: RECAPTCHA_ACTION })
.then(token => {
handleToken(token);
setLoading(false);
})
.catch(() => {
setLoading(false);
setError(true);
setTimeout(() => setError(false), 2000);
});
});
};
if (verified) {
return <Fragment>{children}</Fragment>;
@@ -94,8 +99,16 @@ const CaptchaOverlay = ({ apiEndpoint, onVerified, children }) => {
</div>
<h1 className="captcha-title">Ready to Play?</h1>
<p className="captcha-description">
Verifying your session...
Click below to verify you&apos;re human and start playing.
</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>
);
@@ -104,7 +117,7 @@ const CaptchaOverlay = ({ apiEndpoint, onVerified, children }) => {
export default CaptchaOverlay;
CaptchaOverlay.propTypes = {
apiEndpoint: string.isRequired,
onVerified: func,
children: node,
siteKey: string.isRequired,
onVerified: func,
children: node,
};
@@ -19,7 +19,7 @@ export const GameBoard = ({ gameAssoc, gameInherited, opponentName = '', isEnvDe
const { onClick, resign } = useServerCommunication(gameAssoc, gameInherited, opponentName, isEnvDev);
const [captchaVerified, setCaptchaVerified] = useState(false);
const apiEndpoint = document.getElementById('mine-wrapper')?.dataset.capApiEndpoint;
const siteKey = document.getElementById('mine-wrapper')?.dataset.recaptchaSiteKey;
if (!gridReady) {
return (
@@ -29,9 +29,9 @@ export const GameBoard = ({ gameAssoc, gameInherited, opponentName = '', isEnvDe
);
}
if (!captchaVerified && apiEndpoint) {
if (!captchaVerified && siteKey) {
return (
<CaptchaOverlay apiEndpoint={apiEndpoint} onVerified={() => setCaptchaVerified(true)} />
<CaptchaOverlay siteKey={siteKey} onVerified={() => setCaptchaVerified(true)} />
);
}
+57 -60
View File
@@ -11,27 +11,26 @@
"@fontsource/open-sans": "^5.2.7",
"@fontsource/rajdhani": "^5.2.7",
"@fortawesome/fontawesome-free": "^7.2.0",
"@mui/material": "^9.0.1",
"@mui/x-charts": "^9.3.0",
"@tanstack/react-query": "^5.100.14",
"cap-widget": "^0.1.53",
"@mui/material": "^9.0.0",
"@mui/x-charts": "^9.0.2",
"@tanstack/react-query": "^5.99.2",
"howler": "^2.2.4",
"lodash": "^4.18.1",
"prop-types": "^15.8.1",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react": "^19.2.5",
"react-dom": "^19.2.5",
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "10.0.1",
"@stylistic/eslint-plugin": "5.10.0",
"@vitejs/plugin-react": "^6.0.2",
"eslint": "^10.4.1",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "10.2.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "7.1.1",
"globals": "^17.6.0",
"sass": "^1.100.0",
"vite": "^8.0.16",
"globals": "17.5.0",
"sass": "^1.99.0",
"vite": "^8.0.8",
"vite-plugin-symfony": "^8.2.4",
},
},
@@ -71,9 +70,9 @@
"@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.10.0", "http://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@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=="],
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "http://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "http://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "http://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
@@ -109,7 +108,7 @@
"@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.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "http://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="],
"@eslint/core": ["@eslint/core@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=="],
@@ -119,7 +118,7 @@
"@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.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "http://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
"@fontsource/changa-one": ["@fontsource/changa-one@5.2.8", "http://registry.npmjs.org/@fontsource/changa-one/-/changa-one-5.2.8.tgz", {}, "sha512-gagiU8sMWLs9ejh41NmrYlGdgasSYWFegz5/+22WqYdJVS9HZbaUEXj/6jj3ZKgi+dQZT0kYI+Nha2UQGbO/mA=="],
@@ -147,29 +146,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=="],
"@mui/core-downloads-tracker": ["@mui/core-downloads-tracker@9.0.1", "http://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.0.1.tgz", {}, "sha512-GzamIIhZ1bH77dq7eKaeyRgJdkypsxin4jBFq2EMs4lBWRR0LFO1CSVMsoebn/VvjcNrnrOrjy48MkrkQUK2iw=="],
"@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/material": ["@mui/material@9.0.1", "http://registry.npmjs.org/@mui/material/-/material-9.0.1.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@mui/core-downloads-tracker": "^9.0.1", "@mui/system": "^9.0.1", "@mui/types": "^9.0.0", "@mui/utils": "^9.0.1", "@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.1", "@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-voyCpeUxcSWLN7KPZuq0pGCIt726T9K6kiVM3XUcywZDAlZSarLHaUxJVQpospbjjOzN53hwyjo8s6KoWl6utw=="],
"@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/private-theming": ["@mui/private-theming@9.0.1", "http://registry.npmjs.org/@mui/private-theming/-/private-theming-9.0.1.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@mui/utils": "^9.0.1", "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-pSIGq4Yw749KHEwlkYZWVERgHgwJELP6ODtBNUfV8V4oIb5H+h7IQDFXuk/b2oQccODK1enJAtiEzlgLZmq+8g=="],
"@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/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/system": ["@mui/system@9.0.1", "http://registry.npmjs.org/@mui/system/-/system-9.0.1.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@mui/private-theming": "^9.0.1", "@mui/styled-engine": "^9.0.0", "@mui/types": "^9.0.0", "@mui/utils": "^9.0.1", "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-WvlioaLxk6ewUIOfh0StxUvOPDS1mCfzaulcudsL1brZNXuh0N9FMk7RpH7ImJKjEz412SEy/V/yvqmtxbqxCQ=="],
"@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/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/utils": ["@mui/utils@9.0.1", "http://registry.npmjs.org/@mui/utils/-/utils-9.0.1.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-f3UO3jNN1pYg5zxqXC81Bvv8hx5ACcYc0387382ZI7M5ono1heIwHYLrKsz85myguWdeVKPRZGmDdynWUBjK2g=="],
"@mui/utils": ["@mui/utils@9.0.0", "http://registry.npmjs.org/@mui/utils/-/utils-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@mui/types": "^9.0.0", "@types/prop-types": "^15.7.15", "clsx": "^2.1.1", "prop-types": "^15.8.1", "react-is": "^19.2.4" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-bQcqyg/gjULUqTuyUjSAFr6LQGLvtkNtDbJerAtoUn9kGZ0hg5QJiN1PLHMLbeFpe3te1831uq7GFl2ITokGdg=="],
"@mui/x-charts": ["@mui/x-charts@9.3.0", "http://registry.npmjs.org/@mui/x-charts/-/x-charts-9.3.0.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@mui/utils": "9.0.1", "@mui/x-charts-vendor": "^9.0.0", "@mui/x-internal-gestures": "^9.3.0", "@mui/x-internals": "^9.1.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-KyMdNYJvHBmmF4jaBQZMI6gyi6hhOt66Zx4cIOtN/w1JVvCKuvHUruZwHPONVlMKcKwn0AJTo/QV19EGGItGcQ=="],
"@mui/x-charts": ["@mui/x-charts@9.0.2", "http://registry.npmjs.org/@mui/x-charts/-/x-charts-9.0.2.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@mui/utils": "9.0.0", "@mui/x-charts-vendor": "^9.0.0", "@mui/x-internal-gestures": "^9.0.2", "@mui/x-internals": "^9.0.0", "bezier-easing": "^2.1.0", "clsx": "^2.1.1", "prop-types": "^15.8.1", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", "@mui/material": "^7.3.0 || ^9.0.0", "@mui/system": "^7.3.0 || ^9.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled"] }, "sha512-bKgjGD+uJbDN/g7tMjVmlNdm+iM4UkCJoYruQmgpQ0l+cip8Kn4kmn1iD//rZ35an+LdWaUZ4MHvMzV76D6EJw=="],
"@mui/x-charts-vendor": ["@mui/x-charts-vendor@9.0.0", "http://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@types/d3-array": "^3.2.2", "@types/d3-color": "^3.1.3", "@types/d3-format": "^3.0.4", "@types/d3-interpolate": "^3.0.4", "@types/d3-path": "^3.1.1", "@types/d3-scale": "^4.0.9", "@types/d3-shape": "^3.1.8", "@types/d3-time": "^3.0.4", "@types/d3-time-format": "^4.0.3", "@types/d3-timer": "^3.0.2", "d3-array": "^3.2.4", "d3-color": "^3.1.0", "d3-format": "^3.1.2", "d3-interpolate": "^3.0.1", "d3-path": "^3.1.0", "d3-scale": "^4.0.2", "d3-shape": "^3.2.0", "d3-time": "^3.1.0", "d3-time-format": "^4.1.0", "d3-timer": "^3.0.1", "flatqueue": "^3.0.0", "internmap": "^2.0.3" } }, "sha512-Do91i+fZiNj/4LN5oaGpJoutolzDBDwdfw6tHrx2LKXDMCRlaImCfreLbdbkk7dFsi9fuIP7hWiMV4vDJKPJTA=="],
"@mui/x-internal-gestures": ["@mui/x-internal-gestures@9.3.0", "http://registry.npmjs.org/@mui/x-internal-gestures/-/x-internal-gestures-9.3.0.tgz", { "dependencies": { "@babel/runtime": "^7.29.2" } }, "sha512-OXgda1tnUKaDF4+6uxERdlyz8uFkhVrqchRbTiT/vWmUtZ3PcicSwp4rqZyaKcoDMgPwCoEEXVBlwZaiLo8Whw=="],
"@mui/x-internal-gestures": ["@mui/x-internal-gestures@9.0.2", "http://registry.npmjs.org/@mui/x-internal-gestures/-/x-internal-gestures-9.0.2.tgz", { "dependencies": { "@babel/runtime": "^7.28.6" } }, "sha512-xCp99a7cSb7iH1bj4G524ooMOFe92H8m/rONCUiKyj7LvV1YUGzTfHgJysQgDCZJqHYaW7YAGLvwMUyEMZVzqQ=="],
"@mui/x-internals": ["@mui/x-internals@9.1.0", "http://registry.npmjs.org/@mui/x-internals/-/x-internals-9.1.0.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@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-fVezTa1lU+Hb3y9UMI8D/iWXADhs0I8PaZqoh2LOUXjGEUJmKqwsRD19ZXInZsH2yu+YS0dqYMPDvzjYTTyo+Q=="],
"@mui/x-internals": ["@mui/x-internals@9.0.0", "http://registry.npmjs.org/@mui/x-internals/-/x-internals-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@mui/utils": "9.0.0", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-E/4rdg69JjhyybpPGypCjAKSKLLnSdCFM+O6P/nkUg47+qt3uftxQEhjQO53rcn6ahHl6du/uNZ9BLgeY6kYxQ=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "http://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", { "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.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "http://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
@@ -177,7 +176,7 @@
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "http://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@oxc-project/types": ["@oxc-project/types@0.133.0", "http://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="],
"@oxc-project/types": ["@oxc-project/types@0.126.0", "", {}, "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ=="],
"@parcel/watcher": ["@parcel/watcher@2.5.6", "http://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],
@@ -211,43 +210,43 @@
"@popperjs/core": ["@popperjs/core@2.11.8", "http://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "http://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.16", "", { "os": "android", "cpu": "arm64" }, "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "http://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.3", "http://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.3", "http://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.16", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.3", "http://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", { "os": "linux", "cpu": "arm" }, "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm" }, "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.3", "http://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.3", "http://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.3", "http://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.3", "http://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "s390x" }, "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.3", "http://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", { "os": "linux", "cpu": "x64" }, "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.3", "http://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", { "os": "linux", "cpu": "x64" }, "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.3", "http://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", { "os": "none", "cpu": "arm64" }, "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.16", "", { "os": "none", "cpu": "arm64" }, "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.3", "http://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.16", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.3", "http://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.3", "http://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", { "os": "win32", "cpu": "x64" }, "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "x64" }, "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "http://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "http://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.10.0", "http://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.56.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": "^9.0.0 || ^10.0.0" } }, "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ=="],
"@tanstack/query-core": ["@tanstack/query-core@5.100.14", "http://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz", {}, "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew=="],
"@tanstack/query-core": ["@tanstack/query-core@5.99.2", "http://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.2.tgz", {}, "sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA=="],
"@tanstack/react-query": ["@tanstack/react-query@5.100.14", "http://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.14.tgz", { "dependencies": { "@tanstack/query-core": "5.100.14" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw=="],
"@tanstack/react-query": ["@tanstack/react-query@5.99.2", "http://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.2.tgz", { "dependencies": { "@tanstack/query-core": "5.99.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vM91UEe45QUS9ED6OklsVL15i8qKcRqNwpWzPTVWvRPRSEgDudDgHpvyTjcdlwHcrKNa80T+xXYcchT2noPnZA=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "http://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
@@ -287,7 +286,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=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.2", "http://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", { "dependencies": { "@rolldown/pluginutils": "^1.0.0" }, "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-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "http://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
"acorn": ["acorn@8.16.0", "http://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
@@ -339,9 +338,7 @@
"caniuse-lite": ["caniuse-lite@1.0.30001788", "http://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", {}, "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ=="],
"cap-widget": ["cap-widget@0.1.53", "http://registry.npmjs.org/cap-widget/-/cap-widget-0.1.53.tgz", {}, "sha512-sF8exFiaOwQOuTkxAh8mDtA6VU/x3O9L5ZXNRmmAlEjPtOzbczcO0mleLnPq9R/7qM0KcenYl9roqBNUfdTcXw=="],
"chokidar": ["chokidar@5.0.0", "http://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"chokidar": ["chokidar@4.0.3", "http://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"clsx": ["clsx@2.1.1", "http://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
@@ -421,7 +418,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=="],
"eslint": ["eslint@10.4.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.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.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw=="],
"eslint": ["eslint@10.2.1", "http://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q=="],
"eslint-plugin-react": ["eslint-plugin-react@7.37.5", "http://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="],
@@ -489,7 +486,7 @@
"glob-parent": ["glob-parent@6.0.2", "http://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@17.6.0", "", {}, "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA=="],
"globals": ["globals@17.5.0", "http://registry.npmjs.org/globals/-/globals-17.5.0.tgz", {}, "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g=="],
"globalthis": ["globalthis@1.0.4", "http://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
@@ -653,7 +650,7 @@
"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.12", "http://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
"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=="],
"natural-compare": ["natural-compare@1.4.0", "http://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
@@ -703,7 +700,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=="],
"postcss": ["postcss@8.5.15", "http://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
"postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"prelude-ls": ["prelude-ls@1.2.1", "http://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
@@ -713,15 +710,15 @@
"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.7", "http://registry.npmjs.org/react/-/react-19.2.7.tgz", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
"react": ["react@19.2.5", "http://registry.npmjs.org/react/-/react-19.2.5.tgz", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
"react-dom": ["react-dom@19.2.7", "http://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
"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-is": ["react-is@19.2.5", "http://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", {}, "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ=="],
"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@5.0.0", "http://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"readdirp": ["readdirp@4.1.2", "http://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"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=="],
@@ -735,7 +732,7 @@
"reusify": ["reusify@1.1.0", "http://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rolldown": ["rolldown@1.0.3", "http://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="],
"rolldown": ["rolldown@1.0.0-rc.16", "", { "dependencies": { "@oxc-project/types": "=0.126.0", "@rolldown/pluginutils": "1.0.0-rc.16" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-x64": "1.0.0-rc.16", "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g=="],
"run-parallel": ["run-parallel@1.2.0", "http://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
@@ -745,7 +742,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=="],
"sass": ["sass@1.100.0", "http://registry.npmjs.org/sass/-/sass-1.100.0.tgz", { "dependencies": { "chokidar": "^5.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ=="],
"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=="],
"scheduler": ["scheduler@0.27.0", "http://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
@@ -793,7 +790,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=="],
"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=="],
"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=="],
"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=="],
@@ -819,7 +816,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=="],
"vite": ["vite@8.0.16", "http://registry.npmjs.org/vite/-/vite-8.0.16.tgz", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "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-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="],
"vite": ["vite@8.0.9", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.16", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw=="],
"vite-plugin-symfony": ["vite-plugin-symfony@8.2.4", "http://registry.npmjs.org/vite-plugin-symfony/-/vite-plugin-symfony-8.2.4.tgz", { "dependencies": { "debug": "^4.4.1", "fast-glob": "^3.3.3", "picocolors": "^1.1.1", "sirv": "^3.0.1" }, "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-ph98EMPx8FhA6QIp43ZiK0zjODV9jumB7EHXfZEhRme2lo9oBa9sAXCNCCIvdVk/m9EWkNZpcRBjequjXZiSuA=="],
@@ -853,8 +850,6 @@
"@eslint/eslintrc/globals": ["globals@14.0.0", "http://registry.npmjs.org/globals/-/globals-14.0.0.tgz", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@mui/x-internals/@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=="],
"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=="],
@@ -871,6 +866,8 @@
"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=="],
"@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/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=="],
+5 -3
View File
@@ -25,8 +25,8 @@ services:
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}
RECAPTCHA_SITE_KEY: ${RECAPTCHA_SITE_KEY}
RECAPTCHA_SECRET_KEY: ${RECAPTCHA_SECRET_KEY}
WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:-localhost}
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-https://localhost}
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
@@ -105,7 +105,7 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}" ]
interval: 5s
@@ -121,3 +121,5 @@ volumes:
caddy_config:
postfix_spool:
minio_data:
Generated
+624 -666
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -4,4 +4,4 @@ twig:
strict_variables: '%kernel.debug%'
globals:
version: "%jotunheimr.version%"
cap_api_endpoint: "%env(CAP_API_ENDPOINT)%"
recaptcha_site_key: "%env(RECAPTCHA_SITE_KEY)%"
+8
View File
@@ -0,0 +1,8 @@
# 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
@@ -0,0 +1,11 @@
.env
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.egg-info/
dist/
build/
.venv/
venv/
+47
View File
@@ -0,0 +1,47 @@
"""
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
@@ -0,0 +1,93 @@
"""
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
@@ -0,0 +1,52 @@
"""
This file is part of the SplendidBear Websites' projects.
Copyright (c) 2026 @ www.splendidbear.org
For the full copyright and license information, please view the LICENSE
file that was distributed with this source code.
"""
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
@@ -0,0 +1,117 @@
"""
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
@@ -0,0 +1,163 @@
"""
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
@@ -0,0 +1,108 @@
"""
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
@@ -0,0 +1,30 @@
"""
This file is part of the SplendidBear Websites' projects.
Copyright (c) 2026 @ www.splendidbear.org
For the full copyright and license information, please view the LICENSE
file that was distributed with this source code.
"""
import 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
@@ -0,0 +1,90 @@
"""
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
@@ -0,0 +1,267 @@
"""
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
@@ -0,0 +1,45 @@
"""
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
@@ -0,0 +1,109 @@
"""
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
@@ -0,0 +1,96 @@
"""
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
@@ -0,0 +1,477 @@
"""
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
@@ -0,0 +1,239 @@
"""
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
@@ -0,0 +1,185 @@
"""
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
@@ -0,0 +1,146 @@
"""
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
@@ -0,0 +1,116 @@
"""
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
@@ -0,0 +1,102 @@
"""
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
@@ -0,0 +1,112 @@
"""
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
@@ -0,0 +1,17 @@
# 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
@@ -0,0 +1,31 @@
#!/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" "$@"
+10 -11
View File
@@ -20,27 +20,26 @@
"@fontsource/open-sans": "^5.2.7",
"@fontsource/rajdhani": "^5.2.7",
"@fortawesome/fontawesome-free": "^7.2.0",
"@mui/material": "^9.0.1",
"@mui/x-charts": "^9.3.0",
"@tanstack/react-query": "^5.100.14",
"cap-widget": "^0.1.53",
"@mui/material": "^9.0.0",
"@mui/x-charts": "^9.0.2",
"@tanstack/react-query": "^5.99.2",
"howler": "^2.2.4",
"lodash": "^4.18.1",
"prop-types": "^15.8.1",
"react": "^19.2.7",
"react-dom": "^19.2.7"
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "10.0.1",
"@stylistic/eslint-plugin": "5.10.0",
"@vitejs/plugin-react": "^6.0.2",
"eslint": "^10.4.1",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "10.2.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "7.1.1",
"globals": "^17.6.0",
"sass": "^1.100.0",
"vite": "^8.0.16",
"globals": "17.5.0",
"sass": "^1.99.0",
"vite": "^8.0.9",
"vite-plugin-symfony": "^8.2.4"
},
"scripts": {
+3 -3
View File
@@ -25,13 +25,13 @@ use Symfony\Component\Routing\Attribute\Route;
* Class ApiAuthController
*
* Provides a JSON login endpoint for native desktop clients.
* This endpoint is intentionally exempt from the CAPTCHA listener
* because desktop clients cannot display or solve the Cap widget.
* This endpoint is intentionally exempt from the reCAPTCHA listener
* because desktop clients cannot execute reCAPTCHA v3.
*
* After a successful password login, if the user has TOTP enabled the response
* returns { requiresTwoFactor: true }. The client must then POST the 6-digit
* code to the standard /2fa_check endpoint (which is already exempt from
* the CAPTCHA listener via LoginCaptchaListener).
* the reCAPTCHA listener via LoginCaptchaListener).
*
* @package App\Controller
* @author Lang <https://www.splendidbear.org>
+21
View File
@@ -12,6 +12,7 @@ namespace App\Controller;
use App\Entity\PlayedGame;
use App\Repository\PlayedGameRepository;
use App\Service\MercureJwtService;
use App\Service\ResolveUserNamesService;
use App\Util\RpcManager;
use App\Util\TopicManager;
@@ -23,6 +24,7 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;
/**
* Class MercureController
@@ -45,9 +47,28 @@ class MercureController extends AbstractController
private readonly TopicManager $topicManager,
private readonly RpcManager $rpcManager,
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'])]
public function start(Request $request): JsonResponse
{
+4 -4
View File
@@ -19,7 +19,7 @@ use Symfony\Component\Security\Http\Event\CheckPassportEvent;
/**
* Class LoginCaptchaListener
*
* Validates the Cap CAPTCHA token during form-login authentication.
* Validates the Google reCAPTCHA v3 token during form-login authentication.
* Fires on CheckPassportEvent, which is dispatched after credentials are
* collected but before the user is authenticated.
*
@@ -53,12 +53,12 @@ readonly class LoginCaptchaListener
return;
}
$token = $request->request->getString('cap-token');
$token = $request->request->getString('g-recaptcha-response');
if ($this->recaptcha->verify($token)) {
if ($this->recaptcha->verify($token, $request->getClientIp())) {
return;
}
throw new CustomUserMessageAuthenticationException('CAPTCHA verification failed. Please try again.');
throw new CustomUserMessageAuthenticationException('reCAPTCHA verification failed. Please try again.');
}
}
+5 -4
View File
@@ -22,8 +22,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Class RecaptchaType
*
* Reads the Cap CAPTCHA token from the raw POST field
* `cap-token` (auto-injected by the cap-widget web component) and injects
* Reads the Google reCAPTCHA v3 token from the raw POST field
* `g-recaptcha-response` (populated by JS before form submit) and injects
* it as this field's value before validation runs.
*
* @package App\Form
@@ -41,8 +41,9 @@ class RecaptchaType extends AbstractType
{
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (PreSubmitEvent $event): void {
$request = $this->requestStack->getCurrentRequest();
$token = $request?->request->getString('cap-token') ?? '';
// For forms that set the token directly on the field, fall back to the submitted value.
$token = $request?->request->getString('g-recaptcha-response') ?? '';
// For forms that set the token directly on the field (e.g. registration_form[recaptcha])
// rather than via a standalone g-recaptcha-response input, fall back to the submitted value.
if ($token === '') {
$token = (string) ($event->getData() ?? '');
}
+25 -13
View File
@@ -26,38 +26,50 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
*/
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(
private HttpClientInterface $httpClient,
private LoggerInterface $logger,
#[Autowire(env: 'CAP_API_ENDPOINT')]
private string $apiEndpoint,
#[Autowire(env: 'CAP_SECRET_KEY')]
#[Autowire(env: 'RECAPTCHA_SECRET_KEY')]
private string $secretKey,
) {}
public function verify(string $token): bool
public function verify(string $token, string $remoteIp = ''): bool
{
if ($token === '') {
return false;
}
try {
$siteverifyUrl = rtrim($this->apiEndpoint, '/') . '/siteverify';
$body = ['secret' => $this->secretKey, 'response' => $token];
if ($remoteIp !== '') {
$body['remoteip'] = $remoteIp;
}
$data = $this->httpClient
->request('POST', $siteverifyUrl, [
'body' => ['secret' => $this->secretKey, 'response' => $token],
])
->request('POST', self::SITEVERIFY_URL, ['body' => $body])
->toArray();
$this->logger->info('Cap verify response', [
'success' => $data['success'] ?? null,
'token_length' => strlen($token),
$this->logger->info('reCAPTCHA verify response', [
'success' => $data['success'] ?? null,
'score' => $data['score'] ?? null,
'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) {
$this->logger->error('Cap verification failed: ' . $e->getMessage());
$this->logger->error('reCAPTCHA verification failed: ' . $e->getMessage());
return false;
}
+6 -1
View File
@@ -11,6 +11,7 @@
namespace App\Validator;
use App\Service\RecaptchaService;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
@@ -29,6 +30,7 @@ final class RecaptchaValidator extends ConstraintValidator
{
public function __construct(
private readonly RecaptchaService $recaptcha,
private readonly RequestStack $requestStack,
) {
}
@@ -38,7 +40,10 @@ final class RecaptchaValidator extends ConstraintValidator
throw new UnexpectedTypeException($constraint, Recaptcha::class);
}
if ($this->recaptcha->verify((string)$value)) {
$request = $this->requestStack->getCurrentRequest();
$remoteIp = $request !== null ? ((string)$request->getClientIp()) : '';
if ($this->recaptcha->verify((string)$value, $remoteIp)) {
return;
}
+5 -9
View File
@@ -3,14 +3,9 @@
{% block title %} - Play!{% endblock %}
{% block body %}
<div class="game-brand-bar">
<a class="back-from-game" href="{{ path('MineSeekerBundle_homepage') }}">
<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>
<a class="back-from-game" href="{{ path('MineSeekerBundle_homepage') }}">
<img src="{{ asset('/images/mine-logo-txt.png') }}" alt="Mineseeker Logo">
</a>
<div class="mine-container">
<div id="mine-wrapper"
data-env="{{ env }}"
@@ -19,7 +14,7 @@
data-is-authenticated="{{ app.user ? '1' : '0' }}"
data-mercure-hub-url="{{ mercure_hub_url }}"
data-mercure-subscriber-jwt="{{ mercure_subscriber_jwt }}"
data-cap-api-endpoint="{{ cap_api_endpoint }}">
data-recaptcha-site-key="{{ recaptcha_site_key }}">
</div>
</div>
{% endblock %}
@@ -44,5 +39,6 @@
{% block javascripts %}
{{ parent() }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
{{ vite_entry_script_tags('mineseeker', { dependency: 'react' }) }}
{% endblock %}
+6 -9
View File
@@ -37,14 +37,6 @@
</p>
<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'}}) }}
<div class="auth-field">
@@ -135,5 +127,10 @@
{% block javascripts %}
{{ parent() }}
{{ vite_entry_script_tags('cap') }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
{{ 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 %}
+17 -8
View File
@@ -34,14 +34,8 @@
</div>
{% else %}
<div class="auth-card">
<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>
<h2 class="auth-title">Forgot Password</h2>
<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'}}) }}
@@ -81,5 +75,20 @@
{% block javascripts %}
{{ parent() }}
{{ vite_entry_script_tags('cap') }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
<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 %}
+18 -9
View File
@@ -36,12 +36,7 @@
{% endfor %}
<div class="auth-card">
<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>
<h2 class="auth-title">Sign In</h2>
<p class="auth-sub">Welcome back, commander</p>
{% if error %}
@@ -51,8 +46,6 @@
</div>
{% 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') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}"/>
@@ -99,6 +92,8 @@
</p>
</div>
<input type="hidden" id="g-recaptcha-response" name="g-recaptcha-response"/>
<button type="submit" class="auth-submit">
<i class="fas fa-right-to-bracket"></i> Sign In
</button>
@@ -126,6 +121,20 @@
{% block javascripts %}
{{ parent() }}
{{ vite_entry_script_tags('cap') }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
<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' }) }}
{% endblock %}
+17 -8
View File
@@ -42,14 +42,8 @@
</div>
{% else %}
<div class="auth-card">
<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>
<h2 class="auth-title">Create Account</h2>
<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'}}) }}
@@ -159,5 +153,20 @@
{% block javascripts %}
{{ parent() }}
{{ vite_entry_script_tags('cap') }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
<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 %}
+16 -8
View File
@@ -16,14 +16,8 @@
{% block body %}
<div class="auth-page">
<div class="auth-card">
<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>
<h2 class="auth-title">Reset Password</h2>
<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'}}) }}
@@ -71,5 +65,19 @@
{% block javascripts %}
{{ parent() }}
{{ vite_entry_script_tags('cap') }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
<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 %}
+88 -25
View File
@@ -34,17 +34,16 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
#[TestDox('Recaptcha Service')]
class RecaptchaServiceTest extends TestCase
{
private const API_ENDPOINT = 'http://localhost:3000/test-site-key/';
private const SECRET_KEY = 'test-secret-key';
private const SECRET_KEY = 'test-secret-key';
#[Test]
#[TestDox('Verify returns false for empty token')]
public function verifyReturnsFalseForEmptyToken(): void
{
$httpClient = $this->createMock(HttpClientInterface::class);
$logger = $this->createMock(LoggerInterface::class);
$logger = $this->createMock(LoggerInterface::class);
$service = new RecaptchaService($httpClient, $logger, self::API_ENDPOINT, self::SECRET_KEY);
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$result = $service->verify('');
@@ -56,14 +55,15 @@ class RecaptchaServiceTest extends TestCase
public function verifyReturnsFalseWhenApiReturnsFailure(): void
{
$mockResponse = new MockResponse(json_encode([
'success' => false,
'success' => false,
'error-codes' => ['invalid-input-secret'],
], JSON_THROW_ON_ERROR));
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('info');
$service = new RecaptchaService($httpClient, $logger, self::API_ENDPOINT, self::SECRET_KEY);
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$result = $service->verify('invalid-token');
@@ -71,24 +71,47 @@ class RecaptchaServiceTest extends TestCase
}
#[Test]
#[TestDox('Verify returns true when api returns success')]
public function verifyReturnsTrueWhenApiReturnsSuccess(): void
#[TestDox('Verify returns true when api returns success and high score')]
public function verifyReturnsTrueWhenApiReturnsSuccessAndHighScore(): void
{
$mockResponse = new MockResponse(json_encode([
'success' => true,
'success' => true,
'score' => 0.8,
'hostname' => 'test.com',
], JSON_THROW_ON_ERROR));
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('info');
$service = new RecaptchaService($httpClient, $logger, self::API_ENDPOINT, self::SECRET_KEY);
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$result = $service->verify('valid-token');
$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]
#[TestDox('Verify returns false when api throws exception')]
public function verifyReturnsFalseWhenApiThrowsException(): void
@@ -96,10 +119,10 @@ class RecaptchaServiceTest extends TestCase
$mockResponse = new MockResponse('', ['http_code' => 500]);
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('error');
$service = new RecaptchaService($httpClient, $logger, self::API_ENDPOINT, self::SECRET_KEY);
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$result = $service->verify('test-token');
@@ -107,19 +130,59 @@ class RecaptchaServiceTest extends TestCase
}
#[Test]
#[TestDox('Siteverify URL is derived from api endpoint')]
public function siteverifyUrlIsDerivedFromApiEndpoint(): void
#[TestDox('Verify includes remote ip when provided')]
public function verifyIncludesRemoteIpWhenProvided(): void
{
$mockResponse = new MockResponse(json_encode(['success' => true], JSON_THROW_ON_ERROR));
$mockResponse = new MockResponse(json_encode([
'success' => true,
'score' => 0.9,
], JSON_THROW_ON_ERROR));
$httpClient = new MockHttpClient(function (string $method, string $url) use ($mockResponse): MockResponse {
$this->assertSame('http://localhost:3000/test-site-key/siteverify', $url);
return $mockResponse;
});
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('info');
$logger = $this->createMock(LoggerInterface::class);
$service = new RecaptchaService($httpClient, $logger, self::API_ENDPOINT, self::SECRET_KEY);
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$service->verify('test-token');
$result = $service->verify('test-token', '192.168.1.1');
$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
View File
@@ -28,7 +28,6 @@ export default defineConfig({
passkey: './assets/js/passkey.jsx',
profile: './assets/js/profile.jsx',
contact: './assets/js/contact.jsx',
cap: './assets/js/cap.js',
mineseekerStyle: './assets/css/style.mineseeker.scss',
homeStyle: './assets/css/style.layout.scss',
passkeyStyle: './assets/css/passkey.scss',