Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6484133199 |
@@ -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
|
DATABASE_URL=postgresql://POSTGRES_USER:POSTGRES_PASSWORD@127.0.0.1:15432/POSTGRES_DB?serverVersion=18&charset=utf8
|
||||||
###< doctrine/doctrine-bundle ###
|
###< doctrine/doctrine-bundle ###
|
||||||
|
|
||||||
###> trycap.dev/cap ###
|
###> google/recaptcha ###
|
||||||
# CAP_API_ENDPOINT: Full URL including site-key path, e.g. https://cap.example.com/my-site-key/
|
RECAPTCHA_SITE_KEY=changethis
|
||||||
# After deploying the Cap service, create a site in the dashboard and paste the endpoint here.
|
RECAPTCHA_SECRET_KEY=changethis
|
||||||
CAP_API_ENDPOINT=http://localhost:3000/changethis/
|
###< google/recaptcha ###
|
||||||
CAP_SECRET_KEY=changethis
|
|
||||||
###< trycap.dev/cap ###
|
|
||||||
|
|
||||||
###> minio/minio ###
|
###> minio/minio ###
|
||||||
MINIO_ROOT_USER=changethis
|
MINIO_ROOT_USER=changethis
|
||||||
|
|||||||
@@ -78,8 +78,7 @@ Edit `.env` and fill in every value. Key ones:
|
|||||||
| `MINIO_ROOT_USER/PASSWORD` | MinIO admin credentials |
|
| `MINIO_ROOT_USER/PASSWORD` | MinIO admin credentials |
|
||||||
| `MINIO_ENDPOINT` | `http://localhost:9000` (bare-metal) |
|
| `MINIO_ENDPOINT` | `http://localhost:9000` (bare-metal) |
|
||||||
| `MINIO_PUBLIC_URL` | Public URL browsers use to reach MinIO |
|
| `MINIO_PUBLIC_URL` | Public URL browsers use to reach MinIO |
|
||||||
| `CAP_API_ENDPOINT` | Full URL of your Cap instance including the site-key path (e.g. `https://cap.example.com/my-site-key/`) |
|
| `RECAPTCHA_SITE_KEY/SECRET_KEY` | Google reCAPTCHA v3 keys for your domain |
|
||||||
| `CAP_SECRET_KEY` | Secret key from your Cap site dashboard (stored in `PROD_ENV_FILE` Gitea secret on prod) |
|
|
||||||
| `MERCURE_JWT_SECRET` | Random secret (generated in step 3) |
|
| `MERCURE_JWT_SECRET` | Random secret (generated in step 3) |
|
||||||
| `MERCURE_JWT_TOKEN` | Signed publisher JWT (generated in step 3) |
|
| `MERCURE_JWT_TOKEN` | Signed publisher JWT (generated in step 3) |
|
||||||
| `MERCURE_SUBSCRIBER_JWT` | Signed subscriber JWT (generated in step 3) |
|
| `MERCURE_SUBSCRIBER_JWT` | Signed subscriber JWT (generated in step 3) |
|
||||||
@@ -223,8 +222,8 @@ MINIO_PUBLIC_URL=https://aws.mineseeker.hu
|
|||||||
MAILER_DSN=smtp://mail:25?verify_peer=0
|
MAILER_DSN=smtp://mail:25?verify_peer=0
|
||||||
MAIL_DOMAIN=mineseeker.hu
|
MAIL_DOMAIN=mineseeker.hu
|
||||||
|
|
||||||
CAP_API_ENDPOINT="https://cap.example.com/your-site-key/"
|
RECAPTCHA_SITE_KEY="<your reCAPTCHA v3 site key>"
|
||||||
CAP_SECRET_KEY="<secret key from your Cap site dashboard>"
|
RECAPTCHA_SECRET_KEY="<your reCAPTCHA v3 secret key>"
|
||||||
|
|
||||||
MERCURE_URL=https://mineseeker.hu/.well-known/mercure
|
MERCURE_URL=https://mineseeker.hu/.well-known/mercure
|
||||||
MERCURE_PUBLIC_URL=https://mineseeker.hu/.well-known/mercure
|
MERCURE_PUBLIC_URL=https://mineseeker.hu/.well-known/mercure
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 44px 48px 40px;
|
padding: 44px 48px 40px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 520px;
|
max-width: 420px;
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
box-shadow: 0 8px 48px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 8px 48px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
@@ -87,65 +87,6 @@
|
|||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-title-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
|
|
||||||
.auth-title {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-cap-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid rgba(149, 207, 245, 0.35);
|
|
||||||
background:
|
|
||||||
linear-gradient(115deg, transparent 0 38%, rgba(255, 255, 255, 0.18) 48%, transparent 58% 100%),
|
|
||||||
rgba(35, 111, 135, 0.2);
|
|
||||||
background-position: 120% 50%, 0 0;
|
|
||||||
background-size: 260% 100%, auto;
|
|
||||||
color: #95cff5;
|
|
||||||
font: 600 11px 'Rajdhani', sans-serif;
|
|
||||||
letter-spacing: 0.8px;
|
|
||||||
text-decoration: none;
|
|
||||||
text-transform: uppercase;
|
|
||||||
transition:
|
|
||||||
background-position 650ms ease,
|
|
||||||
border-color 200ms ease,
|
|
||||||
box-shadow 200ms ease,
|
|
||||||
color 200ms ease,
|
|
||||||
transform 200ms ease;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:focus-visible {
|
|
||||||
background-position: -60% 50%, 0 0;
|
|
||||||
border-color: rgba(149, 207, 245, 0.65);
|
|
||||||
box-shadow: 0 0 18px rgba(35, 111, 135, 0.28);
|
|
||||||
color: #d5f1ff;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:focus,
|
|
||||||
&:active,
|
|
||||||
&:visited {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid rgba(149, 207, 245, 0.75);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-sub {
|
.auth-sub {
|
||||||
font: 400 14px 'Rajdhani', sans-serif;
|
font: 400 14px 'Rajdhani', sans-serif;
|
||||||
color: rgba(149, 207, 245, 0.6);
|
color: rgba(149, 207, 245, 0.6);
|
||||||
|
|||||||
@@ -7,17 +7,12 @@
|
|||||||
* file that was distributed with this source code.
|
* file that was distributed with this source code.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.game-brand-bar {
|
.back-from-game {
|
||||||
display: flex;
|
display: inline-block;
|
||||||
align-items: center;
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 20px;
|
top: 20px;
|
||||||
left: 20px;
|
left: 20px;
|
||||||
z-index: 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-from-game {
|
|
||||||
display: inline-block;
|
|
||||||
-ms-transform: scale(1);
|
-ms-transform: scale(1);
|
||||||
-webkit-transform: scale(1);
|
-webkit-transform: scale(1);
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
@@ -35,71 +30,4 @@
|
|||||||
transform: scale(1.2);
|
transform: scale(1.2);
|
||||||
-webkit-transition: all 250ms cubic-bezier(.17, .67, .83, .67);
|
-webkit-transition: all 250ms cubic-bezier(.17, .67, .83, .67);
|
||||||
transition: all 250ms cubic-bezier(.17, .67, .83, .67);
|
transition: all 250ms cubic-bezier(.17, .67, .83, .67);
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-cap-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
position: fixed;
|
|
||||||
bottom: 20px;
|
|
||||||
left: 50%;
|
|
||||||
z-index: 20;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border: 1px solid rgba(255, 216, 92, 0.42);
|
|
||||||
-webkit-border-radius: 999px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background:
|
|
||||||
linear-gradient(115deg, transparent 0 38%, rgba(255, 255, 255, 0.22) 48%, transparent 58% 100%),
|
|
||||||
rgba(14, 16, 18, 0.72);
|
|
||||||
background-position: 120% 50%, 0 0;
|
|
||||||
background-size: 260% 100%, auto;
|
|
||||||
-webkit-box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.32), 0 8px 22px rgba(0, 0, 0, 0.28);
|
|
||||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.32), 0 8px 22px rgba(0, 0, 0, 0.28);
|
|
||||||
color: #ffe58a;
|
|
||||||
font: 700 11px 'Rajdhani', sans-serif;
|
|
||||||
letter-spacing: 0.9px;
|
|
||||||
line-height: 1;
|
|
||||||
text-decoration: none;
|
|
||||||
text-transform: uppercase;
|
|
||||||
-ms-transform: translateX(-50%);
|
|
||||||
-webkit-transform: translateX(-50%);
|
|
||||||
transform: translateX(-50%);
|
|
||||||
white-space: nowrap;
|
|
||||||
-webkit-transition:
|
|
||||||
background-position 650ms ease,
|
|
||||||
border-color 200ms ease,
|
|
||||||
box-shadow 200ms ease,
|
|
||||||
color 200ms ease,
|
|
||||||
transform 200ms ease;
|
|
||||||
transition:
|
|
||||||
background-position 650ms ease,
|
|
||||||
border-color 200ms ease,
|
|
||||||
box-shadow 200ms ease,
|
|
||||||
color 200ms ease,
|
|
||||||
transform 200ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-cap-badge:hover,
|
|
||||||
.game-cap-badge:focus-visible {
|
|
||||||
background-position: -60% 50%, 0 0;
|
|
||||||
border-color: rgba(255, 229, 138, 0.78);
|
|
||||||
-webkit-box-shadow: 0 0 0 1px rgba(255, 229, 138, 0.18), 0 0 24px rgba(255, 195, 50, 0.28);
|
|
||||||
box-shadow: 0 0 0 1px rgba(255, 229, 138, 0.18), 0 0 24px rgba(255, 195, 50, 0.28);
|
|
||||||
color: #fff4bd;
|
|
||||||
text-decoration: none;
|
|
||||||
-ms-transform: translateX(-50%) translateY(-1px);
|
|
||||||
-webkit-transform: translateX(-50%) translateY(-1px);
|
|
||||||
transform: translateX(-50%) translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-cap-badge:focus,
|
|
||||||
.game-cap-badge:active,
|
|
||||||
.game-cap-badge:visited {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-cap-badge:focus-visible {
|
|
||||||
outline: 2px solid rgba(255, 229, 138, 0.75);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
@@ -7,6 +7,83 @@
|
|||||||
* file that was distributed with this source code.
|
* 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.
|
* file that was distributed with this source code.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import 'cap-widget';
|
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import { func, node, string } from 'prop-types';
|
import { func, node, string } from 'prop-types';
|
||||||
|
|
||||||
const CAPTCHA_STORAGE_KEY = 'mineseeker_captcha_verified';
|
const CAPTCHA_STORAGE_KEY = 'mineseeker_captcha_verified';
|
||||||
const CAPTCHA_TOKEN_KEY = 'mineseeker_captcha_token';
|
const CAPTCHA_TOKEN_KEY = 'mineseeker_captcha_token';
|
||||||
|
const RECAPTCHA_ACTION = 'mineseeker_play';
|
||||||
|
|
||||||
const CaptchaOverlay = ({ apiEndpoint, onVerified, children }) => {
|
const CaptchaOverlay = ({ siteKey, onVerified, children }) => {
|
||||||
const [verified, setVerified] = useState(false);
|
const [verified, setVerified] = useState(false);
|
||||||
const capRef = useRef(null);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
const handleToken = useCallback(token => {
|
const handleToken = useCallback(token => {
|
||||||
const wrapper = document.getElementById('mine-wrapper');
|
const wrapper = document.getElementById('mine-wrapper');
|
||||||
@@ -29,6 +30,12 @@ const CaptchaOverlay = ({ apiEndpoint, onVerified, children }) => {
|
|||||||
onVerified?.();
|
onVerified?.();
|
||||||
}, [onVerified]);
|
}, [onVerified]);
|
||||||
|
|
||||||
|
const buttonClasses = useMemo(() => [
|
||||||
|
'captcha-button',
|
||||||
|
error && 'captcha-button--error',
|
||||||
|
loading && 'captcha-button--loading',
|
||||||
|
].filter(Boolean).join(' '), [error, loading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedToken = sessionStorage.getItem(CAPTCHA_TOKEN_KEY);
|
const storedToken = sessionStorage.getItem(CAPTCHA_TOKEN_KEY);
|
||||||
const storedTime = sessionStorage.getItem(CAPTCHA_STORAGE_KEY);
|
const storedTime = sessionStorage.getItem(CAPTCHA_STORAGE_KEY);
|
||||||
@@ -45,42 +52,40 @@ const CaptchaOverlay = ({ apiEndpoint, onVerified, children }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [onVerified]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (window.grecaptcha) {
|
||||||
const widget = document.createElement('cap-widget');
|
window.grecaptcha.ready(() => {
|
||||||
widget.style.display = 'none';
|
window.grecaptcha
|
||||||
capRef.current = widget;
|
.execute(siteKey, { action: RECAPTCHA_ACTION })
|
||||||
document.body.appendChild(widget);
|
.then(token => {
|
||||||
|
handleToken(token);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [siteKey, onVerified, handleToken]);
|
||||||
|
|
||||||
const cap = new window.Cap({ apiEndpoint }, widget);
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
const run = async () => {
|
const handleClick = () => {
|
||||||
try {
|
setLoading(true);
|
||||||
const result = await cap.solve();
|
setError(false);
|
||||||
if (!cancelled && result?.token) {
|
|
||||||
handleToken(result.token);
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
if (!cancelled) {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!cancelled) {
|
|
||||||
run();
|
|
||||||
}
|
|
||||||
}, 1200);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
run();
|
window.grecaptcha.ready(() => {
|
||||||
|
window.grecaptcha
|
||||||
return () => {
|
.execute(siteKey, { action: RECAPTCHA_ACTION })
|
||||||
cancelled = true;
|
.then(token => {
|
||||||
widget.remove();
|
handleToken(token);
|
||||||
capRef.current = null;
|
setLoading(false);
|
||||||
};
|
})
|
||||||
}, [apiEndpoint, handleToken]);
|
.catch(() => {
|
||||||
|
setLoading(false);
|
||||||
|
setError(true);
|
||||||
|
setTimeout(() => setError(false), 2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (verified) {
|
if (verified) {
|
||||||
return <Fragment>{children}</Fragment>;
|
return <Fragment>{children}</Fragment>;
|
||||||
@@ -94,8 +99,16 @@ const CaptchaOverlay = ({ apiEndpoint, onVerified, children }) => {
|
|||||||
</div>
|
</div>
|
||||||
<h1 className="captcha-title">Ready to Play?</h1>
|
<h1 className="captcha-title">Ready to Play?</h1>
|
||||||
<p className="captcha-description">
|
<p className="captcha-description">
|
||||||
Verifying your session...
|
Click below to verify you're human and start playing.
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
className={buttonClasses}
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<i className={`fa ${loading ? 'fa-spinner fa-spin' : error ? 'fa-exclamation-circle' : 'fa-play'}`} />
|
||||||
|
{loading ? 'Verifying...' : error ? 'Try Again' : 'Start Playing'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -104,7 +117,7 @@ const CaptchaOverlay = ({ apiEndpoint, onVerified, children }) => {
|
|||||||
export default CaptchaOverlay;
|
export default CaptchaOverlay;
|
||||||
|
|
||||||
CaptchaOverlay.propTypes = {
|
CaptchaOverlay.propTypes = {
|
||||||
apiEndpoint: string.isRequired,
|
siteKey: string.isRequired,
|
||||||
onVerified: func,
|
onVerified: func,
|
||||||
children: node,
|
children: node,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const GameBoard = ({ gameAssoc, gameInherited, opponentName = '', isEnvDe
|
|||||||
const { onClick, resign } = useServerCommunication(gameAssoc, gameInherited, opponentName, isEnvDev);
|
const { onClick, resign } = useServerCommunication(gameAssoc, gameInherited, opponentName, isEnvDev);
|
||||||
const [captchaVerified, setCaptchaVerified] = useState(false);
|
const [captchaVerified, setCaptchaVerified] = useState(false);
|
||||||
|
|
||||||
const apiEndpoint = document.getElementById('mine-wrapper')?.dataset.capApiEndpoint;
|
const siteKey = document.getElementById('mine-wrapper')?.dataset.recaptchaSiteKey;
|
||||||
|
|
||||||
if (!gridReady) {
|
if (!gridReady) {
|
||||||
return (
|
return (
|
||||||
@@ -29,9 +29,9 @@ export const GameBoard = ({ gameAssoc, gameInherited, opponentName = '', isEnvDe
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!captchaVerified && apiEndpoint) {
|
if (!captchaVerified && siteKey) {
|
||||||
return (
|
return (
|
||||||
<CaptchaOverlay apiEndpoint={apiEndpoint} onVerified={() => setCaptchaVerified(true)} />
|
<CaptchaOverlay siteKey={siteKey} onVerified={() => setCaptchaVerified(true)} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,27 +11,26 @@
|
|||||||
"@fontsource/open-sans": "^5.2.7",
|
"@fontsource/open-sans": "^5.2.7",
|
||||||
"@fontsource/rajdhani": "^5.2.7",
|
"@fontsource/rajdhani": "^5.2.7",
|
||||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||||
"@mui/material": "^9.0.1",
|
"@mui/material": "^9.0.0",
|
||||||
"@mui/x-charts": "^9.3.0",
|
"@mui/x-charts": "^9.0.2",
|
||||||
"@tanstack/react-query": "^5.100.14",
|
"@tanstack/react-query": "^5.99.2",
|
||||||
"cap-widget": "^0.1.53",
|
|
||||||
"howler": "^2.2.4",
|
"howler": "^2.2.4",
|
||||||
"lodash": "^4.18.1",
|
"lodash": "^4.18.1",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.5",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.5",
|
"@eslint/eslintrc": "^3.3.5",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@stylistic/eslint-plugin": "5.10.0",
|
"@stylistic/eslint-plugin": "5.10.0",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"eslint": "^10.4.1",
|
"eslint": "10.2.1",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-hooks": "7.1.1",
|
"eslint-plugin-react-hooks": "7.1.1",
|
||||||
"globals": "^17.6.0",
|
"globals": "17.5.0",
|
||||||
"sass": "^1.100.0",
|
"sass": "^1.99.0",
|
||||||
"vite": "^8.0.16",
|
"vite": "^8.0.8",
|
||||||
"vite-plugin-symfony": "^8.2.4",
|
"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=="],
|
"@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=="],
|
"@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-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=="],
|
"@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/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=="],
|
"@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=="],
|
"@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/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/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-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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"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=="],
|
"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@4.0.3", "http://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||||
|
|
||||||
"chokidar": ["chokidar@5.0.0", "http://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
|
||||||
|
|
||||||
"clsx": ["clsx@2.1.1", "http://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
"clsx": ["clsx@2.1.1", "http://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
@@ -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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"@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=="],
|
"babel-plugin-macros/resolve": ["resolve@1.22.12", "http://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
|
||||||
|
|
||||||
"eslint/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "http://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
"eslint/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "http://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||||
@@ -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=="],
|
"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/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=="],
|
"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=="],
|
||||||
|
|||||||
+6
-4
@@ -25,8 +25,8 @@ services:
|
|||||||
MERCURE_JWT_TOKEN: ${MERCURE_JWT_TOKEN}
|
MERCURE_JWT_TOKEN: ${MERCURE_JWT_TOKEN}
|
||||||
MERCURE_SUBSCRIBER_JWT: ${MERCURE_SUBSCRIBER_JWT}
|
MERCURE_SUBSCRIBER_JWT: ${MERCURE_SUBSCRIBER_JWT}
|
||||||
MAILER_DSN: smtp://mail:25?verify_peer=0
|
MAILER_DSN: smtp://mail:25?verify_peer=0
|
||||||
CAP_API_ENDPOINT: ${CAP_API_ENDPOINT}
|
RECAPTCHA_SITE_KEY: ${RECAPTCHA_SITE_KEY}
|
||||||
CAP_SECRET_KEY: ${CAP_SECRET_KEY}
|
RECAPTCHA_SECRET_KEY: ${RECAPTCHA_SECRET_KEY}
|
||||||
WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:-localhost}
|
WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:-localhost}
|
||||||
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-https://localhost}
|
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-https://localhost}
|
||||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||||
@@ -105,7 +105,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}" ]
|
test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}" ]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -113,7 +113,7 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
ports:
|
ports:
|
||||||
- "5452:5432"
|
- "15432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
app_var:
|
app_var:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
@@ -121,3 +121,5 @@ volumes:
|
|||||||
caddy_config:
|
caddy_config:
|
||||||
postfix_spool:
|
postfix_spool:
|
||||||
minio_data:
|
minio_data:
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Generated
+624
-666
File diff suppressed because it is too large
Load Diff
@@ -4,4 +4,4 @@ twig:
|
|||||||
strict_variables: '%kernel.debug%'
|
strict_variables: '%kernel.debug%'
|
||||||
globals:
|
globals:
|
||||||
version: "%jotunheimr.version%"
|
version: "%jotunheimr.version%"
|
||||||
cap_api_endpoint: "%env(CAP_API_ENDPOINT)%"
|
recaptcha_site_key: "%env(RECAPTCHA_SITE_KEY)%"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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))
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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}")
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Executable
+31
@@ -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
@@ -20,27 +20,26 @@
|
|||||||
"@fontsource/open-sans": "^5.2.7",
|
"@fontsource/open-sans": "^5.2.7",
|
||||||
"@fontsource/rajdhani": "^5.2.7",
|
"@fontsource/rajdhani": "^5.2.7",
|
||||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||||
"@mui/material": "^9.0.1",
|
"@mui/material": "^9.0.0",
|
||||||
"@mui/x-charts": "^9.3.0",
|
"@mui/x-charts": "^9.0.2",
|
||||||
"@tanstack/react-query": "^5.100.14",
|
"@tanstack/react-query": "^5.99.2",
|
||||||
"cap-widget": "^0.1.53",
|
|
||||||
"howler": "^2.2.4",
|
"howler": "^2.2.4",
|
||||||
"lodash": "^4.18.1",
|
"lodash": "^4.18.1",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.7"
|
"react-dom": "^19.2.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.5",
|
"@eslint/eslintrc": "^3.3.5",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@stylistic/eslint-plugin": "5.10.0",
|
"@stylistic/eslint-plugin": "5.10.0",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"eslint": "^10.4.1",
|
"eslint": "10.2.1",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-hooks": "7.1.1",
|
"eslint-plugin-react-hooks": "7.1.1",
|
||||||
"globals": "^17.6.0",
|
"globals": "17.5.0",
|
||||||
"sass": "^1.100.0",
|
"sass": "^1.99.0",
|
||||||
"vite": "^8.0.16",
|
"vite": "^8.0.9",
|
||||||
"vite-plugin-symfony": "^8.2.4"
|
"vite-plugin-symfony": "^8.2.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -25,13 +25,13 @@ use Symfony\Component\Routing\Attribute\Route;
|
|||||||
* Class ApiAuthController
|
* Class ApiAuthController
|
||||||
*
|
*
|
||||||
* Provides a JSON login endpoint for native desktop clients.
|
* Provides a JSON login endpoint for native desktop clients.
|
||||||
* This endpoint is intentionally exempt from the CAPTCHA listener
|
* This endpoint is intentionally exempt from the reCAPTCHA listener
|
||||||
* because desktop clients cannot display or solve the Cap widget.
|
* because desktop clients cannot execute reCAPTCHA v3.
|
||||||
*
|
*
|
||||||
* After a successful password login, if the user has TOTP enabled the response
|
* After a successful password login, if the user has TOTP enabled the response
|
||||||
* returns { requiresTwoFactor: true }. The client must then POST the 6-digit
|
* returns { requiresTwoFactor: true }. The client must then POST the 6-digit
|
||||||
* code to the standard /2fa_check endpoint (which is already exempt from
|
* code to the standard /2fa_check endpoint (which is already exempt from
|
||||||
* the CAPTCHA listener via LoginCaptchaListener).
|
* the reCAPTCHA listener via LoginCaptchaListener).
|
||||||
*
|
*
|
||||||
* @package App\Controller
|
* @package App\Controller
|
||||||
* @author Lang <https://www.splendidbear.org>
|
* @author Lang <https://www.splendidbear.org>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ namespace App\Controller;
|
|||||||
|
|
||||||
use App\Entity\PlayedGame;
|
use App\Entity\PlayedGame;
|
||||||
use App\Repository\PlayedGameRepository;
|
use App\Repository\PlayedGameRepository;
|
||||||
|
use App\Service\MercureJwtService;
|
||||||
use App\Service\ResolveUserNamesService;
|
use App\Service\ResolveUserNamesService;
|
||||||
use App\Util\RpcManager;
|
use App\Util\RpcManager;
|
||||||
use App\Util\TopicManager;
|
use App\Util\TopicManager;
|
||||||
@@ -23,6 +24,7 @@ use Symfony\Component\HttpFoundation\Request;
|
|||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class MercureController
|
* Class MercureController
|
||||||
@@ -45,9 +47,28 @@ class MercureController extends AbstractController
|
|||||||
private readonly TopicManager $topicManager,
|
private readonly TopicManager $topicManager,
|
||||||
private readonly RpcManager $rpcManager,
|
private readonly RpcManager $rpcManager,
|
||||||
private readonly ResolveUserNamesService $userNamesService,
|
private readonly ResolveUserNamesService $userNamesService,
|
||||||
|
private readonly MercureJwtService $mercureJwtService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a fresh Mercure subscriber JWT and a new gameAssoc UUID.
|
||||||
|
* Intended for native desktop clients that cannot parse the JWT from HTML.
|
||||||
|
*
|
||||||
|
* Response: { "mercureJwt": "<jwt>", "gameAssoc": "<uuid>" }
|
||||||
|
*/
|
||||||
|
#[Route('/api/game/token', name: 'MineSeekerBundle_api_game_token', methods: ['GET'])]
|
||||||
|
public function token(): JsonResponse
|
||||||
|
{
|
||||||
|
$gameAssoc = Uuid::v4()->toRfc4122();
|
||||||
|
$userName = $this->userNamesService->resolveUserName();
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'mercureJwt' => $this->mercureJwtService->mintSubscriberToken($gameAssoc, $userName),
|
||||||
|
'gameAssoc' => $gameAssoc,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/api/game/start', name: 'MineSeekerBundle_api_game_start', methods: ['POST'])]
|
#[Route('/api/game/start', name: 'MineSeekerBundle_api_game_start', methods: ['POST'])]
|
||||||
public function start(Request $request): JsonResponse
|
public function start(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use Symfony\Component\Security\Http\Event\CheckPassportEvent;
|
|||||||
/**
|
/**
|
||||||
* Class LoginCaptchaListener
|
* 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
|
* Fires on CheckPassportEvent, which is dispatched after credentials are
|
||||||
* collected but before the user is authenticated.
|
* collected but before the user is authenticated.
|
||||||
*
|
*
|
||||||
@@ -53,12 +53,12 @@ readonly class LoginCaptchaListener
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$token = $request->request->getString('cap-token');
|
$token = $request->request->getString('g-recaptcha-response');
|
||||||
|
|
||||||
if ($this->recaptcha->verify($token)) {
|
if ($this->recaptcha->verify($token, $request->getClientIp())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new CustomUserMessageAuthenticationException('CAPTCHA verification failed. Please try again.');
|
throw new CustomUserMessageAuthenticationException('reCAPTCHA verification failed. Please try again.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
|||||||
/**
|
/**
|
||||||
* Class RecaptchaType
|
* Class RecaptchaType
|
||||||
*
|
*
|
||||||
* Reads the Cap CAPTCHA token from the raw POST field
|
* Reads the Google reCAPTCHA v3 token from the raw POST field
|
||||||
* `cap-token` (auto-injected by the cap-widget web component) and injects
|
* `g-recaptcha-response` (populated by JS before form submit) and injects
|
||||||
* it as this field's value before validation runs.
|
* it as this field's value before validation runs.
|
||||||
*
|
*
|
||||||
* @package App\Form
|
* @package App\Form
|
||||||
@@ -41,8 +41,9 @@ class RecaptchaType extends AbstractType
|
|||||||
{
|
{
|
||||||
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (PreSubmitEvent $event): void {
|
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (PreSubmitEvent $event): void {
|
||||||
$request = $this->requestStack->getCurrentRequest();
|
$request = $this->requestStack->getCurrentRequest();
|
||||||
$token = $request?->request->getString('cap-token') ?? '';
|
$token = $request?->request->getString('g-recaptcha-response') ?? '';
|
||||||
// For forms that set the token directly on the field, fall back to the submitted value.
|
// 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 === '') {
|
if ($token === '') {
|
||||||
$token = (string) ($event->getData() ?? '');
|
$token = (string) ($event->getData() ?? '');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,38 +26,50 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|||||||
*/
|
*/
|
||||||
readonly final class RecaptchaService
|
readonly final class RecaptchaService
|
||||||
{
|
{
|
||||||
|
private const string SITEVERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum score to accept a request (0.0 = bot, 1.0 = human).
|
||||||
|
* 0.5 is Google's recommended default threshold.
|
||||||
|
*/
|
||||||
|
private const float SCORE_THRESHOLD = 0.5;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private HttpClientInterface $httpClient,
|
private HttpClientInterface $httpClient,
|
||||||
private LoggerInterface $logger,
|
private LoggerInterface $logger,
|
||||||
#[Autowire(env: 'CAP_API_ENDPOINT')]
|
#[Autowire(env: 'RECAPTCHA_SECRET_KEY')]
|
||||||
private string $apiEndpoint,
|
|
||||||
#[Autowire(env: 'CAP_SECRET_KEY')]
|
|
||||||
private string $secretKey,
|
private string $secretKey,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function verify(string $token): bool
|
public function verify(string $token, string $remoteIp = ''): bool
|
||||||
{
|
{
|
||||||
if ($token === '') {
|
if ($token === '') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$siteverifyUrl = rtrim($this->apiEndpoint, '/') . '/siteverify';
|
$body = ['secret' => $this->secretKey, 'response' => $token];
|
||||||
|
|
||||||
|
if ($remoteIp !== '') {
|
||||||
|
$body['remoteip'] = $remoteIp;
|
||||||
|
}
|
||||||
|
|
||||||
$data = $this->httpClient
|
$data = $this->httpClient
|
||||||
->request('POST', $siteverifyUrl, [
|
->request('POST', self::SITEVERIFY_URL, ['body' => $body])
|
||||||
'body' => ['secret' => $this->secretKey, 'response' => $token],
|
|
||||||
])
|
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
$this->logger->info('Cap verify response', [
|
$this->logger->info('reCAPTCHA verify response', [
|
||||||
'success' => $data['success'] ?? null,
|
'success' => $data['success'] ?? null,
|
||||||
'token_length' => strlen($token),
|
'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) {
|
} catch (\Throwable $e) {
|
||||||
$this->logger->error('Cap verification failed: ' . $e->getMessage());
|
$this->logger->error('reCAPTCHA verification failed: ' . $e->getMessage());
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
namespace App\Validator;
|
namespace App\Validator;
|
||||||
|
|
||||||
use App\Service\RecaptchaService;
|
use App\Service\RecaptchaService;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
use Symfony\Component\Validator\Constraint;
|
use Symfony\Component\Validator\Constraint;
|
||||||
use Symfony\Component\Validator\ConstraintValidator;
|
use Symfony\Component\Validator\ConstraintValidator;
|
||||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||||
@@ -29,6 +30,7 @@ final class RecaptchaValidator extends ConstraintValidator
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly RecaptchaService $recaptcha,
|
private readonly RecaptchaService $recaptcha,
|
||||||
|
private readonly RequestStack $requestStack,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +40,10 @@ final class RecaptchaValidator extends ConstraintValidator
|
|||||||
throw new UnexpectedTypeException($constraint, Recaptcha::class);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,9 @@
|
|||||||
{% block title %} - Play!{% endblock %}
|
{% block title %} - Play!{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="game-brand-bar">
|
<a class="back-from-game" href="{{ path('MineSeekerBundle_homepage') }}">
|
||||||
<a class="back-from-game" href="{{ path('MineSeekerBundle_homepage') }}">
|
<img src="{{ asset('/images/mine-logo-txt.png') }}" alt="Mineseeker Logo">
|
||||||
<img src="{{ asset('/images/mine-logo-txt.png') }}" alt="Mineseeker Logo">
|
</a>
|
||||||
</a>
|
|
||||||
<a class="game-cap-badge" href="https://trycap.dev" target="_blank" rel="noopener noreferrer">
|
|
||||||
<i class="fas fa-shield-halved"></i> Protected by Cap
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="mine-container">
|
<div class="mine-container">
|
||||||
<div id="mine-wrapper"
|
<div id="mine-wrapper"
|
||||||
data-env="{{ env }}"
|
data-env="{{ env }}"
|
||||||
@@ -19,7 +14,7 @@
|
|||||||
data-is-authenticated="{{ app.user ? '1' : '0' }}"
|
data-is-authenticated="{{ app.user ? '1' : '0' }}"
|
||||||
data-mercure-hub-url="{{ mercure_hub_url }}"
|
data-mercure-hub-url="{{ mercure_hub_url }}"
|
||||||
data-mercure-subscriber-jwt="{{ mercure_subscriber_jwt }}"
|
data-mercure-subscriber-jwt="{{ mercure_subscriber_jwt }}"
|
||||||
data-cap-api-endpoint="{{ cap_api_endpoint }}">
|
data-recaptcha-site-key="{{ recaptcha_site_key }}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -44,5 +39,6 @@
|
|||||||
|
|
||||||
{% block javascripts %}
|
{% block javascripts %}
|
||||||
{{ parent() }}
|
{{ parent() }}
|
||||||
|
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
|
||||||
{{ vite_entry_script_tags('mineseeker', { dependency: 'react' }) }}
|
{{ vite_entry_script_tags('mineseeker', { dependency: 'react' }) }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -37,14 +37,6 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="auth-card" style="max-width: 600px; margin: 0 auto;">
|
<div class="auth-card" style="max-width: 600px; margin: 0 auto;">
|
||||||
<div class="auth-title-row">
|
|
||||||
<h2 class="auth-title">Contact Form</h2>
|
|
||||||
<a class="auth-cap-badge" href="https://trycap.dev" target="_blank" rel="noopener noreferrer">
|
|
||||||
<i class="fas fa-shield-halved"></i> Protected by Cap
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<p class="auth-sub">Your message is protected by automated abuse checks.</p>
|
|
||||||
<div data-cap-api-endpoint="{{ cap_api_endpoint }}" style="display: none;" aria-hidden="true"></div>
|
|
||||||
{{ form_start(form, {attr: {class: 'auth-form'}}) }}
|
{{ form_start(form, {attr: {class: 'auth-form'}}) }}
|
||||||
|
|
||||||
<div class="auth-field">
|
<div class="auth-field">
|
||||||
@@ -135,5 +127,10 @@
|
|||||||
|
|
||||||
{% block javascripts %}
|
{% block javascripts %}
|
||||||
{{ parent() }}
|
{{ 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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -34,14 +34,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<div class="auth-title-row">
|
<h2 class="auth-title">Forgot Password</h2>
|
||||||
<h2 class="auth-title">Forgot Password</h2>
|
|
||||||
<a class="auth-cap-badge" href="https://trycap.dev" target="_blank" rel="noopener noreferrer">
|
|
||||||
<i class="fas fa-shield-halved"></i> Protected by Cap
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<p class="auth-sub">Enter your email and we'll send you a reset link</p>
|
<p class="auth-sub">Enter your email and we'll send you a reset link</p>
|
||||||
<div data-cap-api-endpoint="{{ cap_api_endpoint }}" style="display: none;" aria-hidden="true"></div>
|
|
||||||
|
|
||||||
{{ form_start(form, {attr: {class: 'auth-form'}}) }}
|
{{ form_start(form, {attr: {class: 'auth-form'}}) }}
|
||||||
|
|
||||||
@@ -81,5 +75,20 @@
|
|||||||
|
|
||||||
{% block javascripts %}
|
{% block javascripts %}
|
||||||
{{ parent() }}
|
{{ 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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -36,12 +36,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<div class="auth-title-row">
|
<h2 class="auth-title">Sign In</h2>
|
||||||
<h2 class="auth-title">Sign In</h2>
|
|
||||||
<a class="auth-cap-badge" href="https://trycap.dev" target="_blank" rel="noopener noreferrer">
|
|
||||||
<i class="fas fa-shield-halved"></i> Protected by Cap
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<p class="auth-sub">Welcome back, commander</p>
|
<p class="auth-sub">Welcome back, commander</p>
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
@@ -51,8 +46,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div data-cap-api-endpoint="{{ cap_api_endpoint }}" style="display: none;" aria-hidden="true"></div>
|
|
||||||
|
|
||||||
<form class="auth-form" method="post" action="{{ path('MineSeekerBundle_login') }}">
|
<form class="auth-form" method="post" action="{{ path('MineSeekerBundle_login') }}">
|
||||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}"/>
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}"/>
|
||||||
|
|
||||||
@@ -99,6 +92,8 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" id="g-recaptcha-response" name="g-recaptcha-response"/>
|
||||||
|
|
||||||
<button type="submit" class="auth-submit">
|
<button type="submit" class="auth-submit">
|
||||||
<i class="fas fa-right-to-bracket"></i> Sign In
|
<i class="fas fa-right-to-bracket"></i> Sign In
|
||||||
</button>
|
</button>
|
||||||
@@ -126,6 +121,20 @@
|
|||||||
{% block javascripts %}
|
{% block javascripts %}
|
||||||
{{ parent() }}
|
{{ 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' }) }}
|
{{ vite_entry_script_tags('passkey', { dependency: 'react' }) }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -42,14 +42,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<div class="auth-title-row">
|
<h2 class="auth-title">Create Account</h2>
|
||||||
<h2 class="auth-title">Create Account</h2>
|
|
||||||
<a class="auth-cap-badge" href="https://trycap.dev" target="_blank" rel="noopener noreferrer">
|
|
||||||
<i class="fas fa-shield-halved"></i> Protected by Cap
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<p class="auth-sub">Join the battle — no subscription required</p>
|
<p class="auth-sub">Join the battle — no subscription required</p>
|
||||||
<div data-cap-api-endpoint="{{ cap_api_endpoint }}" style="display: none;" aria-hidden="true"></div>
|
|
||||||
|
|
||||||
{{ form_start(form, {attr: {class: 'auth-form'}}) }}
|
{{ form_start(form, {attr: {class: 'auth-form'}}) }}
|
||||||
|
|
||||||
@@ -159,5 +153,20 @@
|
|||||||
|
|
||||||
{% block javascripts %}
|
{% block javascripts %}
|
||||||
{{ parent() }}
|
{{ 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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -16,14 +16,8 @@
|
|||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="auth-page">
|
<div class="auth-page">
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<div class="auth-title-row">
|
<h2 class="auth-title">Reset Password</h2>
|
||||||
<h2 class="auth-title">Reset Password</h2>
|
|
||||||
<a class="auth-cap-badge" href="https://trycap.dev" target="_blank" rel="noopener noreferrer">
|
|
||||||
<i class="fas fa-shield-halved"></i> Protected by Cap
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<p class="auth-sub">Choose a new password for your account</p>
|
<p class="auth-sub">Choose a new password for your account</p>
|
||||||
<div data-cap-api-endpoint="{{ cap_api_endpoint }}" style="display: none;" aria-hidden="true"></div>
|
|
||||||
|
|
||||||
{{ form_start(form, {attr: {class: 'auth-form'}}) }}
|
{{ form_start(form, {attr: {class: 'auth-form'}}) }}
|
||||||
|
|
||||||
@@ -71,5 +65,19 @@
|
|||||||
|
|
||||||
{% block javascripts %}
|
{% block javascripts %}
|
||||||
{{ parent() }}
|
{{ 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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -34,17 +34,16 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|||||||
#[TestDox('Recaptcha Service')]
|
#[TestDox('Recaptcha Service')]
|
||||||
class RecaptchaServiceTest extends TestCase
|
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]
|
#[Test]
|
||||||
#[TestDox('Verify returns false for empty token')]
|
#[TestDox('Verify returns false for empty token')]
|
||||||
public function verifyReturnsFalseForEmptyToken(): void
|
public function verifyReturnsFalseForEmptyToken(): void
|
||||||
{
|
{
|
||||||
$httpClient = $this->createMock(HttpClientInterface::class);
|
$httpClient = $this->createMock(HttpClientInterface::class);
|
||||||
$logger = $this->createMock(LoggerInterface::class);
|
$logger = $this->createMock(LoggerInterface::class);
|
||||||
|
|
||||||
$service = new RecaptchaService($httpClient, $logger, self::API_ENDPOINT, self::SECRET_KEY);
|
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
|
||||||
|
|
||||||
$result = $service->verify('');
|
$result = $service->verify('');
|
||||||
|
|
||||||
@@ -56,14 +55,15 @@ class RecaptchaServiceTest extends TestCase
|
|||||||
public function verifyReturnsFalseWhenApiReturnsFailure(): void
|
public function verifyReturnsFalseWhenApiReturnsFailure(): void
|
||||||
{
|
{
|
||||||
$mockResponse = new MockResponse(json_encode([
|
$mockResponse = new MockResponse(json_encode([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
|
'error-codes' => ['invalid-input-secret'],
|
||||||
], JSON_THROW_ON_ERROR));
|
], JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
$httpClient = new MockHttpClient($mockResponse);
|
$httpClient = new MockHttpClient($mockResponse);
|
||||||
$logger = $this->createMock(LoggerInterface::class);
|
$logger = $this->createMock(LoggerInterface::class);
|
||||||
$logger->expects($this->once())->method('info');
|
$logger->expects($this->once())->method('info');
|
||||||
|
|
||||||
$service = new RecaptchaService($httpClient, $logger, self::API_ENDPOINT, self::SECRET_KEY);
|
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
|
||||||
|
|
||||||
$result = $service->verify('invalid-token');
|
$result = $service->verify('invalid-token');
|
||||||
|
|
||||||
@@ -71,24 +71,47 @@ class RecaptchaServiceTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
#[TestDox('Verify returns true when api returns success')]
|
#[TestDox('Verify returns true when api returns success and high score')]
|
||||||
public function verifyReturnsTrueWhenApiReturnsSuccess(): void
|
public function verifyReturnsTrueWhenApiReturnsSuccessAndHighScore(): void
|
||||||
{
|
{
|
||||||
$mockResponse = new MockResponse(json_encode([
|
$mockResponse = new MockResponse(json_encode([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
|
'score' => 0.8,
|
||||||
|
'hostname' => 'test.com',
|
||||||
], JSON_THROW_ON_ERROR));
|
], JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
$httpClient = new MockHttpClient($mockResponse);
|
$httpClient = new MockHttpClient($mockResponse);
|
||||||
$logger = $this->createMock(LoggerInterface::class);
|
$logger = $this->createMock(LoggerInterface::class);
|
||||||
$logger->expects($this->once())->method('info');
|
$logger->expects($this->once())->method('info');
|
||||||
|
|
||||||
$service = new RecaptchaService($httpClient, $logger, self::API_ENDPOINT, self::SECRET_KEY);
|
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
|
||||||
|
|
||||||
$result = $service->verify('valid-token');
|
$result = $service->verify('valid-token');
|
||||||
|
|
||||||
$this->assertTrue($result);
|
$this->assertTrue($result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Verify returns false when score below threshold')]
|
||||||
|
public function verifyReturnsFalseWhenScoreBelowThreshold(): void
|
||||||
|
{
|
||||||
|
$mockResponse = new MockResponse(json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'score' => 0.3,
|
||||||
|
'hostname' => 'test.com',
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
$httpClient = new MockHttpClient($mockResponse);
|
||||||
|
$logger = $this->createMock(LoggerInterface::class);
|
||||||
|
$logger->expects($this->once())->method('info');
|
||||||
|
|
||||||
|
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
|
||||||
|
|
||||||
|
$result = $service->verify('low-score-token');
|
||||||
|
|
||||||
|
$this->assertFalse($result);
|
||||||
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
#[TestDox('Verify returns false when api throws exception')]
|
#[TestDox('Verify returns false when api throws exception')]
|
||||||
public function verifyReturnsFalseWhenApiThrowsException(): void
|
public function verifyReturnsFalseWhenApiThrowsException(): void
|
||||||
@@ -96,10 +119,10 @@ class RecaptchaServiceTest extends TestCase
|
|||||||
$mockResponse = new MockResponse('', ['http_code' => 500]);
|
$mockResponse = new MockResponse('', ['http_code' => 500]);
|
||||||
|
|
||||||
$httpClient = new MockHttpClient($mockResponse);
|
$httpClient = new MockHttpClient($mockResponse);
|
||||||
$logger = $this->createMock(LoggerInterface::class);
|
$logger = $this->createMock(LoggerInterface::class);
|
||||||
$logger->expects($this->once())->method('error');
|
$logger->expects($this->once())->method('error');
|
||||||
|
|
||||||
$service = new RecaptchaService($httpClient, $logger, self::API_ENDPOINT, self::SECRET_KEY);
|
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
|
||||||
|
|
||||||
$result = $service->verify('test-token');
|
$result = $service->verify('test-token');
|
||||||
|
|
||||||
@@ -107,19 +130,59 @@ class RecaptchaServiceTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
#[TestDox('Siteverify URL is derived from api endpoint')]
|
#[TestDox('Verify includes remote ip when provided')]
|
||||||
public function siteverifyUrlIsDerivedFromApiEndpoint(): void
|
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 {
|
$httpClient = new MockHttpClient($mockResponse);
|
||||||
$this->assertSame('http://localhost:3000/test-site-key/siteverify', $url);
|
$logger = $this->createMock(LoggerInterface::class);
|
||||||
return $mockResponse;
|
$logger->expects($this->once())->method('info');
|
||||||
});
|
|
||||||
|
|
||||||
$logger = $this->createMock(LoggerInterface::class);
|
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
|
||||||
$service = new RecaptchaService($httpClient, $logger, self::API_ENDPOINT, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ export default defineConfig({
|
|||||||
passkey: './assets/js/passkey.jsx',
|
passkey: './assets/js/passkey.jsx',
|
||||||
profile: './assets/js/profile.jsx',
|
profile: './assets/js/profile.jsx',
|
||||||
contact: './assets/js/contact.jsx',
|
contact: './assets/js/contact.jsx',
|
||||||
cap: './assets/js/cap.js',
|
|
||||||
mineseekerStyle: './assets/css/style.mineseeker.scss',
|
mineseekerStyle: './assets/css/style.mineseeker.scss',
|
||||||
homeStyle: './assets/css/style.layout.scss',
|
homeStyle: './assets/css/style.layout.scss',
|
||||||
passkeyStyle: './assets/css/passkey.scss',
|
passkeyStyle: './assets/css/passkey.scss',
|
||||||
|
|||||||
Reference in New Issue
Block a user