Private
Public Access
1
0

Compare commits

...

10 Commits

Author SHA1 Message Date
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
15ba26ccf2 chg: pkg: new version release !skipChangelog
All checks were successful
Deploy to Production / deploy (push) Successful in 3s
2026-04-14 16:47:15 +02:00
d3fa0cbbf9 fix: dev: quickfix for email sending #4
All checks were successful
Deploy to Production / deploy (push) Successful in 12s
2026-04-14 16:38:55 +02:00
38 changed files with 1311 additions and 422 deletions

View File

@@ -2,11 +2,12 @@ Changelog
========= =========
v2026.01 (2026-04-14) (unreleased)
--------------------- ------------
New New
~~~ ~~~
- 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 +19,12 @@ New
Changes Changes
~~~~~~~ ~~~~~~~
- 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 +64,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

@@ -22,6 +22,8 @@ RUN install-php-extensions \
apcu \ apcu \
sodium sodium
RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core && 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

@@ -216,7 +216,7 @@ POSTGRES_VERSION=18
MINIO_ROOT_USER=mineseeker MINIO_ROOT_USER=mineseeker
MINIO_ROOT_PASSWORD="<strong password>" MINIO_ROOT_PASSWORD="<strong password>"
MINIO_ENDPOINT=http://minio:9000 MINIO_ENDPOINT=http://minio:9000
MINIO_PUBLIC_URL=https://minio.mineseeker.hu MINIO_PUBLIC_URL=https://aws.mineseeker.hu
MAILER_DSN=smtp://mail:25?verify_peer=0 MAILER_DSN=smtp://mail:25?verify_peer=0
MAIL_DOMAIN=mineseeker.hu MAIL_DOMAIN=mineseeker.hu

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

@@ -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

@@ -161,7 +161,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(() => {

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

@@ -76,8 +76,13 @@ services:
image: boky/postfix:latest image: boky/postfix:latest
restart: unless-stopped restart: unless-stopped
container_name: '${APP_NAME}-mail' container_name: '${APP_NAME}-mail'
hostname: postfix.mail.${MAIL_DOMAIN:-localhost}
environment: environment:
ALLOWED_SENDER_DOMAINS: ${MAIL_DOMAIN:-localhost} ALLOWED_SENDER_DOMAINS: ${MAIL_DOMAIN:-localhost}
HOSTNAME: postfix.mail.${MAIL_DOMAIN:-localhost}
POSTFIX_myhostname: postfix.mail.${MAIL_DOMAIN:-localhost}
POSTFIX_mynetworks: "127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16"
POSTFIX_smtpd_tls_security_level: none
RELAYHOST: ${MAIL_RELAYHOST:-} RELAYHOST: ${MAIL_RELAYHOST:-}
RELAYHOST_AUTH: ${MAIL_RELAYHOST_AUTH:-} RELAYHOST_AUTH: ${MAIL_RELAYHOST_AUTH:-}
RELAYHOST_PASSWORD: ${MAIL_RELAYHOST_PASSWORD:-} RELAYHOST_PASSWORD: ${MAIL_RELAYHOST_PASSWORD:-}

View File

@@ -5,6 +5,7 @@
"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

@@ -25,6 +25,10 @@ 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'
Aws\S3\S3Client: Aws\S3\S3Client:
arguments: arguments:
- version: 'latest' - version: 'latest'

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,7 +51,8 @@ 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,
) { ) {
} }
@@ -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(static function (PlayedGame $game) use ($userId): 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';
@@ -130,6 +142,7 @@ class ProfileController extends AbstractController
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' =>
@@ -159,16 +172,21 @@ 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();
@@ -201,6 +219,29 @@ class ProfileController extends AbstractController
]); ]);
} }
#[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 +269,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 +320,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

@@ -94,7 +94,7 @@ class SecurityController extends AbstractController
$mailer->send( $mailer->send(
new TemplatedEmail() new TemplatedEmail()
->from('noreply@mineseeker.ninja') ->from('noreply@mineseeker.hu')
->to($user->getEmail()) ->to($user->getEmail())
->subject('Activate your MineSeeker account') ->subject('Activate your MineSeeker account')
->htmlTemplate('emails/activation.html.twig') ->htmlTemplate('emails/activation.html.twig')
@@ -145,7 +145,7 @@ class SecurityController extends AbstractController
$mailer->send( $mailer->send(
new TemplatedEmail() new TemplatedEmail()
->from('noreply@mineseeker.ninja') ->from('noreply@mineseeker.hu')
->to($email) ->to($email)
->subject('Reset your MineSeeker password') ->subject('Reset your MineSeeker password')
->htmlTemplate('emails/reset_password.html.twig') ->htmlTemplate('emails/reset_password.html.twig')

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,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,180 @@
<?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 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 W = 1200;
private const H = 630;
private const FONT = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
public function __construct(private readonly string $cacheDir) { }
/** 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)) {
mkdir($this->cacheDir, 0755, true);
}
$this->render($game, $path);
return $path;
}
private function render(PlayedGame $game, string $dest): void
{
$im = imagecreatetruecolor(self::W, self::H);
// 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::W; $x += 40) {
for ($y = 40; $y < self::H; $y += 40) {
imagesetpixel($im, $x, $y, $dot);
}
}
// Horizontal accent lines
imageline($im, 0, 90, self::W, 90, $divider);
imageline($im, 0, self::H - 60, self::W, self::H - 60, $divider);
// Vertical centre divider
imageline($im, self::W / 2, 110, self::W / 2, self::H - 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::W / 2, 58, $muted);
$this->centeredText($im, 'RED', 16, self::W / 4, 130, $red);
$this->centeredText($im, 'BLUE', 16, self::W * 3 / 4, 130, $blue);
$redColor = $winner === 'red' ? $gold : ($winner === 'draw' ? $white : $red);
$blueColor = $winner === 'blue' ? $gold : ($winner === 'draw' ? $white : $blue);
$this->centeredTextFit($im, $redName, 48, self::W / 4, 265, $redColor, self::W / 2 - 80);
$this->centeredTextFit($im, $blueName, 48, self::W * 3 / 4, 265, $blueColor, self::W / 2 - 80);
$scoreText = $redPts !== null && $bluePts !== null ? $redPts . ' : ' . $bluePts : 'VS';
$this->centeredText($im, $scoreText, 72, self::W / 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::W / 2, 460, $resultColor);
}
if ($resign) {
$this->centeredText($im, ucfirst($resign) . ' resigned', 18, self::W / 2, 498, $muted);
}
$this->centeredText($im, 'mineseeker.hu', 16, self::W / 2, self::H - 20, $muted);
imagepng($im, $dest);
imagedestroy($im);
}
/** 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,32 +3,33 @@
{% 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">
{{ redName|slice(0,2)|upper }} {{ redName|slice(0,2)|upper }}
@@ -36,7 +37,6 @@
<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 +48,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,7 +72,6 @@
{% 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">
{{ blueName|slice(0,2)|upper }} {{ blueName|slice(0,2)|upper }}
@@ -82,10 +79,7 @@
<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 +106,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 +114,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 %}

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,26 @@
{% 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>Contact and user support</h2>
<h3>Under construction</h3> <h3>Under construction</h3>
<a href="mailto:langlasz@gmail.com">langlasz@gmail.com</a> <a href="mailto:langlasz@gmail.com">langlasz@gmail.com</a>
</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,13 +2,29 @@
{% 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>
<h3>1. Terms</h3> <h3>1. Terms</h3>
<p>By accessing the website at <a href="https://www.mineseeker.ninja">https://www.mineseeker.ninja</a>, you are <p>By accessing the website at <a href="https://www.mineseeker.hu">https://www.mineseeker.hu</a>, you are
agreeing to be bound by these terms of service, all applicable laws and regulations, and agree that you are agreeing to be bound by these terms of service, all applicable laws and regulations, and agree that you are
responsible for compliance with any applicable local laws. If you do not agree with any of these terms, you responsible for compliance with any applicable local laws. If you do not agree with any of these terms, you
are are

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">