Compare commits
28 Commits
v2026.2.0-
...
v2026.2.1-
| Author | SHA1 | Date | |
|---|---|---|---|
| a511b86db8 | |||
| 1c0ad054bb | |||
| 5a8799bb7f | |||
| 6c443d8e86 | |||
| 8795fedda9 | |||
| 588fb57299 | |||
| eb345e17ca | |||
| c2693c4648 | |||
| 43efc16562 | |||
| 80d6440ece | |||
| 5ee972f003 | |||
| 6f3edb41ea | |||
| c52939a7a3 | |||
| 573d409606 | |||
| 9a58bc9a5e | |||
| 8780800dff | |||
| f442942faf | |||
| a61d881a4e | |||
| 926b614136 | |||
| c0c84f4651 | |||
| 176e255037 | |||
| b134358e9e | |||
| 3525aaeeb7 | |||
| af67ec3931 | |||
| d515f42cfd | |||
| 5d6aff8d90 | |||
| 15ba26ccf2 | |||
| d3fa0cbbf9 |
@@ -6,6 +6,12 @@
|
|||||||
APP_ENV=dev
|
APP_ENV=dev
|
||||||
APP_SECRET=changethis
|
APP_SECRET=changethis
|
||||||
APP_NAME=mineseeker
|
APP_NAME=mineseeker
|
||||||
|
# APP_PUBLIC_HOSTNAME: The public hostname for your application (used for generating absolute URLs in emails)
|
||||||
|
# For production, set this to your domain (e.g., mineseeker.com)
|
||||||
|
APP_PUBLIC_HOSTNAME=localhost
|
||||||
|
# TRUSTED_PROXIES: Only needed for bare-metal dev behind a reverse proxy
|
||||||
|
# For Docker development, this is set in compose.override.yaml
|
||||||
|
# For production, set in PROD_ENV_FILE Gitea secret (use 172.18.0.0/16 initially)
|
||||||
#TRUSTED_PROXIES=127.0.0.1,127.0.0.2
|
#TRUSTED_PROXIES=127.0.0.1,127.0.0.2
|
||||||
#TRUSTED_HOSTS=localhost,example.com
|
#TRUSTED_HOSTS=localhost,example.com
|
||||||
###< symfony/framework-bundle ###
|
###< symfony/framework-bundle ###
|
||||||
|
|||||||
27
CHANGELOG.md
27
CHANGELOG.md
@@ -2,11 +2,14 @@ Changelog
|
|||||||
=========
|
=========
|
||||||
|
|
||||||
|
|
||||||
v2026.01 (2026-04-14)
|
(unreleased)
|
||||||
---------------------
|
------------
|
||||||
|
|
||||||
New
|
New
|
||||||
~~~
|
~~~
|
||||||
|
- Add notification email when a user is registered #4. [Lang]
|
||||||
|
- Add Contact page with email sending behaviour #4. [Lang]
|
||||||
|
- Add timer for the acceptance of the challenge #4. [Lang]
|
||||||
- Registered users have avatars next to the timer #4. [Lang]
|
- Registered users have avatars next to the timer #4. [Lang]
|
||||||
- Add opportunity to use profile picture. #4. [Lang]
|
- Add opportunity to use profile picture. #4. [Lang]
|
||||||
- Add more stats and a dialog for the recent battle that can be
|
- Add more stats and a dialog for the recent battle that can be
|
||||||
@@ -18,6 +21,17 @@ New
|
|||||||
|
|
||||||
Changes
|
Changes
|
||||||
~~~~~~~
|
~~~~~~~
|
||||||
|
- Add notification on activation too #4. [Lang]
|
||||||
|
- Change the shareable battle - add avatars to it - even on the og tags
|
||||||
|
#4. [Lang]
|
||||||
|
- Change text #4. [Lang]
|
||||||
|
- Add donation button #4. [Lang]
|
||||||
|
- Protect the gameplay with recaptcha #4. [Lang]
|
||||||
|
- The waiting dialog is uncloseable until the time is up #4. [Lang]
|
||||||
|
- Add share button to the overlay when the game ends #4. [Lang]
|
||||||
|
- Make fancy og tags - and create a special one for battle sharing #4.
|
||||||
|
[Lang]
|
||||||
|
- The user's avatar will be saved as a uuid.extension #4. [Lang]
|
||||||
- Fix missing favicon #4. [Lang]
|
- Fix missing favicon #4. [Lang]
|
||||||
- Add modern Webauthn authentication #4. [Lang]
|
- Add modern Webauthn authentication #4. [Lang]
|
||||||
- Refactor all forms to have Symfony Form Types & Validation
|
- Refactor all forms to have Symfony Form Types & Validation
|
||||||
@@ -57,6 +71,15 @@ 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
|
||||||
|
~~~
|
||||||
|
- The meta tags does not have https scheme - nothing worked in
|
||||||
|
configuration #4. [Lang]
|
||||||
|
- Another attempt to fix the email assets #4. [Lang]
|
||||||
|
- The images does not shows in emails #4. [Lang]
|
||||||
|
- 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]
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
|
|
||||||
encode zstd br gzip
|
encode zstd br gzip
|
||||||
|
|
||||||
|
# Forward scheme information to the PHP application
|
||||||
|
header X-Forwarded-Proto {scheme}
|
||||||
|
header X-Forwarded-Host {host}
|
||||||
|
|
||||||
mercure {
|
mercure {
|
||||||
transport_url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db}
|
transport_url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db}
|
||||||
publisher_jwt {$MERCURE_JWT_SECRET} HS256
|
publisher_jwt {$MERCURE_JWT_SECRET} HS256
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ RUN install-php-extensions \
|
|||||||
apcu \
|
apcu \
|
||||||
sodium
|
sodium
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
fonts-dejavu-core \
|
||||||
|
fontconfig \
|
||||||
|
&& fc-cache -f -v \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
|
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
|
||||||
RUN printf '[opcache]\nopcache.enable=1\nopcache.memory_consumption=256\nopcache.max_accelerated_files=20000\nopcache.validate_timestamps=0\n' \
|
RUN printf '[opcache]\nopcache.enable=1\nopcache.memory_consumption=256\nopcache.max_accelerated_files=20000\nopcache.validate_timestamps=0\n' \
|
||||||
> "$PHP_INI_DIR/conf.d/opcache.ini"
|
> "$PHP_INI_DIR/conf.d/opcache.ini"
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -151,6 +151,7 @@ services:
|
|||||||
app:
|
app:
|
||||||
environment:
|
environment:
|
||||||
MAILER_DSN: smtp://mail:1025?verify_peer=0
|
MAILER_DSN: smtp://mail:1025?verify_peer=0
|
||||||
|
TRUSTED_PROXIES: "0.0.0.0/0"
|
||||||
mail:
|
mail:
|
||||||
image: mailhog/mailhog:latest
|
image: mailhog/mailhog:latest
|
||||||
ports:
|
ports:
|
||||||
@@ -216,7 +217,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
|
||||||
@@ -233,8 +234,13 @@ MERCURE_SUBSCRIBER_JWT="<generated by make mercure-jwt>"
|
|||||||
APP_PUBLIC_HOSTNAME=mineseeker.hu
|
APP_PUBLIC_HOSTNAME=mineseeker.hu
|
||||||
WEBAUTHN_RP_ID=mineseeker.hu
|
WEBAUTHN_RP_ID=mineseeker.hu
|
||||||
WEBAUTHN_ORIGIN=https://mineseeker.hu
|
WEBAUTHN_ORIGIN=https://mineseeker.hu
|
||||||
```
|
|
||||||
|
|
||||||
|
# OG Tags & Social Media Sharing (IMPORTANT for Docker deployments)
|
||||||
|
# TRUSTED_PROXIES: IP address (or range) of your reverse proxy (Caddy/Nginx)
|
||||||
|
# This ensures OG image tags use HTTPS URLs instead of HTTP
|
||||||
|
TRUSTED_PROXIES="172.18.0.0/16"
|
||||||
|
TRUSTED_HOSTS="mineseeker.hu,www.mineseeker.hu"
|
||||||
|
```
|
||||||
### Production server: one-time setup
|
### Production server: one-time setup
|
||||||
|
|
||||||
The server needs Docker, Git, and a self-hosted `act_runner` registered against the Gitea repository. Bun and Composer run inside the multi-stage Dockerfile, so they are not needed on the server.
|
The server needs Docker, Git, and a self-hosted `act_runner` registered against the Gitea repository. Bun and Composer run inside the multi-stage Dockerfile, so they are not needed on the server.
|
||||||
@@ -254,7 +260,7 @@ make mercure-jwt
|
|||||||
|
|
||||||
Copy the three printed values into the `PROD_ENV_FILE` secret.
|
Copy the three printed values into the `PROD_ENV_FILE` secret.
|
||||||
|
|
||||||
#### 5. First deploy
|
#### 3. First deploy
|
||||||
|
|
||||||
Trigger it by pushing the first tag:
|
Trigger it by pushing the first tag:
|
||||||
|
|
||||||
@@ -265,7 +271,7 @@ git push origin v2026.01
|
|||||||
|
|
||||||
This writes `.env`, builds the Docker image, starts all services, runs migrations, and initialises the MinIO buckets automatically via `minio_init`.
|
This writes `.env`, builds the Docker image, starts all services, runs migrations, and initialises the MinIO buckets automatically via `minio_init`.
|
||||||
|
|
||||||
#### 6. Verify
|
#### 4. Verify
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose ps # all services should be healthy/running
|
docker compose ps # all services should be healthy/running
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -180,6 +180,41 @@
|
|||||||
input[type="checkbox"] { accent-color: #236f87; }
|
input[type="checkbox"] { accent-color: #236f87; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-checkbox {
|
||||||
|
accent-color: #236f87;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font: 400 14px 'Rajdhani', sans-serif;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #95cff5;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: color 180ms;
|
||||||
|
|
||||||
|
&:hover { color: #c5e8ff; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.auth-input {
|
||||||
|
padding: 11px 14px;
|
||||||
|
min-height: 120px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-submit {
|
.auth-submit {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -45,33 +45,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Bar chart — large, centre
|
// Bar chart — large, centre
|
||||||
i.fa-bar-chart {
|
i.fa-chart-bar {
|
||||||
font-size: 140px;
|
font-size: 160px;
|
||||||
color: rgba(35, 111, 135, 0.35);
|
color: rgba(35, 111, 135, 0.5);
|
||||||
filter: drop-shadow(0 0 30px rgba(35, 111, 135, 0.3));
|
filter: drop-shadow(0 0 40px rgba(35, 111, 135, 0.5));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trophy — top right
|
// Trophy — top right
|
||||||
i.fa-trophy {
|
i.fa-trophy {
|
||||||
font-size: 64px;
|
font-size: 80px;
|
||||||
top: 12px;
|
top: 0px;
|
||||||
right: 30px;
|
right: 20px;
|
||||||
color: rgba(246, 125, 82, 0.5);
|
color: rgba(246, 125, 82, 0.7);
|
||||||
filter: drop-shadow(0 0 16px rgba(246, 125, 82, 0.25));
|
filter: drop-shadow(0 0 25px rgba(246, 125, 82, 0.4));
|
||||||
}
|
}
|
||||||
|
|
||||||
// History — bottom left
|
// Clock history — bottom left
|
||||||
i.fa-history {
|
i.fa-clock-rotate-left {
|
||||||
font-size: 52px;
|
font-size: 68px;
|
||||||
bottom: 18px;
|
bottom: 0px;
|
||||||
left: 30px;
|
left: 20px;
|
||||||
color: rgba(149, 207, 245, 0.4);
|
color: rgba(149, 207, 245, 0.65);
|
||||||
filter: drop-shadow(0 0 12px rgba(149, 207, 245, 0.2));
|
filter: drop-shadow(0 0 20px rgba(149, 207, 245, 0.35));
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover i.fa-bar-chart { color: rgba(35, 111, 135, 0.6); }
|
&:hover i.fa-chart-bar { color: rgba(35, 111, 135, 0.8); filter: drop-shadow(0 0 50px rgba(35, 111, 135, 0.7)); }
|
||||||
&:hover i.fa-trophy { color: rgba(246, 125, 82, 0.75); }
|
&:hover i.fa-trophy { color: rgba(246, 125, 82, 0.9); filter: drop-shadow(0 0 35px rgba(246, 125, 82, 0.6)); }
|
||||||
&:hover i.fa-history { color: rgba(149, 207, 245, 0.65); }
|
&:hover i.fa-clock-rotate-left { color: rgba(149, 207, 245, 0.9); filter: drop-shadow(0 0 30px rgba(149, 207, 245, 0.5)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MSN visual
|
// MSN visual
|
||||||
@@ -107,6 +107,98 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Privacy visual
|
||||||
|
.feature-block__visual--privacy {
|
||||||
|
height: 260px;
|
||||||
|
gap: 0;
|
||||||
|
|
||||||
|
i {
|
||||||
|
position: absolute;
|
||||||
|
color: rgba(35, 111, 135, 0.5);
|
||||||
|
transition: color 300ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shield — centre, large
|
||||||
|
i.fa-shield {
|
||||||
|
font-size: 140px;
|
||||||
|
color: rgba(34, 197, 94, 0.3);
|
||||||
|
filter: drop-shadow(0 0 30px rgba(34, 197, 94, 0.25));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock — top right
|
||||||
|
i.fa-lock {
|
||||||
|
font-size: 56px;
|
||||||
|
top: 20px;
|
||||||
|
right: 35px;
|
||||||
|
color: rgba(168, 85, 247, 0.5);
|
||||||
|
filter: drop-shadow(0 0 16px rgba(168, 85, 247, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eye slash — bottom left
|
||||||
|
i.fa-eye-slash {
|
||||||
|
font-size: 48px;
|
||||||
|
bottom: 28px;
|
||||||
|
left: 40px;
|
||||||
|
color: rgba(59, 130, 246, 0.5);
|
||||||
|
filter: drop-shadow(0 0 12px rgba(59, 130, 246, 0.15));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover i.fa-shield { color: rgba(34, 197, 94, 0.6); }
|
||||||
|
&:hover i.fa-lock { color: rgba(168, 85, 247, 0.75); }
|
||||||
|
&:hover i.fa-eye-slash { color: rgba(59, 130, 246, 0.7); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Practice visual
|
||||||
|
.feature-block__visual--practice {
|
||||||
|
height: 260px;
|
||||||
|
gap: 0;
|
||||||
|
|
||||||
|
i {
|
||||||
|
position: absolute;
|
||||||
|
color: rgba(35, 111, 135, 0.5);
|
||||||
|
transition: color 300ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Laptop — centre, large
|
||||||
|
i.fa-laptop {
|
||||||
|
font-size: 140px;
|
||||||
|
color: rgba(251, 146, 60, 0.3);
|
||||||
|
filter: drop-shadow(0 0 30px rgba(251, 146, 60, 0.25));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linux — top left
|
||||||
|
i.fa-linux {
|
||||||
|
font-size: 48px;
|
||||||
|
top: 20px;
|
||||||
|
left: 35px;
|
||||||
|
color: rgba(245, 158, 11, 0.5);
|
||||||
|
filter: drop-shadow(0 0 16px rgba(245, 158, 11, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apple — top right
|
||||||
|
i.fa-apple {
|
||||||
|
font-size: 56px;
|
||||||
|
top: 20px;
|
||||||
|
right: 35px;
|
||||||
|
color: rgba(156, 163, 175, 0.5);
|
||||||
|
filter: drop-shadow(0 0 16px rgba(156, 163, 175, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows — bottom left
|
||||||
|
i.fa-windows {
|
||||||
|
font-size: 48px;
|
||||||
|
bottom: 28px;
|
||||||
|
left: 40px;
|
||||||
|
color: rgba(59, 130, 246, 0.5);
|
||||||
|
filter: drop-shadow(0 0 12px rgba(59, 130, 246, 0.15));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover i.fa-laptop { color: rgba(251, 146, 60, 0.6); }
|
||||||
|
&:hover i.fa-linux { color: rgba(245, 158, 11, 0.75); }
|
||||||
|
&:hover i.fa-apple { color: rgba(156, 163, 175, 0.75); }
|
||||||
|
&:hover i.fa-windows { color: rgba(59, 130, 246, 0.7); }
|
||||||
|
}
|
||||||
|
|
||||||
// Text side
|
// Text side
|
||||||
.feature-block__text {
|
.feature-block__text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -161,6 +253,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.practice-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.practice-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font: 700 13px 'Rajdhani', sans-serif;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: rgba(149, 207, 245, 0.85);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||||
|
background: rgba(59, 130, 246, 0.08);
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 200ms ease;
|
||||||
|
width: fit-content;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.2);
|
||||||
|
border-color: rgba(59, 130, 246, 0.6);
|
||||||
|
color: #fff;
|
||||||
|
transform: translateX(4px);
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.practice-link-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.practice-link:hover .practice-link-icon {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
border-color: rgba(59, 130, 246, 0.4);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 900px) {
|
@media screen and (max-width: 900px) {
|
||||||
.feature-block__inner,
|
.feature-block__inner,
|
||||||
.feature-block--reverse .feature-block__inner {
|
.feature-block--reverse .feature-block__inner {
|
||||||
@@ -199,4 +341,14 @@
|
|||||||
.feature-block {
|
.feature-block {
|
||||||
padding: 60px 24px;
|
padding: 60px 24px;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
.practice-links {
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.practice-link {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -747,6 +747,13 @@
|
|||||||
font: 800 24px 'Rajdhani', sans-serif;
|
font: 800 24px 'Rajdhani', sans-serif;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
|
|
||||||
|
&__img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
&--red {
|
&--red {
|
||||||
background: linear-gradient(135deg, rgba(173, 10, 5, 0.6) 0%, rgba(246, 125, 82, 0.4) 100%);
|
background: linear-gradient(135deg, rgba(173, 10, 5, 0.6) 0%, rgba(246, 125, 82, 0.4) 100%);
|
||||||
border: 2px solid rgba(173, 10, 5, 0.5);
|
border: 2px solid rgba(173, 10, 5, 0.5);
|
||||||
|
|||||||
@@ -471,58 +471,122 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .share-copy-btn {
|
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .share-copy-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
background: linear-gradient(to bottom, #236f87 0%, #1a5068 100%);
|
background: linear-gradient(to bottom, #236f87 0%, #1a5068 100%);
|
||||||
border: 2px solid #2e7a9a;
|
border: 2px solid #2e7a9a;
|
||||||
color: #e0f4ff;
|
color: #e0f4ff;
|
||||||
font-family: 'Rajdhani', sans-serif;
|
font-family: 'Rajdhani', sans-serif;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
transition: all 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 4px 12px rgba(35, 111, 135, 0.25);
|
box-shadow: 0 4px 12px rgba(35, 111, 135, 0.25);
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: -100%;
|
left: -100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||||
transition: left 0.4s ease;
|
transition: left 0.4s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: linear-gradient(to bottom, #2d8aa8 0%, #236f87 100%);
|
background: linear-gradient(to bottom, #2d8aa8 0%, #236f87 100%);
|
||||||
border-color: #5ba4d4;
|
border-color: #5ba4d4;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: 0 8px 24px rgba(35, 111, 135, 0.4);
|
box-shadow: 0 8px 24px rgba(35, 111, 135, 0.4);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
left: 100%;
|
left: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.copied {
|
&.copied {
|
||||||
background: linear-gradient(to bottom, #1a6844 0%, #135233 100%);
|
background: linear-gradient(to bottom, #1a6844 0%, #135233 100%);
|
||||||
border-color: #2a9e60;
|
border-color: #2a9e60;
|
||||||
color: #a0f0c0;
|
color: #a0f0c0;
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
@use 'homepage/hero';
|
@use 'homepage/hero';
|
||||||
@use 'homepage/hero-compact';
|
@use 'homepage/hero-compact';
|
||||||
@use 'homepage/cta';
|
@use 'homepage/cta';
|
||||||
|
@use 'homepage/donate';
|
||||||
@use 'homepage/auth-bar';
|
@use 'homepage/auth-bar';
|
||||||
@use 'homepage/auth';
|
@use 'homepage/auth';
|
||||||
@use 'homepage/content';
|
@use 'homepage/content';
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const RESULT_META = {
|
|||||||
icon: 'fa-trophy',
|
icon: 'fa-trophy',
|
||||||
},
|
},
|
||||||
loss: {
|
loss: {
|
||||||
label: 'Defeat',
|
label: 'Defeated',
|
||||||
color: '#f67d52',
|
color: '#f67d52',
|
||||||
bg: 'rgba(173,10,5,0.15)',
|
bg: 'rgba(173,10,5,0.15)',
|
||||||
border: 'rgba(173,10,5,0.4)',
|
border: 'rgba(173,10,5,0.4)',
|
||||||
@@ -50,7 +50,7 @@ const RESULT_META = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function Avatar({ name, color }) {
|
function Avatar({ name, color, avatarUrl }) {
|
||||||
const isRed = 'red' === color;
|
const isRed = 'red' === color;
|
||||||
const initials = (name || '?').slice(0, 2).toUpperCase();
|
const initials = (name || '?').slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
@@ -69,16 +69,29 @@ function Avatar({ name, color }) {
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 72, height: 72, borderRadius: '50%',
|
width: 72, height: 72, borderRadius: '50%',
|
||||||
background: gradient,
|
background: avatarUrl ? 'transparent' : gradient,
|
||||||
border: `2px solid ${border}`,
|
border: `2px solid ${border}`,
|
||||||
boxShadow: glow,
|
boxShadow: glow,
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
font: '800 24px \'Rajdhani\', sans-serif',
|
font: '800 24px \'Rajdhani\', sans-serif',
|
||||||
color: textColor,
|
color: textColor,
|
||||||
letterSpacing: 2,
|
letterSpacing: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{initials}
|
{avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={name}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
initials
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span style={{
|
<span style={{
|
||||||
font: '700 15px \'Rajdhani\', sans-serif',
|
font: '700 15px \'Rajdhani\', sans-serif',
|
||||||
@@ -134,8 +147,8 @@ function StatRow({ icon, label, value, valueColor }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function BattleDialog({ games }) {
|
export default function BattleDialog({ games }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [game, setGame] = useState(null);
|
const [game, setGame] = useState(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -156,18 +169,18 @@ export default function BattleDialog({ games }) {
|
|||||||
return <ThemeProvider theme={darkTheme}><Dialog open={false} sx={DIALOG_SX} /></ThemeProvider>;
|
return <ThemeProvider theme={darkTheme}><Dialog open={false} sx={DIALOG_SX} /></ThemeProvider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = RESULT_META[game.result] ?? RESULT_META.draw;
|
const meta = RESULT_META[game.result] ?? RESULT_META.draw;
|
||||||
const resign = game.resign;
|
const resign = game.resign;
|
||||||
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(() => {
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2200);
|
setTimeout(() => setCopied(false), 2200);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -197,7 +210,7 @@ export default function BattleDialog({ games }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bd-vs-panel">
|
<div className="bd-vs-panel">
|
||||||
<Avatar name={game.redName} color="red" />
|
<Avatar name={game.redName} color="red" avatarUrl={game.redAvatar} />
|
||||||
<div className="bd-vs-center">
|
<div className="bd-vs-center">
|
||||||
<div className="bd-vs-score">
|
<div className="bd-vs-score">
|
||||||
<span className="bd-vs-score__red">{game.redPoints ?? '—'}</span>
|
<span className="bd-vs-score__red">{game.redPoints ?? '—'}</span>
|
||||||
@@ -212,7 +225,7 @@ export default function BattleDialog({ games }) {
|
|||||||
<i className={`fa ${meta.icon}`} /> {meta.label}
|
<i className={`fa ${meta.icon}`} /> {meta.label}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Avatar name={game.blueName} color="blue" />
|
<Avatar name={game.blueName} color="blue" avatarUrl={game.blueAvatar} />
|
||||||
</div>
|
</div>
|
||||||
<div className="bd-stats">
|
<div className="bd-stats">
|
||||||
<StatRow icon="fa-calendar" label="Date" value={game.date ?? '—'} />
|
<StatRow icon="fa-calendar" label="Date" value={game.date ?? '—'} />
|
||||||
|
|||||||
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
169
assets/js/mine-seeker/components/CaptchaOverlay.jsx
Normal file
169
assets/js/mine-seeker/components/CaptchaOverlay.jsx
Normal 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'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;
|
||||||
41
assets/js/mine-seeker/components/ChallengeCountdown.jsx
Normal file
41
assets/js/mine-seeker/components/ChallengeCountdown.jsx
Normal 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;
|
||||||
@@ -26,6 +26,7 @@ export const GameBoard = ({ gameAssoc, gameInherited, isEnvDev }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GridControl
|
<GridControl
|
||||||
|
gameAssoc={gameAssoc}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
resign={resign}
|
resign={resign}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,32 +182,44 @@ 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>
|
||||||
<div className="opd-search-wrap">
|
{0 < waitingCountdown ? (
|
||||||
<i className="fa fa-search opd-search-icon" />
|
<div className="opd-waiting">
|
||||||
<input
|
<i className="fa fa-hourglass-start" />
|
||||||
className="opd-search"
|
<p>Waiting {waitingCountdown} second{1 === waitingCountdown ? '' : 's'} for opponent's answer...</p>
|
||||||
placeholder="Search by username…"
|
</div>
|
||||||
value={search}
|
) : (
|
||||||
onChange={e => setSearch(e.target.value)}
|
<div className="opd-search-wrap">
|
||||||
/>
|
<i className="fa fa-search opd-search-icon" />
|
||||||
{search && (
|
<input
|
||||||
<button className="opd-search-clear" onClick={() => setSearch('')}>
|
className="opd-search"
|
||||||
<i className="fa fa-times" />
|
placeholder="Search by username…"
|
||||||
</button>
|
value={search}
|
||||||
)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
</div>
|
/>
|
||||||
|
{search && (
|
||||||
|
<button className="opd-search-clear" onClick={() => setSearch('')}>
|
||||||
|
<i className="fa fa-times" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="opd-list">
|
<div className="opd-list">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="opd-empty">
|
<div className="opd-empty">
|
||||||
|
|||||||
@@ -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>
|
||||||
<h2>{overlaySubTitle}</h2>
|
{'string' === typeof overlaySubTitle ? (
|
||||||
|
<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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
11
compose.yaml
11
compose.yaml
@@ -11,6 +11,8 @@ services:
|
|||||||
SERVER_NAME: ${SERVER_NAME:-:80}
|
SERVER_NAME: ${SERVER_NAME:-:80}
|
||||||
APP_ENV: prod
|
APP_ENV: prod
|
||||||
APP_SECRET: ${APP_SECRET}
|
APP_SECRET: ${APP_SECRET}
|
||||||
|
APP_PUBLIC_HOSTNAME: ${APP_PUBLIC_HOSTNAME:-localhost}
|
||||||
|
APP_CONTACT_MAIL_ADDRESS: ${APP_CONTACT_MAIL_ADDRESS:-7system7@gmail.com}
|
||||||
DATABASE_URL: >-
|
DATABASE_URL: >-
|
||||||
postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?serverVersion=${POSTGRES_VERSION}&charset=utf8
|
postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?serverVersion=${POSTGRES_VERSION}&charset=utf8
|
||||||
POSTGRES_URL: db
|
POSTGRES_URL: db
|
||||||
@@ -31,6 +33,7 @@ services:
|
|||||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||||
MINIO_ENDPOINT: http://minio:9000
|
MINIO_ENDPOINT: http://minio:9000
|
||||||
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9000}
|
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9000}
|
||||||
|
TRUSTED_PROXIES: ${TRUSTED_PROXIES}
|
||||||
volumes:
|
volumes:
|
||||||
- app_var:/app/var
|
- app_var:/app/var
|
||||||
- caddy_data:/data
|
- caddy_data:/data
|
||||||
@@ -76,13 +79,19 @@ 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:-}
|
||||||
volumes:
|
volumes:
|
||||||
- postfix_spool:/var/spool/postfix
|
- postfix_spool:/var/spool/postfix
|
||||||
|
- ./docker/aliases:/tmp/aliases:ro
|
||||||
db:
|
db:
|
||||||
image: postgres:${POSTGRES_VERSION:-18}-alpine
|
image: postgres:${POSTGRES_VERSION:-18}-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -108,3 +117,5 @@ volumes:
|
|||||||
caddy_config:
|
caddy_config:
|
||||||
postfix_spool:
|
postfix_spool:
|
||||||
minio_data:
|
minio_data:
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
189
composer.json
189
composer.json
@@ -1,94 +1,103 @@
|
|||||||
{
|
{
|
||||||
"type": "project",
|
"name": "splendid-bear/mineseeker",
|
||||||
"license": "proprietary",
|
"version": "2026.2.1",
|
||||||
"require": {
|
"license": "GPL-3.0-or-later",
|
||||||
"php": ">=8.5",
|
"author": "https://www.splendidbear.org",
|
||||||
"ext-iconv": "*",
|
"bugs": "https://source.splendidbear.org",
|
||||||
"ext-json": "*",
|
"description": "This is a minesweeper game that is inspired from MSN Messenger's game.",
|
||||||
"doctrine/dbal": "^3.7",
|
"minimum-stability": "dev",
|
||||||
"doctrine/doctrine-bundle": ">=2.11 <2.14",
|
"type": "project",
|
||||||
"doctrine/doctrine-migrations-bundle": "^3.0",
|
"prefer-stable": true,
|
||||||
"doctrine/orm": "^2.6",
|
"private": true,
|
||||||
"endroid/qr-code": "^6.1",
|
"require": {
|
||||||
"league/flysystem-aws-s3-v3": "^3.0",
|
"php": ">=8.5",
|
||||||
"league/flysystem-bundle": "^3.6",
|
"ext-iconv": "*",
|
||||||
"liip/imagine-bundle": "^2.13",
|
"ext-json": "*",
|
||||||
"pentatrion/vite-bundle": "^8.2",
|
"ext-gd": "*",
|
||||||
"scheb/2fa-backup-code": "^8.5",
|
"doctrine/dbal": "^3.7",
|
||||||
"scheb/2fa-bundle": "^8.5",
|
"doctrine/doctrine-bundle": ">=2.11 <2.14",
|
||||||
"scheb/2fa-totp": "^8.5",
|
"doctrine/doctrine-migrations-bundle": "^3.0",
|
||||||
"symfony/console": "7.4.*",
|
"doctrine/orm": "^2.6",
|
||||||
"symfony/flex": "^2.10.0",
|
"endroid/qr-code": "^6.1",
|
||||||
"symfony/form": "7.4.*",
|
"league/flysystem-aws-s3-v3": "^3.0",
|
||||||
"symfony/framework-bundle": "7.4.*",
|
"league/flysystem-bundle": "^3.6",
|
||||||
"symfony/http-client": "7.4.*",
|
"liip/imagine-bundle": "^2.13",
|
||||||
"symfony/mailer": "7.4.*",
|
"pentatrion/vite-bundle": "^8.2",
|
||||||
"symfony/mercure": "^0.6",
|
"scheb/2fa-backup-code": "^8.5",
|
||||||
"symfony/mercure-bundle": "*",
|
"scheb/2fa-bundle": "^8.5",
|
||||||
"symfony/monolog-bundle": "^3.8",
|
"scheb/2fa-totp": "^8.5",
|
||||||
"symfony/security-bundle": "7.4.*",
|
"symfony/console": "7.4.*",
|
||||||
"symfony/translation": "7.4.*",
|
"symfony/flex": "^2.10.0",
|
||||||
"symfony/twig-bundle": "7.4.*",
|
"symfony/form": "7.4.*",
|
||||||
"symfony/validator": "7.4.*",
|
"symfony/framework-bundle": "7.4.*",
|
||||||
"symfony/yaml": "7.4.*",
|
"symfony/http-client": "7.4.*",
|
||||||
"web-auth/webauthn-framework": "^5.2"
|
"symfony/mailer": "7.4.*",
|
||||||
|
"symfony/mercure": "^0.6",
|
||||||
|
"symfony/mercure-bundle": "*",
|
||||||
|
"symfony/monolog-bundle": "^3.8",
|
||||||
|
"symfony/security-bundle": "7.4.*",
|
||||||
|
"symfony/translation": "7.4.*",
|
||||||
|
"symfony/twig-bundle": "7.4.*",
|
||||||
|
"symfony/validator": "7.4.*",
|
||||||
|
"symfony/yaml": "7.4.*",
|
||||||
|
"web-auth/webauthn-framework": "^5.2"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"firebase/php-jwt": "^7.0",
|
||||||
|
"roave/security-advisories": "dev-master",
|
||||||
|
"symfony/dotenv": "7.4.*",
|
||||||
|
"symfony/maker-bundle": "^1.5",
|
||||||
|
"symfony/stopwatch": "7.4.*",
|
||||||
|
"symfony/web-profiler-bundle": "7.4.*"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"preferred-install": {
|
||||||
|
"*": "dist"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"sort-packages": true,
|
||||||
"firebase/php-jwt": "^7.0",
|
"allow-plugins": {
|
||||||
"roave/security-advisories": "dev-master",
|
"symfony/flex": true
|
||||||
"symfony/dotenv": "7.4.*",
|
|
||||||
"symfony/maker-bundle": "^1.5",
|
|
||||||
"symfony/stopwatch": "7.4.*",
|
|
||||||
"symfony/web-profiler-bundle": "7.4.*"
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"preferred-install": {
|
|
||||||
"*": "dist"
|
|
||||||
},
|
|
||||||
"sort-packages": true,
|
|
||||||
"allow-plugins": {
|
|
||||||
"symfony/flex": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"App\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload-dev": {
|
|
||||||
"psr-4": {
|
|
||||||
"App\\Tests\\": "tests/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"replace": {
|
|
||||||
"symfony/polyfill-iconv": "*",
|
|
||||||
"symfony/polyfill-ctype": "*",
|
|
||||||
"symfony/polyfill-php73": "*",
|
|
||||||
"symfony/polyfill-php72": "*",
|
|
||||||
"symfony/polyfill-php71": "*",
|
|
||||||
"symfony/polyfill-php70": "*",
|
|
||||||
"symfony/polyfill-php56": "*"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"auto-scripts": {
|
|
||||||
"cache:clear": "symfony-cmd",
|
|
||||||
"assets:install --symlink --relative %PUBLIC_DIR%": "symfony-cmd"
|
|
||||||
},
|
|
||||||
"post-install-cmd": [
|
|
||||||
"@auto-scripts"
|
|
||||||
],
|
|
||||||
"post-update-cmd": [
|
|
||||||
"@auto-scripts"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"conflict": {
|
|
||||||
"symfony/symfony": "*",
|
|
||||||
"doctrine/persistence": "<1.3"
|
|
||||||
},
|
|
||||||
"extra": {
|
|
||||||
"symfony": {
|
|
||||||
"allow-contrib": false,
|
|
||||||
"require": "7.4.*"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"replace": {
|
||||||
|
"symfony/polyfill-iconv": "*",
|
||||||
|
"symfony/polyfill-ctype": "*",
|
||||||
|
"symfony/polyfill-php73": "*",
|
||||||
|
"symfony/polyfill-php72": "*",
|
||||||
|
"symfony/polyfill-php71": "*",
|
||||||
|
"symfony/polyfill-php70": "*",
|
||||||
|
"symfony/polyfill-php56": "*"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"auto-scripts": {
|
||||||
|
"cache:clear": "symfony-cmd",
|
||||||
|
"assets:install --symlink --relative %PUBLIC_DIR%": "symfony-cmd"
|
||||||
|
},
|
||||||
|
"post-install-cmd": [
|
||||||
|
"@auto-scripts"
|
||||||
|
],
|
||||||
|
"post-update-cmd": [
|
||||||
|
"@auto-scripts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"symfony/symfony": "*",
|
||||||
|
"doctrine/persistence": "<1.3"
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"symfony": {
|
||||||
|
"allow-contrib": false,
|
||||||
|
"require": "7.4.*"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ framework:
|
|||||||
session:
|
session:
|
||||||
handler_id: ~
|
handler_id: ~
|
||||||
|
|
||||||
|
# Trust headers from reverse proxy (Caddy)
|
||||||
|
# This ensures absolute_url() uses HTTPS scheme when behind a reverse proxy
|
||||||
|
# Production: TRUSTED_PROXIES from .env (Gitea secret)
|
||||||
|
# Development: TRUSTED_PROXIES from compose.override.yaml
|
||||||
|
trusted_proxies: '%env(TRUSTED_PROXIES)%'
|
||||||
|
trusted_headers: ['x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host', 'x-forwarded-port']
|
||||||
|
|
||||||
#esi: true
|
#esi: true
|
||||||
#fragments: true
|
#fragments: true
|
||||||
php_errors:
|
php_errors:
|
||||||
|
|||||||
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']
|
||||||
@@ -25,6 +25,11 @@ services:
|
|||||||
resource: '../src/Controller'
|
resource: '../src/Controller'
|
||||||
tags: [ 'controller.service_arguments' ]
|
tags: [ 'controller.service_arguments' ]
|
||||||
|
|
||||||
|
App\Service\BattleCardGenerator:
|
||||||
|
arguments:
|
||||||
|
$cacheDir: '%kernel.project_dir%/var/og-cache'
|
||||||
|
$minioMediaStorage: '@mineseeker.media.storage'
|
||||||
|
|
||||||
Aws\S3\S3Client:
|
Aws\S3\S3Client:
|
||||||
arguments:
|
arguments:
|
||||||
- version: 'latest'
|
- version: 'latest'
|
||||||
|
|||||||
5
docker/aliases
Normal file
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",
|
"name": "mine-seeker",
|
||||||
"version": "1.0.0",
|
"version": "2026.2.1",
|
||||||
"description": "Mine Seeker Game by system7",
|
"author": "https://www.splendidbear.org",
|
||||||
"keywords": [
|
"license": "GPL-3.0-or-later",
|
||||||
"mine",
|
"bugs": "https://source.splendidbear.org",
|
||||||
"seeker",
|
"description": "Mine Seeker Game by system7",
|
||||||
"game",
|
"private": true,
|
||||||
"multiplayer",
|
"keywords": [
|
||||||
"websocket"
|
"mine",
|
||||||
],
|
"seeker",
|
||||||
"author": "Laszlo Lang <system7>",
|
"game",
|
||||||
"license": "UNLICENSED",
|
"multiplayer",
|
||||||
"private": true,
|
"websocket"
|
||||||
"dependencies": {
|
],
|
||||||
"@emotion/react": "^11.14.0",
|
"dependencies": {
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/react": "^11.14.0",
|
||||||
"@fontsource/changa-one": "^5.2.8",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@fontsource/open-sans": "^5.2.7",
|
"@fontsource/changa-one": "^5.2.8",
|
||||||
"@fontsource/rajdhani": "^5.2.7",
|
"@fontsource/open-sans": "^5.2.7",
|
||||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
"@fontsource/rajdhani": "^5.2.7",
|
||||||
"@mui/material": "^9.0.0",
|
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||||
"@mui/x-charts": "^9.0.1",
|
"@mui/material": "^9.0.0",
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@mui/x-charts": "^9.0.1",
|
||||||
"howler": "^2.1.2",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"lodash": "^4.18.1",
|
"howler": "^2.1.2",
|
||||||
"prop-types": "^15.7.2",
|
"lodash": "^4.18.1",
|
||||||
"react": "^19.0.0",
|
"prop-types": "^15.7.2",
|
||||||
"react-dom": "^19.0.0"
|
"react": "^19.0.0",
|
||||||
},
|
"react-dom": "^19.0.0"
|
||||||
"devDependencies": {
|
},
|
||||||
"@eslint/eslintrc": "^3.3.5",
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.0.0",
|
"@eslint/eslintrc": "^3.3.5",
|
||||||
"@stylistic/eslint-plugin": "^4.0.0",
|
"@eslint/js": "^9.0.0",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@stylistic/eslint-plugin": "^4.0.0",
|
||||||
"eslint": "^9.0.0",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"eslint-plugin-react": "^7.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
"eslint-plugin-react": "^7.0.0",
|
||||||
"globals": "^15.0.0",
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
"sass": "^1.77.0",
|
"globals": "^15.0.0",
|
||||||
"vite": "^8.0.8",
|
"sass": "^1.77.0",
|
||||||
"vite-plugin-symfony": "^8.2.4"
|
"vite": "^8.0.8",
|
||||||
},
|
"vite-plugin-symfony": "^8.2.4"
|
||||||
"scripts": {
|
},
|
||||||
"dev": "vite",
|
"scripts": {
|
||||||
"watch": "vite build --watch",
|
"dev": "vite",
|
||||||
"build": "vite build && cp -r node_modules/@fortawesome/fontawesome-free/webfonts public/build/webfonts",
|
"watch": "vite build --watch",
|
||||||
"lint": "eslint assets/js/"
|
"build": "vite build && cp -r node_modules/@fortawesome/fontawesome-free/webfonts public/build/webfonts",
|
||||||
}
|
"lint": "eslint assets/js/"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/images/another-games/apple-minesweeper.png
Normal file
BIN
public/images/another-games/apple-minesweeper.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
BIN
public/images/another-games/gnome-mines.png
Normal file
BIN
public/images/another-games/gnome-mines.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
BIN
public/images/another-games/microsoft-minesweeper.png
Normal file
BIN
public/images/another-games/microsoft-minesweeper.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
@@ -25,7 +25,7 @@ if ($debug) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($trustedProxies = $_SERVER['TRUSTED_PROXIES'] ?? false) {
|
if ($trustedProxies = $_SERVER['TRUSTED_PROXIES'] ?? false) {
|
||||||
Request::setTrustedProxies(explode(',', $trustedProxies), Request::HEADER_X_FORWARDED_ALL ^ Request::HEADER_X_FORWARDED_HOST);
|
Request::setTrustedProxies(explode(',', $trustedProxies), Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PROTO);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($trustedHosts = $_SERVER['TRUSTED_HOSTS'] ?? false) {
|
if ($trustedHosts = $_SERVER['TRUSTED_HOSTS'] ?? false) {
|
||||||
|
|||||||
@@ -10,10 +10,19 @@
|
|||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\ContactMessage;
|
||||||
|
use App\Form\ContactFormType;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||||
|
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
|
||||||
|
use Symfony\Component\Mailer\MailerInterface;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,11 +40,14 @@ class GameController extends AbstractController
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
#[Autowire(env: 'APP_ENV')]
|
#[Autowire(env: 'APP_ENV')]
|
||||||
private readonly string $env,
|
private readonly string $env,
|
||||||
#[Autowire(env: 'MERCURE_PUBLIC_URL')]
|
#[Autowire(env: 'MERCURE_PUBLIC_URL')]
|
||||||
private readonly string $mercurePublicUrl,
|
private readonly string $mercurePublicUrl,
|
||||||
#[Autowire(env: 'MERCURE_SUBSCRIBER_JWT')]
|
#[Autowire(env: 'MERCURE_SUBSCRIBER_JWT')]
|
||||||
private readonly string $mercureSubscriberJwt,
|
private readonly string $mercureSubscriberJwt,
|
||||||
|
#[Autowire(env: 'APP_CONTACT_MAIL_ADDRESS')]
|
||||||
|
private readonly string $appContactMailAddress,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,9 +81,28 @@ class GameController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/contact', name: 'MineSeekerBundle_contact')]
|
#[Route('/contact', name: 'MineSeekerBundle_contact')]
|
||||||
public function contact(): Response
|
public function contact(
|
||||||
{
|
Request $request,
|
||||||
return $this->render('Official/contact.html.twig');
|
EntityManagerInterface $em,
|
||||||
|
MailerInterface $mailer,
|
||||||
|
): Response {
|
||||||
|
$contactMessage = new ContactMessage();
|
||||||
|
$form = $this->createForm(ContactFormType::class, $contactMessage);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
$contactMessage->setIpAddress($request->getClientIp());
|
||||||
|
$em->persist($contactMessage);
|
||||||
|
$em->flush();
|
||||||
|
$this->sendMail($mailer, $contactMessage);
|
||||||
|
$this->addFlash('contact_success', 'Thank you for your message! We will get back to you soon.');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('MineSeekerBundle_contact');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('Official/contact.html.twig', [
|
||||||
|
'form' => $form,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/landing-page', name: 'MineSeekerBundle_landing')]
|
#[Route('/landing-page', name: 'MineSeekerBundle_landing')]
|
||||||
@@ -79,4 +110,31 @@ class GameController extends AbstractController
|
|||||||
{
|
{
|
||||||
return $this->render('Official/landing.html.twig');
|
return $this->render('Official/landing.html.twig');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function sendMail(MailerInterface $mailer, ContactMessage $contactMessage): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$mailer->send(
|
||||||
|
new TemplatedEmail()
|
||||||
|
->from('noreply@mineseeker.hu')
|
||||||
|
->to($this->appContactMailAddress)
|
||||||
|
->replyTo($contactMessage->getEmail())
|
||||||
|
->subject('New Contact Message from ' . $contactMessage->getName())
|
||||||
|
->htmlTemplate('emails/contact_notification.html.twig')
|
||||||
|
->context(['message' => $contactMessage])
|
||||||
|
);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->logger->error('Failed to send contact notification email: ' . $e->getMessage(), [
|
||||||
|
'exception' => $e,
|
||||||
|
'message' => $contactMessage,
|
||||||
|
]);
|
||||||
|
throw new RuntimeException('Failed to send contact notification email: ' . $e->getMessage());
|
||||||
|
} catch (TransportExceptionInterface $e) {
|
||||||
|
$this->logger->error('Failed to send contact notification email: ' . $e->getMessage(), [
|
||||||
|
'exception' => $e,
|
||||||
|
'message' => $contactMessage,
|
||||||
|
]);
|
||||||
|
throw new RuntimeException('Failed to send contact notification email: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,20 +10,31 @@
|
|||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\PlayedGame;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Repository\PlayedGameRepository;
|
use App\Repository\PlayedGameRepository;
|
||||||
|
use App\Service\BattleCardGenerator;
|
||||||
use App\Service\WebAuthnService;
|
use App\Service\WebAuthnService;
|
||||||
|
use DateTime;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use League\Flysystem\FilesystemException;
|
||||||
use League\Flysystem\FilesystemOperator;
|
use League\Flysystem\FilesystemOperator;
|
||||||
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
|
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use RuntimeException;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
use Throwable;
|
||||||
|
use function count;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class ProfileController
|
* Class ProfileController
|
||||||
@@ -40,12 +51,13 @@ class ProfileController extends AbstractController
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PlayedGameRepository $repo,
|
private readonly PlayedGameRepository $repo,
|
||||||
private readonly WebAuthnService $webAuthnService
|
private readonly WebAuthnService $webAuthnService,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/profile', name: 'MineSeekerBundle_profile')]
|
#[Route('/profile', name: 'MineSeekerBundle_profile')]
|
||||||
public function index(): Response
|
public function index(CacheManager $cacheManager): Response
|
||||||
{
|
{
|
||||||
/** @var User $user */
|
/** @var User $user */
|
||||||
$user = $this->getUser();
|
$user = $this->getUser();
|
||||||
@@ -56,15 +68,15 @@ class ProfileController extends AbstractController
|
|||||||
$losses = $this->repo->countLossesForUser($user);
|
$losses = $this->repo->countLossesForUser($user);
|
||||||
$draws = $this->repo->countDrawsForUser($user);
|
$draws = $this->repo->countDrawsForUser($user);
|
||||||
|
|
||||||
// Build monthly buckets for the last 6 months
|
/** Build monthly buckets for the last 6 months */
|
||||||
$monthlyData = [];
|
$monthlyData = [];
|
||||||
for ($i = 5; $i >= 0; $i--) {
|
for ($i = 5; $i >= 0; $i--) {
|
||||||
$dt = new \DateTime("first day of -$i months midnight");
|
$dt = new DateTime("first day of -$i months midnight");
|
||||||
$key = $dt->format('Y-m');
|
$key = $dt->format('Y-m');
|
||||||
$monthlyData[$key] = ['label' => $dt->format('M'), 'wins' => 0, 'losses' => 0, 'draws' => 0];
|
$monthlyData[$key] = ['label' => $dt->format('M'), 'wins' => 0, 'losses' => 0, 'draws' => 0];
|
||||||
}
|
}
|
||||||
|
|
||||||
$since = new \DateTime('first day of -5 months midnight');
|
$since = new DateTime('first day of -5 months midnight');
|
||||||
$recentGames = $this->repo->findFinishedForUserSince($user, $since);
|
$recentGames = $this->repo->findFinishedForUserSince($user, $since);
|
||||||
$userId = $user->getId();
|
$userId = $user->getId();
|
||||||
|
|
||||||
@@ -112,7 +124,7 @@ class ProfileController extends AbstractController
|
|||||||
'bestScore' => $this->repo->findBestScoreForUser($user),
|
'bestScore' => $this->repo->findBestScoreForUser($user),
|
||||||
],
|
],
|
||||||
'recent' => ($recent = $this->repo->findRecentFinishedForUser($user)),
|
'recent' => ($recent = $this->repo->findRecentFinishedForUser($user)),
|
||||||
'gamesData' => array_map(static function (\App\Entity\PlayedGame $game) use ($userId): array {
|
'gamesData' => array_map(function (PlayedGame $game) use ($userId, $cacheManager): array {
|
||||||
$isRed = $game->getRed()?->getId() === $userId;
|
$isRed = $game->getRed()?->getId() === $userId;
|
||||||
$resign = $game->getResign();
|
$resign = $game->getResign();
|
||||||
$myColor = $isRed ? 'red' : 'blue';
|
$myColor = $isRed ? 'red' : 'blue';
|
||||||
@@ -128,12 +140,18 @@ class ProfileController extends AbstractController
|
|||||||
elseif ($myPts < $oppPts) $result = 'loss';
|
elseif ($myPts < $oppPts) $result = 'loss';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$redAvatarPath = $game->getRed()?->getAvatarPath();
|
||||||
|
$blueAvatarPath = $game->getBlue()?->getAvatarPath();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $game->getId(),
|
'id' => $game->getId(),
|
||||||
|
'uuid' => $game->getUuid()?->toRfc4122(),
|
||||||
'redName' =>
|
'redName' =>
|
||||||
$game->getRed()?->getUsername() ?? $game->getRedAnon()?->getUserName() ?? 'Guest',
|
$game->getRed()?->getUsername() ?? $game->getRedAnon()?->getUserName() ?? 'Guest',
|
||||||
'blueName' =>
|
'blueName' =>
|
||||||
$game->getBlue()?->getUsername() ?? $game->getBlueAnon()?->getUserName() ?? 'Guest',
|
$game->getBlue()?->getUsername() ?? $game->getBlueAnon()?->getUserName() ?? 'Guest',
|
||||||
|
'redAvatar' => $redAvatarPath ? $cacheManager->generateUrl($redAvatarPath, 'avatar_thumb') : null,
|
||||||
|
'blueAvatar' => $blueAvatarPath ? $cacheManager->generateUrl($blueAvatarPath, 'avatar_thumb') : null,
|
||||||
'redPoints' => $game->getRedPoints(),
|
'redPoints' => $game->getRedPoints(),
|
||||||
'bluePoints' => $game->getBluePoints(),
|
'bluePoints' => $game->getBluePoints(),
|
||||||
'redExplodedBomb' => $game->getRedExplodedBomb(),
|
'redExplodedBomb' => $game->getRedExplodedBomb(),
|
||||||
@@ -159,19 +177,26 @@ class ProfileController extends AbstractController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/battle/{id}', name: 'MineSeekerBundle_battle_share', requirements: ['id' => '\d+'], methods: ['GET'])]
|
#[Route(
|
||||||
public function battleShare(int $id): Response
|
'/battle/{uuid}',
|
||||||
|
name: 'MineSeekerBundle_battle_share',
|
||||||
|
requirements: ['uuid' => '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'],
|
||||||
|
methods: ['GET'],
|
||||||
|
)]
|
||||||
|
public function battleShare(Uuid $uuid): Response
|
||||||
{
|
{
|
||||||
$game = $this->repo->find($id);
|
$game = $this->repo->findOneBy(['uuid' => $uuid]);
|
||||||
if (!$game) {
|
if (!$game) {
|
||||||
throw $this->createNotFoundException('Battle not found.');
|
throw $this->createNotFoundException('Battle not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$redName = $game->getRed()?->getUsername() ?? $game->getRedAnon()?->getUserName() ?? 'Guest';
|
$redName = $game->getRed()?->getUsername() ?? ($game->getRedAnon() !== null ? 'Anonymous' : 'Guest');
|
||||||
$blueName = $game->getBlue()?->getUsername() ?? $game->getBlueAnon()?->getUserName() ?? 'Guest';
|
$blueName = $game->getBlue()?->getUsername() ?? ($game->getBlueAnon() !== null ? 'Anonymous' : 'Guest');
|
||||||
$redPts = $game->getRedPoints();
|
$redPts = $game->getRedPoints();
|
||||||
$bluePts = $game->getBluePoints();
|
$bluePts = $game->getBluePoints();
|
||||||
$resign = $game->getResign();
|
$resign = $game->getResign();
|
||||||
|
$redAvatar = $game->getRed()?->getAvatarPath();
|
||||||
|
$blueAvatar = $game->getBlue()?->getAvatarPath();
|
||||||
|
|
||||||
if ($resign === 'red') {
|
if ($resign === 'red') {
|
||||||
$summary = "$redName resigned — $blueName wins";
|
$summary = "$redName resigned — $blueName wins";
|
||||||
@@ -190,17 +215,42 @@ class ProfileController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
return $this->render('Game/battle_share.html.twig', [
|
return $this->render('Game/battle_share.html.twig', [
|
||||||
'game' => $game,
|
'game' => $game,
|
||||||
'redName' => $redName,
|
'redName' => $redName,
|
||||||
'blueName' => $blueName,
|
'blueName' => $blueName,
|
||||||
'redPts' => $redPts,
|
'redPts' => $redPts,
|
||||||
'bluePts' => $bluePts,
|
'bluePts' => $bluePts,
|
||||||
'resign' => $resign,
|
'resign' => $resign,
|
||||||
'ogTitle' => "MineSeeker · $summary",
|
'redAvatar' => $redAvatar,
|
||||||
'ogDesc' => "Watch the battle replay: $summary — played on MineSeeker, the multiplayer minesweeper.",
|
'blueAvatar' => $blueAvatar,
|
||||||
|
'ogTitle' => "MineSeeker · $summary",
|
||||||
|
'ogDesc' => "Watch the battle replay: $summary — played on MineSeeker, the multiplayer minesweeper.",
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Route(
|
||||||
|
'/og/battle/{uuid}.png',
|
||||||
|
name: 'MineSeekerBundle_og_battle',
|
||||||
|
requirements: ['uuid' => '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'],
|
||||||
|
methods: ['GET'],
|
||||||
|
)]
|
||||||
|
public function battleOgImage(Uuid $uuid, BattleCardGenerator $generator): BinaryFileResponse
|
||||||
|
{
|
||||||
|
$game = $this->repo->findOneBy(['uuid' => $uuid]);
|
||||||
|
if (!$game) {
|
||||||
|
throw $this->createNotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $generator->generate($game);
|
||||||
|
$response = new BinaryFileResponse($path);
|
||||||
|
$response->headers->set('Content-Type', 'image/png');
|
||||||
|
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE);
|
||||||
|
$response->setMaxAge(86400 * 30);
|
||||||
|
$response->setPublic();
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/profile/avatar', name: 'MineSeekerBundle_profile_avatar', methods: ['POST'])]
|
#[Route('/profile/avatar', name: 'MineSeekerBundle_profile_avatar', methods: ['POST'])]
|
||||||
public function uploadAvatar(
|
public function uploadAvatar(
|
||||||
Request $request,
|
Request $request,
|
||||||
@@ -228,23 +278,27 @@ class ProfileController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$ext = $file->guessExtension() ?? 'jpg';
|
$ext = $file->guessExtension() ?? 'jpg';
|
||||||
$newPath = sprintf('avatar/%d.%s', $user->getId(), $ext);
|
$newPath = sprintf('avatar/%s.%s', Uuid::v4()->toRfc4122(), $ext);
|
||||||
$oldPath = $user->getAvatarPath();
|
$oldPath = $user->getAvatarPath();
|
||||||
|
|
||||||
// Remove old file and any cached thumbnails
|
/** Remove old file and any cached thumbnails */
|
||||||
if ($oldPath) {
|
if ($oldPath) {
|
||||||
if ($oldPath !== $newPath) {
|
try {
|
||||||
try {
|
$mediaStorage->delete($oldPath);
|
||||||
$mediaStorage->delete($oldPath);
|
} catch (Throwable) {
|
||||||
} catch (\Throwable) {
|
$this->logger->error('Unable to delete old avatar: ' . $oldPath);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
$cacheManager->remove($oldPath, 'avatar_thumb');
|
$cacheManager->remove($oldPath, 'avatar_thumb');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload original to MinIO media/avatar/
|
/** Upload original to MinIO media/avatar/ */
|
||||||
$stream = fopen($file->getPathname(), 'r');
|
$stream = fopen($file->getPathname(), 'rb');
|
||||||
$mediaStorage->writeStream($newPath, $stream);
|
try {
|
||||||
|
$mediaStorage->writeStream($newPath, $stream);
|
||||||
|
} catch (FilesystemException $e) {
|
||||||
|
$this->logger->error('Unable to write new avatar: ' . $e->getMessage());
|
||||||
|
throw new RuntimeException('Unable to write new avatar: ' . $e->getMessage());
|
||||||
|
}
|
||||||
fclose($stream);
|
fclose($stream);
|
||||||
|
|
||||||
$user->setAvatarPath($newPath);
|
$user->setAvatarPath($newPath);
|
||||||
@@ -275,7 +329,7 @@ class ProfileController extends AbstractController
|
|||||||
return $this->render('Security/profile_security.html.twig', [
|
return $this->render('Security/profile_security.html.twig', [
|
||||||
'credentials' => $credentialsData,
|
'credentials' => $credentialsData,
|
||||||
'isTotpEnabled' => $user->isTotpAuthenticationEnabled(),
|
'isTotpEnabled' => $user->isTotpAuthenticationEnabled(),
|
||||||
'backupCodesCount' => \count($user->getBackupCodes()),
|
'backupCodesCount' => count($user->getBackupCodes()),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use DateTime;
|
|||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||||
@@ -41,6 +42,12 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
|
|||||||
#[AsController]
|
#[AsController]
|
||||||
class SecurityController extends AbstractController
|
class SecurityController extends AbstractController
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(env: 'APP_CONTACT_MAIL_ADDRESS')]
|
||||||
|
private readonly string $appContactMailAddress,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/login', name: 'MineSeekerBundle_login')]
|
#[Route('/login', name: 'MineSeekerBundle_login')]
|
||||||
public function login(AuthenticationUtils $authenticationUtils): Response
|
public function login(AuthenticationUtils $authenticationUtils): Response
|
||||||
{
|
{
|
||||||
@@ -92,9 +99,14 @@ class SecurityController extends AbstractController
|
|||||||
UrlGeneratorInterface::ABSOLUTE_URL,
|
UrlGeneratorInterface::ABSOLUTE_URL,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Ensure HTTPS scheme in production */
|
||||||
|
if ($this->getParameter('kernel.environment') === 'prod') {
|
||||||
|
$activationUrl = str_replace('http://', 'https://', $activationUrl);
|
||||||
|
}
|
||||||
|
|
||||||
$mailer->send(
|
$mailer->send(
|
||||||
new TemplatedEmail()
|
new TemplatedEmail()
|
||||||
->from('noreply@mineseeker.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')
|
||||||
@@ -104,6 +116,19 @@ class SecurityController extends AbstractController
|
|||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Send admin notification about new user registration */
|
||||||
|
$mailer->send(
|
||||||
|
new TemplatedEmail()
|
||||||
|
->from('noreply@mineseeker.hu')
|
||||||
|
->to($this->appContactMailAddress)
|
||||||
|
->subject('🎉 New User Registration: ' . $user->getUsername())
|
||||||
|
->htmlTemplate('emails/user_registration_notification.html.twig')
|
||||||
|
->context([
|
||||||
|
'user' => $user,
|
||||||
|
'registeredAt' => new DateTime(),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
$this->addFlash('verify_email', $user->getEmail());
|
$this->addFlash('verify_email', $user->getEmail());
|
||||||
|
|
||||||
return $this->redirectToRoute('MineSeekerBundle_register');
|
return $this->redirectToRoute('MineSeekerBundle_register');
|
||||||
@@ -143,9 +168,14 @@ class SecurityController extends AbstractController
|
|||||||
UrlGeneratorInterface::ABSOLUTE_URL,
|
UrlGeneratorInterface::ABSOLUTE_URL,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Ensure HTTPS scheme in production */
|
||||||
|
if ($this->getParameter('kernel.environment') === 'prod') {
|
||||||
|
$resetUrl = str_replace('http://', 'https://', $resetUrl);
|
||||||
|
}
|
||||||
|
|
||||||
$mailer->send(
|
$mailer->send(
|
||||||
new TemplatedEmail()
|
new TemplatedEmail()
|
||||||
->from('noreply@mineseeker.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')
|
||||||
@@ -199,7 +229,7 @@ class SecurityController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/activate/{token}', name: 'MineSeekerBundle_activate')]
|
#[Route('/activate/{token}', name: 'MineSeekerBundle_activate')]
|
||||||
public function activate(string $token, EntityManagerInterface $em): Response
|
public function activate(string $token, EntityManagerInterface $em, MailerInterface $mailer): Response
|
||||||
{
|
{
|
||||||
$user = $em->getRepository(User::class)->findOneBy(['verificationToken' => $token]);
|
$user = $em->getRepository(User::class)->findOneBy(['verificationToken' => $token]);
|
||||||
|
|
||||||
@@ -211,6 +241,19 @@ class SecurityController extends AbstractController
|
|||||||
$user->setIsVerified(true)->setVerificationToken(null);
|
$user->setIsVerified(true)->setVerificationToken(null);
|
||||||
$em->flush();
|
$em->flush();
|
||||||
|
|
||||||
|
/** Send admin notification about account activation */
|
||||||
|
$mailer->send(
|
||||||
|
new TemplatedEmail()
|
||||||
|
->from('noreply@mineseeker.hu')
|
||||||
|
->to($this->appContactMailAddress)
|
||||||
|
->subject('✅ User Account Activated: ' . $user->getUsername())
|
||||||
|
->htmlTemplate('emails/user_activation_notification.html.twig')
|
||||||
|
->context([
|
||||||
|
'user' => $user,
|
||||||
|
'activatedAt' => new DateTime(),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
$this->addFlash('success', 'Your account is now active. Welcome, ' . $user->getUsername() . '!');
|
$this->addFlash('success', 'Your account is now active. Welcome, ' . $user->getUsername() . '!');
|
||||||
|
|
||||||
return $this->redirectToRoute('MineSeekerBundle_login');
|
return $this->redirectToRoute('MineSeekerBundle_login');
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|||||||
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/Version20260414000000.php
Normal file
47
src/Migrations/2026/04/Version20260414000000.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 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
305
src/Service/BattleCardGenerator.php
Normal file
305
src/Service/BattleCardGenerator.php
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
/*
|
||||||
|
* This file is part of the SplendidBear Websites' projects.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2026 @ www.splendidbear.org
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\PlayedGame;
|
||||||
|
use Exception;
|
||||||
|
use GdImage;
|
||||||
|
use League\Flysystem\FilesystemOperator;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class BattleCardGenerator
|
||||||
|
*
|
||||||
|
* Generates a 1200x630 PNG battle card for Open Graph sharing.
|
||||||
|
*
|
||||||
|
* @package App\Service
|
||||||
|
* @author Lang <https://www.splendidbear.org>
|
||||||
|
* @category Class
|
||||||
|
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
||||||
|
* @link www.splendidbear.org
|
||||||
|
* @since 2026. 04. 14.
|
||||||
|
*/
|
||||||
|
class BattleCardGenerator
|
||||||
|
{
|
||||||
|
private const int WIDTH = 1200;
|
||||||
|
private const int HEIGHT = 630;
|
||||||
|
private const string FONT = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
|
||||||
|
private const int AVATAR_SIZE = 120;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $cacheDir,
|
||||||
|
private readonly FilesystemOperator $minioMediaStorage,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a deterministic UUID v5 for the given battle ID — same battle always maps to the same filename. */
|
||||||
|
public function cachePath(int $battleId): string
|
||||||
|
{
|
||||||
|
$uuid = Uuid::v5(Uuid::fromString(Uuid::NAMESPACE_URL), 'mineseeker-battle-' . $battleId);
|
||||||
|
|
||||||
|
return $this->cacheDir . '/' . $uuid->toRfc4122() . '.png';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generate(PlayedGame $game): string
|
||||||
|
{
|
||||||
|
$path = $this->cachePath((int)$game->getId());
|
||||||
|
|
||||||
|
if (is_file($path)) {
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_dir($this->cacheDir)) {
|
||||||
|
if (
|
||||||
|
!mkdir($concurrentDirectory = $this->cacheDir, 0755, true)
|
||||||
|
&& !is_dir($concurrentDirectory)
|
||||||
|
) {
|
||||||
|
$this->logger->error(sprintf('Failed to create directory "%s" for battle card cache', $concurrentDirectory));
|
||||||
|
throw new RuntimeException(sprintf('Directory "%s" was not created', $concurrentDirectory));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->render($game, $path);
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function render(PlayedGame $game, string $dest): void
|
||||||
|
{
|
||||||
|
$im = imagecreatetruecolor(self::WIDTH, self::HEIGHT);
|
||||||
|
|
||||||
|
/** Palette*/
|
||||||
|
$bg = imagecolorallocate($im, 13, 13, 28);
|
||||||
|
$dot = imagecolorallocate($im, 30, 30, 55);
|
||||||
|
$divider = imagecolorallocate($im, 40, 40, 70);
|
||||||
|
$white = imagecolorallocate($im, 230, 230, 240);
|
||||||
|
$muted = imagecolorallocate($im, 90, 90, 115);
|
||||||
|
$red = imagecolorallocate($im, 246, 125, 82);
|
||||||
|
$blue = imagecolorallocate($im, 149, 207, 245);
|
||||||
|
$gold = imagecolorallocate($im, 255, 200, 50);
|
||||||
|
|
||||||
|
/** Background*/
|
||||||
|
imagefill($im, 0, 0, $bg);
|
||||||
|
|
||||||
|
/** Dot-grid texture*/
|
||||||
|
for ($x = 40; $x < self::WIDTH; $x += 40) {
|
||||||
|
for ($y = 40; $y < self::HEIGHT; $y += 40) {
|
||||||
|
imagesetpixel($im, $x, $y, $dot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Horizontal accent lines*/
|
||||||
|
imageline($im, 0, 90, self::WIDTH, 90, $divider);
|
||||||
|
imageline($im, 0, self::HEIGHT - 60, self::WIDTH, self::HEIGHT - 60, $divider);
|
||||||
|
|
||||||
|
/** Vertical centre divider*/
|
||||||
|
imageline($im, self::WIDTH / 2, 110, self::WIDTH / 2, self::HEIGHT - 80, $divider);
|
||||||
|
|
||||||
|
/** Resolve names*/
|
||||||
|
$redName = $game->getRed()?->getUsername()
|
||||||
|
?? ($game->getRedAnon() !== null ? 'Anonymous' : 'Guest');
|
||||||
|
$blueName = $game->getBlue()?->getUsername()
|
||||||
|
?? ($game->getBlueAnon() !== null ? 'Anonymous' : 'Guest');
|
||||||
|
$redPts = $game->getRedPoints();
|
||||||
|
$bluePts = $game->getBluePoints();
|
||||||
|
$resign = $game->getResign();
|
||||||
|
|
||||||
|
/** Winner*/
|
||||||
|
$winner = null;
|
||||||
|
if ($resign === 'red') {
|
||||||
|
$winner = 'blue';
|
||||||
|
} elseif ($resign === 'blue') {
|
||||||
|
$winner = 'red';
|
||||||
|
} elseif ($redPts !== null && $bluePts !== null) {
|
||||||
|
if ($redPts > $bluePts) $winner = 'red';
|
||||||
|
elseif ($bluePts > $redPts) $winner = 'blue';
|
||||||
|
else $winner = 'draw';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->centeredText($im, 'MineSeeker', 20, self::WIDTH / 2, 58, $muted);
|
||||||
|
|
||||||
|
/** RED and BLUE labels aligned with avatars horizontally*/
|
||||||
|
$this->centeredText($im, 'RED', 16, 220, 130, $red);
|
||||||
|
$this->centeredText($im, 'BLUE', 16, 980, 130, $blue);
|
||||||
|
|
||||||
|
/** Draw avatars below the team labels (moved down by 60px total: 200 → 260)*/
|
||||||
|
$redAvatar = $game->getRed()?->getAvatarPath();
|
||||||
|
$blueAvatar = $game->getBlue()?->getAvatarPath();
|
||||||
|
|
||||||
|
$this->drawAvatar($im, $redAvatar, 220, 260, $red, $redName);
|
||||||
|
$this->drawAvatar($im, $blueAvatar, 980, 260, $blue, $blueName);
|
||||||
|
|
||||||
|
$redColor = $winner === 'red' ? $gold : ($winner === 'draw' ? $white : $red);
|
||||||
|
$blueColor = $winner === 'blue' ? $gold : ($winner === 'draw' ? $white : $blue);
|
||||||
|
|
||||||
|
/** Truncate long usernames (max 10 chars + "...")*/
|
||||||
|
$redNameDisplay = mb_strlen($redName) > 10 ? mb_substr($redName, 0, 10) . '...' : $redName;
|
||||||
|
$blueNameDisplay = mb_strlen($blueName) > 10 ? mb_substr($blueName, 0, 10) . '...' : $blueName;
|
||||||
|
|
||||||
|
/** Player names lower below avatars (moved down by 60px total: 310 → 370)*/
|
||||||
|
$this->centeredTextFit($im, $redNameDisplay, 36, 220, 370, $redColor, 400);
|
||||||
|
$this->centeredTextFit($im, $blueNameDisplay, 36, 980, 370, $blueColor, 400);
|
||||||
|
|
||||||
|
$scoreText = $redPts !== null && $bluePts !== null ? $redPts . ' : ' . $bluePts : 'VS';
|
||||||
|
$this->centeredText($im, $scoreText, 72, self::WIDTH / 2, 390, $white);
|
||||||
|
|
||||||
|
if ($winner === 'red') {
|
||||||
|
$resultText = $redName . ' wins';
|
||||||
|
$resultColor = $gold;
|
||||||
|
} elseif ($winner === 'blue') {
|
||||||
|
$resultText = $blueName . ' wins';
|
||||||
|
$resultColor = $gold;
|
||||||
|
} elseif ($winner === 'draw') {
|
||||||
|
$resultText = 'Draw';
|
||||||
|
$resultColor = $muted;
|
||||||
|
} else {
|
||||||
|
$resultText = '';
|
||||||
|
$resultColor = $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($resultText !== '') {
|
||||||
|
$this->centeredText($im, $resultText, 30, self::WIDTH / 2, 460, $resultColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($resign) {
|
||||||
|
$this->centeredText($im, ucfirst($resign) . ' resigned', 18, self::WIDTH / 2, 498, $muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->centeredText($im, 'mineseeker.hu', 16, self::WIDTH / 2, self::HEIGHT - 20, $muted);
|
||||||
|
|
||||||
|
imagepng($im, $dest);
|
||||||
|
imagedestroy($im);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draw avatar or initials centered on $cx, $cy. */
|
||||||
|
private function drawAvatar(GdImage $im, ?string $avatarPath, int $cx, int $cy, int $color, string $name): void
|
||||||
|
{
|
||||||
|
$avatarImg = null;
|
||||||
|
|
||||||
|
/** Try to load avatar from MinIO if path exists*/
|
||||||
|
if ($avatarPath) {
|
||||||
|
try {
|
||||||
|
/** Remove 'avatar/' prefix if it exists since storage already has media/ prefix*/
|
||||||
|
$path = str_starts_with($avatarPath, 'avatar/') ? $avatarPath : 'avatar/' . $avatarPath;
|
||||||
|
$avatarData = $this->minioMediaStorage->read($path);
|
||||||
|
$avatarImg = imagecreatefromstring($avatarData);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
/** Failed to load avatar, will use initials*/
|
||||||
|
$avatarImg = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$x = $cx - self::AVATAR_SIZE / 2;
|
||||||
|
$y = $cy - self::AVATAR_SIZE / 2;
|
||||||
|
|
||||||
|
if ($avatarImg) {
|
||||||
|
/** Draw circular avatar image*/
|
||||||
|
$mask = imagecreatetruecolor(self::AVATAR_SIZE, self::AVATAR_SIZE);
|
||||||
|
$transparent = imagecolorallocatealpha($mask, 0, 0, 0, 127);
|
||||||
|
imagefill($mask, 0, 0, $transparent);
|
||||||
|
|
||||||
|
/** Create circular mask*/
|
||||||
|
imagefilledellipse(
|
||||||
|
$mask,
|
||||||
|
self::AVATAR_SIZE / 2,
|
||||||
|
self::AVATAR_SIZE / 2,
|
||||||
|
self::AVATAR_SIZE,
|
||||||
|
self::AVATAR_SIZE,
|
||||||
|
imagecolorallocate($mask, 255, 255, 255),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Resize and crop avatar*/
|
||||||
|
$resized = imagecreatetruecolor(self::AVATAR_SIZE, self::AVATAR_SIZE);
|
||||||
|
imagealphablending($resized, false);
|
||||||
|
imagesavealpha($resized, true);
|
||||||
|
$bg = imagecolorallocatealpha($resized, 0, 0, 0, 127);
|
||||||
|
imagefill($resized, 0, 0, $bg);
|
||||||
|
|
||||||
|
$srcW = imagesx($avatarImg);
|
||||||
|
$srcH = imagesy($avatarImg);
|
||||||
|
$size = min($srcW, $srcH);
|
||||||
|
$srcX = ($srcW - $size) / 2;
|
||||||
|
$srcY = ($srcH - $size) / 2;
|
||||||
|
|
||||||
|
imagecopyresampled(
|
||||||
|
$resized,
|
||||||
|
$avatarImg,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
(int)$srcX,
|
||||||
|
(int)$srcY,
|
||||||
|
self::AVATAR_SIZE,
|
||||||
|
self::AVATAR_SIZE,
|
||||||
|
$size,
|
||||||
|
$size,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Apply circular mask*/
|
||||||
|
for ($py = 0; $py < self::AVATAR_SIZE; $py++) {
|
||||||
|
for ($px = 0; $px < self::AVATAR_SIZE; $px++) {
|
||||||
|
$maskColor = imagecolorat($mask, $px, $py);
|
||||||
|
if (($maskColor >> 16) & 0xFF) {
|
||||||
|
$resizedColor = imagecolorat($resized, $px, $py);
|
||||||
|
imagesetpixel($im, (int)($x + $px), (int)($y + $py), $resizedColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
imagedestroy($avatarImg);
|
||||||
|
imagedestroy($resized);
|
||||||
|
imagedestroy($mask);
|
||||||
|
} else {
|
||||||
|
/** Draw circular background with initials*/
|
||||||
|
imagefilledellipse($im, (int)$cx, (int)$cy, self::AVATAR_SIZE, self::AVATAR_SIZE, $color);
|
||||||
|
|
||||||
|
/** Draw initials */
|
||||||
|
$initials = mb_strtoupper(mb_substr($name, 0, 2));
|
||||||
|
$fontSize = 48;
|
||||||
|
$bbox = imagettfbbox($fontSize, 0, self::FONT, $initials);
|
||||||
|
$textW = $bbox[2] - $bbox[0];
|
||||||
|
$textH = $bbox[1] - $bbox[7];
|
||||||
|
$textX = $cx - $textW / 2;
|
||||||
|
$textY = $cy + $textH / 2;
|
||||||
|
|
||||||
|
$white = imagecolorallocate($im, 255, 255, 255);
|
||||||
|
imagettftext($im, $fontSize, 0, (int)$textX, (int)$textY, $white, self::FONT, $initials);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render text centered on $cx. */
|
||||||
|
private function centeredText(GdImage $im, string $text, int $size, int $cx, int $y, int $color): void
|
||||||
|
{
|
||||||
|
$bbox = imagettfbbox($size, 0, self::FONT, $text);
|
||||||
|
$w = $bbox[2] - $bbox[0];
|
||||||
|
imagettftext($im, $size, 0, (int)($cx - $w / 2), $y, $color, self::FONT, $text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render text centered on $cx, shrinking font size to fit $maxWidth. */
|
||||||
|
private function centeredTextFit(
|
||||||
|
GdImage $im,
|
||||||
|
string $text,
|
||||||
|
int $size,
|
||||||
|
int $cx,
|
||||||
|
int $y,
|
||||||
|
int $color,
|
||||||
|
int $maxWidth
|
||||||
|
): void {
|
||||||
|
$bbox = imagettfbbox($size, 0, self::FONT, $text);
|
||||||
|
$w = $bbox[2] - $bbox[0];
|
||||||
|
if ($w > $maxWidth) {
|
||||||
|
$size = (int)($size * $maxWidth / $w);
|
||||||
|
}
|
||||||
|
$this->centeredText($im, $text, $size, $cx, $y, $color);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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--) {
|
||||||
$j = random_int(0, $i);
|
try {
|
||||||
|
$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++) {
|
||||||
|
|||||||
@@ -3,40 +3,46 @@
|
|||||||
{% block title %} - Battle Report{% endblock %}
|
{% block title %} - Battle Report{% endblock %}
|
||||||
|
|
||||||
{% block metas %}
|
{% block metas %}
|
||||||
{% set shareUrl = url('MineSeekerBundle_battle_share', { id: game.id }) %}
|
{%- set shareUrl = url('MineSeekerBundle_battle_share', { uuid: game.uuid }) | replace({'http://': 'https://'}) -%}
|
||||||
<meta property="og:url" content="{{ shareUrl }}"/>
|
{%- set _ogImage = url('MineSeekerBundle_og_battle', { uuid: game.uuid }) | replace({'http://': 'https://'}) -%}
|
||||||
<meta property="og:type" content="website"/>
|
<meta property="og:url" content="{{ shareUrl }}"/>
|
||||||
<meta property="og:title" content="{{ ogTitle }}"/>
|
<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: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 name="twitter:card" content="summary_large_image"/>
|
<meta property="og:image:alt" content="{{ ogTitle }}"/>
|
||||||
<meta name="twitter:title" content="{{ ogTitle }}"/>
|
<meta name="twitter:card" content="summary_large_image"/>
|
||||||
|
<meta name="twitter:site" content="@MineSeeker"/>
|
||||||
|
<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 }}
|
{% if redAvatar %}
|
||||||
|
<img src="{{ redAvatar|imagine_filter('avatar_thumb') }}"
|
||||||
|
alt="{{ redName }}"
|
||||||
|
class="bshare-avatar__img">
|
||||||
|
{% else %}
|
||||||
|
{{ redName|slice(0,2)|upper }}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<span class="bshare-player__name">{{ redName }}</span>
|
<span class="bshare-player__name">{{ redName }}</span>
|
||||||
<span class="bshare-player__side">Red</span>
|
<span class="bshare-player__side">Red</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bshare-vs__center">
|
<div class="bshare-vs__center">
|
||||||
{% if redPts is not null and bluePts is not null %}
|
{% if redPts is not null and bluePts is not null %}
|
||||||
<div class="bshare-score">
|
<div class="bshare-score">
|
||||||
@@ -48,8 +54,6 @@
|
|||||||
<div class="bshare-score bshare-score--na">— : —</div>
|
<div class="bshare-score bshare-score--na">— : —</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="bshare-vs__label">VS</div>
|
<div class="bshare-vs__label">VS</div>
|
||||||
|
|
||||||
{# Result badge #}
|
|
||||||
{% if resign == 'red' %}
|
{% if resign == 'red' %}
|
||||||
<div class="bshare-badge bshare-badge--blue">
|
<div class="bshare-badge bshare-badge--blue">
|
||||||
<i class="fas fa-trophy"></i> Blue wins
|
<i class="fas fa-trophy"></i> Blue wins
|
||||||
@@ -74,18 +78,20 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bshare-player bshare-player--blue">
|
<div class="bshare-player bshare-player--blue">
|
||||||
<div class="bshare-avatar bshare-avatar--blue">
|
<div class="bshare-avatar bshare-avatar--blue">
|
||||||
{{ 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>
|
</div>
|
||||||
<span class="bshare-player__name">{{ blueName }}</span>
|
<span class="bshare-player__name">{{ blueName }}</span>
|
||||||
<span class="bshare-player__side">Blue</span>
|
<span class="bshare-player__side">Blue</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Details #}
|
|
||||||
<div class="bshare-details">
|
<div class="bshare-details">
|
||||||
{% if resign %}
|
{% if resign %}
|
||||||
<div class="bshare-detail">
|
<div class="bshare-detail">
|
||||||
@@ -112,7 +118,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bshare-cta">
|
<div class="bshare-cta">
|
||||||
<a href="{{ path('MineSeekerBundle_gamePlay') }}" class="bshare-btn">
|
<a href="{{ path('MineSeekerBundle_gamePlay') }}" class="bshare-btn">
|
||||||
<i class="fas fa-play"></i> Play MineSeeker
|
<i class="fas fa-play"></i> Play MineSeeker
|
||||||
@@ -121,8 +126,6 @@
|
|||||||
<i class="fas fa-house"></i> Homepage
|
<i class="fas fa-house"></i> Homepage
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,12 +3,23 @@
|
|||||||
{% block title %} - The Game{% endblock %}
|
{% block title %} - The Game{% endblock %}
|
||||||
|
|
||||||
{% block metas %}
|
{% block metas %}
|
||||||
<meta property="og:url" content="{{ url('MineSeekerBundle_homepage') }}"/>
|
{%- set _ogImage = app.request.getSchemeAndHttpHost() ~ asset('images/mine-1600x627.png') -%}
|
||||||
|
<meta property="og:url" content="{{ url('MineSeekerBundle_homepage') | replace({'http://': 'https://'}) }}"/>
|
||||||
<meta property="og:type" content="website"/>
|
<meta property="og:type" content="website"/>
|
||||||
<meta property="og:title" content="MineSeeker"/>
|
<meta property="og:site_name" content="MineSeeker"/>
|
||||||
<meta property="og:description" content="A multiplayer minesweeper game"/>
|
<meta property="og:locale" content="en_US"/>
|
||||||
<meta property="og:image"
|
<meta property="og:title" content="MineSeeker — Multiplayer Minesweeper"/>
|
||||||
content="{{ app.request.getSchemeAndHttpHost() }}{{ asset('images/mine-1600x627.png') }}"/>
|
<meta property="og:description"
|
||||||
|
content="Race a friend on a hidden minefield. Real-time 1v1 minesweeper in your browser — no account needed. Just play."/>
|
||||||
|
<meta property="og:image" content="{{ _ogImage }}"/>
|
||||||
|
<meta property="og:image:width" content="1600"/>
|
||||||
|
<meta property="og:image:height" content="627"/>
|
||||||
|
<meta property="og:image:alt" content="MineSeeker — Multiplayer Minesweeper"/>
|
||||||
|
<meta name="twitter:card" content="summary_large_image"/>
|
||||||
|
<meta name="twitter:title" content="MineSeeker — Multiplayer Minesweeper"/>
|
||||||
|
<meta name="twitter:description"
|
||||||
|
content="Race a friend on a hidden minefield. Real-time 1v1 minesweeper in your browser — no account needed. Just play."/>
|
||||||
|
<meta name="twitter:image" content="{{ _ogImage }}"/>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block header %}
|
{% block header %}
|
||||||
@@ -61,6 +72,10 @@
|
|||||||
<h1>No account needed.<br>Just play.</h1>
|
<h1>No account needed.<br>Just play.</h1>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{{ path('MineSeekerBundle_gamePlay') }}" class="hero-cta">Play Now</a>
|
<a href="{{ path('MineSeekerBundle_gamePlay') }}" class="hero-cta">Play Now</a>
|
||||||
|
<p class="hero-donate-text">Love this game?</p>
|
||||||
|
<a href="https://ko-fi.com/splendidbear" target="_blank" rel="noopener" class="hero-donate">
|
||||||
|
Buy me a coffee
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
@@ -80,7 +95,8 @@
|
|||||||
<p class="feature-block__body">
|
<p class="feature-block__body">
|
||||||
Create a free account and every game you play gets recorded.
|
Create a free account and every game you play gets recorded.
|
||||||
Watch your win rate climb, revisit past battles, and prove your dominance
|
Watch your win rate climb, revisit past battles, and prove your dominance
|
||||||
on the board — one detonation at a time.
|
on the board — one detonation at a time. Share your greatest victories with friends,
|
||||||
|
replay epic showdowns, and celebrate your legendary moments.
|
||||||
</p>
|
</p>
|
||||||
{% if not is_granted("IS_AUTHENTICATED_REMEMBERED") %}
|
{% if not is_granted("IS_AUTHENTICATED_REMEMBERED") %}
|
||||||
<a href="{{ path('MineSeekerBundle_register') }}" class="feature-block__cta">
|
<a href="{{ path('MineSeekerBundle_register') }}" class="feature-block__cta">
|
||||||
@@ -110,6 +126,86 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="feature-block feature-block--privacy">
|
||||||
|
<div class="feature-block__inner">
|
||||||
|
<div class="feature-block__visual feature-block__visual--privacy">
|
||||||
|
<i class="fas fa-shield"></i>
|
||||||
|
<i class="fas fa-lock"></i>
|
||||||
|
<i class="fas fa-eye-slash"></i>
|
||||||
|
</div>
|
||||||
|
<div class="feature-block__text">
|
||||||
|
<p class="feature-block__label">Your data, your control</p>
|
||||||
|
<h2 class="feature-block__title">Privacy by Design</h2>
|
||||||
|
<p class="feature-block__body">
|
||||||
|
We believe in transparency and simplicity. Your <strong>username</strong> is your identity here—
|
||||||
|
we never expose your email publicly. Forgot your password? No problem. We keep no social integrations,
|
||||||
|
no third-party tracking, and absolutely zero AI-generated content. Just a pure, clean game experience.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="feature-block feature-block--reverse feature-block--practice">
|
||||||
|
<div class="feature-block__inner">
|
||||||
|
<div class="feature-block__visual feature-block__visual--practice">
|
||||||
|
<i class="fas fa-laptop"></i>
|
||||||
|
<i class="fab fa-linux"></i>
|
||||||
|
<i class="fab fa-apple"></i>
|
||||||
|
<i class="fab fa-windows"></i>
|
||||||
|
</div>
|
||||||
|
<div class="feature-block__text">
|
||||||
|
<p class="feature-block__label">Train & Compete</p>
|
||||||
|
<h2 class="feature-block__title">Multiplayer Online, Solo Practice</h2>
|
||||||
|
<p class="feature-block__body">
|
||||||
|
Love the challenge of real-time battles? Here, you'll compete live against friends and players worldwide.
|
||||||
|
Want to sharpen your skills solo first? Download our standalone versions for Windows, macOS, and Linux.
|
||||||
|
Practice on your own time, then bring your A-game online.
|
||||||
|
</p>
|
||||||
|
<div class="practice-links">
|
||||||
|
<a
|
||||||
|
href="https://flathub.org/en/apps/org.gnome.Mines"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="practice-link"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="{{ asset('images/another-games/gnome-mines.png') }}"
|
||||||
|
alt="Mines"
|
||||||
|
class="practice-link-icon"
|
||||||
|
/>
|
||||||
|
Linux (Flatpak) • GNOME Mines
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://apps.microsoft.com/detail/9wzdncrfhwcn?hl=en-US&gl=HU"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="practice-link"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="{{ asset('images/another-games/microsoft-minesweeper.png') }}"
|
||||||
|
alt="Microsoft Minesweeper"
|
||||||
|
class="practice-link-icon"
|
||||||
|
/>
|
||||||
|
Windows • Microsoft Minesweeper
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://apps.apple.com/us/app/minesweeper/id1404304731"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="practice-link"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="{{ asset('images/another-games/apple-minesweeper.png') }}"
|
||||||
|
alt="Minesweeper"
|
||||||
|
class="practice-link-icon"
|
||||||
|
/>
|
||||||
|
Apple • Minesweeper
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="tech-section">
|
<div class="tech-section">
|
||||||
<p class="tech-label">Built with</p>
|
<p class="tech-label">Built with</p>
|
||||||
<div class="tech-logos">
|
<div class="tech-logos">
|
||||||
|
|||||||
@@ -11,13 +11,14 @@
|
|||||||
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 %}
|
||||||
|
|
||||||
{% block metas %}
|
{% block metas %}
|
||||||
<meta property="og:url" content="{{ url('MineSeekerBundle_gamePlay') }}"/>
|
<meta property="og:url" content="{{ url('MineSeekerBundle_gamePlay') | replace({'http://': 'https://'}) }}"/>
|
||||||
<meta property="og:type" content="website"/>
|
<meta property="og:type" content="website"/>
|
||||||
<meta property="og:title" content="Your friend challenges YOU!"/>
|
<meta property="og:title" content="Your friend challenges YOU!"/>
|
||||||
<meta property="og:description" content="Do you accept the challenge?"/>
|
<meta property="og:description" content="Do you accept the challenge?"/>
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -2,12 +2,135 @@
|
|||||||
|
|
||||||
{% block title %} - Contact{% endblock %}
|
{% block title %} - Contact{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block metas %}
|
||||||
<div class="txt">
|
{%- set _ogImage = app.request.getSchemeAndHttpHost() ~ asset('images/mine-1600x627.png') -%}
|
||||||
<h2>Contact and user support</h2>
|
<meta property="og:url" content="{{ url('MineSeekerBundle_contact') | replace({'http://': 'https://'}) }}"/>
|
||||||
|
<meta property="og:type" content="website"/>
|
||||||
<h3>Under construction</h3>
|
<meta property="og:site_name" content="MineSeeker"/>
|
||||||
|
<meta property="og:title" content="Contact · MineSeeker"/>
|
||||||
<a href="mailto:langlasz@gmail.com">langlasz@gmail.com</a>
|
<meta property="og:description" content="Get in touch with the MineSeeker team."/>
|
||||||
</div>
|
<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 %}
|
||||||
|
<div class="txt">
|
||||||
|
<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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,51 +2,153 @@
|
|||||||
|
|
||||||
{% block title %} - Privacy Policy{% endblock %}
|
{% block title %} - Privacy Policy{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block metas %}
|
||||||
<div class="txt">
|
{%- set _ogImage = app.request.getSchemeAndHttpHost() ~ asset('images/mine-1600x627.png') -%}
|
||||||
<h2>MineSeeker Privacy Policy</h2>
|
<meta property="og:url" content="{{ url('MineSeekerBundle_privacy') | replace({'http://': 'https://'}) }}"/>
|
||||||
|
<meta property="og:type" content="website"/>
|
||||||
<p>Your privacy is important to us.</p>
|
<meta property="og:site_name" content="MineSeeker"/>
|
||||||
|
<meta property="og:title" content="Privacy Policy · MineSeeker"/>
|
||||||
<p>It is MineSeeker's policy to respect your privacy regarding any information we may collect while operating
|
<meta property="og:description" content="Read how MineSeeker collects, uses and protects your personal data."/>
|
||||||
our
|
<meta property="og:image" content="{{ _ogImage }}"/>
|
||||||
website. Accordingly, we have developed this privacy policy in order for you to understand how we collect,
|
<meta property="og:image:width" content="1600"/>
|
||||||
use,
|
<meta property="og:image:height" content="627"/>
|
||||||
communicate, disclose and otherwise make use of personal information. We have outlined our privacy policy
|
<meta name="twitter:card" content="summary_large_image"/>
|
||||||
below.</p>
|
<meta name="twitter:title" content="Privacy Policy · MineSeeker"/>
|
||||||
|
<meta name="twitter:description" content="Read how MineSeeker collects, uses and protects your personal data."/>
|
||||||
<ul>
|
<meta name="twitter:image" content="{{ _ogImage }}"/>
|
||||||
<li>We will collect personal information by lawful and fair means and, where appropriate, with the knowledge
|
{% endblock %}
|
||||||
or
|
|
||||||
consent of the individual concerned.
|
{% block body %}
|
||||||
</li>
|
<div class="txt">
|
||||||
<li>Before or at the time of collecting personal information, we will identify the purposes for which
|
<h2>MineSeeker Privacy Policy</h2>
|
||||||
information is being collected.
|
|
||||||
</li>
|
<p>Your privacy is important to us. MineSeeker is built on principles of transparency and minimal data collection. We believe you should have full control over your personal information and understand exactly how it's used.</p>
|
||||||
<li>We will collect and use personal information solely for fulfilling those purposes specified by us and
|
|
||||||
for
|
<p>This Privacy Policy explains how we collect, use, protect, and manage your personal information when you use MineSeeker.</p>
|
||||||
other ancillary purposes, unless we obtain the consent of the individual concerned or as required by
|
|
||||||
law.
|
<h3>1. Our Privacy Principles</h3>
|
||||||
</li>
|
|
||||||
<li>Personal data should be relevant to the purposes for which it is to be used, and, to the extent
|
<ul>
|
||||||
necessary
|
<li>We collect personal information by lawful and fair means, with your knowledge and consent.</li>
|
||||||
for those purposes, should be accurate, complete, and up-to-date.
|
<li>We collect and use personal information solely for the purposes specified and no others.</li>
|
||||||
</li>
|
<li>Personal data is kept accurate, complete, and up-to-date.</li>
|
||||||
<li>We will protect personal information by using reasonable security safeguards against loss or theft, as
|
<li>We protect personal information with reasonable security safeguards against loss, theft, unauthorized access, disclosure, and modification.</li>
|
||||||
well
|
<li>We retain personal information only for as long as necessary.</li>
|
||||||
as unauthorized access, disclosure, copying, use or modification.
|
<li>We are transparent about our data practices and policies.</li>
|
||||||
</li>
|
</ul>
|
||||||
<li>We will make readily available to customers information about our policies and practices relating to the
|
|
||||||
management of personal information.
|
<h3>2. What Information We Collect</h3>
|
||||||
</li>
|
|
||||||
<li>We will only retain personal information for as long as necessary for the fulfilment of those
|
<p><strong>Account Information:</strong> When you create a MineSeeker account, we collect:</p>
|
||||||
purposes.
|
<ul>
|
||||||
</li>
|
<li><strong>Username</strong> - Your unique identifier in the game (publicly visible to other players)</li>
|
||||||
</ul>
|
<li><strong>Email address</strong> - Used only for account recovery and password resets (never publicly exposed)</li>
|
||||||
|
<li><strong>Password</strong> - Securely hashed and encrypted (we never store plain-text passwords)</li>
|
||||||
<p>We are committed to conducting our business in accordance with these principles in order to ensure that the
|
<li><strong>Optional profile data</strong> - Avatar image (if you choose to upload one)</li>
|
||||||
confidentiality of personal information is protected and maintained. MineSeeker may change this privacy
|
</ul>
|
||||||
policy
|
|
||||||
from time to time at MineSeeker's sole discretion.</p>
|
<p><strong>Game Data:</strong> To provide multiplayer functionality, we automatically collect:</p>
|
||||||
</div>
|
<ul>
|
||||||
|
<li>Game statistics (win/loss records, game duration, board size preferences)</li>
|
||||||
|
<li>Game history (replay data for sharing past battles)</li>
|
||||||
|
<li>Timestamps of games played</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p><strong>Technical Information:</strong> We collect minimal technical data:</p>
|
||||||
|
<ul>
|
||||||
|
<li>IP address (for multiplayer game sessions only, not stored long-term)</li>
|
||||||
|
<li>Browser type and version (for compatibility purposes)</li>
|
||||||
|
<li>Device information (to optimize game performance)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>3. What We Do NOT Collect</h3>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>❌ No social authentication:</strong> We don't use Facebook, Google, Twitter, or other social platforms. You create your own account.</li>
|
||||||
|
<li><strong>❌ No AI-generated data:</strong> We don't use AI to profile you or generate insights about your behavior.</li>
|
||||||
|
<li><strong>❌ No tracking cookies or analytics:</strong> We don't use third-party analytics tools to track your movement across the web.</li>
|
||||||
|
<li><strong>❌ No advertisement profiling:</strong> We don't sell or share your data with advertisers.</li>
|
||||||
|
<li><strong>❌ No location tracking:</strong> We don't collect or store your location data.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>4. How We Use Your Information</h3>
|
||||||
|
|
||||||
|
<p>Your information is used exclusively for these purposes:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Account management:</strong> Creating and maintaining your account</li>
|
||||||
|
<li><strong>Game functionality:</strong> Matchmaking, real-time multiplayer connections, and game replay storage</li>
|
||||||
|
<li><strong>Security:</strong> Preventing fraud, cheating, and unauthorized access</li>
|
||||||
|
<li><strong>Communication:</strong> Sending password resets or important service updates (very rarely)</li>
|
||||||
|
<li><strong>Improvement:</strong> Analyzing game performance to fix bugs and improve server stability</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>We will <strong>never</strong> use your data for marketing, profiling, or any purpose other than those listed above.</p>
|
||||||
|
|
||||||
|
<h3>5. Data Retention</h3>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>Active accounts:</strong> Your data is retained while your account is active.</li>
|
||||||
|
<li><strong>Account deletion:</strong> If you delete your account, we retain only anonymized game statistics (for leaderboards) and permanently delete all personal information within 30 days.</li>
|
||||||
|
<li><strong>Game data:</strong> Your game history and replays are retained indefinitely unless you explicitly request deletion.</li>
|
||||||
|
<li><strong>Technical logs:</strong> IP addresses and technical logs are automatically deleted after 90 days.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>6. Data Security</h3>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>All passwords are hashed using industry-standard encryption (bcrypt)</li>
|
||||||
|
<li>Communication between your device and our servers uses HTTPS/TLS encryption</li>
|
||||||
|
<li>Sensitive data is stored on secure, isolated servers</li>
|
||||||
|
<li>We regularly audit security and patch vulnerabilities</li>
|
||||||
|
<li>We conduct no data sharing with third parties unless required by law</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>7. Your Rights</h3>
|
||||||
|
|
||||||
|
<p>You have the right to:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Access:</strong> Request a copy of all personal data we hold about you</li>
|
||||||
|
<li><strong>Correction:</strong> Update or correct inaccurate information</li>
|
||||||
|
<li><strong>Deletion:</strong> Request deletion of your account and personal data (with 30-day retention for legal compliance)</li>
|
||||||
|
<li><strong>Data portability:</strong> Export your game data in a machine-readable format</li>
|
||||||
|
<li><strong>Withdraw consent:</strong> Opt out of non-essential data collection at any time</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>To exercise any of these rights, contact us at <a href="mailto:privacy@mineseeker.hu">privacy@mineseeker.hu</a></p>
|
||||||
|
|
||||||
|
<h3>8. Third-Party Services</h3>
|
||||||
|
|
||||||
|
<p>We use the following third-party services (strictly necessary for game operation):</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Mercure (WebSocket server):</strong> Real-time multiplayer connections</li>
|
||||||
|
<li><strong>Symfony framework:</strong> Web application backend</li>
|
||||||
|
<li><strong>Cloud storage:</strong> Game replays and user data (hosted in EU data centers)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>These services are contractually bound to respect your privacy and use data only for the purposes we specify.</p>
|
||||||
|
|
||||||
|
<h3>9. Legal Basis for Processing</h3>
|
||||||
|
|
||||||
|
<p>We process your personal data based on:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Contractual necessity:</strong> To provide the MineSeeker service you've agreed to use</li>
|
||||||
|
<li><strong>Legitimate interest:</strong> To maintain security and prevent fraud</li>
|
||||||
|
<li><strong>Compliance with law:</strong> When required by Hungarian or EU law</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>10. Contact & Disputes</h3>
|
||||||
|
|
||||||
|
<p>If you have questions about this privacy policy or wish to exercise your rights:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Email:</strong> <a href="mailto:privacy@mineseeker.hu">privacy@mineseeker.hu</a></li>
|
||||||
|
<li><strong>Mailing address:</strong> MineSeeker, Budapest, Hungary</li>
|
||||||
|
<li><strong>EU DPA disputes:</strong> If you're in the EU and believe we've violated GDPR, you may lodge a complaint with your local data protection authority</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>11. Policy Changes</h3>
|
||||||
|
|
||||||
|
<p>MineSeeker may update this privacy policy to reflect changes in our practices or applicable law. We will notify you of significant changes via email or by posting a notice on our website. Your continued use of MineSeeker after changes constitute acceptance of the updated policy.</p>
|
||||||
|
|
||||||
|
<p><strong>Last updated:</strong> {{ "now"|date("F j, Y") }}</p>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,99 +2,161 @@
|
|||||||
|
|
||||||
{% block title %} - Terms of Service{% endblock %}
|
{% block title %} - Terms of Service{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block metas %}
|
||||||
<div class="txt">
|
{%- set _ogImage = app.request.getSchemeAndHttpHost() ~ asset('images/mine-1600x627.png') -%}
|
||||||
<h2>MineSeeker Terms of Service</h2>
|
<meta property="og:url" content="{{ url('MineSeekerBundle_terms') | replace({'http://': 'https://'}) }}"/>
|
||||||
|
<meta property="og:type" content="website"/>
|
||||||
<h3>1. Terms</h3>
|
<meta property="og:site_name" content="MineSeeker"/>
|
||||||
|
<meta property="og:title" content="Terms of Use · MineSeeker"/>
|
||||||
<p>By accessing the website at <a href="https://www.mineseeker.ninja">https://www.mineseeker.ninja</a>, you are
|
<meta property="og:description" content="Read the MineSeeker terms of use before playing."/>
|
||||||
agreeing to be bound by these terms of service, all applicable laws and regulations, and agree that you are
|
<meta property="og:image" content="{{ _ogImage }}"/>
|
||||||
responsible for compliance with any applicable local laws. If you do not agree with any of these terms, you
|
<meta property="og:image:width" content="1600"/>
|
||||||
are
|
<meta property="og:image:height" content="627"/>
|
||||||
prohibited from using or accessing this site. The materials contained in this website are protected by
|
<meta name="twitter:card" content="summary_large_image"/>
|
||||||
applicable copyright and trademark law.</p>
|
<meta name="twitter:title" content="Terms of Use · MineSeeker"/>
|
||||||
|
<meta name="twitter:description" content="Read the MineSeeker terms of use before playing."/>
|
||||||
<h3>2. Use License</h3>
|
<meta name="twitter:image" content="{{ _ogImage }}"/>
|
||||||
|
{% endblock %}
|
||||||
<ol type="a">
|
|
||||||
<li>
|
{% block body %}
|
||||||
Permission is granted to temporarily download one copy of the materials (information or software) on
|
<div class="txt">
|
||||||
MineSeeker's website for personal, non-commercial transitory viewing only. This is the grant of a
|
<h2>MineSeeker Terms of Service</h2>
|
||||||
license,
|
|
||||||
not a transfer of title, and under this license you may not:
|
<h3>1. Agreement to Terms</h3>
|
||||||
|
|
||||||
<ol type="i">
|
<p>By accessing and using MineSeeker (https://www.mineseeker.hu), you agree 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 are prohibited from using this service. The materials and content contained in MineSeeker are protected by applicable copyright and trademark law.</p>
|
||||||
<li>modify or copy the materials;</li>
|
|
||||||
<li>use the materials for any commercial purpose, or for any public display (commercial or
|
<h3>2. Use License</h3>
|
||||||
non-commercial);
|
|
||||||
</li>
|
<ol type="a">
|
||||||
<li>attempt to decompile or reverse engineer any software contained on MineSeeker's website;</li>
|
<li>
|
||||||
<li>remove any copyright or other proprietary notations from the materials; or</li>
|
<p>MineSeeker grants you a limited, non-exclusive, revocable license to access and play MineSeeker for personal, non-commercial purposes only. Under this license, you may not:</p>
|
||||||
<li>transfer the materials to another person or "mirror" the materials on any other server.</li>
|
<ol type="i">
|
||||||
</ol>
|
<li>modify, copy, or reproduce the game code or assets</li>
|
||||||
</li>
|
<li>use MineSeeker for any commercial purpose or for any public display</li>
|
||||||
<li>This license shall automatically terminate if you violate any of these restrictions and may be
|
<li>attempt to decompile, reverse engineer, or hack the game software</li>
|
||||||
terminated by
|
<li>remove any copyright, trademark, or proprietary notices</li>
|
||||||
MineSeeker at any time. Upon terminating your viewing of these materials or upon the termination of this
|
<li>use automated tools, bots, or scripts to play the game (cheating)</li>
|
||||||
license, you must destroy any downloaded materials in your possession whether in electronic or printed
|
<li>use the game to harass, abuse, or harm other players</li>
|
||||||
format.
|
<li>attempt to exploit game vulnerabilities for unfair advantage</li>
|
||||||
</li>
|
<li>mirror, redistribute, or host MineSeeker on any other server</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
</li>
|
||||||
<h3>3. Disclaimer</h3>
|
<li>
|
||||||
|
<p>This license automatically terminates if you violate any of these restrictions. MineSeeker reserves the right to terminate your account and access at any time for violating these terms or for any other reason. Upon termination, you must cease all use of the service.</p>
|
||||||
<ol type="a">
|
</li>
|
||||||
<li>The materials on MineSeeker's website are provided on an 'as is' basis. MineSeeker makes no warranties,
|
</ol>
|
||||||
expressed or implied, and hereby disclaims and negates all other warranties including, without
|
|
||||||
limitation,
|
<h3>3. Account Responsibilities</h3>
|
||||||
implied warranties or conditions of merchantability, fitness for a particular purpose, or
|
|
||||||
non-infringement
|
<ul>
|
||||||
of intellectual property or other violation of rights.
|
<li><strong>Account creation:</strong> You are responsible for providing accurate information when creating an account and for maintaining the confidentiality of your password.</li>
|
||||||
</li>
|
<li><strong>Account security:</strong> You are responsible for all activities that occur under your account. If you suspect unauthorized access, contact us immediately.</li>
|
||||||
<li>Further, MineSeeker does not warrant or make any representations concerning the accuracy, likely
|
<li><strong>Username conduct:</strong> Your username must not be offensive, defamatory, or violate anyone's rights. We reserve the right to force username changes.</li>
|
||||||
results, or
|
<li><strong>Account termination:</strong> You may delete your account at any time. Upon deletion, your personal information will be deleted within 30 days, but game statistics may be retained anonymously.</li>
|
||||||
reliability of the use of the materials on its website or otherwise relating to such materials or on any
|
</ul>
|
||||||
sites linked to this site.
|
|
||||||
</li>
|
<h3>4. User Conduct & Prohibited Behavior</h3>
|
||||||
</ol>
|
|
||||||
|
<p>You agree NOT to:</p>
|
||||||
<h3>4. Limitations</h3>
|
<ul>
|
||||||
|
<li>Use cheats, hacks, exploits, or automation tools</li>
|
||||||
<p>In no event shall MineSeeker or its suppliers be liable for any damages (including, without limitation,
|
<li>Engage in harassment, abuse, threats, or hate speech toward other players</li>
|
||||||
damages
|
<li>Share or distribute malware, viruses, or malicious code</li>
|
||||||
for loss of data or profit, or due to business interruption) arising out of the use or inability to use the
|
<li>Attempt to gain unauthorized access to MineSeeker systems</li>
|
||||||
materials on MineSeeker's website, even if MineSeeker or a MineSeeker authorized representative has been
|
<li>Use the game to spam, phish, or scam other users</li>
|
||||||
notified orally or in writing of the possibility of such damage. Because some jurisdictions do not allow
|
<li>Share another person's personal information without consent</li>
|
||||||
limitations on implied warranties, or limitations of liability for consequential or incidental damages,
|
<li>Boost accounts, trade accounts, or share login credentials</li>
|
||||||
these
|
</ul>
|
||||||
limitations may not apply to you.</p>
|
|
||||||
|
<p>Violations of these rules may result in account suspension or permanent ban at our sole discretion.</p>
|
||||||
<h3>5. Accuracy of materials</h3>
|
|
||||||
|
<h3>5. Intellectual Property Rights</h3>
|
||||||
<p>The materials appearing on MineSeeker's website could include technical, typographical, or photographic
|
|
||||||
errors.
|
<ul>
|
||||||
MineSeeker does not warrant that any of the materials on its website are accurate, complete or current.
|
<li>All game content, including code, graphics, sound, and design, is the exclusive property of MineSeeker and protected by copyright.</li>
|
||||||
MineSeeker may make changes to the materials contained on its website at any time without notice. However
|
<li>Your gameplay and game statistics belong to you, but we retain the right to use anonymized gameplay data for improving the service.</li>
|
||||||
MineSeeker does not make any commitment to update the materials.</p>
|
<li>Game replays you create belong to you and can be shared with other players or deleted at your discretion.</li>
|
||||||
|
<li>You grant MineSeeker a license to display your username and game achievements on public leaderboards.</li>
|
||||||
<h3>6. Links</h3>
|
</ul>
|
||||||
|
|
||||||
<p>MineSeeker has not reviewed all of the sites linked to its website and is not responsible for the contents of
|
<h3>6. Disclaimer of Warranties</h3>
|
||||||
any
|
|
||||||
such linked site. The inclusion of any link does not imply endorsement by MineSeeker of the site. Use of any
|
<ol type="a">
|
||||||
such linked website is at the user's own risk.</p>
|
<li>
|
||||||
|
<p>MineSeeker is provided on an "AS IS" and "AS AVAILABLE" basis. MineSeeker makes no warranties, express or implied, and hereby disclaims all other warranties including:</p>
|
||||||
<h3>7. Modifications</h3>
|
<ul>
|
||||||
|
<li>Implied warranties of merchantability or fitness for a particular purpose</li>
|
||||||
<p>MineSeeker may revise these terms of service for its website at any time without notice. By using this
|
<li>Warranties of accuracy, reliability, or uninterrupted service</li>
|
||||||
website
|
<li>Warranties of non-infringement of intellectual property rights</li>
|
||||||
you are agreeing to be bound by the then current version of these terms of service.</p>
|
</ul>
|
||||||
|
</li>
|
||||||
<h3>8. Governing Law</h3>
|
<li>MineSeeker does not warrant that the service will be error-free, secure, or meet your specific requirements.</li>
|
||||||
|
<li>MineSeeker is not responsible for delays, delivery failures, or any other issues caused by factors outside our control (internet outages, player hardware, etc.).</li>
|
||||||
<p>These terms and conditions are governed by and construed in accordance with the laws of Budapest, Hungary and
|
</ol>
|
||||||
you
|
|
||||||
irrevocably submit to the exclusive jurisdiction of the courts in that State or location.</p>
|
<h3>7. Limitation of Liability</h3>
|
||||||
</div>
|
|
||||||
|
<p>In no event shall MineSeeker or its creators be liable for any damages arising out of the use or inability to use MineSeeker, including:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Loss of data, game progress, or winnings</li>
|
||||||
|
<li>Loss of profit or business interruption</li>
|
||||||
|
<li>Personal injury or property damage</li>
|
||||||
|
<li>Any indirect, incidental, special, or consequential damages</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>Some jurisdictions do not allow limitation of liability, so these limitations may not apply to you.</p>
|
||||||
|
|
||||||
|
<h3>8. Content Accuracy & Changes</h3>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>MineSeeker may contain technical, typographical, or photographic errors. We don't warrant accuracy or completeness.</li>
|
||||||
|
<li>MineSeeker reserves the right to modify, update, or discontinue features without notice (we will attempt to notify users of major changes).</li>
|
||||||
|
<li>We may take servers offline for maintenance with minimal notice.</li>
|
||||||
|
<li>Game rules and mechanics may be adjusted for balance or fairness.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>9. External Links</h3>
|
||||||
|
|
||||||
|
<p>MineSeeker may contain links to external websites. We are not responsible for the content, accuracy, or practices of linked sites. Use of linked websites is at your own risk and subject to their terms of service.</p>
|
||||||
|
|
||||||
|
<h3>10. Termination of Service</h3>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>MineSeeker may suspend or terminate your account if you violate these terms.</li>
|
||||||
|
<li>MineSeeker may discontinue the service at any time with reasonable notice (we will provide at least 30 days notice before full shutdown).</li>
|
||||||
|
<li>Upon termination, your right to use MineSeeker immediately ceases.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>11. Modifications to Terms</h3>
|
||||||
|
|
||||||
|
<p>MineSeeker may revise these terms at any time without notice. Your continued use of the service after changes constitutes acceptance of the updated terms. We recommend reviewing these terms periodically.</p>
|
||||||
|
|
||||||
|
<h3>12. Governing Law & Jurisdiction</h3>
|
||||||
|
|
||||||
|
<p>These terms are governed by and construed in accordance with the laws of Budapest, Hungary, without regard to its conflicts of law principles. You irrevocably submit to the exclusive jurisdiction of the courts located in Budapest, Hungary.</p>
|
||||||
|
|
||||||
|
<h3>13. Dispute Resolution</h3>
|
||||||
|
|
||||||
|
<p>Before initiating legal action, you agree to attempt to resolve disputes through good-faith negotiation. If a dispute cannot be resolved informally, you may pursue legal remedies in the courts of Budapest, Hungary.</p>
|
||||||
|
|
||||||
|
<h3>14. Severability</h3>
|
||||||
|
|
||||||
|
<p>If any provision of these terms is found to be invalid or unenforceable, that provision shall be severed, and the remaining provisions shall remain in full force and effect.</p>
|
||||||
|
|
||||||
|
<h3>15. Entire Agreement</h3>
|
||||||
|
|
||||||
|
<p>These Terms of Service, together with our Privacy Policy, constitute the entire agreement between you and MineSeeker regarding your use of the service and supersede all prior agreements.</p>
|
||||||
|
|
||||||
|
<h3>16. Contact Information</h3>
|
||||||
|
|
||||||
|
<p>If you have questions or concerns about these terms:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Email:</strong> <a href="mailto:contact@mineseeker.hu">contact@mineseeker.hu</a></li>
|
||||||
|
<li><strong>Website:</strong> <a href="https://www.mineseeker.hu">https://www.mineseeker.hu</a></li>
|
||||||
|
<li><strong>Location:</strong> Budapest, Hungary</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p><strong>Last updated:</strong> {{ "now"|date("F j, Y") }}</p>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -51,4 +62,4 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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') %}
|
||||||
@@ -26,28 +39,28 @@
|
|||||||
|
|
||||||
{{ form_start(form, {attr: {class: 'auth-form'}}) }}
|
{{ form_start(form, {attr: {class: 'auth-form'}}) }}
|
||||||
|
|
||||||
<div class="auth-field">
|
<div class="auth-field">
|
||||||
<label for="{{ form.email.vars.id }}" class="auth-label">Email</label>
|
<label for="{{ form.email.vars.id }}" class="auth-label">Email</label>
|
||||||
<div class="auth-input-wrap">
|
<div class="auth-input-wrap">
|
||||||
<i class="fas fa-envelope auth-input-icon"></i>
|
<i class="fas fa-envelope auth-input-icon"></i>
|
||||||
{{ form_widget(form.email, {
|
{{ form_widget(form.email, {
|
||||||
attr: {
|
attr: {
|
||||||
class: 'auth-input' ~ (not form.email.vars.valid ? ' auth-input--error' : ''),
|
class: 'auth-input' ~ (not form.email.vars.valid ? ' auth-input--error' : ''),
|
||||||
autocomplete: 'email',
|
autocomplete: 'email',
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
}
|
}
|
||||||
}) }}
|
}) }}
|
||||||
</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>
|
||||||
|
{% 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>
|
||||||
|
|
||||||
<button type="submit" class="auth-submit">
|
<button type="submit" class="auth-submit">
|
||||||
<i class="fas fa-paper-plane"></i> Send Reset Link
|
<i class="fas fa-paper-plane"></i> Send Reset Link
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{{ form_end(form) }}
|
{{ form_end(form) }}
|
||||||
|
|
||||||
|
|||||||
@@ -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') %}
|
||||||
|
|||||||
@@ -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') | replace({'http://': 'https://'}) }}"/>
|
||||||
|
<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">
|
||||||
|
|||||||
@@ -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') | replace({'http://': 'https://'}) }}"/>
|
||||||
|
<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">
|
||||||
|
|||||||
@@ -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') %}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="wrapper">
|
<div class="wrapper">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<img src="{{ absolute_url(asset('images/mine-logo-txt.png')) }}" alt="MineSeeker"/>
|
<img src="{{ absolute_url(asset('images/mine-logo-txt.png')) | replace({'http://': 'https://'}) }}" alt="MineSeeker"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>One step to go</h1>
|
<h1>One step to go</h1>
|
||||||
@@ -100,4 +100,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
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>
|
<body>
|
||||||
<div class="wrapper">
|
<div class="wrapper">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<img src="{{ absolute_url(asset('images/mine-logo-txt.png')) }}" alt="MineSeeker"/>
|
<img src="{{ absolute_url(asset('images/mine-logo-txt.png')) | replace({'http://': 'https://'}) }}" alt="MineSeeker"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>Reset your password</h1>
|
<h1>Reset your password</h1>
|
||||||
|
|||||||
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',
|
mineseeker: './assets/js/app.jsx',
|
||||||
passkey: './assets/js/passkey.jsx',
|
passkey: './assets/js/passkey.jsx',
|
||||||
profile: './assets/js/profile.jsx',
|
profile: './assets/js/profile.jsx',
|
||||||
|
contact: './assets/js/contact.jsx',
|
||||||
mineseekerStyle: './assets/css/style.mineseeker.scss',
|
mineseekerStyle: './assets/css/style.mineseeker.scss',
|
||||||
homeStyle: './assets/css/style.layout.scss',
|
homeStyle: './assets/css/style.layout.scss',
|
||||||
passkeyStyle: './assets/css/passkey.scss',
|
passkeyStyle: './assets/css/passkey.scss',
|
||||||
|
|||||||
Reference in New Issue
Block a user