Compare commits
21 Commits
v2026.2.0-
...
v2026.2.1-
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 ###
|
||||||
|
|||||||
21
CHANGELOG.md
21
CHANGELOG.md
@@ -2,11 +2,13 @@ Changelog
|
|||||||
=========
|
=========
|
||||||
|
|
||||||
|
|
||||||
v2026.01 (2026-04-14)
|
v2026.2.1 (2026-04-15)
|
||||||
---------------------
|
----------------------
|
||||||
|
|
||||||
New
|
New
|
||||||
~~~
|
~~~
|
||||||
|
- Add Contact page with email sending behaviour #4. [Lang]
|
||||||
|
- Add timer for the acceptance of the challenge #4. [Lang]
|
||||||
- Registered users have avatars next to the timer #4. [Lang]
|
- Registered users have avatars next to the timer #4. [Lang]
|
||||||
- Add opportunity to use profile picture. #4. [Lang]
|
- Add opportunity to use profile picture. #4. [Lang]
|
||||||
- Add more stats and a dialog for the recent battle that can be
|
- Add more stats and a dialog for the recent battle that can be
|
||||||
@@ -18,6 +20,16 @@ New
|
|||||||
|
|
||||||
Changes
|
Changes
|
||||||
~~~~~~~
|
~~~~~~~
|
||||||
|
- Change the shareable battle - add avatars to it - even on the og tags
|
||||||
|
#4. [Lang]
|
||||||
|
- Change text #4. [Lang]
|
||||||
|
- Add donation button #4. [Lang]
|
||||||
|
- Protect the gameplay with recaptcha #4. [Lang]
|
||||||
|
- The waiting dialog is uncloseable until the time is up #4. [Lang]
|
||||||
|
- Add share button to the overlay when the game ends #4. [Lang]
|
||||||
|
- Make fancy og tags - and create a special one for battle sharing #4.
|
||||||
|
[Lang]
|
||||||
|
- The user's avatar will be saved as a uuid.extension #4. [Lang]
|
||||||
- Fix missing favicon #4. [Lang]
|
- Fix missing favicon #4. [Lang]
|
||||||
- Add modern Webauthn authentication #4. [Lang]
|
- Add modern Webauthn authentication #4. [Lang]
|
||||||
- Refactor all forms to have Symfony Form Types & Validation
|
- Refactor all forms to have Symfony Form Types & Validation
|
||||||
@@ -57,6 +69,11 @@ Changes
|
|||||||
- Doc in README.md #3. [Lang]
|
- Doc in README.md #3. [Lang]
|
||||||
- Gitignore a js.map file #2. [Lang]
|
- Gitignore a js.map file #2. [Lang]
|
||||||
|
|
||||||
|
Fix
|
||||||
|
~~~
|
||||||
|
- Missing font-awesome icons on bare-metal environment #4. [Lang]
|
||||||
|
- Quickfix for email sending #4. [Lang]
|
||||||
|
|
||||||
Other
|
Other
|
||||||
~~~~~
|
~~~~~
|
||||||
- Hg: pkg: new version release !skipChangelog. [Lang]
|
- Hg: pkg: new version release !skipChangelog. [Lang]
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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/"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ class SecurityController extends AbstractController
|
|||||||
|
|
||||||
$mailer->send(
|
$mailer->send(
|
||||||
new TemplatedEmail()
|
new TemplatedEmail()
|
||||||
->from('noreply@mineseeker.ninja')
|
->from('noreply@mineseeker.hu')
|
||||||
->to($user->getEmail())
|
->to($user->getEmail())
|
||||||
->subject('Activate your MineSeeker account')
|
->subject('Activate your MineSeeker account')
|
||||||
->htmlTemplate('emails/activation.html.twig')
|
->htmlTemplate('emails/activation.html.twig')
|
||||||
@@ -145,7 +145,7 @@ class SecurityController extends AbstractController
|
|||||||
|
|
||||||
$mailer->send(
|
$mailer->send(
|
||||||
new TemplatedEmail()
|
new TemplatedEmail()
|
||||||
->from('noreply@mineseeker.ninja')
|
->from('noreply@mineseeker.hu')
|
||||||
->to($email)
|
->to($email)
|
||||||
->subject('Reset your MineSeeker password')
|
->subject('Reset your MineSeeker password')
|
||||||
->htmlTemplate('emails/reset_password.html.twig')
|
->htmlTemplate('emails/reset_password.html.twig')
|
||||||
|
|||||||
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 }) -%}
|
||||||
<meta property="og:url" content="{{ shareUrl }}"/>
|
{%- set _ogImage = url('MineSeekerBundle_og_battle', { uuid: game.uuid }) -%}
|
||||||
<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 %}
|
||||||
|
{%- set _ogImage = app.request.getSchemeAndHttpHost() ~ asset('images/mine-1600x627.png') -%}
|
||||||
<meta property="og:url" content="{{ url('MineSeekerBundle_homepage') }}"/>
|
<meta property="og:url" content="{{ url('MineSeekerBundle_homepage') }}"/>
|
||||||
<meta property="og:type" content="website"/>
|
<meta property="og:type" content="website"/>
|
||||||
<meta property="og:title" content="MineSeeker"/>
|
<meta property="og:site_name" content="MineSeeker"/>
|
||||||
<meta property="og:description" content="A multiplayer minesweeper game"/>
|
<meta property="og:locale" content="en_US"/>
|
||||||
<meta property="og:image"
|
<meta property="og:title" content="MineSeeker — Multiplayer Minesweeper"/>
|
||||||
content="{{ app.request.getSchemeAndHttpHost() }}{{ asset('images/mine-1600x627.png') }}"/>
|
<meta property="og:description"
|
||||||
|
content="Race a friend on a hidden minefield. Real-time 1v1 minesweeper in your browser — no account needed. Just play."/>
|
||||||
|
<meta property="og:image" content="{{ _ogImage }}"/>
|
||||||
|
<meta property="og:image:width" content="1600"/>
|
||||||
|
<meta property="og:image:height" content="627"/>
|
||||||
|
<meta property="og:image:alt" content="MineSeeker — Multiplayer Minesweeper"/>
|
||||||
|
<meta name="twitter:card" content="summary_large_image"/>
|
||||||
|
<meta name="twitter:title" content="MineSeeker — Multiplayer Minesweeper"/>
|
||||||
|
<meta name="twitter:description"
|
||||||
|
content="Race a friend on a hidden minefield. Real-time 1v1 minesweeper in your browser — no account needed. Just play."/>
|
||||||
|
<meta name="twitter:image" content="{{ _ogImage }}"/>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block header %}
|
{% block header %}
|
||||||
@@ -61,6 +72,10 @@
|
|||||||
<h1>No account needed.<br>Just play.</h1>
|
<h1>No account needed.<br>Just play.</h1>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{{ path('MineSeekerBundle_gamePlay') }}" class="hero-cta">Play Now</a>
|
<a href="{{ path('MineSeekerBundle_gamePlay') }}" class="hero-cta">Play Now</a>
|
||||||
|
<p class="hero-donate-text">Love this game?</p>
|
||||||
|
<a href="https://ko-fi.com/splendidbear" target="_blank" rel="noopener" class="hero-donate">
|
||||||
|
Buy me a coffee
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
data-env="{{ env }}"
|
data-env="{{ env }}"
|
||||||
data-game-id="{{ app.request.get('gameAssoc') }}"
|
data-game-id="{{ app.request.get('gameAssoc') }}"
|
||||||
data-mercure-hub-url="{{ mercure_hub_url }}"
|
data-mercure-hub-url="{{ mercure_hub_url }}"
|
||||||
data-mercure-subscriber-jwt="{{ mercure_subscriber_jwt }}">
|
data-mercure-subscriber-jwt="{{ mercure_subscriber_jwt }}"
|
||||||
|
data-recaptcha-site-key="{{ recaptcha_site_key }}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -27,7 +28,6 @@
|
|||||||
|
|
||||||
{% block stylesheets %}
|
{% block stylesheets %}
|
||||||
{{ vite_entry_link_tags('mineseekerStyle') }}
|
{{ vite_entry_link_tags('mineseekerStyle') }}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.mine-container {
|
.mine-container {
|
||||||
background: url('/images/bg-mineseeker-{{ random(1) }}-outbg.jpg') no-repeat;
|
background: url('/images/bg-mineseeker-{{ random(1) }}-outbg.jpg') no-repeat;
|
||||||
@@ -37,5 +37,6 @@
|
|||||||
|
|
||||||
{% block javascripts %}
|
{% block javascripts %}
|
||||||
{{ parent() }}
|
{{ parent() }}
|
||||||
|
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
|
||||||
{{ vite_entry_script_tags('mineseeker', { dependency: 'react' }) }}
|
{{ vite_entry_script_tags('mineseeker', { dependency: 'react' }) }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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') }}"/>
|
||||||
|
<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,67 @@
|
|||||||
|
|
||||||
{% 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') }}"/>
|
||||||
|
<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.</p>
|
||||||
<li>We will collect and use personal information solely for fulfilling those purposes specified by us and
|
|
||||||
for
|
<p>It is MineSeeker's policy to respect your privacy regarding any information we may collect while operating
|
||||||
other ancillary purposes, unless we obtain the consent of the individual concerned or as required by
|
our
|
||||||
law.
|
website. Accordingly, we have developed this privacy policy in order for you to understand how we collect,
|
||||||
</li>
|
use,
|
||||||
<li>Personal data should be relevant to the purposes for which it is to be used, and, to the extent
|
communicate, disclose and otherwise make use of personal information. We have outlined our privacy policy
|
||||||
necessary
|
below.</p>
|
||||||
for those purposes, should be accurate, complete, and up-to-date.
|
|
||||||
</li>
|
<ul>
|
||||||
<li>We will protect personal information by using reasonable security safeguards against loss or theft, as
|
<li>We will collect personal information by lawful and fair means and, where appropriate, with the knowledge
|
||||||
well
|
or
|
||||||
as unauthorized access, disclosure, copying, use or modification.
|
consent of the individual concerned.
|
||||||
</li>
|
</li>
|
||||||
<li>We will make readily available to customers information about our policies and practices relating to the
|
<li>Before or at the time of collecting personal information, we will identify the purposes for which
|
||||||
management of personal information.
|
information is being collected.
|
||||||
</li>
|
</li>
|
||||||
<li>We will only retain personal information for as long as necessary for the fulfilment of those
|
<li>We will collect and use personal information solely for fulfilling those purposes specified by us and
|
||||||
purposes.
|
for
|
||||||
</li>
|
other ancillary purposes, unless we obtain the consent of the individual concerned or as required by
|
||||||
</ul>
|
law.
|
||||||
|
</li>
|
||||||
<p>We are committed to conducting our business in accordance with these principles in order to ensure that the
|
<li>Personal data should be relevant to the purposes for which it is to be used, and, to the extent
|
||||||
confidentiality of personal information is protected and maintained. MineSeeker may change this privacy
|
necessary
|
||||||
policy
|
for those purposes, should be accurate, complete, and up-to-date.
|
||||||
from time to time at MineSeeker's sole discretion.</p>
|
</li>
|
||||||
</div>
|
<li>We will protect personal information by using reasonable security safeguards against loss or theft, as
|
||||||
|
well
|
||||||
|
as unauthorized access, disclosure, copying, use or modification.
|
||||||
|
</li>
|
||||||
|
<li>We will make readily available to customers information about our policies and practices relating to the
|
||||||
|
management of personal information.
|
||||||
|
</li>
|
||||||
|
<li>We will only retain personal information for as long as necessary for the fulfilment of those
|
||||||
|
purposes.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>We are committed to conducting our business in accordance with these principles in order to ensure that the
|
||||||
|
confidentiality of personal information is protected and maintained. MineSeeker may change this privacy
|
||||||
|
policy
|
||||||
|
from time to time at MineSeeker's sole discretion.</p>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,99 +2,115 @@
|
|||||||
|
|
||||||
{% 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') }}"/>
|
||||||
|
<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. Terms</h3>
|
||||||
|
|
||||||
<ol type="i">
|
<p>By accessing the website at <a href="https://www.mineseeker.hu">https://www.mineseeker.hu</a>, you are
|
||||||
<li>modify or copy the materials;</li>
|
agreeing to be bound by these terms of service, all applicable laws and regulations, and agree that you are
|
||||||
<li>use the materials for any commercial purpose, or for any public display (commercial or
|
responsible for compliance with any applicable local laws. If you do not agree with any of these terms, you
|
||||||
non-commercial);
|
are
|
||||||
</li>
|
prohibited from using or accessing this site. The materials contained in this website are protected by
|
||||||
<li>attempt to decompile or reverse engineer any software contained on MineSeeker's website;</li>
|
applicable copyright and trademark law.</p>
|
||||||
<li>remove any copyright or other proprietary notations from the materials; or</li>
|
|
||||||
<li>transfer the materials to another person or "mirror" the materials on any other server.</li>
|
<h3>2. Use License</h3>
|
||||||
</ol>
|
|
||||||
</li>
|
<ol type="a">
|
||||||
<li>This license shall automatically terminate if you violate any of these restrictions and may be
|
<li>
|
||||||
terminated by
|
Permission is granted to temporarily download one copy of the materials (information or software) on
|
||||||
MineSeeker at any time. Upon terminating your viewing of these materials or upon the termination of this
|
MineSeeker's website for personal, non-commercial transitory viewing only. This is the grant of a
|
||||||
license, you must destroy any downloaded materials in your possession whether in electronic or printed
|
license,
|
||||||
format.
|
not a transfer of title, and under this license you may not:
|
||||||
</li>
|
|
||||||
</ol>
|
<ol type="i">
|
||||||
|
<li>modify or copy the materials;</li>
|
||||||
<h3>3. Disclaimer</h3>
|
<li>use the materials for any commercial purpose, or for any public display (commercial or
|
||||||
|
non-commercial);
|
||||||
<ol type="a">
|
</li>
|
||||||
<li>The materials on MineSeeker's website are provided on an 'as is' basis. MineSeeker makes no warranties,
|
<li>attempt to decompile or reverse engineer any software contained on MineSeeker's website;</li>
|
||||||
expressed or implied, and hereby disclaims and negates all other warranties including, without
|
<li>remove any copyright or other proprietary notations from the materials; or</li>
|
||||||
limitation,
|
<li>transfer the materials to another person or "mirror" the materials on any other server.</li>
|
||||||
implied warranties or conditions of merchantability, fitness for a particular purpose, or
|
</ol>
|
||||||
non-infringement
|
</li>
|
||||||
of intellectual property or other violation of rights.
|
<li>This license shall automatically terminate if you violate any of these restrictions and may be
|
||||||
</li>
|
terminated by
|
||||||
<li>Further, MineSeeker does not warrant or make any representations concerning the accuracy, likely
|
MineSeeker at any time. Upon terminating your viewing of these materials or upon the termination of this
|
||||||
results, or
|
license, you must destroy any downloaded materials in your possession whether in electronic or printed
|
||||||
reliability of the use of the materials on its website or otherwise relating to such materials or on any
|
format.
|
||||||
sites linked to this site.
|
</li>
|
||||||
</li>
|
</ol>
|
||||||
</ol>
|
|
||||||
|
<h3>3. Disclaimer</h3>
|
||||||
<h3>4. Limitations</h3>
|
|
||||||
|
<ol type="a">
|
||||||
<p>In no event shall MineSeeker or its suppliers be liable for any damages (including, without limitation,
|
<li>The materials on MineSeeker's website are provided on an 'as is' basis. MineSeeker makes no warranties,
|
||||||
damages
|
expressed or implied, and hereby disclaims and negates all other warranties including, without
|
||||||
for loss of data or profit, or due to business interruption) arising out of the use or inability to use the
|
limitation,
|
||||||
materials on MineSeeker's website, even if MineSeeker or a MineSeeker authorized representative has been
|
implied warranties or conditions of merchantability, fitness for a particular purpose, or
|
||||||
notified orally or in writing of the possibility of such damage. Because some jurisdictions do not allow
|
non-infringement
|
||||||
limitations on implied warranties, or limitations of liability for consequential or incidental damages,
|
of intellectual property or other violation of rights.
|
||||||
these
|
</li>
|
||||||
limitations may not apply to you.</p>
|
<li>Further, MineSeeker does not warrant or make any representations concerning the accuracy, likely
|
||||||
|
results, or
|
||||||
<h3>5. Accuracy of materials</h3>
|
reliability of the use of the materials on its website or otherwise relating to such materials or on any
|
||||||
|
sites linked to this site.
|
||||||
<p>The materials appearing on MineSeeker's website could include technical, typographical, or photographic
|
</li>
|
||||||
errors.
|
</ol>
|
||||||
MineSeeker does not warrant that any of the materials on its website are accurate, complete or current.
|
|
||||||
MineSeeker may make changes to the materials contained on its website at any time without notice. However
|
<h3>4. Limitations</h3>
|
||||||
MineSeeker does not make any commitment to update the materials.</p>
|
|
||||||
|
<p>In no event shall MineSeeker or its suppliers be liable for any damages (including, without limitation,
|
||||||
<h3>6. Links</h3>
|
damages
|
||||||
|
for loss of data or profit, or due to business interruption) arising out of the use or inability to use the
|
||||||
<p>MineSeeker has not reviewed all of the sites linked to its website and is not responsible for the contents of
|
materials on MineSeeker's website, even if MineSeeker or a MineSeeker authorized representative has been
|
||||||
any
|
notified orally or in writing of the possibility of such damage. Because some jurisdictions do not allow
|
||||||
such linked site. The inclusion of any link does not imply endorsement by MineSeeker of the site. Use of any
|
limitations on implied warranties, or limitations of liability for consequential or incidental damages,
|
||||||
such linked website is at the user's own risk.</p>
|
these
|
||||||
|
limitations may not apply to you.</p>
|
||||||
<h3>7. Modifications</h3>
|
|
||||||
|
<h3>5. Accuracy of materials</h3>
|
||||||
<p>MineSeeker may revise these terms of service for its website at any time without notice. By using this
|
|
||||||
website
|
<p>The materials appearing on MineSeeker's website could include technical, typographical, or photographic
|
||||||
you are agreeing to be bound by the then current version of these terms of service.</p>
|
errors.
|
||||||
|
MineSeeker does not warrant that any of the materials on its website are accurate, complete or current.
|
||||||
<h3>8. Governing Law</h3>
|
MineSeeker may make changes to the materials contained on its website at any time without notice. However
|
||||||
|
MineSeeker does not make any commitment to update the materials.</p>
|
||||||
<p>These terms and conditions are governed by and construed in accordance with the laws of Budapest, Hungary and
|
|
||||||
you
|
<h3>6. Links</h3>
|
||||||
irrevocably submit to the exclusive jurisdiction of the courts in that State or location.</p>
|
|
||||||
</div>
|
<p>MineSeeker has not reviewed all of the sites linked to its website and is not responsible for the contents of
|
||||||
|
any
|
||||||
|
such linked site. The inclusion of any link does not imply endorsement by MineSeeker of the site. Use of any
|
||||||
|
such linked website is at the user's own risk.</p>
|
||||||
|
|
||||||
|
<h3>7. Modifications</h3>
|
||||||
|
|
||||||
|
<p>MineSeeker may revise these terms of service for its website at any time without notice. By using this
|
||||||
|
website
|
||||||
|
you are agreeing to be bound by the then current version of these terms of service.</p>
|
||||||
|
|
||||||
|
<h3>8. Governing Law</h3>
|
||||||
|
|
||||||
|
<p>These terms and conditions are governed by and construed in accordance with the laws of Budapest, Hungary and
|
||||||
|
you
|
||||||
|
irrevocably submit to the exclusive jurisdiction of the courts in that State or location.</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') }}"/>
|
||||||
|
<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') }}"/>
|
||||||
|
<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">
|
||||||
|
|||||||
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>
|
||||||
|
|
||||||
@@ -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