Private
Public Access
1
0

Compare commits

...

22 Commits

Author SHA1 Message Date
8795fedda9 chg: usr: add notification on activation too #4
All checks were successful
Deploy to Production / deploy (push) Successful in 11s
2026-04-15 20:23:41 +02:00
588fb57299 new: usr: add notification email when a user is registered #4 2026-04-15 20:19:29 +02:00
eb345e17ca chg: pkg: new version release !skipChangelog
All checks were successful
Deploy to Production / deploy (push) Successful in 19s
2026-04-15 20:13:38 +02:00
c2693c4648 fix: usr: another attempt to fix the email assets #4
All checks were successful
Deploy to Production / deploy (push) Successful in 11s
2026-04-15 20:03:48 +02:00
43efc16562 fix: usr: the images does not shows in emails #4
All checks were successful
Deploy to Production / deploy (push) Successful in 15s
2026-04-15 19:50:14 +02:00
80d6440ece chg: pkg: new version release !skipChangelog
All checks were successful
Deploy to Production / deploy (push) Successful in 16s
2026-04-15 19:00:43 +02:00
5ee972f003 chg: pkg: add missing .env variable and increase the version number and add missing data from front-end and back-end deps descriptor #4
Some checks failed
Deploy to Production / deploy (push) Failing after 0s
2026-04-15 18:59:52 +02:00
6f3edb41ea new: usr: add Contact page with email sending behaviour #4
All checks were successful
Deploy to Production / deploy (push) Successful in 39s
2026-04-15 18:35:05 +02:00
c52939a7a3 chg: usr: change the shareable battle - add avatars to it - even on the og tags #4 2026-04-15 16:44:57 +02:00
573d409606 fix: pkg: the mailhog is crashed on development env #4 2026-04-15 14:45:44 +02:00
9a58bc9a5e chg: usr: change text #4 2026-04-15 14:38:25 +02:00
8780800dff fix: pkg: the og tags did not have proper http schema - they should have https #4 2026-04-15 14:33:53 +02:00
f442942faf chg: pkg: new version release !skipChangelog
All checks were successful
Deploy to Production / deploy (push) Successful in 25s
2026-04-14 21:51:17 +02:00
a61d881a4e chg: usr: add donation button #4 2026-04-14 21:50:58 +02:00
926b614136 chg: pkg: new version release !skipChangelog
All checks were successful
Deploy to Production / deploy (push) Successful in 23s
2026-04-14 21:08:27 +02:00
c0c84f4651 chg: dev: protect the gameplay with recaptcha #4 2026-04-14 21:07:54 +02:00
176e255037 chg: usr: the waiting dialog is uncloseable until the time is up #4 2026-04-14 21:04:05 +02:00
b134358e9e new: usr: add timer for the acceptance of the challenge #4 2026-04-14 20:30:18 +02:00
3525aaeeb7 fix: usr: missing font-awesome icons on bare-metal environment #4 2026-04-14 19:44:01 +02:00
af67ec3931 chg: usr: add share button to the overlay when the game ends #4 2026-04-14 19:37:42 +02:00
d515f42cfd chg: usr: make fancy og tags - and create a special one for battle sharing #4 2026-04-14 18:54:44 +02:00
5d6aff8d90 chg: dev: the user's avatar will be saved as a uuid.extension #4
All checks were successful
Deploy to Production / deploy (push) Successful in 10s
2026-04-14 16:53:16 +02:00
62 changed files with 2627 additions and 498 deletions

View File

@@ -6,6 +6,12 @@
APP_ENV=dev APP_ENV=dev
APP_SECRET=changethis APP_SECRET=changethis
APP_NAME=mineseeker APP_NAME=mineseeker
# APP_PUBLIC_HOSTNAME: The public hostname for your application (used for generating absolute URLs in emails)
# For production, set this to your domain (e.g., mineseeker.com)
APP_PUBLIC_HOSTNAME=localhost
# TRUSTED_PROXIES: Only needed for bare-metal dev behind a reverse proxy
# For Docker development, this is set in compose.override.yaml
# For production, set in PROD_ENV_FILE Gitea secret (use 172.18.0.0/16 initially)
#TRUSTED_PROXIES=127.0.0.1,127.0.0.2 #TRUSTED_PROXIES=127.0.0.1,127.0.0.2
#TRUSTED_HOSTS=localhost,example.com #TRUSTED_HOSTS=localhost,example.com
###< symfony/framework-bundle ### ###< symfony/framework-bundle ###

View File

@@ -2,11 +2,13 @@ Changelog
========= =========
v2026.01 (2026-04-14) v2026.2.1 (2026-04-15)
--------------------- ----------------------
New New
~~~ ~~~
- Add Contact page with email sending behaviour #4. [Lang]
- Add timer for the acceptance of the challenge #4. [Lang]
- Registered users have avatars next to the timer #4. [Lang] - Registered users have avatars next to the timer #4. [Lang]
- Add opportunity to use profile picture. #4. [Lang] - Add opportunity to use profile picture. #4. [Lang]
- Add more stats and a dialog for the recent battle that can be - Add more stats and a dialog for the recent battle that can be
@@ -18,6 +20,16 @@ New
Changes Changes
~~~~~~~ ~~~~~~~
- Change the shareable battle - add avatars to it - even on the og tags
#4. [Lang]
- Change text #4. [Lang]
- Add donation button #4. [Lang]
- Protect the gameplay with recaptcha #4. [Lang]
- The waiting dialog is uncloseable until the time is up #4. [Lang]
- Add share button to the overlay when the game ends #4. [Lang]
- Make fancy og tags - and create a special one for battle sharing #4.
[Lang]
- The user's avatar will be saved as a uuid.extension #4. [Lang]
- Fix missing favicon #4. [Lang] - Fix missing favicon #4. [Lang]
- Add modern Webauthn authentication #4. [Lang] - Add modern Webauthn authentication #4. [Lang]
- Refactor all forms to have Symfony Form Types & Validation - Refactor all forms to have Symfony Form Types & Validation
@@ -57,6 +69,11 @@ Changes
- Doc in README.md #3. [Lang] - Doc in README.md #3. [Lang]
- Gitignore a js.map file #2. [Lang] - Gitignore a js.map file #2. [Lang]
Fix
~~~
- Missing font-awesome icons on bare-metal environment #4. [Lang]
- Quickfix for email sending #4. [Lang]
Other Other
~~~~~ ~~~~~
- Hg: pkg: new version release !skipChangelog. [Lang] - Hg: pkg: new version release !skipChangelog. [Lang]

View File

@@ -13,6 +13,10 @@
encode zstd br gzip encode zstd br gzip
# Forward scheme information to the PHP application
header X-Forwarded-Proto {scheme}
header X-Forwarded-Host {host}
mercure { mercure {
transport_url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db} transport_url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db}
publisher_jwt {$MERCURE_JWT_SECRET} HS256 publisher_jwt {$MERCURE_JWT_SECRET} HS256

View File

@@ -22,6 +22,12 @@ RUN install-php-extensions \
apcu \ apcu \
sodium sodium
RUN apt-get update && apt-get install -y --no-install-recommends \
fonts-dejavu-core \
fontconfig \
&& fc-cache -f -v \
&& rm -rf /var/lib/apt/lists/*
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
RUN printf '[opcache]\nopcache.enable=1\nopcache.memory_consumption=256\nopcache.max_accelerated_files=20000\nopcache.validate_timestamps=0\n' \ RUN printf '[opcache]\nopcache.enable=1\nopcache.memory_consumption=256\nopcache.max_accelerated_files=20000\nopcache.validate_timestamps=0\n' \
> "$PHP_INI_DIR/conf.d/opcache.ini" > "$PHP_INI_DIR/conf.d/opcache.ini"

View File

@@ -151,6 +151,7 @@ services:
app: app:
environment: environment:
MAILER_DSN: smtp://mail:1025?verify_peer=0 MAILER_DSN: smtp://mail:1025?verify_peer=0
TRUSTED_PROXIES: "0.0.0.0/0"
mail: mail:
image: mailhog/mailhog:latest image: mailhog/mailhog:latest
ports: ports:
@@ -233,8 +234,13 @@ MERCURE_SUBSCRIBER_JWT="<generated by make mercure-jwt>"
APP_PUBLIC_HOSTNAME=mineseeker.hu APP_PUBLIC_HOSTNAME=mineseeker.hu
WEBAUTHN_RP_ID=mineseeker.hu WEBAUTHN_RP_ID=mineseeker.hu
WEBAUTHN_ORIGIN=https://mineseeker.hu WEBAUTHN_ORIGIN=https://mineseeker.hu
```
# OG Tags & Social Media Sharing (IMPORTANT for Docker deployments)
# TRUSTED_PROXIES: IP address (or range) of your reverse proxy (Caddy/Nginx)
# This ensures OG image tags use HTTPS URLs instead of HTTP
TRUSTED_PROXIES="172.18.0.0/16"
TRUSTED_HOSTS="mineseeker.hu,www.mineseeker.hu"
```
### Production server: one-time setup ### Production server: one-time setup
The server needs Docker, Git, and a self-hosted `act_runner` registered against the Gitea repository. Bun and Composer run inside the multi-stage Dockerfile, so they are not needed on the server. The server needs Docker, Git, and a self-hosted `act_runner` registered against the Gitea repository. Bun and Composer run inside the multi-stage Dockerfile, so they are not needed on the server.
@@ -254,7 +260,7 @@ make mercure-jwt
Copy the three printed values into the `PROD_ENV_FILE` secret. Copy the three printed values into the `PROD_ENV_FILE` secret.
#### 5. First deploy #### 3. First deploy
Trigger it by pushing the first tag: Trigger it by pushing the first tag:
@@ -265,7 +271,7 @@ git push origin v2026.01
This writes `.env`, builds the Docker image, starts all services, runs migrations, and initialises the MinIO buckets automatically via `minio_init`. This writes `.env`, builds the Docker image, starts all services, runs migrations, and initialises the MinIO buckets automatically via `minio_init`.
#### 6. Verify #### 4. Verify
```bash ```bash
docker compose ps # all services should be healthy/running docker compose ps # all services should be healthy/running

View File

@@ -7,7 +7,7 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
$font-path: "/webfonts"; $font-path: "/build/webfonts";
@import '@fortawesome/fontawesome-free/scss/fontawesome'; @import '@fortawesome/fontawesome-free/scss/fontawesome';
@import '@fortawesome/fontawesome-free/scss/brands'; @import '@fortawesome/fontawesome-free/scss/brands';

View File

@@ -180,6 +180,41 @@
input[type="checkbox"] { accent-color: #236f87; } input[type="checkbox"] { accent-color: #236f87; }
} }
.auth-checkbox {
accent-color: #236f87;
cursor: pointer;
width: 18px;
height: 18px;
flex-shrink: 0;
}
.auth-checkbox-label {
display: flex;
align-items: flex-start;
cursor: pointer;
user-select: none;
font: 400 14px 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.6);
line-height: 1.5;
a {
color: #95cff5;
text-decoration: none;
font-weight: 600;
transition: color 180ms;
&:hover { color: #c5e8ff; }
}
}
textarea.auth-input {
padding: 11px 14px;
min-height: 120px;
resize: vertical;
font-family: 'Rajdhani', sans-serif;
line-height: 1.5;
}
.auth-submit { .auth-submit {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -0,0 +1,72 @@
/*!*
* 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.
*/
.hero-donate-text {
position: relative;
z-index: 1;
font: 400 12px 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.5);
letter-spacing: 0.5px;
margin-top: 42px;
margin-bottom: 8px;
animation: rise 0.8s 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.hero-donate {
position: relative;
display: inline-block;
font: 500 12px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 1px;
text-decoration: none;
color: rgba(255, 184, 82, 0.75);
padding: 6px 14px;
border-radius: 2px;
border: 1px solid rgba(255, 184, 82, 0.25);
background: rgba(255, 140, 30, 0.05);
box-shadow:
0 0 0 1px rgba(255, 140, 30, 0.1),
0 0 8px rgba(255, 140, 30, 0.08);
transition: transform 200ms ease, box-shadow 200ms ease, color 200ms ease, background 200ms ease;
animation: rise 0.8s 0.58s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.hero-donate::before {
content: '';
position: absolute;
inset: -2px;
border-radius: 3px;
background: rgba(255, 140, 30, 0.15);
filter: blur(8px);
opacity: 0.1;
z-index: -1;
transition: opacity 200ms ease;
}
.hero-donate:hover {
transform: translateY(-1px);
color: rgba(255, 200, 100, 0.9);
background: rgba(255, 140, 30, 0.1);
box-shadow:
0 0 0 1px rgba(255, 140, 30, 0.2),
0 0 12px rgba(255, 140, 30, 0.15);
}
.hero-donate:hover::before {
opacity: 0.2;
}
.hero-donate:active {
transform: translateY(0px);
}

View File

@@ -747,6 +747,13 @@
font: 800 24px 'Rajdhani', sans-serif; font: 800 24px 'Rajdhani', sans-serif;
letter-spacing: 2px; letter-spacing: 2px;
&__img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
&--red { &--red {
background: linear-gradient(135deg, rgba(173, 10, 5, 0.6) 0%, rgba(246, 125, 82, 0.4) 100%); background: linear-gradient(135deg, rgba(173, 10, 5, 0.6) 0%, rgba(246, 125, 82, 0.4) 100%);
border: 2px solid rgba(173, 10, 5, 0.5); border: 2px solid rgba(173, 10, 5, 0.5);

View File

@@ -526,3 +526,67 @@
box-shadow: 0 4px 12px rgba(26, 104, 68, 0.4); box-shadow: 0 4px 12px rgba(26, 104, 68, 0.4);
} }
} }
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .game-overlay-share {
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
background: linear-gradient(to bottom, #236f87 0%, #1a5068 100%);
border: 2px solid #2e7a9a;
color: #e0f4ff;
font-family: 'Rajdhani', sans-serif;
font-size: 13px;
font-weight: 800;
letter-spacing: 1px;
text-transform: uppercase;
padding: 12px 24px;
border-radius: 8px;
cursor: pointer;
transition: all 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
width: 100%;
margin-top: 20px;
position: relative;
overflow: hidden;
box-shadow: 0 4px 12px rgba(35, 111, 135, 0.25);
z-index: 10;
&::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.4s ease;
}
&:hover {
background: linear-gradient(to bottom, #2d8aa8 0%, #236f87 100%);
border-color: #5ba4d4;
color: #fff;
box-shadow: 0 8px 24px rgba(35, 111, 135, 0.4);
transform: translateY(-2px);
&::before {
left: 100%;
}
}
&:active {
transform: translateY(0);
}
&.copied {
background: linear-gradient(to bottom, #1a6844 0%, #135233 100%);
border-color: #2a9e60;
color: #a0f0c0;
box-shadow: 0 4px 12px rgba(26, 104, 68, 0.4);
}
i {
font-size: 15px;
}
}

View File

@@ -305,3 +305,43 @@
padding-top: 14px; padding-top: 14px;
border-top: 1px solid rgba(35, 111, 135, 0.14); border-top: 1px solid rgba(35, 111, 135, 0.14);
} }
.opd-header-actions {
.opd-refresh[disabled] {
opacity: 0.4;
cursor: not-allowed;
}
.opd-close[disabled] {
opacity: 0.4;
cursor: not-allowed;
}
}
.opd-waiting {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
margin-bottom: 16px;
background: rgba(35, 111, 135, 0.07);
border: 1px solid rgba(35, 111, 135, 0.28);
border-radius: 8px;
color: #95cff5;
i {
font-size: 16px;
animation: opd-hourglass 1s ease-in-out infinite;
}
p {
margin: 0;
font: 600 14px 'Rajdhani', sans-serif;
letter-spacing: 0.5px;
}
}
@keyframes opd-hourglass {
0%, 100% { transform: rotate(0deg); }
50% { transform: rotate(180deg); }
}

View File

@@ -4,6 +4,7 @@
@use 'homepage/hero'; @use 'homepage/hero';
@use 'homepage/hero-compact'; @use 'homepage/hero-compact';
@use 'homepage/cta'; @use 'homepage/cta';
@use 'homepage/donate';
@use 'homepage/auth-bar'; @use 'homepage/auth-bar';
@use 'homepage/auth'; @use 'homepage/auth';
@use 'homepage/content'; @use 'homepage/content';

View File

@@ -35,7 +35,7 @@ const RESULT_META = {
icon: 'fa-trophy', icon: 'fa-trophy',
}, },
loss: { loss: {
label: 'Defeat', label: 'Defeated',
color: '#f67d52', color: '#f67d52',
bg: 'rgba(173,10,5,0.15)', bg: 'rgba(173,10,5,0.15)',
border: 'rgba(173,10,5,0.4)', border: 'rgba(173,10,5,0.4)',
@@ -50,7 +50,7 @@ const RESULT_META = {
}, },
}; };
function Avatar({ name, color }) { function Avatar({ name, color, avatarUrl }) {
const isRed = 'red' === color; const isRed = 'red' === color;
const initials = (name || '?').slice(0, 2).toUpperCase(); const initials = (name || '?').slice(0, 2).toUpperCase();
@@ -69,16 +69,29 @@ function Avatar({ name, color }) {
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10 }}>
<div style={{ <div style={{
width: 72, height: 72, borderRadius: '50%', width: 72, height: 72, borderRadius: '50%',
background: gradient, background: avatarUrl ? 'transparent' : gradient,
border: `2px solid ${border}`, border: `2px solid ${border}`,
boxShadow: glow, boxShadow: glow,
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
font: '800 24px \'Rajdhani\', sans-serif', font: '800 24px \'Rajdhani\', sans-serif',
color: textColor, color: textColor,
letterSpacing: 2, letterSpacing: 2,
overflow: 'hidden',
}} }}
> >
{initials} {avatarUrl ? (
<img
src={avatarUrl}
alt={name}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
initials
)}
</div> </div>
<span style={{ <span style={{
font: '700 15px \'Rajdhani\', sans-serif', font: '700 15px \'Rajdhani\', sans-serif',
@@ -161,7 +174,7 @@ export default function BattleDialog({ games }) {
const endReason = resign const endReason = resign
? `${resign.charAt(0).toUpperCase() + resign.slice(1)} resigned` ? `${resign.charAt(0).toUpperCase() + resign.slice(1)} resigned`
: 'Points'; : 'Points';
const shareUrl = `${window.location.origin}/battle/${game.id}`; const shareUrl = `${window.location.origin}/battle/${game.uuid}`;
const handleShare = () => { const handleShare = () => {
navigator.clipboard.writeText(shareUrl).then(() => { navigator.clipboard.writeText(shareUrl).then(() => {
@@ -197,7 +210,7 @@ export default function BattleDialog({ games }) {
</div> </div>
</div> </div>
<div className="bd-vs-panel"> <div className="bd-vs-panel">
<Avatar name={game.redName} color="red" /> <Avatar name={game.redName} color="red" avatarUrl={game.redAvatar} />
<div className="bd-vs-center"> <div className="bd-vs-center">
<div className="bd-vs-score"> <div className="bd-vs-score">
<span className="bd-vs-score__red">{game.redPoints ?? '—'}</span> <span className="bd-vs-score__red">{game.redPoints ?? '—'}</span>
@@ -212,7 +225,7 @@ export default function BattleDialog({ games }) {
<i className={`fa ${meta.icon}`} /> {meta.label} <i className={`fa ${meta.icon}`} /> {meta.label}
</div> </div>
</div> </div>
<Avatar name={game.blueName} color="blue" /> <Avatar name={game.blueName} color="blue" avatarUrl={game.blueAvatar} />
</div> </div>
<div className="bd-stats"> <div className="bd-stats">
<StatRow icon="fa-calendar" label="Date" value={game.date ?? '—'} /> <StatRow icon="fa-calendar" label="Date" value={game.date ?? '—'} />

View File

@@ -0,0 +1,83 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import { useEffect, useRef } from 'react';
/**
* 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;
};
export default ContactForm;

31
assets/js/contact.jsx Normal file
View File

@@ -0,0 +1,31 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React from 'react';
import { createRoot } from 'react-dom/client';
import ContactForm from './components/ContactForm';
const wrapper = document.getElementById('contact-form-wrapper');
if (wrapper) {
const siteKey = wrapper.dataset.siteKey;
const recaptchaFieldId = wrapper.dataset.recaptchaFieldId;
if (siteKey && recaptchaFieldId) {
createRoot(wrapper).render(
<ContactForm
siteKey={siteKey}
recaptchaFieldId={recaptchaFieldId}
/>
);
} else {
console.error('ContactForm: Missing siteKey or recaptchaFieldId in data attributes');
}
}

View File

@@ -0,0 +1,169 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React, { useEffect, useState } from 'react';
const CAPTCHA_STORAGE_KEY = 'mineseeker_captcha_verified';
const CAPTCHA_TOKEN_KEY = 'mineseeker_captcha_token';
const RECAPTCHA_ACTION = 'mineseeker_play';
const CaptchaOverlay = ({ siteKey, onVerified, children }) => {
const [verified, setVerified] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
useEffect(() => {
const storedToken = sessionStorage.getItem(CAPTCHA_TOKEN_KEY);
const storedTime = sessionStorage.getItem(CAPTCHA_STORAGE_KEY);
if (storedToken && storedTime) {
const elapsed = (Date.now() - parseInt(storedTime)) / 1000;
if (110 > elapsed) {
const wrapper = document.getElementById('mine-wrapper');
if (wrapper) {
wrapper.dataset.captchaToken = storedToken;
}
setVerified(true);
onVerified?.();
return;
}
}
if (window.grecaptcha) {
window.grecaptcha.ready(() => {
window.grecaptcha
.execute(siteKey, { action: RECAPTCHA_ACTION })
.then(token => {
handleToken(token);
})
.catch(() => {
setError(true);
});
});
}
}, [siteKey, onVerified]);
const handleToken = token => {
const wrapper = document.getElementById('mine-wrapper');
if (wrapper) {
wrapper.dataset.captchaToken = token;
}
sessionStorage.setItem(CAPTCHA_TOKEN_KEY, token);
sessionStorage.setItem(CAPTCHA_STORAGE_KEY, Date.now().toString());
setVerified(true);
onVerified?.();
};
const handleClick = () => {
setLoading(true);
setError(false);
window.grecaptcha.ready(() => {
window.grecaptcha
.execute(siteKey, { action: RECAPTCHA_ACTION })
.then(token => {
handleToken(token);
setLoading(false);
})
.catch(() => {
setLoading(false);
setError(true);
setTimeout(() => setError(false), 2000);
});
});
};
if (verified) {
return <>{children}</>;
}
const overlayStyles = {
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'rgba(7, 9, 13, 0.95)',
backdropFilter: 'blur(8px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
};
const contentStyles = {
textAlign: 'center',
color: '#fff',
maxWidth: '400px',
padding: '40px',
};
const iconStyles = {
fontSize: '64px',
color: '#236f87',
marginBottom: '24px',
};
const h1Styles = {
font: '800 32px Rajdhani, sans-serif',
margin: '0 0 16px',
letterSpacing: '1px',
};
const pStyles = {
color: 'rgba(149, 207, 245, 0.7)',
font: '400 16px Rajdhani, sans-serif',
margin: '0 0 32px',
letterSpacing: '0.5px',
};
const buttonStyles = {
background: error
? 'linear-gradient(#8a2323 0%, #681a1a 100%)'
: loading
? 'linear-gradient(#236f87 0%, #1a5068 100%)'
: 'linear-gradient(#236f87 0%, #1a5068 100%)',
border: `2px solid ${error ? '#9a2e2e' : loading ? '#2e7a9a' : '#2e7a9a'}`,
borderRadius: '8px',
color: '#e0f4ff',
cursor: loading ? 'wait' : 'pointer',
font: '800 18px Rajdhani, sans-serif',
letterSpacing: '2px',
padding: '16px 40px',
textTransform: 'uppercase',
transition: 'all 0.3s ease',
display: 'inline-flex',
alignItems: 'center',
gap: '12px',
opacity: loading ? 0.7 : 1,
};
return (
<div style={overlayStyles}>
<div style={contentStyles}>
<div style={iconStyles}>
<i className="fa fa-shield-halved" />
</div>
<h1 style={h1Styles}>Ready to Play?</h1>
<p style={pStyles}>
Click below to verify you&apos;re human and start playing.
</p>
<button
style={buttonStyles}
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>
);
};
export default CaptchaOverlay;

View File

@@ -0,0 +1,41 @@
/**
* 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 { Fragment, useEffect, useState } from 'react';
const ChallengeCountdown = ({ onAccept, onDecline, seconds = 30 }) => {
const [countdown, setCountdown] = useState(seconds);
useEffect(() => {
const interval = setInterval(() => {
setCountdown(prev => {
if (1 >= prev) {
clearInterval(interval);
onDecline();
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}, [onDecline]);
return (
<Fragment>
<p style={{ textAlign: 'center', marginBottom: 20, color: '#95cff5' }}>
You have {countdown} second{1 === countdown ? '' : 's'} to answer to the challenge!
</p>
<div className="resign">
<a onClick={onAccept}>Accept</a>
<a onClick={onDecline}>Decline</a>
</div>
</Fragment>
);
};
export default ChallengeCountdown;

View File

@@ -26,6 +26,7 @@ export const GameBoard = ({ gameAssoc, gameInherited, isEnvDev }) => {
return ( return (
<GridControl <GridControl
gameAssoc={gameAssoc}
onClick={onClick} onClick={onClick}
resign={resign} resign={resign}
/> />

View File

@@ -47,6 +47,7 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
const [snapshotLoaded, setSnapshotLoaded] = useState(false); const [snapshotLoaded, setSnapshotLoaded] = useState(false);
const [challengingGameAssoc, setChallengingGameAssoc] = useState(null); const [challengingGameAssoc, setChallengingGameAssoc] = useState(null);
const [declinedMsg, setDeclinedMsg] = useState(''); const [declinedMsg, setDeclinedMsg] = useState('');
const [waitingCountdown, setWaitingCountdown] = useState(0);
const declinedTimerRef = useRef(null); const declinedTimerRef = useRef(null);
const addPlayer = useCallback(entry => { const addPlayer = useCallback(entry => {
@@ -111,6 +112,7 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
setChallengingGameAssoc(null); setChallengingGameAssoc(null);
clearTimeout(declinedTimerRef.current); clearTimeout(declinedTimerRef.current);
setDeclinedMsg('Challenge was not accepted.'); setDeclinedMsg('Challenge was not accepted.');
setWaitingCountdown(0);
declinedTimerRef.current = setTimeout(() => setDeclinedMsg(''), 3500); declinedTimerRef.current = setTimeout(() => setDeclinedMsg(''), 3500);
}; };
window.addEventListener('challenge-declined', handler); window.addEventListener('challenge-declined', handler);
@@ -120,15 +122,30 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
}; };
}, []); }, []);
useEffect(() => {
if (!waitingCountdown) return;
const interval = setInterval(() => {
setWaitingCountdown(prev => {
if (1 >= prev) return 0;
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}, [waitingCountdown]);
const handleChallenge = player => { const handleChallenge = player => {
if (challengingGameAssoc) return; if (challengingGameAssoc) return;
setChallengingGameAssoc(player.gameAssoc); setChallengingGameAssoc(player.gameAssoc);
setDeclinedMsg(''); setDeclinedMsg('');
setWaitingCountdown(30);
fetch('/api/game/challenge/' + player.gameAssoc, { fetch('/api/game/challenge/' + player.gameAssoc, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challengerGameAssoc: currentGameAssoc }), body: JSON.stringify({ challengerGameAssoc: currentGameAssoc }),
}).catch(() => setChallengingGameAssoc(null)); }).catch(() => {
setChallengingGameAssoc(null);
setWaitingCountdown(0);
});
}; };
const visible = players const visible = players
@@ -147,7 +164,12 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
} }
return ( return (
<Dialog open={open} onClose={onClose} sx={DIALOG_SX}> <Dialog
open={open}
onClose={0 < waitingCountdown ? undefined : onClose}
disableEscapeKeyDown={0 < waitingCountdown}
sx={DIALOG_SX}
>
<div className="opd"> <div className="opd">
<div className="opd-header"> <div className="opd-header">
<div className="opd-header-text"> <div className="opd-header-text">
@@ -160,18 +182,29 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
<div className="opd-header-actions"> <div className="opd-header-actions">
<button <button
className={`opd-refresh${loading ? ' opd-refresh--spin' : ''}`} className={`opd-refresh${loading ? ' opd-refresh--spin' : ''}`}
onClick={() => setRefreshKey(k => k + 1)} onClick={() => { if (0 === waitingCountdown) setRefreshKey(k => k + 1); }}
disabled={loading} disabled={loading || 0 < waitingCountdown}
aria-label="Refresh" aria-label="Refresh"
title="Refresh list" title="Refresh list"
> >
<i className="fa fa-refresh" /> <i className="fa fa-refresh" />
</button> </button>
<button className="opd-close" onClick={onClose} aria-label="Close"> <button
className="opd-close"
onClick={() => { if (0 === waitingCountdown) onClose(); }}
disabled={0 < waitingCountdown}
aria-label="Close"
>
<i className="fa fa-times" /> <i className="fa fa-times" />
</button> </button>
</div> </div>
</div> </div>
{0 < waitingCountdown ? (
<div className="opd-waiting">
<i className="fa fa-hourglass-start" />
<p>Waiting {waitingCountdown} second{1 === waitingCountdown ? '' : 's'} for opponent's answer...</p>
</div>
) : (
<div className="opd-search-wrap"> <div className="opd-search-wrap">
<i className="fa fa-search opd-search-icon" /> <i className="fa fa-search opd-search-icon" />
<input <input
@@ -186,6 +219,7 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
</button> </button>
)} )}
</div> </div>
)}
<div className="opd-list"> <div className="opd-list">
{loading && ( {loading && (
<div className="opd-empty"> <div className="opd-empty">

View File

@@ -7,20 +7,31 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
import React, { Fragment } from 'react'; import React, { Fragment, useState } from 'react';
import { useGame } from '@mine-contexts'; import { useGame } from '@mine-contexts';
import GridField from './GridField'; import GridField from './GridField';
import UserControl from '../user/UserControl'; import UserControl from '../user/UserControl';
import GameTimer from '../GameTimer'; import GameTimer from '../GameTimer';
import { BOMB_SYMBOLS, bombRadius } from '@mine-utils'; import { BOMB_SYMBOLS, bombRadius } from '@mine-utils';
const GridControl = ({ onClick, resign }) => { const GridControl = ({ gameAssoc, onClick, resign }) => {
const { const {
overlay, overlayTitle, overlaySubTitle, overlay, overlayTitle, overlaySubTitle,
webPlayer, activePlayer, bombSelected, webPlayer, activePlayer, bombSelected,
cells, setCells, cells, setCells, endRef,
} = useGame(); } = useGame();
const [copied, setCopied] = useState(false);
const shareUrl = gameAssoc ? `${window.location.origin}/battle/${gameAssoc}` : null;
const handleShare = () => {
if (!shareUrl) return;
navigator.clipboard.writeText(shareUrl).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2200);
});
};
const handleHover = (row, col) => { const handleHover = (row, col) => {
if (!bombSelected) return; if (!bombSelected) return;
const activeColor = activePlayer ? 'blue' : 'red'; const activeColor = activePlayer ? 'blue' : 'red';
@@ -47,7 +58,22 @@ const GridControl = ({ onClick, resign }) => {
<div className={`game-overlay${overlay ? '' : ' hide'}`}> <div className={`game-overlay${overlay ? '' : ' hide'}`}>
<div className="game-overlay-window"> <div className="game-overlay-window">
<h1>{overlayTitle}</h1> <h1>{overlayTitle}</h1>
{'string' === typeof overlaySubTitle ? (
<h2>{overlaySubTitle}</h2> <h2>{overlaySubTitle}</h2>
) : (
overlaySubTitle
)}
{gameAssoc && endRef.current && (
<button
className={`game-overlay-share${copied ? ' copied' : ''}`}
onClick={handleShare}
title="Copy share link"
aria-label="Copy share link"
>
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} />
{copied ? 'Copied!' : 'Share Battle'}
</button>
)}
</div> </div>
</div> </div>
<UserControl <UserControl

View File

@@ -10,6 +10,7 @@
export { GameBoard } from './GameBoard'; export { GameBoard } from './GameBoard';
export { default as OnlinePlayersDialog } from './OnlinePlayersDialog'; export { default as OnlinePlayersDialog } from './OnlinePlayersDialog';
export { default as WaitingOverlayContent } from './WaitingOverlayContent'; export { default as WaitingOverlayContent } from './WaitingOverlayContent';
export { default as ChallengeCountdown } from './ChallengeCountdown';
export { default as GameTimer } from './GameTimer'; export { default as GameTimer } from './GameTimer';
export { default as GridControl } from './grid/GridControl'; export { default as GridControl } from './grid/GridControl';
export { default as GridField } from './grid/GridField'; export { default as GridField } from './grid/GridField';

View File

@@ -41,6 +41,8 @@ export const GameProvider = ({ children }) => {
connectionLost, setConnectionLost, connectionLost, setConnectionLost,
} = useGameState(); } = useGameState();
const [gameUuid, setGameUuid] = React.useState(null);
const sounds = useRef({ const sounds = useRef({
click: new Howl({ src: ['/sound/click.mp3'] }), click: new Howl({ src: ['/sound/click.mp3'] }),
bomb: new Howl({ src: ['/sound/bomb.mp3'] }), bomb: new Howl({ src: ['/sound/bomb.mp3'] }),
@@ -202,8 +204,11 @@ export const GameProvider = ({ children }) => {
} }
}; };
const resignProcess = color => { const resignProcess = (color, uuid = null) => {
const wp = webPlayerRef.current; const wp = webPlayerRef.current;
if (uuid) {
setGameUuid(uuid);
}
showOverlay( showOverlay(
color === wp ? 'You have been give up' : 'Your opponent has been resigned', color === wp ? 'You have been give up' : 'Your opponent has been resigned',
color === wp ? 'You LOSE!' : 'You WIN!', color === wp ? 'You LOSE!' : 'You WIN!',
@@ -225,9 +230,9 @@ export const GameProvider = ({ children }) => {
value={{ value={{
// State (for rendering) // State (for rendering)
webPlayer, activePlayer, overlay, overlayTitle, overlaySubTitle, webPlayer, activePlayer, overlay, overlayTitle, overlaySubTitle,
mines, bombSelected, foundMines, red, blue, cells, gridReady, connectionLost, mines, bombSelected, foundMines, red, blue, cells, gridReady, connectionLost, gameUuid,
// Setters needed by useServerComm // Setters needed by useServerComm
setCells, setGridReady, setCells, setGridReady, setGameUuid,
// Refs (needed by useServerComm for async-safe reads) // Refs (needed by useServerComm for async-safe reads)
webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef, webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef,
// Sync helpers // Sync helpers

View File

@@ -14,13 +14,14 @@ import { DESC } from '@mine-utils';
import useStepTimer from './useStepTimer'; import useStepTimer from './useStepTimer';
import { WaitingOverlayContent } from '@mine-components'; import { WaitingOverlayContent } from '@mine-components';
/** Handles all server communication: SSE (Mercure), REST calls, and the initialization lifecycle. */ import { ChallengeCountdown } from '@mine-components';
const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => { const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
const { const {
/** Async-safe refs */ /** Async-safe refs */
webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef, webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef,
/** State setters */ /** State setters */
setGridReady, setGridReady, setGameUuid,
/** Sync helpers */ /** Sync helpers */
syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue, syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue,
/** Game logic */ /** Game logic */
@@ -136,8 +137,10 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
const wChallenge = payload => { const wChallenge = payload => {
const { challengerName, challengerGameAssoc } = payload; const { challengerName, challengerGameAssoc } = payload;
let declineTimeout = null;
const handleAccept = () => { const handleAccept = () => {
clearTimeout(declineTimeout);
fetch('/api/game/challenge/respond/' + challengerGameAssoc, { fetch('/api/game/challenge/respond/' + challengerGameAssoc, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -148,6 +151,7 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
}; };
const handleDecline = () => { const handleDecline = () => {
clearTimeout(declineTimeout);
fetch('/api/game/challenge/respond/' + challengerGameAssoc, { fetch('/api/game/challenge/respond/' + challengerGameAssoc, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -162,12 +166,11 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
}).catch(() => {}); }).catch(() => {});
}; };
declineTimeout = setTimeout(handleDecline, 30000);
showOverlay( showOverlay(
challengerName + ' wants to challenge you!', challengerName + ' wants to challenge you!',
<div className="resign"> <ChallengeCountdown onAccept={handleAccept} onDecline={handleDecline} />,
<a onClick={handleAccept}>Accept</a>
<a onClick={handleDecline}>Decline</a>
</div>,
); );
}; };
@@ -193,9 +196,12 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
} }
applyStep(payload.data); applyStep(payload.data);
if (payload.data.uuid && !endRef.current) {
setGameUuid(payload.data.uuid);
}
makeGameEndIfItEnds(payload.data.bluePoints, payload.data.redPoints, false, payload.data.leftMines); makeGameEndIfItEnds(payload.data.bluePoints, payload.data.redPoints, false, payload.data.leftMines);
} else { } else {
resignProcess(payload.data.resign); resignProcess(payload.data.resign, payload.data.uuid);
} }
} }
}; };
@@ -312,6 +318,9 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
try { try {
const result = await stepMutation.mutateAsync(dataPack); const result = await stepMutation.mutateAsync(dataPack);
applyStep(result); applyStep(result);
if (result.uuid && !endRef.current) {
setGameUuid(result.uuid);
}
makeGameEndIfItEnds(result.bluePoints, result.redPoints, false, result.leftMines); makeGameEndIfItEnds(result.bluePoints, result.redPoints, false, result.leftMines);
} catch (e) { } catch (e) {
isEnvDev && console.error('Step error', e); isEnvDev && console.error('Step error', e);
@@ -321,8 +330,16 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
const clickResign = () => { const clickResign = () => {
const color = activePlayerRef.current ? 'blue' : 'red'; const color = activePlayerRef.current ? 'blue' : 'red';
const stepElapsed = getStepElapsed(activePlayerRef.current, isGameRunningRef.current); const stepElapsed = getStepElapsed(activePlayerRef.current, isGameRunningRef.current);
stepMutation.mutate({ resign: color, stepElapsed }); stepMutation.mutate(
resignProcess(webPlayerRef.current); { resign: color, stepElapsed },
{
onSuccess: result => {
if (result?.uuid && !endRef.current) {
resignProcess(webPlayerRef.current, result.uuid);
}
},
}
);
}; };
const resign = () => { const resign = () => {

View File

@@ -11,6 +11,8 @@ services:
SERVER_NAME: ${SERVER_NAME:-:80} SERVER_NAME: ${SERVER_NAME:-:80}
APP_ENV: prod APP_ENV: prod
APP_SECRET: ${APP_SECRET} APP_SECRET: ${APP_SECRET}
APP_PUBLIC_HOSTNAME: ${APP_PUBLIC_HOSTNAME:-localhost}
APP_CONTACT_MAIL_ADDRESS: ${APP_CONTACT_MAIL_ADDRESS:-7system7@gmail.com}
DATABASE_URL: >- DATABASE_URL: >-
postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?serverVersion=${POSTGRES_VERSION}&charset=utf8 postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?serverVersion=${POSTGRES_VERSION}&charset=utf8
POSTGRES_URL: db POSTGRES_URL: db
@@ -31,6 +33,7 @@ services:
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
MINIO_ENDPOINT: http://minio:9000 MINIO_ENDPOINT: http://minio:9000
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9000} MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9000}
TRUSTED_PROXIES: ${TRUSTED_PROXIES}
volumes: volumes:
- app_var:/app/var - app_var:/app/var
- caddy_data:/data - caddy_data:/data
@@ -88,6 +91,7 @@ services:
RELAYHOST_PASSWORD: ${MAIL_RELAYHOST_PASSWORD:-} RELAYHOST_PASSWORD: ${MAIL_RELAYHOST_PASSWORD:-}
volumes: volumes:
- postfix_spool:/var/spool/postfix - postfix_spool:/var/spool/postfix
- ./docker/aliases:/tmp/aliases:ro
db: db:
image: postgres:${POSTGRES_VERSION:-18}-alpine image: postgres:${POSTGRES_VERSION:-18}-alpine
restart: unless-stopped restart: unless-stopped
@@ -113,3 +117,5 @@ volumes:
caddy_config: caddy_config:
postfix_spool: postfix_spool:
minio_data: minio_data:

View File

@@ -1,10 +1,19 @@
{ {
"name": "splendid-bear/mineseeker",
"version": "2026.2.1",
"license": "GPL-3.0-or-later",
"author": "https://www.splendidbear.org",
"bugs": "https://source.splendidbear.org",
"description": "This is a minesweeper game that is inspired from MSN Messenger's game.",
"minimum-stability": "dev",
"type": "project", "type": "project",
"license": "proprietary", "prefer-stable": true,
"private": true,
"require": { "require": {
"php": ">=8.5", "php": ">=8.5",
"ext-iconv": "*", "ext-iconv": "*",
"ext-json": "*", "ext-json": "*",
"ext-gd": "*",
"doctrine/dbal": "^3.7", "doctrine/dbal": "^3.7",
"doctrine/doctrine-bundle": ">=2.11 <2.14", "doctrine/doctrine-bundle": ">=2.11 <2.14",
"doctrine/doctrine-migrations-bundle": "^3.0", "doctrine/doctrine-migrations-bundle": "^3.0",

View File

@@ -8,6 +8,13 @@ framework:
session: session:
handler_id: ~ handler_id: ~
# Trust headers from reverse proxy (Caddy)
# This ensures absolute_url() uses HTTPS scheme when behind a reverse proxy
# Production: TRUSTED_PROXIES from .env (Gitea secret)
# Development: TRUSTED_PROXIES from compose.override.yaml
trusted_proxies: '%env(TRUSTED_PROXIES)%'
trusted_headers: ['x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host', 'x-forwarded-port']
#esi: true #esi: true
#fragments: true #fragments: true
php_errors: php_errors:

View File

@@ -0,0 +1,8 @@
framework:
# In production with FrankenPHP, the reverse proxy (Caddy) is in the same container
# Requests come from 127.0.0.1, so we must trust that IP to process X-Forwarded-Proto headers
# TRUSTED_PROXIES is set in the .env file (stored in Gitea secrets)
# Typical value for Docker: 172.18.0.0/16 (or the specific Docker network CIDR)
# This must be provided by the PROD_ENV_FILE secret in Gitea
trusted_proxies: '%env(TRUSTED_PROXIES)%'
trusted_headers: ['x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host', 'x-forwarded-port']

View File

@@ -25,6 +25,11 @@ services:
resource: '../src/Controller' resource: '../src/Controller'
tags: [ 'controller.service_arguments' ] tags: [ 'controller.service_arguments' ]
App\Service\BattleCardGenerator:
arguments:
$cacheDir: '%kernel.project_dir%/var/og-cache'
$minioMediaStorage: '@mineseeker.media.storage'
Aws\S3\S3Client: Aws\S3\S3Client:
arguments: arguments:
- version: 'latest' - version: 'latest'

5
docker/aliases Normal file
View File

@@ -0,0 +1,5 @@
# Postfix aliases file
# Mail addressed to system users are redirected to this address
postmaster: root
root: root

View File

@@ -1,7 +1,11 @@
{ {
"name": "mine-seeker", "name": "mine-seeker",
"version": "1.0.0", "version": "2026.2.1",
"author": "https://www.splendidbear.org",
"license": "GPL-3.0-or-later",
"bugs": "https://source.splendidbear.org",
"description": "Mine Seeker Game by system7", "description": "Mine Seeker Game by system7",
"private": true,
"keywords": [ "keywords": [
"mine", "mine",
"seeker", "seeker",
@@ -9,9 +13,6 @@
"multiplayer", "multiplayer",
"websocket" "websocket"
], ],
"author": "Laszlo Lang <system7>",
"license": "UNLICENSED",
"private": true,
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",

View File

@@ -25,7 +25,7 @@ if ($debug) {
} }
if ($trustedProxies = $_SERVER['TRUSTED_PROXIES'] ?? false) { if ($trustedProxies = $_SERVER['TRUSTED_PROXIES'] ?? false) {
Request::setTrustedProxies(explode(',', $trustedProxies), Request::HEADER_X_FORWARDED_ALL ^ Request::HEADER_X_FORWARDED_HOST); Request::setTrustedProxies(explode(',', $trustedProxies), Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PROTO);
} }
if ($trustedHosts = $_SERVER['TRUSTED_HOSTS'] ?? false) { if ($trustedHosts = $_SERVER['TRUSTED_HOSTS'] ?? false) {

View File

@@ -10,10 +10,19 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\ContactMessage;
use App\Form\ContactFormType;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
/** /**
@@ -36,6 +45,9 @@ class GameController extends AbstractController
private readonly string $mercurePublicUrl, private readonly string $mercurePublicUrl,
#[Autowire(env: 'MERCURE_SUBSCRIBER_JWT')] #[Autowire(env: 'MERCURE_SUBSCRIBER_JWT')]
private readonly string $mercureSubscriberJwt, private readonly string $mercureSubscriberJwt,
#[Autowire(env: 'APP_CONTACT_MAIL_ADDRESS')]
private readonly string $appContactMailAddress,
private readonly LoggerInterface $logger,
) { ) {
} }
@@ -69,9 +81,28 @@ class GameController extends AbstractController
} }
#[Route('/contact', name: 'MineSeekerBundle_contact')] #[Route('/contact', name: 'MineSeekerBundle_contact')]
public function contact(): Response public function contact(
{ Request $request,
return $this->render('Official/contact.html.twig'); EntityManagerInterface $em,
MailerInterface $mailer,
): Response {
$contactMessage = new ContactMessage();
$form = $this->createForm(ContactFormType::class, $contactMessage);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$contactMessage->setIpAddress($request->getClientIp());
$em->persist($contactMessage);
$em->flush();
$this->sendMail($mailer, $contactMessage);
$this->addFlash('contact_success', 'Thank you for your message! We will get back to you soon.');
return $this->redirectToRoute('MineSeekerBundle_contact');
}
return $this->render('Official/contact.html.twig', [
'form' => $form,
]);
} }
#[Route('/landing-page', name: 'MineSeekerBundle_landing')] #[Route('/landing-page', name: 'MineSeekerBundle_landing')]
@@ -79,4 +110,31 @@ class GameController extends AbstractController
{ {
return $this->render('Official/landing.html.twig'); return $this->render('Official/landing.html.twig');
} }
public function sendMail(MailerInterface $mailer, ContactMessage $contactMessage): void
{
try {
$mailer->send(
new TemplatedEmail()
->from('noreply@mineseeker.hu')
->to($this->appContactMailAddress)
->replyTo($contactMessage->getEmail())
->subject('New Contact Message from ' . $contactMessage->getName())
->htmlTemplate('emails/contact_notification.html.twig')
->context(['message' => $contactMessage])
);
} catch (\Exception $e) {
$this->logger->error('Failed to send contact notification email: ' . $e->getMessage(), [
'exception' => $e,
'message' => $contactMessage,
]);
throw new RuntimeException('Failed to send contact notification email: ' . $e->getMessage());
} catch (TransportExceptionInterface $e) {
$this->logger->error('Failed to send contact notification email: ' . $e->getMessage(), [
'exception' => $e,
'message' => $contactMessage,
]);
throw new RuntimeException('Failed to send contact notification email: ' . $e->getMessage());
}
}
} }

View File

@@ -10,20 +10,31 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\PlayedGame;
use App\Entity\User; use App\Entity\User;
use App\Repository\PlayedGameRepository; use App\Repository\PlayedGameRepository;
use App\Service\BattleCardGenerator;
use App\Service\WebAuthnService; use App\Service\WebAuthnService;
use DateTime;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator; use League\Flysystem\FilesystemOperator;
use Liip\ImagineBundle\Imagine\Cache\CacheManager; use Liip\ImagineBundle\Imagine\Cache\CacheManager;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
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;
use Throwable;
use function count;
/** /**
* Class ProfileController * Class ProfileController
@@ -40,12 +51,13 @@ class ProfileController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly PlayedGameRepository $repo, private readonly PlayedGameRepository $repo,
private readonly WebAuthnService $webAuthnService private readonly WebAuthnService $webAuthnService,
private readonly LoggerInterface $logger,
) { ) {
} }
#[Route('/profile', name: 'MineSeekerBundle_profile')] #[Route('/profile', name: 'MineSeekerBundle_profile')]
public function index(): Response public function index(CacheManager $cacheManager): Response
{ {
/** @var User $user */ /** @var User $user */
$user = $this->getUser(); $user = $this->getUser();
@@ -56,15 +68,15 @@ class ProfileController extends AbstractController
$losses = $this->repo->countLossesForUser($user); $losses = $this->repo->countLossesForUser($user);
$draws = $this->repo->countDrawsForUser($user); $draws = $this->repo->countDrawsForUser($user);
// Build monthly buckets for the last 6 months /** Build monthly buckets for the last 6 months */
$monthlyData = []; $monthlyData = [];
for ($i = 5; $i >= 0; $i--) { for ($i = 5; $i >= 0; $i--) {
$dt = new \DateTime("first day of -$i months midnight"); $dt = new DateTime("first day of -$i months midnight");
$key = $dt->format('Y-m'); $key = $dt->format('Y-m');
$monthlyData[$key] = ['label' => $dt->format('M'), 'wins' => 0, 'losses' => 0, 'draws' => 0]; $monthlyData[$key] = ['label' => $dt->format('M'), 'wins' => 0, 'losses' => 0, 'draws' => 0];
} }
$since = new \DateTime('first day of -5 months midnight'); $since = new DateTime('first day of -5 months midnight');
$recentGames = $this->repo->findFinishedForUserSince($user, $since); $recentGames = $this->repo->findFinishedForUserSince($user, $since);
$userId = $user->getId(); $userId = $user->getId();
@@ -112,7 +124,7 @@ class ProfileController extends AbstractController
'bestScore' => $this->repo->findBestScoreForUser($user), 'bestScore' => $this->repo->findBestScoreForUser($user),
], ],
'recent' => ($recent = $this->repo->findRecentFinishedForUser($user)), 'recent' => ($recent = $this->repo->findRecentFinishedForUser($user)),
'gamesData' => array_map(static function (\App\Entity\PlayedGame $game) use ($userId): array { 'gamesData' => array_map(function (PlayedGame $game) use ($userId, $cacheManager): array {
$isRed = $game->getRed()?->getId() === $userId; $isRed = $game->getRed()?->getId() === $userId;
$resign = $game->getResign(); $resign = $game->getResign();
$myColor = $isRed ? 'red' : 'blue'; $myColor = $isRed ? 'red' : 'blue';
@@ -128,12 +140,18 @@ class ProfileController extends AbstractController
elseif ($myPts < $oppPts) $result = 'loss'; elseif ($myPts < $oppPts) $result = 'loss';
} }
$redAvatarPath = $game->getRed()?->getAvatarPath();
$blueAvatarPath = $game->getBlue()?->getAvatarPath();
return [ return [
'id' => $game->getId(), 'id' => $game->getId(),
'uuid' => $game->getUuid()?->toRfc4122(),
'redName' => 'redName' =>
$game->getRed()?->getUsername() ?? $game->getRedAnon()?->getUserName() ?? 'Guest', $game->getRed()?->getUsername() ?? $game->getRedAnon()?->getUserName() ?? 'Guest',
'blueName' => 'blueName' =>
$game->getBlue()?->getUsername() ?? $game->getBlueAnon()?->getUserName() ?? 'Guest', $game->getBlue()?->getUsername() ?? $game->getBlueAnon()?->getUserName() ?? 'Guest',
'redAvatar' => $redAvatarPath ? $cacheManager->generateUrl($redAvatarPath, 'avatar_thumb') : null,
'blueAvatar' => $blueAvatarPath ? $cacheManager->generateUrl($blueAvatarPath, 'avatar_thumb') : null,
'redPoints' => $game->getRedPoints(), 'redPoints' => $game->getRedPoints(),
'bluePoints' => $game->getBluePoints(), 'bluePoints' => $game->getBluePoints(),
'redExplodedBomb' => $game->getRedExplodedBomb(), 'redExplodedBomb' => $game->getRedExplodedBomb(),
@@ -159,19 +177,26 @@ class ProfileController extends AbstractController
]); ]);
} }
#[Route('/battle/{id}', name: 'MineSeekerBundle_battle_share', requirements: ['id' => '\d+'], methods: ['GET'])] #[Route(
public function battleShare(int $id): Response '/battle/{uuid}',
name: 'MineSeekerBundle_battle_share',
requirements: ['uuid' => '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'],
methods: ['GET'],
)]
public function battleShare(Uuid $uuid): Response
{ {
$game = $this->repo->find($id); $game = $this->repo->findOneBy(['uuid' => $uuid]);
if (!$game) { if (!$game) {
throw $this->createNotFoundException('Battle not found.'); throw $this->createNotFoundException('Battle not found.');
} }
$redName = $game->getRed()?->getUsername() ?? $game->getRedAnon()?->getUserName() ?? 'Guest'; $redName = $game->getRed()?->getUsername() ?? ($game->getRedAnon() !== null ? 'Anonymous' : 'Guest');
$blueName = $game->getBlue()?->getUsername() ?? $game->getBlueAnon()?->getUserName() ?? 'Guest'; $blueName = $game->getBlue()?->getUsername() ?? ($game->getBlueAnon() !== null ? 'Anonymous' : 'Guest');
$redPts = $game->getRedPoints(); $redPts = $game->getRedPoints();
$bluePts = $game->getBluePoints(); $bluePts = $game->getBluePoints();
$resign = $game->getResign(); $resign = $game->getResign();
$redAvatar = $game->getRed()?->getAvatarPath();
$blueAvatar = $game->getBlue()?->getAvatarPath();
if ($resign === 'red') { if ($resign === 'red') {
$summary = "$redName resigned — $blueName wins"; $summary = "$redName resigned — $blueName wins";
@@ -196,11 +221,36 @@ class ProfileController extends AbstractController
'redPts' => $redPts, 'redPts' => $redPts,
'bluePts' => $bluePts, 'bluePts' => $bluePts,
'resign' => $resign, 'resign' => $resign,
'redAvatar' => $redAvatar,
'blueAvatar' => $blueAvatar,
'ogTitle' => "MineSeeker · $summary", 'ogTitle' => "MineSeeker · $summary",
'ogDesc' => "Watch the battle replay: $summary — played on MineSeeker, the multiplayer minesweeper.", 'ogDesc' => "Watch the battle replay: $summary — played on MineSeeker, the multiplayer minesweeper.",
]); ]);
} }
#[Route(
'/og/battle/{uuid}.png',
name: 'MineSeekerBundle_og_battle',
requirements: ['uuid' => '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'],
methods: ['GET'],
)]
public function battleOgImage(Uuid $uuid, BattleCardGenerator $generator): BinaryFileResponse
{
$game = $this->repo->findOneBy(['uuid' => $uuid]);
if (!$game) {
throw $this->createNotFoundException();
}
$path = $generator->generate($game);
$response = new BinaryFileResponse($path);
$response->headers->set('Content-Type', 'image/png');
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE);
$response->setMaxAge(86400 * 30);
$response->setPublic();
return $response;
}
#[Route('/profile/avatar', name: 'MineSeekerBundle_profile_avatar', methods: ['POST'])] #[Route('/profile/avatar', name: 'MineSeekerBundle_profile_avatar', methods: ['POST'])]
public function uploadAvatar( public function uploadAvatar(
Request $request, Request $request,
@@ -228,23 +278,27 @@ class ProfileController extends AbstractController
} }
$ext = $file->guessExtension() ?? 'jpg'; $ext = $file->guessExtension() ?? 'jpg';
$newPath = sprintf('avatar/%d.%s', $user->getId(), $ext); $newPath = sprintf('avatar/%s.%s', Uuid::v4()->toRfc4122(), $ext);
$oldPath = $user->getAvatarPath(); $oldPath = $user->getAvatarPath();
// Remove old file and any cached thumbnails /** Remove old file and any cached thumbnails */
if ($oldPath) { if ($oldPath) {
if ($oldPath !== $newPath) {
try { try {
$mediaStorage->delete($oldPath); $mediaStorage->delete($oldPath);
} catch (\Throwable) { } catch (Throwable) {
} $this->logger->error('Unable to delete old avatar: ' . $oldPath);
} }
$cacheManager->remove($oldPath, 'avatar_thumb'); $cacheManager->remove($oldPath, 'avatar_thumb');
} }
// Upload original to MinIO media/avatar/ /** Upload original to MinIO media/avatar/ */
$stream = fopen($file->getPathname(), 'r'); $stream = fopen($file->getPathname(), 'rb');
try {
$mediaStorage->writeStream($newPath, $stream); $mediaStorage->writeStream($newPath, $stream);
} catch (FilesystemException $e) {
$this->logger->error('Unable to write new avatar: ' . $e->getMessage());
throw new RuntimeException('Unable to write new avatar: ' . $e->getMessage());
}
fclose($stream); fclose($stream);
$user->setAvatarPath($newPath); $user->setAvatarPath($newPath);
@@ -275,7 +329,7 @@ class ProfileController extends AbstractController
return $this->render('Security/profile_security.html.twig', [ return $this->render('Security/profile_security.html.twig', [
'credentials' => $credentialsData, 'credentials' => $credentialsData,
'isTotpEnabled' => $user->isTotpAuthenticationEnabled(), 'isTotpEnabled' => $user->isTotpAuthenticationEnabled(),
'backupCodesCount' => \count($user->getBackupCodes()), 'backupCodesCount' => count($user->getBackupCodes()),
]); ]);
} }
} }

View File

@@ -19,6 +19,7 @@ use DateTime;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Attribute\AsController;
@@ -41,6 +42,12 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
#[AsController] #[AsController]
class SecurityController extends AbstractController class SecurityController extends AbstractController
{ {
public function __construct(
#[Autowire(env: 'APP_CONTACT_MAIL_ADDRESS')]
private readonly string $appContactMailAddress,
) {
}
#[Route('/login', name: 'MineSeekerBundle_login')] #[Route('/login', name: 'MineSeekerBundle_login')]
public function login(AuthenticationUtils $authenticationUtils): Response public function login(AuthenticationUtils $authenticationUtils): Response
{ {
@@ -92,6 +99,11 @@ class SecurityController extends AbstractController
UrlGeneratorInterface::ABSOLUTE_URL, UrlGeneratorInterface::ABSOLUTE_URL,
); );
/** Ensure HTTPS scheme in production */
if ($this->getParameter('kernel.environment') === 'prod') {
$activationUrl = str_replace('http://', 'https://', $activationUrl);
}
$mailer->send( $mailer->send(
new TemplatedEmail() new TemplatedEmail()
->from('noreply@mineseeker.hu') ->from('noreply@mineseeker.hu')
@@ -104,6 +116,19 @@ class SecurityController extends AbstractController
]) ])
); );
/** Send admin notification about new user registration */
$mailer->send(
new TemplatedEmail()
->from('noreply@mineseeker.hu')
->to($this->appContactMailAddress)
->subject('🎉 New User Registration: ' . $user->getUsername())
->htmlTemplate('emails/user_registration_notification.html.twig')
->context([
'user' => $user,
'registeredAt' => new DateTime(),
])
);
$this->addFlash('verify_email', $user->getEmail()); $this->addFlash('verify_email', $user->getEmail());
return $this->redirectToRoute('MineSeekerBundle_register'); return $this->redirectToRoute('MineSeekerBundle_register');
@@ -143,6 +168,11 @@ class SecurityController extends AbstractController
UrlGeneratorInterface::ABSOLUTE_URL, UrlGeneratorInterface::ABSOLUTE_URL,
); );
/** Ensure HTTPS scheme in production */
if ($this->getParameter('kernel.environment') === 'prod') {
$resetUrl = str_replace('http://', 'https://', $resetUrl);
}
$mailer->send( $mailer->send(
new TemplatedEmail() new TemplatedEmail()
->from('noreply@mineseeker.hu') ->from('noreply@mineseeker.hu')
@@ -199,7 +229,7 @@ class SecurityController extends AbstractController
} }
#[Route('/activate/{token}', name: 'MineSeekerBundle_activate')] #[Route('/activate/{token}', name: 'MineSeekerBundle_activate')]
public function activate(string $token, EntityManagerInterface $em): Response public function activate(string $token, EntityManagerInterface $em, MailerInterface $mailer): Response
{ {
$user = $em->getRepository(User::class)->findOneBy(['verificationToken' => $token]); $user = $em->getRepository(User::class)->findOneBy(['verificationToken' => $token]);
@@ -211,6 +241,19 @@ class SecurityController extends AbstractController
$user->setIsVerified(true)->setVerificationToken(null); $user->setIsVerified(true)->setVerificationToken(null);
$em->flush(); $em->flush();
/** Send admin notification about account activation */
$mailer->send(
new TemplatedEmail()
->from('noreply@mineseeker.hu')
->to($this->appContactMailAddress)
->subject('✅ User Account Activated: ' . $user->getUsername())
->htmlTemplate('emails/user_activation_notification.html.twig')
->context([
'user' => $user,
'activatedAt' => new DateTime(),
])
);
$this->addFlash('success', 'Your account is now active. Welcome, ' . $user->getUsername() . '!'); $this->addFlash('success', 'Your account is now active. Welcome, ' . $user->getUsername() . '!');
return $this->redirectToRoute('MineSeekerBundle_login'); return $this->redirectToRoute('MineSeekerBundle_login');

View File

@@ -0,0 +1,128 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Entity;
use App\Repository\ContactMessageRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\GeneratedValue;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping\Table;
/**
* Class ContactMessage
*
* @package App\Entity
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 15.
*/
#[Entity(repositoryClass: ContactMessageRepository::class)]
#[Table(name: 'contact_messages')]
class ContactMessage
{
#[Id, GeneratedValue, Column]
private ?int $id = null;
#[Column]
private string $name;
#[Column]
private string $email;
#[Column(type: Types::TEXT)]
private string $content;
#[Column]
private bool $consent = false;
#[Column]
private DateTimeImmutable $createdAt;
#[Column(length: 45, nullable: true)]
private ?string $ipAddress = null;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $email): self
{
$this->email = $email;
return $this;
}
public function getContent(): string
{
return $this->content;
}
public function setContent(string $content): self
{
$this->content = $content;
return $this;
}
public function isConsent(): bool
{
return $this->consent;
}
public function setConsent(bool $consent): self
{
$this->consent = $consent;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getIpAddress(): ?string
{
return $this->ipAddress;
}
public function setIpAddress(?string $ipAddress): self
{
$this->ipAddress = $ipAddress;
return $this;
}
}

View File

@@ -23,6 +23,7 @@ use Doctrine\ORM\Mapping\JoinColumn;
use Doctrine\ORM\Mapping\ManyToOne; use Doctrine\ORM\Mapping\ManyToOne;
use Doctrine\ORM\Mapping\OneToMany; use Doctrine\ORM\Mapping\OneToMany;
use Doctrine\ORM\Mapping\OneToOne; use Doctrine\ORM\Mapping\OneToOne;
use Symfony\Component\Uid\Uuid;
/** /**
* Class PlayedGame * Class PlayedGame
@@ -40,6 +41,9 @@ class PlayedGame
#[Id, GeneratedValue, Column] #[Id, GeneratedValue, Column]
private ?int $id = null; private ?int $id = null;
#[Column(type: 'uuid', unique: true)]
private ?Uuid $uuid = null;
#[Column(length: 50)] #[Column(length: 50)]
private ?string $gameAssoc = null; private ?string $gameAssoc = null;
@@ -90,6 +94,7 @@ class PlayedGame
public function __construct() public function __construct()
{ {
$this->steps = new ArrayCollection(); $this->steps = new ArrayCollection();
$this->uuid = Uuid::v4();
} }
public function getId(): ?int public function getId(): ?int
@@ -97,6 +102,16 @@ class PlayedGame
return $this->id; return $this->id;
} }
public function getUuid(): ?Uuid
{
return $this->uuid;
}
public function setUuid(?Uuid $uuid): void
{
$this->uuid = $uuid;
}
public function getGameAssoc(): ?string public function getGameAssoc(): ?string
{ {
return $this->gameAssoc; return $this->gameAssoc;

View File

@@ -0,0 +1,89 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form;
use App\Entity\ContactMessage;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\IsTrue;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
/**
* Class ContactFormType
*
* @package App\Form
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 15.
*/
class ContactFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'label' => 'Name',
'constraints' => [
new NotBlank(message: 'Please enter your name.'),
new Length(
min: 2,
max: 255,
minMessage: 'Name must be at least {{ limit }} characters.',
maxMessage: 'Name cannot be longer than {{ limit }} characters.',
),
],
])
->add('email', EmailType::class, [
'label' => 'Email',
'constraints' => [
new NotBlank(message: 'Please enter your email address.'),
new Email(message: 'Please enter a valid email address.'),
],
])
->add('content', TextareaType::class, [
'label' => 'Message',
'constraints' => [
new NotBlank(message: 'Please enter your message.'),
new Length(
min: 10,
max: 5000,
minMessage: 'Message must be at least {{ limit }} characters.',
maxMessage: 'Message cannot be longer than {{ limit }} characters.',
),
],
])
->add('consent', CheckboxType::class, [
'label' => 'I have read the Privacy and Data Processing Policy and I consent to the processing of my data.',
'mapped' => true,
'constraints' => [
new IsTrue(message: 'You must agree to the privacy policy to submit this form.'),
],
])
->add('recaptcha', RecaptchaType::class);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ContactMessage::class,
]);
}
}

View File

@@ -0,0 +1,47 @@
<?php declare(strict_types=1);
/*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20260414000000
*
* @package App\Migrations
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 14.
*/
final class Version20260414000000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add uuid column to played_game for shareable URLs';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE played_game ADD uuid UUID DEFAULT NULL');
$this->addSql('UPDATE played_game SET uuid = gen_random_uuid() WHERE uuid IS NULL');
$this->addSql('ALTER TABLE played_game ADD CONSTRAINT played_game_uuid_unique UNIQUE (uuid)');
$this->addSql('ALTER TABLE played_game ALTER COLUMN uuid SET NOT NULL');
$this->addSql('COMMENT ON COLUMN played_game.uuid IS \'(DC2Type:uuid)\'');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE played_game DROP uuid');
}
}

View File

@@ -0,0 +1,47 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20260415160446
*
* @package App\Migrations
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 15.
*/
final class Version20260415160446 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add contact mail storage support';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE SEQUENCE contact_messages_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE contact_messages (id INT NOT NULL, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, content TEXT NOT NULL, consent BOOLEAN NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, ip_address VARCHAR(45) DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('COMMENT ON COLUMN contact_messages.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER INDEX played_game_uuid_unique RENAME TO UNIQ_54BE8039D17F50A6');
}
public function down(Schema $schema): void
{
$this->addSql('DROP SEQUENCE contact_messages_id_seq CASCADE');
$this->addSql('DROP TABLE contact_messages');
$this->addSql('ALTER INDEX uniq_54be8039d17f50a6 RENAME TO played_game_uuid_unique');
}
}

View File

@@ -0,0 +1,35 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository;
use App\Entity\ContactMessage;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* Class ContactMessageRepository
*
* @package App\Repository
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 15.
*
* @extends ServiceEntityRepository<ContactMessage>
*/
class ContactMessageRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ContactMessage::class);
}
}

View File

@@ -0,0 +1,305 @@
<?php declare(strict_types=1);
/*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Service;
use App\Entity\PlayedGame;
use Exception;
use GdImage;
use League\Flysystem\FilesystemOperator;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Component\Uid\Uuid;
/**
* Class BattleCardGenerator
*
* Generates a 1200x630 PNG battle card for Open Graph sharing.
*
* @package App\Service
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 14.
*/
class BattleCardGenerator
{
private const int WIDTH = 1200;
private const int HEIGHT = 630;
private const string FONT = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
private const int AVATAR_SIZE = 120;
public function __construct(
private readonly string $cacheDir,
private readonly FilesystemOperator $minioMediaStorage,
private readonly LoggerInterface $logger,
) {
}
/** Returns a deterministic UUID v5 for the given battle ID — same battle always maps to the same filename. */
public function cachePath(int $battleId): string
{
$uuid = Uuid::v5(Uuid::fromString(Uuid::NAMESPACE_URL), 'mineseeker-battle-' . $battleId);
return $this->cacheDir . '/' . $uuid->toRfc4122() . '.png';
}
public function generate(PlayedGame $game): string
{
$path = $this->cachePath((int)$game->getId());
if (is_file($path)) {
return $path;
}
if (!is_dir($this->cacheDir)) {
if (
!mkdir($concurrentDirectory = $this->cacheDir, 0755, true)
&& !is_dir($concurrentDirectory)
) {
$this->logger->error(sprintf('Failed to create directory "%s" for battle card cache', $concurrentDirectory));
throw new RuntimeException(sprintf('Directory "%s" was not created', $concurrentDirectory));
}
}
$this->render($game, $path);
return $path;
}
private function render(PlayedGame $game, string $dest): void
{
$im = imagecreatetruecolor(self::WIDTH, self::HEIGHT);
/** Palette*/
$bg = imagecolorallocate($im, 13, 13, 28);
$dot = imagecolorallocate($im, 30, 30, 55);
$divider = imagecolorallocate($im, 40, 40, 70);
$white = imagecolorallocate($im, 230, 230, 240);
$muted = imagecolorallocate($im, 90, 90, 115);
$red = imagecolorallocate($im, 246, 125, 82);
$blue = imagecolorallocate($im, 149, 207, 245);
$gold = imagecolorallocate($im, 255, 200, 50);
/** Background*/
imagefill($im, 0, 0, $bg);
/** Dot-grid texture*/
for ($x = 40; $x < self::WIDTH; $x += 40) {
for ($y = 40; $y < self::HEIGHT; $y += 40) {
imagesetpixel($im, $x, $y, $dot);
}
}
/** Horizontal accent lines*/
imageline($im, 0, 90, self::WIDTH, 90, $divider);
imageline($im, 0, self::HEIGHT - 60, self::WIDTH, self::HEIGHT - 60, $divider);
/** Vertical centre divider*/
imageline($im, self::WIDTH / 2, 110, self::WIDTH / 2, self::HEIGHT - 80, $divider);
/** Resolve names*/
$redName = $game->getRed()?->getUsername()
?? ($game->getRedAnon() !== null ? 'Anonymous' : 'Guest');
$blueName = $game->getBlue()?->getUsername()
?? ($game->getBlueAnon() !== null ? 'Anonymous' : 'Guest');
$redPts = $game->getRedPoints();
$bluePts = $game->getBluePoints();
$resign = $game->getResign();
/** Winner*/
$winner = null;
if ($resign === 'red') {
$winner = 'blue';
} elseif ($resign === 'blue') {
$winner = 'red';
} elseif ($redPts !== null && $bluePts !== null) {
if ($redPts > $bluePts) $winner = 'red';
elseif ($bluePts > $redPts) $winner = 'blue';
else $winner = 'draw';
}
$this->centeredText($im, 'MineSeeker', 20, self::WIDTH / 2, 58, $muted);
/** RED and BLUE labels aligned with avatars horizontally*/
$this->centeredText($im, 'RED', 16, 220, 130, $red);
$this->centeredText($im, 'BLUE', 16, 980, 130, $blue);
/** Draw avatars below the team labels (moved down by 60px total: 200 → 260)*/
$redAvatar = $game->getRed()?->getAvatarPath();
$blueAvatar = $game->getBlue()?->getAvatarPath();
$this->drawAvatar($im, $redAvatar, 220, 260, $red, $redName);
$this->drawAvatar($im, $blueAvatar, 980, 260, $blue, $blueName);
$redColor = $winner === 'red' ? $gold : ($winner === 'draw' ? $white : $red);
$blueColor = $winner === 'blue' ? $gold : ($winner === 'draw' ? $white : $blue);
/** Truncate long usernames (max 10 chars + "...")*/
$redNameDisplay = mb_strlen($redName) > 10 ? mb_substr($redName, 0, 10) . '...' : $redName;
$blueNameDisplay = mb_strlen($blueName) > 10 ? mb_substr($blueName, 0, 10) . '...' : $blueName;
/** Player names lower below avatars (moved down by 60px total: 310 → 370)*/
$this->centeredTextFit($im, $redNameDisplay, 36, 220, 370, $redColor, 400);
$this->centeredTextFit($im, $blueNameDisplay, 36, 980, 370, $blueColor, 400);
$scoreText = $redPts !== null && $bluePts !== null ? $redPts . ' : ' . $bluePts : 'VS';
$this->centeredText($im, $scoreText, 72, self::WIDTH / 2, 390, $white);
if ($winner === 'red') {
$resultText = $redName . ' wins';
$resultColor = $gold;
} elseif ($winner === 'blue') {
$resultText = $blueName . ' wins';
$resultColor = $gold;
} elseif ($winner === 'draw') {
$resultText = 'Draw';
$resultColor = $muted;
} else {
$resultText = '';
$resultColor = $muted;
}
if ($resultText !== '') {
$this->centeredText($im, $resultText, 30, self::WIDTH / 2, 460, $resultColor);
}
if ($resign) {
$this->centeredText($im, ucfirst($resign) . ' resigned', 18, self::WIDTH / 2, 498, $muted);
}
$this->centeredText($im, 'mineseeker.hu', 16, self::WIDTH / 2, self::HEIGHT - 20, $muted);
imagepng($im, $dest);
imagedestroy($im);
}
/** Draw avatar or initials centered on $cx, $cy. */
private function drawAvatar(GdImage $im, ?string $avatarPath, int $cx, int $cy, int $color, string $name): void
{
$avatarImg = null;
/** Try to load avatar from MinIO if path exists*/
if ($avatarPath) {
try {
/** Remove 'avatar/' prefix if it exists since storage already has media/ prefix*/
$path = str_starts_with($avatarPath, 'avatar/') ? $avatarPath : 'avatar/' . $avatarPath;
$avatarData = $this->minioMediaStorage->read($path);
$avatarImg = imagecreatefromstring($avatarData);
} catch (Exception $e) {
/** Failed to load avatar, will use initials*/
$avatarImg = null;
}
}
$x = $cx - self::AVATAR_SIZE / 2;
$y = $cy - self::AVATAR_SIZE / 2;
if ($avatarImg) {
/** Draw circular avatar image*/
$mask = imagecreatetruecolor(self::AVATAR_SIZE, self::AVATAR_SIZE);
$transparent = imagecolorallocatealpha($mask, 0, 0, 0, 127);
imagefill($mask, 0, 0, $transparent);
/** Create circular mask*/
imagefilledellipse(
$mask,
self::AVATAR_SIZE / 2,
self::AVATAR_SIZE / 2,
self::AVATAR_SIZE,
self::AVATAR_SIZE,
imagecolorallocate($mask, 255, 255, 255),
);
/** Resize and crop avatar*/
$resized = imagecreatetruecolor(self::AVATAR_SIZE, self::AVATAR_SIZE);
imagealphablending($resized, false);
imagesavealpha($resized, true);
$bg = imagecolorallocatealpha($resized, 0, 0, 0, 127);
imagefill($resized, 0, 0, $bg);
$srcW = imagesx($avatarImg);
$srcH = imagesy($avatarImg);
$size = min($srcW, $srcH);
$srcX = ($srcW - $size) / 2;
$srcY = ($srcH - $size) / 2;
imagecopyresampled(
$resized,
$avatarImg,
0,
0,
(int)$srcX,
(int)$srcY,
self::AVATAR_SIZE,
self::AVATAR_SIZE,
$size,
$size,
);
/** Apply circular mask*/
for ($py = 0; $py < self::AVATAR_SIZE; $py++) {
for ($px = 0; $px < self::AVATAR_SIZE; $px++) {
$maskColor = imagecolorat($mask, $px, $py);
if (($maskColor >> 16) & 0xFF) {
$resizedColor = imagecolorat($resized, $px, $py);
imagesetpixel($im, (int)($x + $px), (int)($y + $py), $resizedColor);
}
}
}
imagedestroy($avatarImg);
imagedestroy($resized);
imagedestroy($mask);
} else {
/** Draw circular background with initials*/
imagefilledellipse($im, (int)$cx, (int)$cy, self::AVATAR_SIZE, self::AVATAR_SIZE, $color);
/** Draw initials */
$initials = mb_strtoupper(mb_substr($name, 0, 2));
$fontSize = 48;
$bbox = imagettfbbox($fontSize, 0, self::FONT, $initials);
$textW = $bbox[2] - $bbox[0];
$textH = $bbox[1] - $bbox[7];
$textX = $cx - $textW / 2;
$textY = $cy + $textH / 2;
$white = imagecolorallocate($im, 255, 255, 255);
imagettftext($im, $fontSize, 0, (int)$textX, (int)$textY, $white, self::FONT, $initials);
}
}
/** Render text centered on $cx. */
private function centeredText(GdImage $im, string $text, int $size, int $cx, int $y, int $color): void
{
$bbox = imagettfbbox($size, 0, self::FONT, $text);
$w = $bbox[2] - $bbox[0];
imagettftext($im, $size, 0, (int)($cx - $w / 2), $y, $color, self::FONT, $text);
}
/** Render text centered on $cx, shrinking font size to fit $maxWidth. */
private function centeredTextFit(
GdImage $im,
string $text,
int $size,
int $cx,
int $y,
int $color,
int $maxWidth
): void {
$bbox = imagettfbbox($size, 0, self::FONT, $text);
$w = $bbox[2] - $bbox[0];
if ($w > $maxWidth) {
$size = (int)($size * $maxWidth / $w);
}
$this->centeredText($im, $text, $size, $cx, $y, $color);
}
}

View File

@@ -20,7 +20,9 @@ use Doctrine\ORM\EntityManagerInterface;
use Exception; use Exception;
use JsonException; use JsonException;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Random\RandomException;
use RuntimeException; use RuntimeException;
use Symfony\Component\Uid\Uuid;
/** /**
* Class RpcManager * Class RpcManager
@@ -34,9 +36,9 @@ use RuntimeException;
*/ */
class RpcManager implements RpcManagerInterface class RpcManager implements RpcManagerInterface
{ {
private const ROWS = 16; private const int ROWS = 16;
private const COLS = 16; private const int COLS = 16;
private const MINES = 51; private const int MINES = 51;
public function __construct( public function __construct(
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
@@ -99,6 +101,7 @@ class RpcManager implements RpcManagerInterface
$this->entityManager->persist($grid); $this->entityManager->persist($grid);
$playedGame->setGameAssoc($gameAssoc); $playedGame->setGameAssoc($gameAssoc);
$playedGame->setUuid(Uuid::fromString($gameAssoc));
$playedGame->setGrid($grid); $playedGame->setGrid($grid);
$playedGame->setCreated(new DateTime()); $playedGame->setCreated(new DateTime());
$playedGame->setUpdated(new DateTime()); $playedGame->setUpdated(new DateTime());
@@ -117,25 +120,32 @@ class RpcManager implements RpcManagerInterface
*/ */
private function generateGrid(): array private function generateGrid(): array
{ {
// Build flat set: 51 mines ('m') + remaining water ('w') /** Build flat set: 51 mines ('m') + remaining water ('w') */
$set = array_merge( $set = array_merge(
array_fill(0, self::MINES, 'm'), array_fill(0, self::MINES, 'm'),
array_fill(0, self::ROWS * self::COLS - self::MINES, 'w'), array_fill(0, self::ROWS * self::COLS - self::MINES, 'w'),
); );
// Fisher-Yates shuffle /**
* Fisher-Yates shuffle
* @see https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle
*/
for ($i = count($set) - 1; $i > 0; $i--) { for ($i = count($set) - 1; $i > 0; $i--) {
try {
$j = random_int(0, $i); $j = random_int(0, $i);
} catch (RandomException $e) {
throw new RuntimeException('Failed to generate random index: ' . $e->getMessage());
}
[$set[$i], $set[$j]] = [$set[$j], $set[$i]]; [$set[$i], $set[$j]] = [$set[$j], $set[$i]];
} }
// Reshape to 2-D /** Reshape to 2-D */
$grid = []; $grid = [];
for ($r = 0; $r < self::ROWS; $r++) { for ($r = 0; $r < self::ROWS; $r++) {
$grid[$r] = array_slice($set, $r * self::COLS, self::COLS); $grid[$r] = array_slice($set, $r * self::COLS, self::COLS);
} }
// Replace 'w' with adjacent-mine count /** Replace 'w' with adjacent-mine count */
$dirs = [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]]; $dirs = [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]];
for ($r = 0; $r < self::ROWS; $r++) { for ($r = 0; $r < self::ROWS; $r++) {
for ($c = 0; $c < self::COLS; $c++) { for ($c = 0; $c < self::COLS; $c++) {

View File

@@ -3,40 +3,46 @@
{% block title %} - Battle Report{% endblock %} {% block title %} - Battle Report{% endblock %}
{% block metas %} {% block metas %}
{% set shareUrl = url('MineSeekerBundle_battle_share', { id: game.id }) %} {%- set shareUrl = url('MineSeekerBundle_battle_share', { uuid: game.uuid }) -%}
{%- set _ogImage = url('MineSeekerBundle_og_battle', { uuid: game.uuid }) -%}
<meta property="og:url" content="{{ shareUrl }}"/> <meta property="og:url" content="{{ shareUrl }}"/>
<meta property="og:type" content="website"/> <meta property="og:type" content="article"/>
<meta property="og:site_name" content="MineSeeker"/>
<meta property="og:locale" content="en_US"/>
<meta property="og:title" content="{{ ogTitle }}"/> <meta property="og:title" content="{{ ogTitle }}"/>
<meta property="og:description" content="{{ ogDesc }}"/> <meta property="og:description" content="{{ ogDesc }}"/>
<meta property="og:image" content="{{ app.request.getSchemeAndHttpHost() }}{{ asset('images/mine-1600x627.png') }}"/> <meta property="og:image" content="{{ _ogImage }}"/>
<meta property="og:image:width" content="1600"/> <meta property="og:image:width" content="1600"/>
<meta property="og:image:height" content="627"/> <meta property="og:image:height" content="627"/>
<meta property="og:image:alt" content="{{ ogTitle }}"/>
<meta name="twitter:card" content="summary_large_image"/> <meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:site" content="@MineSeeker"/>
<meta name="twitter:title" content="{{ ogTitle }}"/> <meta name="twitter:title" content="{{ ogTitle }}"/>
<meta name="twitter:description" content="{{ ogDesc }}"/> <meta name="twitter:description" content="{{ ogDesc }}"/>
<meta name="twitter:image" content="{{ app.request.getSchemeAndHttpHost() }}{{ asset('images/mine-1600x627.png') }}"/> <meta name="twitter:image" content="{{ _ogImage }}"/>
<meta name="twitter:image:alt" content="{{ ogTitle }}"/>
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<div class="bshare-page"> <div class="bshare-page">
<div class="bshare-card"> <div class="bshare-card">
<div class="bshare-card__eyebrow"> <div class="bshare-card__eyebrow">
<i class="fas fa-crosshairs"></i> Battle Report <i class="fas fa-crosshairs"></i> Battle Report
</div> </div>
{# VS Header #}
<div class="bshare-vs"> <div class="bshare-vs">
<div class="bshare-player bshare-player--red"> <div class="bshare-player bshare-player--red">
<div class="bshare-avatar bshare-avatar--red"> <div class="bshare-avatar bshare-avatar--red">
{% if redAvatar %}
<img src="{{ redAvatar|imagine_filter('avatar_thumb') }}"
alt="{{ redName }}"
class="bshare-avatar__img">
{% else %}
{{ redName|slice(0,2)|upper }} {{ redName|slice(0,2)|upper }}
{% endif %}
</div> </div>
<span class="bshare-player__name">{{ redName }}</span> <span class="bshare-player__name">{{ redName }}</span>
<span class="bshare-player__side">Red</span> <span class="bshare-player__side">Red</span>
</div> </div>
<div class="bshare-vs__center"> <div class="bshare-vs__center">
{% if redPts is not null and bluePts is not null %} {% if redPts is not null and bluePts is not null %}
<div class="bshare-score"> <div class="bshare-score">
@@ -48,8 +54,6 @@
<div class="bshare-score bshare-score--na">— : —</div> <div class="bshare-score bshare-score--na">— : —</div>
{% endif %} {% endif %}
<div class="bshare-vs__label">VS</div> <div class="bshare-vs__label">VS</div>
{# Result badge #}
{% if resign == 'red' %} {% if resign == 'red' %}
<div class="bshare-badge bshare-badge--blue"> <div class="bshare-badge bshare-badge--blue">
<i class="fas fa-trophy"></i> Blue wins <i class="fas fa-trophy"></i> Blue wins
@@ -74,18 +78,20 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
</div> </div>
<div class="bshare-player bshare-player--blue"> <div class="bshare-player bshare-player--blue">
<div class="bshare-avatar bshare-avatar--blue"> <div class="bshare-avatar bshare-avatar--blue">
{% if blueAvatar %}
<img src="{{ blueAvatar|imagine_filter('avatar_thumb') }}"
alt="{{ blueName }}"
class="bshare-avatar__img">
{% else %}
{{ blueName|slice(0,2)|upper }} {{ blueName|slice(0,2)|upper }}
{% endif %}
</div> </div>
<span class="bshare-player__name">{{ blueName }}</span> <span class="bshare-player__name">{{ blueName }}</span>
<span class="bshare-player__side">Blue</span> <span class="bshare-player__side">Blue</span>
</div> </div>
</div> </div>
{# Details #}
<div class="bshare-details"> <div class="bshare-details">
{% if resign %} {% if resign %}
<div class="bshare-detail"> <div class="bshare-detail">
@@ -112,7 +118,6 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div class="bshare-cta"> <div class="bshare-cta">
<a href="{{ path('MineSeekerBundle_gamePlay') }}" class="bshare-btn"> <a href="{{ path('MineSeekerBundle_gamePlay') }}" class="bshare-btn">
<i class="fas fa-play"></i> Play MineSeeker <i class="fas fa-play"></i> Play MineSeeker
@@ -121,8 +126,6 @@
<i class="fas fa-house"></i> Homepage <i class="fas fa-house"></i> Homepage
</a> </a>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -3,12 +3,23 @@
{% block title %} - The Game{% endblock %} {% block title %} - The Game{% endblock %}
{% block metas %} {% block metas %}
{%- set _ogImage = app.request.getSchemeAndHttpHost() ~ asset('images/mine-1600x627.png') -%}
<meta property="og:url" content="{{ url('MineSeekerBundle_homepage') }}"/> <meta property="og:url" content="{{ url('MineSeekerBundle_homepage') }}"/>
<meta property="og:type" content="website"/> <meta property="og:type" content="website"/>
<meta property="og:title" content="MineSeeker"/> <meta property="og:site_name" content="MineSeeker"/>
<meta property="og:description" content="A multiplayer minesweeper game"/> <meta property="og:locale" content="en_US"/>
<meta property="og:image" <meta property="og:title" content="MineSeeker — Multiplayer Minesweeper"/>
content="{{ app.request.getSchemeAndHttpHost() }}{{ asset('images/mine-1600x627.png') }}"/> <meta property="og:description"
content="Race a friend on a hidden minefield. Real-time 1v1 minesweeper in your browser — no account needed. Just play."/>
<meta property="og:image" content="{{ _ogImage }}"/>
<meta property="og:image:width" content="1600"/>
<meta property="og:image:height" content="627"/>
<meta property="og:image:alt" content="MineSeeker — Multiplayer Minesweeper"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:title" content="MineSeeker — Multiplayer Minesweeper"/>
<meta name="twitter:description"
content="Race a friend on a hidden minefield. Real-time 1v1 minesweeper in your browser — no account needed. Just play."/>
<meta name="twitter:image" content="{{ _ogImage }}"/>
{% endblock %} {% endblock %}
{% block header %} {% block header %}
@@ -61,6 +72,10 @@
<h1>No account needed.<br>Just play.</h1> <h1>No account needed.<br>Just play.</h1>
{% endif %} {% endif %}
<a href="{{ path('MineSeekerBundle_gamePlay') }}" class="hero-cta">Play Now</a> <a href="{{ path('MineSeekerBundle_gamePlay') }}" class="hero-cta">Play Now</a>
<p class="hero-donate-text">Love this game?</p>
<a href="https://ko-fi.com/splendidbear" target="_blank" rel="noopener" class="hero-donate">
Buy me a coffee
</a>
</div> </div>
</section> </section>

View File

@@ -11,7 +11,8 @@
data-env="{{ env }}" data-env="{{ env }}"
data-game-id="{{ app.request.get('gameAssoc') }}" data-game-id="{{ app.request.get('gameAssoc') }}"
data-mercure-hub-url="{{ mercure_hub_url }}" data-mercure-hub-url="{{ mercure_hub_url }}"
data-mercure-subscriber-jwt="{{ mercure_subscriber_jwt }}"> data-mercure-subscriber-jwt="{{ mercure_subscriber_jwt }}"
data-recaptcha-site-key="{{ recaptcha_site_key }}">
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
@@ -27,7 +28,6 @@
{% block stylesheets %} {% block stylesheets %}
{{ vite_entry_link_tags('mineseekerStyle') }} {{ vite_entry_link_tags('mineseekerStyle') }}
<style> <style>
.mine-container { .mine-container {
background: url('/images/bg-mineseeker-{{ random(1) }}-outbg.jpg') no-repeat; background: url('/images/bg-mineseeker-{{ random(1) }}-outbg.jpg') no-repeat;
@@ -37,5 +37,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 %}

View File

@@ -2,12 +2,135 @@
{% block title %} - Contact{% endblock %} {% block title %} - Contact{% endblock %}
{% block metas %}
{%- set _ogImage = app.request.getSchemeAndHttpHost() ~ asset('images/mine-1600x627.png') -%}
<meta property="og:url" content="{{ url('MineSeekerBundle_contact') }}"/>
<meta property="og:type" content="website"/>
<meta property="og:site_name" content="MineSeeker"/>
<meta property="og:title" content="Contact · MineSeeker"/>
<meta property="og:description" content="Get in touch with the MineSeeker team."/>
<meta property="og:image" content="{{ _ogImage }}"/>
<meta property="og:image:width" content="1600"/>
<meta property="og:image:height" content="627"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:title" content="Contact · MineSeeker"/>
<meta name="twitter:description" content="Get in touch with the MineSeeker team."/>
<meta name="twitter:image" content="{{ _ogImage }}"/>
{% endblock %}
{% block body %} {% block body %}
<div class="txt"> <div class="txt">
<h2>Contact and user support</h2> <h2 style="text-align: center;">Contact and user support</h2>
<h3>Under construction</h3> {% for message in app.flashes('contact_success') %}
<div class="auth-card auth-card--sent" style="margin: 20px auto; max-width: 600px;">
<div class="auth-sent-icon"><i class="far fa-envelope"></i></div>
<h3 style="color: #667eea; margin: 16px 0;">Message Sent!</h3>
<p class="auth-sent-note">{{ message }}</p>
<a href="{{ path('MineSeekerBundle_homepage') }}" class="auth-submit" style="text-decoration:none; margin-top:16px;">
Back to Home
</a>
</div>
{% else %}
<p style="text-align: center; color: #666; margin-bottom: 30px;">
Have a question, feedback, or need support? We'd love to hear from you!
</p>
<a href="mailto:langlasz@gmail.com">langlasz@gmail.com</a> <div class="auth-card" style="max-width: 600px; margin: 0 auto;">
{{ form_start(form, {attr: {class: 'auth-form'}}) }}
<div class="auth-field">
<label for="{{ form.name.vars.id }}" class="auth-label">Name *</label>
<div class="auth-input-wrap">
<i class="fas fa-user auth-input-icon"></i>
{{ form_widget(form.name, {
attr: {
class: 'auth-input' ~ (not form.name.vars.valid ? ' auth-input--error' : ''),
placeholder: 'Your name',
autofocus: true,
}
}) }}
</div>
{% if not form.name.vars.valid %}
{% for error in form.name.vars.errors %}
<p class="auth-field-error"><i class="fas fa-circle-exclamation"></i> {{ error.message }}</p>
{% endfor %}
{% endif %}
</div>
<div class="auth-field">
<label for="{{ form.email.vars.id }}" class="auth-label">Email *</label>
<div class="auth-input-wrap">
<i class="fas fa-envelope auth-input-icon"></i>
{{ form_widget(form.email, {
attr: {
class: 'auth-input' ~ (not form.email.vars.valid ? ' auth-input--error' : ''),
placeholder: 'your.email@example.com',
}
}) }}
</div>
{% if not form.email.vars.valid %}
{% for error in form.email.vars.errors %}
<p class="auth-field-error"><i class="fas fa-circle-exclamation"></i> {{ error.message }}</p>
{% endfor %}
{% endif %}
</div>
<div class="auth-field">
<label for="{{ form.content.vars.id }}" class="auth-label">Message *</label>
<div class="auth-input-wrap">
<i class="fas fa-comment-dots auth-input-icon" style="top: 16px;"></i>
{{ form_widget(form.content, {
attr: {
class: 'auth-input' ~ (not form.content.vars.valid ? ' auth-input--error' : ''),
placeholder: 'Tell us what\'s on your mind...',
rows: 6,
style: 'min-height: 150px; resize: vertical;'
}
}) }}
</div>
{% if not form.content.vars.valid %}
{% for error in form.content.vars.errors %}
<p class="auth-field-error"><i class="fas fa-circle-exclamation"></i> {{ error.message }}</p>
{% endfor %}
{% endif %}
</div>
<div class="auth-field">
<label class="auth-checkbox-label" style="display: flex; align-items: flex-start; cursor: pointer; user-select: none;">
{{ form_widget(form.consent, {
attr: {
class: 'auth-checkbox',
style: 'margin-right: 10px; margin-top: 3px;'
}
}) }}
<span style="flex: 1; font-size: 14px; line-height: 1.5; color: #666;">
I have read the <a href="{{ path('MineSeekerBundle_privacy') }}" target="_blank" style="color: #667eea; text-decoration: none;">Privacy and Data Processing Policy</a> and I consent to the processing of my data. *
</span>
</label>
{% if not form.consent.vars.valid %}
{% for error in form.consent.vars.errors %}
<p class="auth-field-error"><i class="fas fa-circle-exclamation"></i> {{ error.message }}</p>
{% endfor %}
{% endif %}
</div>
<button type="submit" class="auth-submit">
<i class="fas fa-paper-plane"></i> Send Message
</button>
{{ form_end(form) }}
</div>
{% endfor %}
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
{{ vite_entry_script_tags('contact') }}
<div id="contact-form-wrapper"
data-site-key="{{ recaptcha_site_key }}"
data-recaptcha-field-id="{{ form.recaptcha.vars.id }}">
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -2,6 +2,22 @@
{% block title %} - Privacy Policy{% endblock %} {% block title %} - Privacy Policy{% endblock %}
{% block metas %}
{%- set _ogImage = app.request.getSchemeAndHttpHost() ~ asset('images/mine-1600x627.png') -%}
<meta property="og:url" content="{{ url('MineSeekerBundle_privacy') }}"/>
<meta property="og:type" content="website"/>
<meta property="og:site_name" content="MineSeeker"/>
<meta property="og:title" content="Privacy Policy · MineSeeker"/>
<meta property="og:description" content="Read how MineSeeker collects, uses and protects your personal data."/>
<meta property="og:image" content="{{ _ogImage }}"/>
<meta property="og:image:width" content="1600"/>
<meta property="og:image:height" content="627"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:title" content="Privacy Policy · MineSeeker"/>
<meta name="twitter:description" content="Read how MineSeeker collects, uses and protects your personal data."/>
<meta name="twitter:image" content="{{ _ogImage }}"/>
{% endblock %}
{% block body %} {% block body %}
<div class="txt"> <div class="txt">
<h2>MineSeeker Privacy Policy</h2> <h2>MineSeeker Privacy Policy</h2>

View File

@@ -2,6 +2,22 @@
{% block title %} - Terms of Service{% endblock %} {% block title %} - Terms of Service{% endblock %}
{% block metas %}
{%- set _ogImage = app.request.getSchemeAndHttpHost() ~ asset('images/mine-1600x627.png') -%}
<meta property="og:url" content="{{ url('MineSeekerBundle_terms') }}"/>
<meta property="og:type" content="website"/>
<meta property="og:site_name" content="MineSeeker"/>
<meta property="og:title" content="Terms of Use · MineSeeker"/>
<meta property="og:description" content="Read the MineSeeker terms of use before playing."/>
<meta property="og:image" content="{{ _ogImage }}"/>
<meta property="og:image:width" content="1600"/>
<meta property="og:image:height" content="627"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:title" content="Terms of Use · MineSeeker"/>
<meta name="twitter:description" content="Read the MineSeeker terms of use before playing."/>
<meta name="twitter:image" content="{{ _ogImage }}"/>
{% endblock %}
{% block body %} {% block body %}
<div class="txt"> <div class="txt">
<h2>MineSeeker Terms of Service</h2> <h2>MineSeeker Terms of Service</h2>

View File

@@ -2,6 +2,17 @@
{% block title %} - Two-Factor Authentication{% endblock %} {% block title %} - Two-Factor Authentication{% endblock %}
{% block metas %}
<meta name="robots" content="noindex,nofollow"/>
<meta property="og:url" content="{{ app.request.uri }}"/>
<meta property="og:type" content="website"/>
<meta property="og:site_name" content="MineSeeker"/>
<meta property="og:title" content="Two-Factor Authentication · MineSeeker"/>
<meta property="og:description" content="Verify your identity to access your MineSeeker account."/>
<meta name="twitter:card" content="summary"/>
<meta name="twitter:title" content="Two-Factor Authentication · MineSeeker"/>
{% endblock %}
{% block body %} {% block body %}
<div class="auth-page"> <div class="auth-page">
<div class="auth-card"> <div class="auth-card">

View File

@@ -2,6 +2,17 @@
{% block title %} - Enable Two-Factor Authentication{% endblock %} {% block title %} - Enable Two-Factor Authentication{% endblock %}
{% block metas %}
<meta name="robots" content="noindex,nofollow"/>
<meta property="og:url" content="{{ app.request.uri }}"/>
<meta property="og:type" content="website"/>
<meta property="og:site_name" content="MineSeeker"/>
<meta property="og:title" content="Enable 2FA · MineSeeker"/>
<meta property="og:description" content="Set up two-factor authentication to secure your MineSeeker account."/>
<meta name="twitter:card" content="summary"/>
<meta name="twitter:title" content="Enable 2FA · MineSeeker"/>
{% endblock %}
{% block body %} {% block body %}
<div class="auth-page"> <div class="auth-page">
<div class="auth-card auth-card--wide"> <div class="auth-card auth-card--wide">

View File

@@ -2,6 +2,19 @@
{% block title %} - Forgot Password{% endblock %} {% block title %} - Forgot Password{% endblock %}
{% block metas %}
{%- set _ogImage = app.request.getSchemeAndHttpHost() ~ asset('images/mine-1600x627.png') -%}
<meta name="robots" content="noindex,nofollow"/>
<meta property="og:url" content="{{ app.request.uri }}"/>
<meta property="og:type" content="website"/>
<meta property="og:site_name" content="MineSeeker"/>
<meta property="og:title" content="Reset Password · MineSeeker"/>
<meta property="og:description" content="Reset your MineSeeker account password."/>
<meta property="og:image" content="{{ _ogImage }}"/>
<meta name="twitter:card" content="summary"/>
<meta name="twitter:title" content="Reset Password · MineSeeker"/>
{% endblock %}
{% block body %} {% block body %}
<div class="auth-page"> <div class="auth-page">
{% for email in app.flashes('reset_sent') %} {% for email in app.flashes('reset_sent') %}

View File

@@ -2,6 +2,25 @@
{% block title %} - Sign In{% endblock %} {% block title %} - Sign In{% endblock %}
{% block metas %}
{%- set _ogImage = app.request.getSchemeAndHttpHost() ~ asset('images/mine-1600x627.png') -%}
<meta name="robots" content="noindex,nofollow"/>
<meta property="og:url" content="{{ app.request.uri }}"/>
<meta property="og:type" content="website"/>
<meta property="og:site_name" content="MineSeeker"/>
<meta property="og:title" content="Sign In · MineSeeker"/>
<meta property="og:description"
content="Sign in to MineSeeker and keep track of your wins, stats and battle history."/>
<meta property="og:image" content="{{ _ogImage }}"/>
<meta property="og:image:width" content="1600"/>
<meta property="og:image:height" content="627"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:title" content="Sign In · MineSeeker"/>
<meta name="twitter:description"
content="Sign in to MineSeeker and keep track of your wins, stats and battle history."/>
<meta name="twitter:image" content="{{ _ogImage }}"/>
{% endblock %}
{% block body %} {% block body %}
<div class="auth-page"> <div class="auth-page">
{% for message in app.flashes('success') %} {% for message in app.flashes('success') %}

View File

@@ -2,6 +2,25 @@
{% block title %} - Profile{% endblock %} {% block title %} - Profile{% endblock %}
{% block metas %}
{%- set _ogImage = app.request.getSchemeAndHttpHost() ~ asset('images/mine-1600x627.png') -%}
<meta name="robots" content="noindex,nofollow"/>
<meta property="og:url" content="{{ url('MineSeekerBundle_profile') }}"/>
<meta property="og:type" content="profile"/>
<meta property="og:site_name" content="MineSeeker"/>
<meta property="og:title" content="{{ app.user.username }} · MineSeeker"/>
<meta property="og:description"
content="View {{ app.user.username }}'s battle stats, win rate and recent games on MineSeeker."/>
<meta property="og:image" content="{{ _ogImage }}"/>
<meta property="og:image:width" content="1600"/>
<meta property="og:image:height" content="627"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:title" content="{{ app.user.username }} · MineSeeker"/>
<meta name="twitter:description"
content="View {{ app.user.username }}'s battle stats, win rate and recent games on MineSeeker."/>
<meta name="twitter:image" content="{{ _ogImage }}"/>
{% endblock %}
{% block body %} {% block body %}
<div class="profile-page"> <div class="profile-page">
<div class="profile-header"> <div class="profile-header">

View File

@@ -2,6 +2,25 @@
{% block title %} - Security Settings{% endblock %} {% block title %} - Security Settings{% endblock %}
{% block metas %}
{%- set _ogImage = app.request.getSchemeAndHttpHost() ~ asset('images/mine-1600x627.png') -%}
<meta name="robots" content="noindex,nofollow"/>
<meta property="og:url" content="{{ url('MineSeekerBundle_profile_security') }}"/>
<meta property="og:type" content="website"/>
<meta property="og:site_name" content="MineSeeker"/>
<meta property="og:title" content="Security Settings · MineSeeker"/>
<meta property="og:description"
content="Manage your MineSeeker account security — passkeys, two-factor authentication and more."/>
<meta property="og:image" content="{{ _ogImage }}"/>
<meta property="og:image:width" content="1600"/>
<meta property="og:image:height" content="627"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:title" content="Security Settings · MineSeeker"/>
<meta name="twitter:description"
content="Manage your MineSeeker account security — passkeys, two-factor authentication and more."/>
<meta name="twitter:image" content="{{ _ogImage }}"/>
{% endblock %}
{% block body %} {% block body %}
<div class="profile-page"> <div class="profile-page">
<div class="profile-actions"> <div class="profile-actions">

View File

@@ -2,6 +2,24 @@
{% block title %} - Register{% endblock %} {% block title %} - Register{% endblock %}
{% block metas %}
{%- set _ogImage = app.request.getSchemeAndHttpHost() ~ asset('images/mine-1600x627.png') -%}
<meta property="og:url" content="{{ app.request.uri }}"/>
<meta property="og:type" content="website"/>
<meta property="og:site_name" content="MineSeeker"/>
<meta property="og:title" content="Create Account · MineSeeker"/>
<meta property="og:description"
content="Join MineSeeker for free. Track your wins, relive your best battles and prove you're the better minesweeper."/>
<meta property="og:image" content="{{ _ogImage }}"/>
<meta property="og:image:width" content="1600"/>
<meta property="og:image:height" content="627"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:title" content="Create Account · MineSeeker"/>
<meta name="twitter:description"
content="Join MineSeeker for free. Track your wins, relive your best battles and prove you're the better minesweeper."/>
<meta name="twitter:image" content="{{ _ogImage }}"/>
{% endblock %}
{% block body %} {% block body %}
<div class="auth-page"> <div class="auth-page">
{% for email in app.flashes('verify_email') %} {% for email in app.flashes('verify_email') %}

View File

@@ -2,6 +2,17 @@
{% block title %} - Reset Password{% endblock %} {% block title %} - Reset Password{% endblock %}
{% block metas %}
<meta name="robots" content="noindex,nofollow"/>
<meta property="og:url" content="{{ app.request.uri }}"/>
<meta property="og:type" content="website"/>
<meta property="og:site_name" content="MineSeeker"/>
<meta property="og:title" content="Set New Password · MineSeeker"/>
<meta property="og:description" content="Set a new password for your MineSeeker account."/>
<meta name="twitter:card" content="summary"/>
<meta name="twitter:title" content="Set New Password · MineSeeker"/>
{% endblock %}
{% block body %} {% block body %}
<div class="auth-page"> <div class="auth-page">
<div class="auth-card"> <div class="auth-card">

View File

@@ -77,7 +77,7 @@
<body> <body>
<div class="wrapper"> <div class="wrapper">
<div class="logo"> <div class="logo">
<img src="{{ absolute_url(asset('images/mine-logo-txt.png')) }}" alt="MineSeeker"/> <img src="{{ absolute_url(asset('images/mine-logo-txt.png')) | replace({'http://': 'https://'}) }}" alt="MineSeeker"/>
</div> </div>
<div class="card"> <div class="card">
<h1>One step to go</h1> <h1>One step to go</h1>

View File

@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>New Contact Message</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
border-radius: 8px 8px 0 0;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 24px;
}
.content {
background: #f9f9f9;
padding: 30px;
border-radius: 0 0 8px 8px;
}
.field {
margin-bottom: 20px;
}
.field-label {
font-weight: 600;
color: #666;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 5px;
}
.field-value {
background: white;
padding: 12px;
border-radius: 4px;
border-left: 3px solid #667eea;
}
.message-content {
white-space: pre-wrap;
word-wrap: break-word;
}
.footer {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #ddd;
font-size: 12px;
color: #999;
text-align: center;
}
</style>
</head>
<body>
<div class="header">
<h1>📬 New Contact Message</h1>
</div>
<div class="content">
<div class="field">
<div class="field-label">From</div>
<div class="field-value">
<strong>{{ message.name }}</strong><br>
<a href="mailto:{{ message.email }}">{{ message.email }}</a>
</div>
</div>
<div class="field">
<div class="field-label">Message</div>
<div class="field-value message-content">{{ message.content }}</div>
</div>
<div class="field">
<div class="field-label">Details</div>
<div class="field-value">
<strong>Submitted:</strong> {{ message.createdAt|date('Y-m-d H:i:s') }}<br>
{% if message.ipAddress %}
<strong>IP Address:</strong> {{ message.ipAddress }}<br>
{% endif %}
<strong>Consent:</strong> {% if message.consent %}✓ Given{% else %}✗ Not given{% endif %}
</div>
</div>
<div class="footer">
This message was sent via the MineSeeker contact form.<br>
You can reply directly to this email to respond to {{ message.name }}.
</div>
</div>
</body>
</html>

View File

@@ -91,7 +91,7 @@
<body> <body>
<div class="wrapper"> <div class="wrapper">
<div class="logo"> <div class="logo">
<img src="{{ absolute_url(asset('images/mine-logo-txt.png')) }}" alt="MineSeeker"/> <img src="{{ absolute_url(asset('images/mine-logo-txt.png')) | replace({'http://': 'https://'}) }}" alt="MineSeeker"/>
</div> </div>
<div class="card"> <div class="card">
<h1>Reset your password</h1> <h1>Reset your password</h1>

View File

@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>User Account Activated</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
border-radius: 8px 8px 0 0;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 24px;
}
.content {
background: #f9f9f9;
padding: 30px;
border-radius: 0 0 8px 8px;
}
.field {
margin-bottom: 20px;
}
.field-label {
font-weight: 600;
color: #666;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 5px;
}
.field-value {
background: white;
padding: 12px;
border-radius: 4px;
border-left: 3px solid #667eea;
}
.footer {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #ddd;
font-size: 12px;
color: #999;
text-align: center;
}
</style>
</head>
<body>
<div class="header">
<h1>✅ User Account Activated</h1>
</div>
<div class="content">
<div class="field">
<div class="field-label">Username</div>
<div class="field-value">
<strong>{{ user.username }}</strong>
</div>
</div>
<div class="field">
<div class="field-label">Email</div>
<div class="field-value">
<a href="mailto:{{ user.email }}">{{ user.email }}</a>
</div>
</div>
<div class="field">
<div class="field-label">Details</div>
<div class="field-value">
<strong>Activated:</strong> {{ activatedAt|date('Y-m-d H:i:s') }}<br>
<strong>Status:</strong> ✓ Email Verified - Account Active<br>
<strong>Email Verified:</strong> Yes
</div>
</div>
<div class="footer">
A user has successfully verified their email and activated their account on MineSeeker.<br>
They can now play games immediately.
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>New User Registration</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
border-radius: 8px 8px 0 0;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 24px;
}
.content {
background: #f9f9f9;
padding: 30px;
border-radius: 0 0 8px 8px;
}
.field {
margin-bottom: 20px;
}
.field-label {
font-weight: 600;
color: #666;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 5px;
}
.field-value {
background: white;
padding: 12px;
border-radius: 4px;
border-left: 3px solid #667eea;
}
.footer {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #ddd;
font-size: 12px;
color: #999;
text-align: center;
}
</style>
</head>
<body>
<div class="header">
<h1>👤 New User Registration</h1>
</div>
<div class="content">
<div class="field">
<div class="field-label">Username</div>
<div class="field-value">
<strong>{{ user.username }}</strong>
</div>
</div>
<div class="field">
<div class="field-label">Email</div>
<div class="field-value">
<a href="mailto:{{ user.email }}">{{ user.email }}</a>
</div>
</div>
<div class="field">
<div class="field-label">Details</div>
<div class="field-value">
<strong>Registered:</strong> {{ registeredAt|date('Y-m-d H:i:s') }}<br>
<strong>Status:</strong> {% if user.isVerified %}✓ Verified{% else %}⏳ Awaiting Email Verification{% endif %}<br>
<strong>Email Verified:</strong> {% if user.isVerified %}Yes{% else %}No - activation link sent{% endif %}
</div>
</div>
<div class="footer">
A new user has registered on MineSeeker.<br>
User must verify their email before account is fully activated.
</div>
</div>
</body>
</html>

View File

@@ -25,6 +25,7 @@ export default defineConfig({
mineseeker: './assets/js/app.jsx', mineseeker: './assets/js/app.jsx',
passkey: './assets/js/passkey.jsx', passkey: './assets/js/passkey.jsx',
profile: './assets/js/profile.jsx', profile: './assets/js/profile.jsx',
contact: './assets/js/contact.jsx',
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',