Compare commits
14 Commits
v2026.2.0-
...
v2026.2.1-
| Author | SHA1 | Date | |
|---|---|---|---|
| 8795fedda9 | |||
| 588fb57299 | |||
| eb345e17ca | |||
| c2693c4648 | |||
| 43efc16562 | |||
| 80d6440ece | |||
| 5ee972f003 | |||
| 6f3edb41ea | |||
| c52939a7a3 | |||
| 573d409606 | |||
| 9a58bc9a5e | |||
| 8780800dff | |||
| f442942faf | |||
| a61d881a4e |
@@ -6,6 +6,12 @@
|
||||
APP_ENV=dev
|
||||
APP_SECRET=changethis
|
||||
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_HOSTS=localhost,example.com
|
||||
###< symfony/framework-bundle ###
|
||||
|
||||
@@ -2,11 +2,12 @@ Changelog
|
||||
=========
|
||||
|
||||
|
||||
(unreleased)
|
||||
------------
|
||||
v2026.2.1 (2026-04-15)
|
||||
----------------------
|
||||
|
||||
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]
|
||||
- Add opportunity to use profile picture. #4. [Lang]
|
||||
@@ -19,6 +20,10 @@ New
|
||||
|
||||
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]
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
|
||||
encode zstd br gzip
|
||||
|
||||
# Forward scheme information to the PHP application
|
||||
header X-Forwarded-Proto {scheme}
|
||||
header X-Forwarded-Host {host}
|
||||
|
||||
mercure {
|
||||
transport_url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db}
|
||||
publisher_jwt {$MERCURE_JWT_SECRET} HS256
|
||||
|
||||
@@ -22,7 +22,11 @@ RUN install-php-extensions \
|
||||
apcu \
|
||||
sodium
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core && rm -rf /var/lib/apt/lists/*
|
||||
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 printf '[opcache]\nopcache.enable=1\nopcache.memory_consumption=256\nopcache.max_accelerated_files=20000\nopcache.validate_timestamps=0\n' \
|
||||
|
||||
12
README.md
12
README.md
@@ -151,6 +151,7 @@ services:
|
||||
app:
|
||||
environment:
|
||||
MAILER_DSN: smtp://mail:1025?verify_peer=0
|
||||
TRUSTED_PROXIES: "0.0.0.0/0"
|
||||
mail:
|
||||
image: mailhog/mailhog:latest
|
||||
ports:
|
||||
@@ -233,8 +234,13 @@ MERCURE_SUBSCRIBER_JWT="<generated by make mercure-jwt>"
|
||||
APP_PUBLIC_HOSTNAME=mineseeker.hu
|
||||
WEBAUTHN_RP_ID=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
|
||||
|
||||
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.
|
||||
|
||||
#### 5. First deploy
|
||||
#### 3. First deploy
|
||||
|
||||
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`.
|
||||
|
||||
#### 6. Verify
|
||||
#### 4. Verify
|
||||
|
||||
```bash
|
||||
docker compose ps # all services should be healthy/running
|
||||
|
||||
@@ -180,6 +180,41 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
72
assets/css/homepage/_donate.scss
Normal file
72
assets/css/homepage/_donate.scss
Normal 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -747,6 +747,13 @@
|
||||
font: 800 24px 'Rajdhani', sans-serif;
|
||||
letter-spacing: 2px;
|
||||
|
||||
&__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&--red {
|
||||
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);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@use 'homepage/hero';
|
||||
@use 'homepage/hero-compact';
|
||||
@use 'homepage/cta';
|
||||
@use 'homepage/donate';
|
||||
@use 'homepage/auth-bar';
|
||||
@use 'homepage/auth';
|
||||
@use 'homepage/content';
|
||||
|
||||
@@ -35,7 +35,7 @@ const RESULT_META = {
|
||||
icon: 'fa-trophy',
|
||||
},
|
||||
loss: {
|
||||
label: 'Defeat',
|
||||
label: 'Defeated',
|
||||
color: '#f67d52',
|
||||
bg: 'rgba(173,10,5,0.15)',
|
||||
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 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={{
|
||||
width: 72, height: 72, borderRadius: '50%',
|
||||
background: gradient,
|
||||
background: avatarUrl ? 'transparent' : gradient,
|
||||
border: `2px solid ${border}`,
|
||||
boxShadow: glow,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
font: '800 24px \'Rajdhani\', sans-serif',
|
||||
color: textColor,
|
||||
letterSpacing: 2,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{initials}
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
</div>
|
||||
<span style={{
|
||||
font: '700 15px \'Rajdhani\', sans-serif',
|
||||
@@ -197,7 +210,7 @@ export default function BattleDialog({ games }) {
|
||||
</div>
|
||||
</div>
|
||||
<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-score">
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
<Avatar name={game.blueName} color="blue" />
|
||||
<Avatar name={game.blueName} color="blue" avatarUrl={game.blueAvatar} />
|
||||
</div>
|
||||
<div className="bd-stats">
|
||||
<StatRow icon="fa-calendar" label="Date" value={game.date ?? '—'} />
|
||||
|
||||
83
assets/js/components/ContactForm.jsx
Normal file
83
assets/js/components/ContactForm.jsx
Normal 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
31
assets/js/contact.jsx
Normal 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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ services:
|
||||
SERVER_NAME: ${SERVER_NAME:-:80}
|
||||
APP_ENV: prod
|
||||
APP_SECRET: ${APP_SECRET}
|
||||
APP_PUBLIC_HOSTNAME: ${APP_PUBLIC_HOSTNAME:-localhost}
|
||||
APP_CONTACT_MAIL_ADDRESS: ${APP_CONTACT_MAIL_ADDRESS:-7system7@gmail.com}
|
||||
DATABASE_URL: >-
|
||||
postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?serverVersion=${POSTGRES_VERSION}&charset=utf8
|
||||
POSTGRES_URL: db
|
||||
@@ -31,6 +33,7 @@ services:
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||
MINIO_ENDPOINT: http://minio:9000
|
||||
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9000}
|
||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES}
|
||||
volumes:
|
||||
- app_var:/app/var
|
||||
- caddy_data:/data
|
||||
@@ -88,6 +91,7 @@ services:
|
||||
RELAYHOST_PASSWORD: ${MAIL_RELAYHOST_PASSWORD:-}
|
||||
volumes:
|
||||
- postfix_spool:/var/spool/postfix
|
||||
- ./docker/aliases:/tmp/aliases:ro
|
||||
db:
|
||||
image: postgres:${POSTGRES_VERSION:-18}-alpine
|
||||
restart: unless-stopped
|
||||
@@ -113,3 +117,5 @@ volumes:
|
||||
caddy_config:
|
||||
postfix_spool:
|
||||
minio_data:
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
{
|
||||
"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",
|
||||
"license": "proprietary",
|
||||
"prefer-stable": true,
|
||||
"private": true,
|
||||
"require": {
|
||||
"php": ">=8.5",
|
||||
"ext-iconv": "*",
|
||||
|
||||
@@ -8,6 +8,13 @@ framework:
|
||||
session:
|
||||
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
|
||||
#fragments: true
|
||||
php_errors:
|
||||
|
||||
8
config/packages/prod/framework.yaml
Normal file
8
config/packages/prod/framework.yaml
Normal 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']
|
||||
@@ -28,6 +28,7 @@ services:
|
||||
App\Service\BattleCardGenerator:
|
||||
arguments:
|
||||
$cacheDir: '%kernel.project_dir%/var/og-cache'
|
||||
$minioMediaStorage: '@mineseeker.media.storage'
|
||||
|
||||
Aws\S3\S3Client:
|
||||
arguments:
|
||||
|
||||
5
docker/aliases
Normal file
5
docker/aliases
Normal file
@@ -0,0 +1,5 @@
|
||||
# Postfix aliases file
|
||||
# Mail addressed to system users are redirected to this address
|
||||
postmaster: root
|
||||
root: root
|
||||
|
||||
97
package.json
97
package.json
@@ -1,50 +1,51 @@
|
||||
{
|
||||
"name": "mine-seeker",
|
||||
"version": "1.0.0",
|
||||
"description": "Mine Seeker Game by system7",
|
||||
"keywords": [
|
||||
"mine",
|
||||
"seeker",
|
||||
"game",
|
||||
"multiplayer",
|
||||
"websocket"
|
||||
],
|
||||
"author": "Laszlo Lang <system7>",
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fontsource/changa-one": "^5.2.8",
|
||||
"@fontsource/open-sans": "^5.2.7",
|
||||
"@fontsource/rajdhani": "^5.2.7",
|
||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||
"@mui/material": "^9.0.0",
|
||||
"@mui/x-charts": "^9.0.1",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"howler": "^2.1.2",
|
||||
"lodash": "^4.18.1",
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@stylistic/eslint-plugin": "^4.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-plugin-react": "^7.0.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"globals": "^15.0.0",
|
||||
"sass": "^1.77.0",
|
||||
"vite": "^8.0.8",
|
||||
"vite-plugin-symfony": "^8.2.4"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"watch": "vite build --watch",
|
||||
"build": "vite build && cp -r node_modules/@fortawesome/fontawesome-free/webfonts public/build/webfonts",
|
||||
"lint": "eslint assets/js/"
|
||||
}
|
||||
"name": "mine-seeker",
|
||||
"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",
|
||||
"private": true,
|
||||
"keywords": [
|
||||
"mine",
|
||||
"seeker",
|
||||
"game",
|
||||
"multiplayer",
|
||||
"websocket"
|
||||
],
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fontsource/changa-one": "^5.2.8",
|
||||
"@fontsource/open-sans": "^5.2.7",
|
||||
"@fontsource/rajdhani": "^5.2.7",
|
||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||
"@mui/material": "^9.0.0",
|
||||
"@mui/x-charts": "^9.0.1",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"howler": "^2.1.2",
|
||||
"lodash": "^4.18.1",
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@stylistic/eslint-plugin": "^4.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-plugin-react": "^7.0.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"globals": "^15.0.0",
|
||||
"sass": "^1.77.0",
|
||||
"vite": "^8.0.8",
|
||||
"vite-plugin-symfony": "^8.2.4"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"watch": "vite build --watch",
|
||||
"build": "vite build && cp -r node_modules/@fortawesome/fontawesome-free/webfonts public/build/webfonts",
|
||||
"lint": "eslint assets/js/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ if ($debug) {
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -10,10 +10,19 @@
|
||||
|
||||
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\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
@@ -31,11 +40,14 @@ class GameController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(env: 'APP_ENV')]
|
||||
private readonly string $env,
|
||||
private readonly string $env,
|
||||
#[Autowire(env: 'MERCURE_PUBLIC_URL')]
|
||||
private readonly string $mercurePublicUrl,
|
||||
private readonly string $mercurePublicUrl,
|
||||
#[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')]
|
||||
public function contact(): Response
|
||||
{
|
||||
return $this->render('Official/contact.html.twig');
|
||||
public function contact(
|
||||
Request $request,
|
||||
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')]
|
||||
@@ -79,4 +110,31 @@ class GameController extends AbstractController
|
||||
{
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ class ProfileController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/profile', name: 'MineSeekerBundle_profile')]
|
||||
public function index(): Response
|
||||
public function index(CacheManager $cacheManager): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
@@ -124,7 +124,7 @@ class ProfileController extends AbstractController
|
||||
'bestScore' => $this->repo->findBestScoreForUser($user),
|
||||
],
|
||||
'recent' => ($recent = $this->repo->findRecentFinishedForUser($user)),
|
||||
'gamesData' => array_map(static function (PlayedGame $game) use ($userId): array {
|
||||
'gamesData' => array_map(function (PlayedGame $game) use ($userId, $cacheManager): array {
|
||||
$isRed = $game->getRed()?->getId() === $userId;
|
||||
$resign = $game->getResign();
|
||||
$myColor = $isRed ? 'red' : 'blue';
|
||||
@@ -140,6 +140,9 @@ class ProfileController extends AbstractController
|
||||
elseif ($myPts < $oppPts) $result = 'loss';
|
||||
}
|
||||
|
||||
$redAvatarPath = $game->getRed()?->getAvatarPath();
|
||||
$blueAvatarPath = $game->getBlue()?->getAvatarPath();
|
||||
|
||||
return [
|
||||
'id' => $game->getId(),
|
||||
'uuid' => $game->getUuid()?->toRfc4122(),
|
||||
@@ -147,6 +150,8 @@ class ProfileController extends AbstractController
|
||||
$game->getRed()?->getUsername() ?? $game->getRedAnon()?->getUserName() ?? 'Guest',
|
||||
'blueName' =>
|
||||
$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(),
|
||||
'bluePoints' => $game->getBluePoints(),
|
||||
'redExplodedBomb' => $game->getRedExplodedBomb(),
|
||||
@@ -190,6 +195,8 @@ class ProfileController extends AbstractController
|
||||
$redPts = $game->getRedPoints();
|
||||
$bluePts = $game->getBluePoints();
|
||||
$resign = $game->getResign();
|
||||
$redAvatar = $game->getRed()?->getAvatarPath();
|
||||
$blueAvatar = $game->getBlue()?->getAvatarPath();
|
||||
|
||||
if ($resign === 'red') {
|
||||
$summary = "$redName resigned — $blueName wins";
|
||||
@@ -208,14 +215,16 @@ class ProfileController extends AbstractController
|
||||
}
|
||||
|
||||
return $this->render('Game/battle_share.html.twig', [
|
||||
'game' => $game,
|
||||
'redName' => $redName,
|
||||
'blueName' => $blueName,
|
||||
'redPts' => $redPts,
|
||||
'bluePts' => $bluePts,
|
||||
'resign' => $resign,
|
||||
'ogTitle' => "MineSeeker · $summary",
|
||||
'ogDesc' => "Watch the battle replay: $summary — played on MineSeeker, the multiplayer minesweeper.",
|
||||
'game' => $game,
|
||||
'redName' => $redName,
|
||||
'blueName' => $blueName,
|
||||
'redPts' => $redPts,
|
||||
'bluePts' => $bluePts,
|
||||
'resign' => $resign,
|
||||
'redAvatar' => $redAvatar,
|
||||
'blueAvatar' => $blueAvatar,
|
||||
'ogTitle' => "MineSeeker · $summary",
|
||||
'ogDesc' => "Watch the battle replay: $summary — played on MineSeeker, the multiplayer minesweeper.",
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ use DateTime;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
@@ -41,6 +42,12 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
|
||||
#[AsController]
|
||||
class SecurityController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(env: 'APP_CONTACT_MAIL_ADDRESS')]
|
||||
private readonly string $appContactMailAddress,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/login', name: 'MineSeekerBundle_login')]
|
||||
public function login(AuthenticationUtils $authenticationUtils): Response
|
||||
{
|
||||
@@ -92,6 +99,11 @@ class SecurityController extends AbstractController
|
||||
UrlGeneratorInterface::ABSOLUTE_URL,
|
||||
);
|
||||
|
||||
/** Ensure HTTPS scheme in production */
|
||||
if ($this->getParameter('kernel.environment') === 'prod') {
|
||||
$activationUrl = str_replace('http://', 'https://', $activationUrl);
|
||||
}
|
||||
|
||||
$mailer->send(
|
||||
new TemplatedEmail()
|
||||
->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());
|
||||
|
||||
return $this->redirectToRoute('MineSeekerBundle_register');
|
||||
@@ -143,6 +168,11 @@ class SecurityController extends AbstractController
|
||||
UrlGeneratorInterface::ABSOLUTE_URL,
|
||||
);
|
||||
|
||||
/** Ensure HTTPS scheme in production */
|
||||
if ($this->getParameter('kernel.environment') === 'prod') {
|
||||
$resetUrl = str_replace('http://', 'https://', $resetUrl);
|
||||
}
|
||||
|
||||
$mailer->send(
|
||||
new TemplatedEmail()
|
||||
->from('noreply@mineseeker.hu')
|
||||
@@ -199,7 +229,7 @@ class SecurityController extends AbstractController
|
||||
}
|
||||
|
||||
#[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]);
|
||||
|
||||
@@ -211,6 +241,19 @@ class SecurityController extends AbstractController
|
||||
$user->setIsVerified(true)->setVerificationToken(null);
|
||||
$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() . '!');
|
||||
|
||||
return $this->redirectToRoute('MineSeekerBundle_login');
|
||||
|
||||
128
src/Entity/ContactMessage.php
Normal file
128
src/Entity/ContactMessage.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
89
src/Form/ContactFormType.php
Normal file
89
src/Form/ContactFormType.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
47
src/Migrations/2026/04/Version20260415160446.php
Normal file
47
src/Migrations/2026/04/Version20260415160446.php
Normal 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');
|
||||
}
|
||||
}
|
||||
35
src/Repository/ContactMessageRepository.php
Normal file
35
src/Repository/ContactMessageRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,11 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
@@ -27,11 +32,17 @@ use Symfony\Component\Uid\Uuid;
|
||||
*/
|
||||
class BattleCardGenerator
|
||||
{
|
||||
private const W = 1200;
|
||||
private const H = 630;
|
||||
private const FONT = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
|
||||
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) { }
|
||||
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
|
||||
@@ -50,7 +61,13 @@ class BattleCardGenerator
|
||||
}
|
||||
|
||||
if (!is_dir($this->cacheDir)) {
|
||||
mkdir($this->cacheDir, 0755, true);
|
||||
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);
|
||||
@@ -60,9 +77,9 @@ class BattleCardGenerator
|
||||
|
||||
private function render(PlayedGame $game, string $dest): void
|
||||
{
|
||||
$im = imagecreatetruecolor(self::W, self::H);
|
||||
$im = imagecreatetruecolor(self::WIDTH, self::HEIGHT);
|
||||
|
||||
// Palette
|
||||
/** Palette*/
|
||||
$bg = imagecolorallocate($im, 13, 13, 28);
|
||||
$dot = imagecolorallocate($im, 30, 30, 55);
|
||||
$divider = imagecolorallocate($im, 40, 40, 70);
|
||||
@@ -72,24 +89,24 @@ class BattleCardGenerator
|
||||
$blue = imagecolorallocate($im, 149, 207, 245);
|
||||
$gold = imagecolorallocate($im, 255, 200, 50);
|
||||
|
||||
// Background
|
||||
/** 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) {
|
||||
/** 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::W, 90, $divider);
|
||||
imageline($im, 0, self::H - 60, self::W, self::H - 60, $divider);
|
||||
/** 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::W / 2, 110, self::W / 2, self::H - 80, $divider);
|
||||
/** Vertical centre divider*/
|
||||
imageline($im, self::WIDTH / 2, 110, self::WIDTH / 2, self::HEIGHT - 80, $divider);
|
||||
|
||||
// Resolve names
|
||||
/** Resolve names*/
|
||||
$redName = $game->getRed()?->getUsername()
|
||||
?? ($game->getRedAnon() !== null ? 'Anonymous' : 'Guest');
|
||||
$blueName = $game->getBlue()?->getUsername()
|
||||
@@ -98,7 +115,7 @@ class BattleCardGenerator
|
||||
$bluePts = $game->getBluePoints();
|
||||
$resign = $game->getResign();
|
||||
|
||||
// Winner
|
||||
/** Winner*/
|
||||
$winner = null;
|
||||
if ($resign === 'red') {
|
||||
$winner = 'blue';
|
||||
@@ -110,19 +127,32 @@ class BattleCardGenerator
|
||||
else $winner = 'draw';
|
||||
}
|
||||
|
||||
$this->centeredText($im, 'MineSeeker', 20, self::W / 2, 58, $muted);
|
||||
$this->centeredText($im, 'MineSeeker', 20, self::WIDTH / 2, 58, $muted);
|
||||
|
||||
$this->centeredText($im, 'RED', 16, self::W / 4, 130, $red);
|
||||
$this->centeredText($im, 'BLUE', 16, self::W * 3 / 4, 130, $blue);
|
||||
/** 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);
|
||||
|
||||
$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);
|
||||
/** 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::W / 2, 390, $white);
|
||||
$this->centeredText($im, $scoreText, 72, self::WIDTH / 2, 390, $white);
|
||||
|
||||
if ($winner === 'red') {
|
||||
$resultText = $redName . ' wins';
|
||||
@@ -139,21 +169,116 @@ class BattleCardGenerator
|
||||
}
|
||||
|
||||
if ($resultText !== '') {
|
||||
$this->centeredText($im, $resultText, 30, self::W / 2, 460, $resultColor);
|
||||
$this->centeredText($im, $resultText, 30, self::WIDTH / 2, 460, $resultColor);
|
||||
}
|
||||
|
||||
if ($resign) {
|
||||
$this->centeredText($im, ucfirst($resign) . ' resigned', 18, self::W / 2, 498, $muted);
|
||||
$this->centeredText($im, ucfirst($resign) . ' resigned', 18, self::WIDTH / 2, 498, $muted);
|
||||
}
|
||||
|
||||
$this->centeredText($im, 'mineseeker.hu', 16, self::W / 2, self::H - 20, $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
|
||||
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];
|
||||
@@ -162,13 +287,13 @@ class BattleCardGenerator
|
||||
|
||||
/** 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
|
||||
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];
|
||||
|
||||
@@ -32,7 +32,13 @@
|
||||
<div class="bshare-vs">
|
||||
<div class="bshare-player bshare-player--red">
|
||||
<div class="bshare-avatar bshare-avatar--red">
|
||||
{{ redName|slice(0,2)|upper }}
|
||||
{% if redAvatar %}
|
||||
<img src="{{ redAvatar|imagine_filter('avatar_thumb') }}"
|
||||
alt="{{ redName }}"
|
||||
class="bshare-avatar__img">
|
||||
{% else %}
|
||||
{{ redName|slice(0,2)|upper }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="bshare-player__name">{{ redName }}</span>
|
||||
<span class="bshare-player__side">Red</span>
|
||||
@@ -74,7 +80,13 @@
|
||||
</div>
|
||||
<div class="bshare-player bshare-player--blue">
|
||||
<div class="bshare-avatar bshare-avatar--blue">
|
||||
{{ blueName|slice(0,2)|upper }}
|
||||
{% if blueAvatar %}
|
||||
<img src="{{ blueAvatar|imagine_filter('avatar_thumb') }}"
|
||||
alt="{{ blueName }}"
|
||||
class="bshare-avatar__img">
|
||||
{% else %}
|
||||
{{ blueName|slice(0,2)|upper }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="bshare-player__name">{{ blueName }}</span>
|
||||
<span class="bshare-player__side">Blue</span>
|
||||
|
||||
@@ -72,6 +72,10 @@
|
||||
<h1>No account needed.<br>Just play.</h1>
|
||||
{% endif %}
|
||||
<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>
|
||||
|
||||
</section>
|
||||
|
||||
@@ -20,8 +20,117 @@
|
||||
|
||||
{% block body %}
|
||||
<div class="txt">
|
||||
<h2>Contact and user support</h2>
|
||||
<h3>Under construction</h3>
|
||||
<a href="mailto:langlasz@gmail.com">langlasz@gmail.com</a>
|
||||
<h2 style="text-align: center;">Contact and user support</h2>
|
||||
|
||||
{% 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>
|
||||
|
||||
<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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<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 class="card">
|
||||
<h1>One step to go</h1>
|
||||
|
||||
98
templates/emails/contact_notification.html.twig
Normal file
98
templates/emails/contact_notification.html.twig
Normal 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>
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<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 class="card">
|
||||
<h1>Reset your password</h1>
|
||||
|
||||
92
templates/emails/user_activation_notification.html.twig
Normal file
92
templates/emails/user_activation_notification.html.twig
Normal 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>
|
||||
92
templates/emails/user_registration_notification.html.twig
Normal file
92
templates/emails/user_registration_notification.html.twig
Normal 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>
|
||||
@@ -25,6 +25,7 @@ export default defineConfig({
|
||||
mineseeker: './assets/js/app.jsx',
|
||||
passkey: './assets/js/passkey.jsx',
|
||||
profile: './assets/js/profile.jsx',
|
||||
contact: './assets/js/contact.jsx',
|
||||
mineseekerStyle: './assets/css/style.mineseeker.scss',
|
||||
homeStyle: './assets/css/style.layout.scss',
|
||||
passkeyStyle: './assets/css/passkey.scss',
|
||||
|
||||
Reference in New Issue
Block a user