Compare commits
24
Commits
a290b75238
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b696461128 | ||
|
|
d1249adeca | ||
|
|
9ad6c92759 | ||
|
|
ab0976751f | ||
|
|
28f7e9809e | ||
|
|
008887ab60 | ||
|
|
0edaa9d8cc | ||
|
|
97d35f8fcb | ||
|
|
b565825cd9 | ||
|
|
07e8e68742 | ||
|
|
e470a9848d | ||
|
|
6fb3ed597b | ||
|
|
fd8d1730e8 | ||
|
|
cafa60b0f2 | ||
|
|
5dc01a358a | ||
|
|
4daaa8d102 | ||
|
|
996087ec4e | ||
|
|
c63af7becc | ||
|
|
552c71b718 | ||
|
|
c6227ea8c1 | ||
|
|
1c0ab4780b | ||
|
|
7f389fbbc2 | ||
|
|
ffa2b12786 | ||
|
|
51c542c7bb |
@@ -8,6 +8,7 @@
|
|||||||
/.env.local.php
|
/.env.local.php
|
||||||
/.env.*.local
|
/.env.*.local
|
||||||
/config/secrets/prod/prod.decrypt.private.php
|
/config/secrets/prod/prod.decrypt.private.php
|
||||||
|
/config/reference.php
|
||||||
/public/bundles/
|
/public/bundles/
|
||||||
/var/
|
/var/
|
||||||
!/var/volumes/
|
!/var/volumes/
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { DataTable } from 'simple-datatables';
|
||||||
|
import 'simple-datatables/dist/style.css';
|
||||||
|
|
||||||
|
// Paginate/search/sort any admin table opted in via class="admin-datatable".
|
||||||
|
// Kept off tables that are admin-curated and stay small (games) or where row
|
||||||
|
// order is meaningful and must not be disturbed by sorting (hints).
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.querySelectorAll('table.admin-datatable').forEach((table) => {
|
||||||
|
new DataTable(table, {
|
||||||
|
perPage: 25,
|
||||||
|
perPageSelect: [10, 25, 50, 100],
|
||||||
|
searchable: true,
|
||||||
|
sortable: true,
|
||||||
|
labels: {
|
||||||
|
placeholder: 'Search...',
|
||||||
|
perPage: '{select} entries per page',
|
||||||
|
noRows: 'No matching entries found',
|
||||||
|
info: 'Showing {start} to {end} of {rows} entries',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,3 +8,4 @@ import 'bootstrap/js/dist/dropdown';
|
|||||||
import './game-waiting';
|
import './game-waiting';
|
||||||
import './game-lobby';
|
import './game-lobby';
|
||||||
import './admin-session-tabs';
|
import './admin-session-tabs';
|
||||||
|
import './admin-datatables';
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/*
|
||||||
|
* Small single-purpose Leaflet maps:
|
||||||
|
* - #escaperoom-detail-map : read-only pin on a room's detail page
|
||||||
|
* - #escaperoom-suggest-map : click to drop / move a pin on the "suggest a room"
|
||||||
|
* form, writing the coordinates into the hidden latitude/longitude fields.
|
||||||
|
*/
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
|
import iconUrl from 'leaflet/dist/images/marker-icon.png';
|
||||||
|
import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
|
||||||
|
import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
|
||||||
|
|
||||||
|
delete L.Icon.Default.prototype._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl, shadowUrl });
|
||||||
|
|
||||||
|
const OSM = {
|
||||||
|
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
opts: {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function initDetailMap(el) {
|
||||||
|
const lat = parseFloat(el.dataset.lat);
|
||||||
|
const lng = parseFloat(el.dataset.lng);
|
||||||
|
if (Number.isNaN(lat) || Number.isNaN(lng)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = L.map(el, { scrollWheelZoom: false }).setView([lat, lng], 15);
|
||||||
|
L.tileLayer(OSM.url, OSM.opts).addTo(map);
|
||||||
|
L.marker([lat, lng]).addTo(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSuggestMap(el) {
|
||||||
|
const form = el.closest('form');
|
||||||
|
const latField = form && form.querySelector('input[name$="[latitude]"]');
|
||||||
|
const lngField = form && form.querySelector('input[name$="[longitude]"]');
|
||||||
|
if (!latField || !lngField) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startLat = parseFloat(latField.value);
|
||||||
|
const startLng = parseFloat(lngField.value);
|
||||||
|
const hasStart = !Number.isNaN(startLat) && !Number.isNaN(startLng);
|
||||||
|
|
||||||
|
const map = L.map(el).setView(hasStart ? [startLat, startLng] : [20, 0], hasStart ? 15 : 2);
|
||||||
|
L.tileLayer(OSM.url, OSM.opts).addTo(map);
|
||||||
|
|
||||||
|
let marker = hasStart ? L.marker([startLat, startLng], { draggable: true }).addTo(map) : null;
|
||||||
|
|
||||||
|
const write = (latlng) => {
|
||||||
|
latField.value = latlng.lat.toFixed(7);
|
||||||
|
lngField.value = latlng.lng.toFixed(7);
|
||||||
|
latField.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (marker) {
|
||||||
|
marker.on('dragend', () => write(marker.getLatLng()));
|
||||||
|
}
|
||||||
|
|
||||||
|
map.on('click', (e) => {
|
||||||
|
if (marker) {
|
||||||
|
marker.setLatLng(e.latlng);
|
||||||
|
} else {
|
||||||
|
marker = L.marker(e.latlng, { draggable: true }).addTo(map);
|
||||||
|
marker.on('dragend', () => write(marker.getLatLng()));
|
||||||
|
}
|
||||||
|
write(e.latlng);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const detail = document.getElementById('escaperoom-detail-map');
|
||||||
|
if (detail) {
|
||||||
|
initDetailMap(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggest = document.getElementById('escaperoom-suggest-map');
|
||||||
|
if (suggest) {
|
||||||
|
initSuggestMap(suggest);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/*
|
||||||
|
* Public "physical escape rooms" map + list.
|
||||||
|
*
|
||||||
|
* One JSON fetch (website_escaperooms_data) feeds both the Leaflet map (with
|
||||||
|
* marker clustering, so a worldwide dataset stays responsive) and the searchable
|
||||||
|
* table underneath it.
|
||||||
|
*/
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import 'leaflet.markercluster';
|
||||||
|
import 'leaflet.markercluster/dist/MarkerCluster.css';
|
||||||
|
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
|
||||||
|
import { DataTable } from 'simple-datatables';
|
||||||
|
import 'simple-datatables/dist/style.css';
|
||||||
|
|
||||||
|
import iconUrl from 'leaflet/dist/images/marker-icon.png';
|
||||||
|
import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
|
||||||
|
import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
|
||||||
|
|
||||||
|
// Webpack rewrites the image paths, so hand them to Leaflet explicitly.
|
||||||
|
delete L.Icon.Default.prototype._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl, shadowUrl });
|
||||||
|
|
||||||
|
function slugify(name) {
|
||||||
|
return (name || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '') || 'room';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = value == null ? '' : String(value);
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratingLabel(avg, count) {
|
||||||
|
if (!count) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
return `${avg.toFixed(1)} ★ (${count})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailUrl(template, room) {
|
||||||
|
return template.replace('__ID__', room.id).replace('__SLUG__', slugify(room.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMap(el, rooms, detailTemplate) {
|
||||||
|
const map = L.map(el, { worldCopyJump: true }).setView([20, 0], 2);
|
||||||
|
|
||||||
|
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
const cluster = L.markerClusterGroup({ chunkedLoading: true });
|
||||||
|
const bounds = [];
|
||||||
|
|
||||||
|
rooms.forEach((room) => {
|
||||||
|
if (typeof room.lat !== 'number' || typeof room.lng !== 'number') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const where = [room.city, room.country].filter(Boolean).join(', ');
|
||||||
|
const popup = `
|
||||||
|
<strong>${escapeHtml(room.name)}</strong><br>
|
||||||
|
${where ? `${escapeHtml(where)}<br>` : ''}
|
||||||
|
<span>${escapeHtml(ratingLabel(room.avgRating, room.reviewCount))}</span><br>
|
||||||
|
<a href="${detailUrl(detailTemplate, room)}">View & review »</a>
|
||||||
|
`;
|
||||||
|
const marker = L.marker([room.lat, room.lng]).bindPopup(popup);
|
||||||
|
cluster.addLayer(marker);
|
||||||
|
bounds.push([room.lat, room.lng]);
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer(cluster);
|
||||||
|
|
||||||
|
if (bounds.length) {
|
||||||
|
map.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildList(table, rooms, detailTemplate) {
|
||||||
|
const tbody = table.querySelector('tbody');
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
|
||||||
|
rooms.forEach((room) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const where = room.city ? escapeHtml(room.city) : '';
|
||||||
|
const country = room.country ? escapeHtml(room.country) : '';
|
||||||
|
const ratingValue = room.reviewCount ? room.avgRating : 0;
|
||||||
|
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td><a href="${detailUrl(detailTemplate, room)}">${escapeHtml(room.name)}</a></td>
|
||||||
|
<td>${where}</td>
|
||||||
|
<td>${country}</td>
|
||||||
|
<td data-order="${ratingValue}">${escapeHtml(ratingLabel(room.avgRating, room.reviewCount))}</td>
|
||||||
|
`;
|
||||||
|
frag.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
tbody.replaceChildren(frag);
|
||||||
|
|
||||||
|
new DataTable(table, {
|
||||||
|
perPage: 25,
|
||||||
|
perPageSelect: [25, 50, 100],
|
||||||
|
searchable: true,
|
||||||
|
sortable: true,
|
||||||
|
labels: {
|
||||||
|
placeholder: 'Search rooms…',
|
||||||
|
perPage: '{select} per page',
|
||||||
|
noRows: 'No matching rooms',
|
||||||
|
info: 'Showing {start}–{end} of {rows} rooms',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const mapEl = document.getElementById('escaperoom-map');
|
||||||
|
if (!mapEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = mapEl.dataset.endpoint;
|
||||||
|
const detailTemplate = mapEl.dataset.detailTemplate;
|
||||||
|
const listEl = document.getElementById('escaperoom-list');
|
||||||
|
const statusEl = document.getElementById('escaperoom-status');
|
||||||
|
|
||||||
|
fetch(endpoint, { headers: { Accept: 'application/json' } })
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((payload) => {
|
||||||
|
const rooms = Array.isArray(payload.rooms) ? payload.rooms : [];
|
||||||
|
buildMap(mapEl, rooms, detailTemplate);
|
||||||
|
if (listEl) {
|
||||||
|
buildList(listEl, rooms, detailTemplate);
|
||||||
|
}
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = `${rooms.length.toLocaleString()} rooms on the map`;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = 'Could not load escape rooms right now.';
|
||||||
|
}
|
||||||
|
console.error('escaperoom-map:', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
+4
-4
@@ -7,12 +7,12 @@
|
|||||||
"php": ">=8.2",
|
"php": ">=8.2",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
"doctrine/dbal": "^3.10.5",
|
"doctrine/dbal": "^4.4.4",
|
||||||
"doctrine/doctrine-bundle": "^2.18.3",
|
"doctrine/doctrine-bundle": "^2.18.3",
|
||||||
"doctrine/doctrine-migrations-bundle": "^3.7",
|
"doctrine/doctrine-migrations-bundle": "^3.7",
|
||||||
"doctrine/orm": "^3.6.7",
|
"doctrine/orm": "^3.6.7",
|
||||||
"karser/karser-recaptcha3-bundle": "^0.3.0",
|
"karser/karser-recaptcha3-bundle": "^0.3.0",
|
||||||
"phpdocumentor/reflection-docblock": "^5.6.7",
|
"phpdocumentor/reflection-docblock": "^6.0.3",
|
||||||
"phpstan/phpdoc-parser": "^2.3.2",
|
"phpstan/phpdoc-parser": "^2.3.2",
|
||||||
"symfony/asset": "7.4.*",
|
"symfony/asset": "7.4.*",
|
||||||
"symfony/asset-mapper": "7.4.*",
|
"symfony/asset-mapper": "7.4.*",
|
||||||
@@ -27,9 +27,9 @@
|
|||||||
"symfony/intl": "7.4.*",
|
"symfony/intl": "7.4.*",
|
||||||
"symfony/mailer": "7.4.*",
|
"symfony/mailer": "7.4.*",
|
||||||
"symfony/mailgun-mailer": "7.4.*",
|
"symfony/mailgun-mailer": "7.4.*",
|
||||||
"symfony/mercure-bundle": "^0.3.9",
|
"symfony/mercure-bundle": "^0.5.0",
|
||||||
"symfony/mime": "7.4.*",
|
"symfony/mime": "7.4.*",
|
||||||
"symfony/monolog-bundle": "^3.11.2",
|
"symfony/monolog-bundle": "^4.0.2",
|
||||||
"symfony/notifier": "7.4.*",
|
"symfony/notifier": "7.4.*",
|
||||||
"symfony/process": "7.4.*",
|
"symfony/process": "7.4.*",
|
||||||
"symfony/property-access": "7.4.*",
|
"symfony/property-access": "7.4.*",
|
||||||
|
|||||||
Generated
+364
-360
File diff suppressed because it is too large
Load Diff
@@ -15,11 +15,9 @@ doctrine:
|
|||||||
#server_version: '16'
|
#server_version: '16'
|
||||||
|
|
||||||
profiling_collect_backtrace: '%kernel.debug%'
|
profiling_collect_backtrace: '%kernel.debug%'
|
||||||
use_savepoints: true
|
|
||||||
orm:
|
orm:
|
||||||
auto_generate_proxy_classes: true
|
auto_generate_proxy_classes: true
|
||||||
enable_lazy_ghost_objects: true
|
enable_lazy_ghost_objects: true
|
||||||
report_fields_where_declared: true
|
|
||||||
validate_xml_mapping: true
|
validate_xml_mapping: true
|
||||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||||
auto_mapping: true
|
auto_mapping: true
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# Test / staging environment (`test.escapepage.com`)
|
||||||
|
|
||||||
|
Runs a second copy of the full Docker stack on the same server as production,
|
||||||
|
behind the same Nginx Proxy Manager (NPM). Production is untouched: with no
|
||||||
|
`docker/.env` overrides, `compose.yaml` behaves exactly as before.
|
||||||
|
|
||||||
|
## How isolation works
|
||||||
|
|
||||||
|
`docker/compose.yaml` derives everything instance-specific from `docker/.env`:
|
||||||
|
|
||||||
|
| Concern | Mechanism | prod (no overrides) | test |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Container names | `${STACK_NAME:-escapepage}-*` | `escapepage-php` … | `escapepage-test-php` … |
|
||||||
|
| Compose project (networks, labels) | `COMPOSE_PROJECT_NAME` | `docker` (unchanged) | `escapepage-test` |
|
||||||
|
| Published ports | `${NGINX_HTTP_PORT:-8080}` etc. | `0.0.0.0:8080/8443/3306/8090/8025` | `127.0.0.1:8081/8444/3307/8091/8026` |
|
||||||
|
| Database files | bind mount `../var/volumes/db` | per checkout | per checkout |
|
||||||
|
| Web reachability | `nginx` joined to external `nginx_default` | via NPM | via NPM |
|
||||||
|
|
||||||
|
`STACK_NAME` is a plain variable (not the Compose-managed `COMPOSE_PROJECT_NAME`),
|
||||||
|
so its `:-escapepage` default is reliable and **production needs no `docker/.env`
|
||||||
|
change** to keep its `escapepage-*` container names.
|
||||||
|
|
||||||
|
`nginx`, `mercure` and `mailer` all sit on the external `nginx_default` network,
|
||||||
|
so NPM forwards straight to `escapepage-test-nginx` / `-mercure` by name — no
|
||||||
|
public host port needed.
|
||||||
|
|
||||||
|
## Production checkout — optional
|
||||||
|
|
||||||
|
Nothing is required. Two optional tidy-ups, each needing a `docker compose up -d`
|
||||||
|
to recreate the affected container:
|
||||||
|
|
||||||
|
- The `nginx` service now also attaches to `nginx_default`. If you'd rather have
|
||||||
|
NPM forward to `escapepage-nginx` by name instead of `host:8080`, recreate it
|
||||||
|
and repoint the NPM proxy host. Otherwise the published `8080/8443` still work
|
||||||
|
as before and you can ignore this.
|
||||||
|
- Add `STACK_NAME=escapepage` and `COMPOSE_PROJECT_NAME=escapepage` to the
|
||||||
|
production `docker/.env` to move it off the implicit `docker` project name.
|
||||||
|
Cosmetic; do it only if you want the two stacks named symmetrically.
|
||||||
|
|
||||||
|
## Standing up the test stack
|
||||||
|
|
||||||
|
### 1. DNS
|
||||||
|
`test.escapepage.com` → server IP. (`mercure-test.escapepage.com` too if you want
|
||||||
|
live game features.) On Cloudflare, DNS-only ("grey cloud") keeps it low-profile.
|
||||||
|
|
||||||
|
### 2. Separate checkout
|
||||||
|
```bash
|
||||||
|
git clone <repo> /opt/escapepage-test
|
||||||
|
cd /opt/escapepage-test
|
||||||
|
git checkout <branch-to-test>
|
||||||
|
```
|
||||||
|
Use a **separate clone**, not `git worktree` — the Docker build context is the
|
||||||
|
checkout directory and full isolation avoids surprises.
|
||||||
|
|
||||||
|
### 3. Compose env
|
||||||
|
```bash
|
||||||
|
cp docker/.env.test.example docker/.env
|
||||||
|
# edit: real passwords, fresh MERCURE_JWT_SECRET (openssl rand -hex 32), reCAPTCHA keys
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Symfony env overrides
|
||||||
|
Create `/opt/escapepage-test/.env.local` in the checkout (git-ignored):
|
||||||
|
```
|
||||||
|
APP_SECRET=<openssl rand -hex 16>
|
||||||
|
# Behind NPM the forwarded client IP lands from a Docker-private range; trust them
|
||||||
|
# all on staging so URL generation / HTTPS detection work.
|
||||||
|
TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
|
||||||
|
```
|
||||||
|
`APP_ENV=prod`, `DATABASE_URL`, `SITE_BASE_URL`, `MERCURE_*` and `MAILER_DSN` are
|
||||||
|
already supplied to the containers by `docker/.env`.
|
||||||
|
|
||||||
|
### 5. Build & start
|
||||||
|
```bash
|
||||||
|
./docker/setup.test.sh
|
||||||
|
```
|
||||||
|
The test-specific script (not `setup.sh`): it refuses to run unless
|
||||||
|
`COMPOSE_PROJECT_NAME` in `docker/.env` is a non-prod value, scopes every compose
|
||||||
|
call to that project, runs the DB/cache/console steps as `www-data`, and repairs
|
||||||
|
`var/cache` / `var/log` / `var/sessions` ownership at the end (bare `docker exec`
|
||||||
|
runs as root, which otherwise leaves php-fpm unable to read its own cache — a
|
||||||
|
silent 500). It never touches `var/volumes/db`.
|
||||||
|
|
||||||
|
Builds `escapepage-test-*` images, starts the containers, creates + migrates
|
||||||
|
`escapepage_test`, builds assets. Re-run any time; `--no-build` skips the rebuild.
|
||||||
|
|
||||||
|
### 6. Nginx Proxy Manager — proxy host
|
||||||
|
- Domain: `test.escapepage.com`
|
||||||
|
- Forward to: `escapepage-test-nginx`, port **443**, scheme **https**
|
||||||
|
(the app nginx force-redirects 80→443 and serves a self-signed cert; leave
|
||||||
|
"Verify SSL" off — same arrangement as production)
|
||||||
|
- SSL: request a Let's Encrypt cert, Force SSL, HTTP/2
|
||||||
|
- Optional: add response header `X-Robots-Tag: noindex`
|
||||||
|
|
||||||
|
If you want live game screens, add a second proxy host
|
||||||
|
`mercure-test.escapepage.com` → `escapepage-test-mercure` port `80` (http).
|
||||||
|
|
||||||
|
### 7. Restrict access to your IP — NPM Access List
|
||||||
|
A host firewall on 80/443 can't help: prod and test share those ports on NPM.
|
||||||
|
Restrict at the proxy instead.
|
||||||
|
|
||||||
|
- **NPM → Access Lists → Add Access List**
|
||||||
|
- Name: e.g. `staging-allowlist`
|
||||||
|
- Authorisation: leave empty
|
||||||
|
- Access: `Allow <your.public.ip>` then a final `Deny all`
|
||||||
|
- Satisfy: **Any**
|
||||||
|
- Edit the `test.escapepage.com` proxy host → **Access List** → select it.
|
||||||
|
- Do the same on `mercure-test.escapepage.com` if you added it.
|
||||||
|
|
||||||
|
Everyone else gets `403` at the proxy. Update the IP in one place when it changes.
|
||||||
|
|
||||||
|
Defence in depth (optional): in `/opt/escapepage-test/docker/nginx/default.conf`,
|
||||||
|
inside the `server { listen 443 ... }` block:
|
||||||
|
```nginx
|
||||||
|
set_real_ip_from 172.16.0.0/12; # NPM's docker network
|
||||||
|
real_ip_header X-Forwarded-For;
|
||||||
|
allow <your.public.ip>;
|
||||||
|
deny all;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploying a new version to test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/sites/escapepage-test
|
||||||
|
git fetch && git checkout <branch> && git pull
|
||||||
|
./docker/setup.test.sh --no-build # composer install, migrate, build assets, fix perms
|
||||||
|
```
|
||||||
|
|
||||||
|
`--no-build` skips the image rebuild; drop it if the Dockerfile changed.
|
||||||
|
|
||||||
|
## Restarting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./docker/restart.test.sh # down + up, keeps the DB and images
|
||||||
|
./docker/restart.test.sh --build # also rebuild images
|
||||||
|
./docker/restart.test.sh --fresh-db # also wipe var/volumes/db and re-init MySQL
|
||||||
|
```
|
||||||
|
|
||||||
|
## Safety notes
|
||||||
|
|
||||||
|
- Use the **`*.test.sh`** scripts on this checkout, not `setup.sh` / `restart.sh`.
|
||||||
|
Both refuse to run unless `docker/.env` names a non-prod
|
||||||
|
`COMPOSE_PROJECT_NAME`, and both scope every action to that project — they
|
||||||
|
can't reach the production stack.
|
||||||
|
- `restart.test.sh` **keeps the database** by default (you loaded prod data into
|
||||||
|
it); `--fresh-db` is the only thing that wipes it. It never runs a host-wide
|
||||||
|
`docker system prune` / `docker builder prune`, and it only repairs ownership
|
||||||
|
of `var/cache` / `var/log` / `var/sessions` — **never `var/volumes/db`**
|
||||||
|
(chowning the MySQL data dir is what corrupted it earlier this build).
|
||||||
|
- The test `docker/.env` uses its own `DB_NAME` and passwords so a config slip
|
||||||
|
can't reach the production database.
|
||||||
|
- `MAILER_DSN=smtp://mailer:1026` keeps staging mail inside Mailpit instead of
|
||||||
|
sending through Mailgun.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# docker/.env for a TEST / STAGING stack (e.g. test.escapepage.com)
|
||||||
|
# =============================================================================
|
||||||
|
# Copy this to docker/.env inside the *test* checkout and fill in the blanks.
|
||||||
|
# docker/.env is git-ignored, so it never leaves the server.
|
||||||
|
#
|
||||||
|
# Compose reads this file automatically. Anything unique per stack is derived
|
||||||
|
# from COMPOSE_PROJECT_NAME + the *_PORT vars below, so the same compose.yaml
|
||||||
|
# serves both production and this copy.
|
||||||
|
#
|
||||||
|
# Two config layers, don't mix them up:
|
||||||
|
# - THIS file -> consumed by `docker compose` (container names, ports, and the
|
||||||
|
# runtime env it injects into the php containers).
|
||||||
|
# - <checkout>/.env(.local) -> consumed by Symfony itself (APP_SECRET,
|
||||||
|
# TRUSTED_PROXIES, messenger DSN, CLI database access, ...).
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
## --- Instance identity -------------------------------------------------------
|
||||||
|
# Both must be unique on the host.
|
||||||
|
# STACK_NAME -> prefix for every container_name (escapepage-test-php …)
|
||||||
|
# COMPOSE_PROJECT_NAME -> compose project: isolates networks, volumes and the
|
||||||
|
# labels that `docker compose down` / *.test.sh act on.
|
||||||
|
# Must be a non-prod value or setup.test.sh / restart.test.sh
|
||||||
|
# refuse to run.
|
||||||
|
STACK_NAME=escapepage-test
|
||||||
|
COMPOSE_PROJECT_NAME=escapepage-test
|
||||||
|
|
||||||
|
## --- Published host ports ---------------------------------------------------
|
||||||
|
# Bound to localhost only: the sole public entrypoint is Nginx Proxy Manager,
|
||||||
|
# which reaches the containers over the shared `nginx_default` network by name.
|
||||||
|
# Pick ports that don't clash with the production stack (8080/8443/3306/8090/8025/1025).
|
||||||
|
NGINX_HTTP_PORT=127.0.0.1:8081
|
||||||
|
NGINX_HTTPS_PORT=127.0.0.1:8444
|
||||||
|
DB_HOST_PORT=127.0.0.1:3307
|
||||||
|
MERCURE_HTTP_PORT=127.0.0.1:8091
|
||||||
|
MAILPIT_UI_PORT=127.0.0.1:8026
|
||||||
|
MAILPIT_SMTP_PORT=127.0.0.1:1026
|
||||||
|
|
||||||
|
## --- PHP image build ------------------------------------------------------
|
||||||
|
USER_ID=1000
|
||||||
|
GROUP_ID=1000
|
||||||
|
|
||||||
|
## --- Symfony runtime (injected into php / php-worker / php-cron) ------------
|
||||||
|
APP_ENV=prod
|
||||||
|
SITE_BASE_URL=https://test.escapepage.com
|
||||||
|
|
||||||
|
# Staging should NOT send real mail. Point at the bundled Mailpit and read it
|
||||||
|
# at http://127.0.0.1:8026 on the server (or via an NPM host if you expose it).
|
||||||
|
MAILER_DSN=smtp://mailer:1026
|
||||||
|
MAILER_FROM=mailer@test.escapepage.com
|
||||||
|
|
||||||
|
## --- Database -------------------------------------------------------------
|
||||||
|
# `database` is the compose *service* name and resolves inside this stack's
|
||||||
|
# own network - keep it as-is. Use its own name + fresh credentials so a mistake
|
||||||
|
# here can never point at the production database.
|
||||||
|
DB_NAME=escapepage_test
|
||||||
|
DB_USER=escapepage
|
||||||
|
DB_PASSWORD=CHANGE_ME_test_db_password
|
||||||
|
MYSQL_ROOT_PASSWORD=CHANGE_ME_test_root_password
|
||||||
|
DATABASE_URL=pdo_mysql://escapepage:CHANGE_ME_test_db_password@database:3306/escapepage_test?serverVersion=8.0.32&charset=utf8mb4
|
||||||
|
|
||||||
|
## --- Mercure -----------------------------------------------------------------
|
||||||
|
# Internal hub URL (service name, stays the same). Public URL + CORS must be the
|
||||||
|
# test domain. Add a `mercure-test.escapepage.com` proxy host in NPM ->
|
||||||
|
# <project>-mercure:80.
|
||||||
|
MERCURE_URL=http://mercure/.well-known/mercure
|
||||||
|
MERCURE_PUBLIC_URL=https://mercure-test.escapepage.com/.well-known/mercure
|
||||||
|
MERCURE_JWT_SECRET=CHANGE_ME_generate_with_openssl_rand_hex_32
|
||||||
|
MERCURE_CORS_ALLOWED_ORIGINS="https://test.escapepage.com"
|
||||||
|
MERCURE_TOPIC_BASE=https://test.escapepage.com
|
||||||
|
|
||||||
|
## --- reCAPTCHA v3 ----------------------------------------------------------
|
||||||
|
# Register test.escapepage.com in the reCAPTCHA admin console and paste its keys,
|
||||||
|
# or leave the placeholders and expect the contact / "suggest a room" forms to
|
||||||
|
# fail captcha validation on staging.
|
||||||
|
RECAPTCHA3_KEY=CHANGE_ME_or_reuse_prod_if_domain_added
|
||||||
|
RECAPTCHA3_SECRET=CHANGE_ME_or_reuse_prod_if_domain_added
|
||||||
@@ -17,8 +17,8 @@ services:
|
|||||||
mailer:
|
mailer:
|
||||||
image: axllent/mailpit
|
image: axllent/mailpit
|
||||||
ports:
|
ports:
|
||||||
- "1025:1025"
|
- "${MAILPIT_SMTP_PORT:-1025}:1025"
|
||||||
- "8025:8025"
|
- "${MAILPIT_UI_PORT:-8025}:8025"
|
||||||
environment:
|
environment:
|
||||||
MP_SMTP_AUTH_ACCEPT_ANY: 1
|
MP_SMTP_AUTH_ACCEPT_ANY: 1
|
||||||
MP_SMTP_AUTH_ALLOW_INSECURE: 1
|
MP_SMTP_AUTH_ALLOW_INSECURE: 1
|
||||||
|
|||||||
+26
-15
@@ -1,5 +1,14 @@
|
|||||||
version: '3.7'
|
version: '3.7'
|
||||||
|
|
||||||
|
# This stack can run more than once on the same host (e.g. production + a
|
||||||
|
# test.escapepage.com staging copy). Everything that must be unique per instance
|
||||||
|
# comes from docker/.env:
|
||||||
|
# STACK_NAME -> container name prefix (default: escapepage)
|
||||||
|
# COMPOSE_PROJECT_NAME -> compose project / network namespace
|
||||||
|
# NGINX_HTTP_PORT etc. -> published host ports
|
||||||
|
# With no docker/.env overrides it behaves exactly as before: containers
|
||||||
|
# escapepage-*, ports 8080/8443/3306/8090/8025.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
php:
|
php:
|
||||||
build:
|
build:
|
||||||
@@ -8,7 +17,7 @@ services:
|
|||||||
args:
|
args:
|
||||||
USER_ID: ${USER_ID}
|
USER_ID: ${USER_ID}
|
||||||
GROUP_ID: ${GROUP_ID}
|
GROUP_ID: ${GROUP_ID}
|
||||||
container_name: escapepage-php
|
container_name: ${STACK_NAME:-escapepage}-php
|
||||||
volumes:
|
volumes:
|
||||||
- ../:/var/www/html:delegated
|
- ../:/var/www/html:delegated
|
||||||
- /etc/hosts:/etc/hosts:ro
|
- /etc/hosts:/etc/hosts:ro
|
||||||
@@ -40,7 +49,7 @@ services:
|
|||||||
args:
|
args:
|
||||||
USER_ID: ${USER_ID}
|
USER_ID: ${USER_ID}
|
||||||
GROUP_ID: ${GROUP_ID}
|
GROUP_ID: ${GROUP_ID}
|
||||||
container_name: escapepage-php-worker
|
container_name: ${STACK_NAME:-escapepage}-php-worker
|
||||||
volumes:
|
volumes:
|
||||||
- ../:/var/www/html:delegated
|
- ../:/var/www/html:delegated
|
||||||
- /etc/hosts:/etc/hosts:ro
|
- /etc/hosts:/etc/hosts:ro
|
||||||
@@ -73,7 +82,7 @@ services:
|
|||||||
args:
|
args:
|
||||||
USER_ID: ${USER_ID}
|
USER_ID: ${USER_ID}
|
||||||
GROUP_ID: ${GROUP_ID}
|
GROUP_ID: ${GROUP_ID}
|
||||||
container_name: escapepage-php-cron
|
container_name: ${STACK_NAME:-escapepage}-php-cron
|
||||||
volumes:
|
volumes:
|
||||||
- ../:/var/www/html:delegated
|
- ../:/var/www/html:delegated
|
||||||
- /etc/hosts:/etc/hosts:ro
|
- /etc/hosts:/etc/hosts:ro
|
||||||
@@ -101,10 +110,10 @@ services:
|
|||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:1.29.4-alpine
|
image: nginx:1.29.4-alpine
|
||||||
container_name: escapepage-nginx
|
container_name: ${STACK_NAME:-escapepage}-nginx
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "${NGINX_HTTP_PORT:-8080}:80"
|
||||||
- "8443:443"
|
- "${NGINX_HTTPS_PORT:-8443}:443"
|
||||||
volumes:
|
volumes:
|
||||||
- ../:/var/www/html:ro
|
- ../:/var/www/html:ro
|
||||||
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
@@ -112,16 +121,18 @@ services:
|
|||||||
- /etc/hosts:/etc/hosts:ro
|
- /etc/hosts:/etc/hosts:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- php
|
- php
|
||||||
# networks:
|
# Joined to the Nginx Proxy Manager network so NPM can forward straight to
|
||||||
# backend:
|
# "<project>-nginx" without going back out to a published host port.
|
||||||
# ipv4_address: 172.23.0.12
|
networks:
|
||||||
|
- default
|
||||||
|
- nginx_proxy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
mailer:
|
mailer:
|
||||||
image: axllent/mailpit:latest
|
image: axllent/mailpit:latest
|
||||||
container_name: escapepage-mailer
|
container_name: ${STACK_NAME:-escapepage}-mailer
|
||||||
ports:
|
ports:
|
||||||
- "8025:8025"
|
- "${MAILPIT_UI_PORT:-8025}:8025"
|
||||||
volumes:
|
volumes:
|
||||||
- /etc/hosts:/etc/hosts:ro
|
- /etc/hosts:/etc/hosts:ro
|
||||||
networks:
|
networks:
|
||||||
@@ -131,7 +142,7 @@ services:
|
|||||||
|
|
||||||
mercure:
|
mercure:
|
||||||
image: dunglas/mercure:v0.21
|
image: dunglas/mercure:v0.21
|
||||||
container_name: escapepage-mercure
|
container_name: ${STACK_NAME:-escapepage}-mercure
|
||||||
environment:
|
environment:
|
||||||
SERVER_NAME: "http://:80"
|
SERVER_NAME: "http://:80"
|
||||||
MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_JWT_SECRET}
|
MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_JWT_SECRET}
|
||||||
@@ -143,7 +154,7 @@ services:
|
|||||||
publish_origins ${MERCURE_CORS_ALLOWED_ORIGINS}
|
publish_origins ${MERCURE_CORS_ALLOWED_ORIGINS}
|
||||||
anonymous
|
anonymous
|
||||||
ports:
|
ports:
|
||||||
- "8090:80"
|
- "${MERCURE_HTTP_PORT:-8090}:80"
|
||||||
volumes:
|
volumes:
|
||||||
- /etc/hosts:/etc/hosts:ro
|
- /etc/hosts:/etc/hosts:ro
|
||||||
networks:
|
networks:
|
||||||
@@ -154,7 +165,7 @@ services:
|
|||||||
###> doctrine/doctrine-bundle ###
|
###> doctrine/doctrine-bundle ###
|
||||||
database:
|
database:
|
||||||
image: mysql:8.0
|
image: mysql:8.0
|
||||||
container_name: escapepage-db
|
container_name: ${STACK_NAME:-escapepage}-db
|
||||||
environment:
|
environment:
|
||||||
MYSQL_DATABASE: ${DB_NAME}
|
MYSQL_DATABASE: ${DB_NAME}
|
||||||
MYSQL_USER: ${DB_USER}
|
MYSQL_USER: ${DB_USER}
|
||||||
@@ -173,7 +184,7 @@ services:
|
|||||||
- /etc/hosts:/etc/hosts:ro
|
- /etc/hosts:/etc/hosts:ro
|
||||||
# Uncomment the two lines below if you need to access MySQL from your host (workbench, etc.)
|
# Uncomment the two lines below if you need to access MySQL from your host (workbench, etc.)
|
||||||
ports:
|
ports:
|
||||||
- "3306:3306"
|
- "${DB_HOST_PORT:-3306}:3306"
|
||||||
# networks:
|
# networks:
|
||||||
# backend:
|
# backend:
|
||||||
# ipv4_address: 172.23.0.15
|
# ipv4_address: 172.23.0.15
|
||||||
|
|||||||
+39
-11
@@ -1,22 +1,50 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Script to completely restart the project as requested
|
# Completely restart ONE project stack (prod or a test/staging copy).
|
||||||
# Can be run from any directory
|
# Can be run from any directory. Everything is scoped to the Compose project
|
||||||
|
# name so running this from the test checkout never touches the prod stack.
|
||||||
|
#
|
||||||
|
# ./docker/restart.sh # rebuild-less restart of this checkout's stack
|
||||||
|
# ./docker/restart.sh --prune-all # also run host-wide `docker system/builder prune`
|
||||||
|
# # (old behaviour; skip it when another stack shares the host)
|
||||||
|
|
||||||
DOCKER_DIR=$(cd "$(dirname "$0")" && pwd)
|
DOCKER_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||||
ROOT_DIR=$(cd "$DOCKER_DIR/.." && pwd)
|
ROOT_DIR=$(cd "$DOCKER_DIR/.." && pwd)
|
||||||
|
|
||||||
echo "Stopping and removing containers..."
|
PRUNE_ALL=0
|
||||||
(cd "$DOCKER_DIR" && docker compose -f compose.yaml -f compose.override.yaml down -v --remove-orphans) || true
|
for arg in "$@"; do
|
||||||
docker network rm escapepage_network || true
|
case "$arg" in
|
||||||
docker network rm $(docker network ls -q --filter name=escapepage) || true
|
--prune-all) PRUNE_ALL=1 ;;
|
||||||
docker network prune -f || true
|
*) echo "Unknown option: $arg" >&2; exit 1 ;;
|
||||||
docker rm -f escapepage-db escapepage-php escapepage-nginx escapepage-mercure escapepage-mailer escapepage-php-worker escapepage-php-cron || true
|
esac
|
||||||
docker system prune -f || true
|
done
|
||||||
|
|
||||||
echo "Clearing Docker build cache..."
|
# Read the identifiers from docker/.env (same file Compose uses). STACK_NAME is
|
||||||
docker builder prune -af
|
# the container-name prefix; COMPOSE_PROJECT_NAME is the compose project.
|
||||||
|
read_env() { [ -f "$DOCKER_DIR/.env" ] && grep -E "^$1=" "$DOCKER_DIR/.env" | tail -n1 | cut -d= -f2- | tr -d "\"'" || true; }
|
||||||
|
STACK="$(read_env STACK_NAME)"; STACK="${STACK:-escapepage}"
|
||||||
|
PROJECT="$(read_env COMPOSE_PROJECT_NAME)"; PROJECT="${PROJECT:-$STACK}"
|
||||||
|
echo "Restarting stack: $STACK (compose project: $PROJECT)"
|
||||||
|
|
||||||
|
echo "Stopping and removing containers..."
|
||||||
|
(cd "$DOCKER_DIR" && docker compose -p "$PROJECT" -f compose.yaml -f compose.override.yaml down -v --remove-orphans) || true
|
||||||
|
|
||||||
|
# Belt-and-suspenders: drop anything still lingering for THIS stack only.
|
||||||
|
docker rm -f \
|
||||||
|
"${STACK}-db" "${STACK}-php" "${STACK}-nginx" "${STACK}-mercure" \
|
||||||
|
"${STACK}-mailer" "${STACK}-php-worker" "${STACK}-php-cron" 2>/dev/null || true
|
||||||
|
for net in $(docker network ls -q --filter "name=^${PROJECT}_" 2>/dev/null); do
|
||||||
|
docker network rm "$net" || true
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$PRUNE_ALL" -eq 1 ]; then
|
||||||
|
echo "Host-wide prune (containers, networks, build cache)..."
|
||||||
|
docker system prune -f || true
|
||||||
|
docker builder prune -af || true
|
||||||
|
else
|
||||||
|
echo "Skipping host-wide prune (pass --prune-all to force it)."
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Setting permissions for var/volumes/db and var directories..."
|
echo "Setting permissions for var/volumes/db and var directories..."
|
||||||
sudo chown -R 1000:1000 "$ROOT_DIR/var/volumes/db" || true
|
sudo chown -R 1000:1000 "$ROOT_DIR/var/volumes/db" || true
|
||||||
|
|||||||
Executable
+84
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Restart the TEST (staging) stack. Scoped entirely to this checkout's compose
|
||||||
|
# project, so it can never touch the production stack. Unlike docker/restart.sh
|
||||||
|
# it:
|
||||||
|
# - keeps the database by default (you loaded prod data into it) — pass
|
||||||
|
# --fresh-db to wipe var/volumes/db and let MySQL re-initialise
|
||||||
|
# - never runs a host-wide `docker system/builder prune`
|
||||||
|
# - only repairs ownership of var/cache, var/log, var/sessions — NEVER
|
||||||
|
# var/volumes/db (that dir belongs to the mysql process; chowning it is
|
||||||
|
# what corrupts the data directory)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./docker/restart.test.sh # down + up (keeps DB and images)
|
||||||
|
# ./docker/restart.test.sh --build # also rebuild images
|
||||||
|
# ./docker/restart.test.sh --fresh-db # also wipe + re-initialise the database
|
||||||
|
|
||||||
|
DOCKER_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
ROOT_DIR=$(cd "$DOCKER_DIR/.." && pwd)
|
||||||
|
|
||||||
|
env_val() { [ -f "$DOCKER_DIR/.env" ] && grep -E "^$1=" "$DOCKER_DIR/.env" | tail -n1 | cut -d= -f2- | tr -d "\"' " || true; }
|
||||||
|
|
||||||
|
if [ ! -f "$DOCKER_DIR/.env" ]; then
|
||||||
|
echo "Error: $DOCKER_DIR/.env not found. Copy docker/.env.test.example to docker/.env first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
PROJECT="$(env_val COMPOSE_PROJECT_NAME)"
|
||||||
|
STACK="$(env_val STACK_NAME)"; STACK="${STACK:-$PROJECT}"
|
||||||
|
case "${PROJECT:-}" in
|
||||||
|
""|escapepage|docker)
|
||||||
|
echo "Error: COMPOSE_PROJECT_NAME in docker/.env is '${PROJECT:-<unset>}'." >&2
|
||||||
|
echo "Refusing to run a test script against the production stack." >&2
|
||||||
|
exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if docker compose version >/dev/null 2>&1; then
|
||||||
|
DOCKER_COMPOSE="docker compose"
|
||||||
|
elif command -v docker-compose >/dev/null 2>&1; then
|
||||||
|
DOCKER_COMPOSE="docker-compose"
|
||||||
|
else
|
||||||
|
echo "Error: neither 'docker compose' nor 'docker-compose' is available." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
FRESH_DB=0; BUILD=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--fresh-db) FRESH_DB=1 ;;
|
||||||
|
--build) BUILD=1 ;;
|
||||||
|
*) echo "Unknown option: $arg" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
dc() { (cd "$DOCKER_DIR" && $DOCKER_COMPOSE -p "$PROJECT" -f compose.yaml -f compose.override.yaml "$@"); }
|
||||||
|
|
||||||
|
echo "Restarting test stack: $STACK (compose project: $PROJECT)"
|
||||||
|
|
||||||
|
echo "Stopping containers..."
|
||||||
|
dc down --remove-orphans || true
|
||||||
|
# scoped fallback — only this stack
|
||||||
|
docker rm -f \
|
||||||
|
"${STACK}-db" "${STACK}-php" "${STACK}-nginx" "${STACK}-mercure" \
|
||||||
|
"${STACK}-mailer" "${STACK}-php-worker" "${STACK}-php-cron" 2>/dev/null || true
|
||||||
|
for net in $(docker network ls -q --filter "name=^${PROJECT}_" 2>/dev/null); do
|
||||||
|
docker network rm "$net" || true
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$FRESH_DB" -eq 1 ]; then
|
||||||
|
echo "Wiping the test database (var/volumes/db)..."
|
||||||
|
sudo rm -rf "$ROOT_DIR/var/volumes/db"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Repairing var/ ownership (cache/log/sessions only)..."
|
||||||
|
sudo mkdir -p "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log/php" "$ROOT_DIR/var/log/cron" "$ROOT_DIR/var/sessions"
|
||||||
|
sudo chown -R 1000:1000 "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log" "$ROOT_DIR/var/sessions"
|
||||||
|
sudo chmod -R u+rwX,g+rwX "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log" "$ROOT_DIR/var/sessions"
|
||||||
|
|
||||||
|
echo "Bringing the stack back up..."
|
||||||
|
if [ "$BUILD" -eq 1 ]; then
|
||||||
|
"$DOCKER_DIR/setup.test.sh"
|
||||||
|
else
|
||||||
|
"$DOCKER_DIR/setup.test.sh" --no-build
|
||||||
|
fi
|
||||||
Executable
+134
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Bootstrap / re-provision a TEST (staging) stack — e.g. test.escapepage.com.
|
||||||
|
# Same job as docker/setup.sh, but:
|
||||||
|
# - refuses to run unless docker/.env marks this as a non-prod stack
|
||||||
|
# - scopes every compose call to COMPOSE_PROJECT_NAME
|
||||||
|
# - runs DB / cache / console steps as www-data and repairs var/ ownership at
|
||||||
|
# the end, so php-fpm (www-data) can actually read the compiled container
|
||||||
|
# (bare `docker exec` runs as root and would leave var/cache root-owned)
|
||||||
|
# - NEVER touches var/volumes/db (MySQL owns that)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./docker/setup.test.sh # full setup (build images)
|
||||||
|
# ./docker/setup.test.sh --no-build # skip image rebuild
|
||||||
|
# ./docker/setup.test.sh --recreate # force-recreate containers
|
||||||
|
# ./docker/setup.test.sh --down # stop and remove this stack's containers
|
||||||
|
|
||||||
|
DOCKER_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
ROOT_DIR=$(cd "$DOCKER_DIR/.." && pwd)
|
||||||
|
|
||||||
|
env_val() { [ -f "$DOCKER_DIR/.env" ] && grep -E "^$1=" "$DOCKER_DIR/.env" | tail -n1 | cut -d= -f2- | tr -d "\"' " || true; }
|
||||||
|
|
||||||
|
# --- safety gate: never let a *.test.sh script act on the production stack ----
|
||||||
|
if [ ! -f "$DOCKER_DIR/.env" ]; then
|
||||||
|
echo "Error: $DOCKER_DIR/.env not found. Copy docker/.env.test.example to docker/.env and fill it in." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
PROJECT="$(env_val COMPOSE_PROJECT_NAME)"
|
||||||
|
STACK="$(env_val STACK_NAME)"; STACK="${STACK:-$PROJECT}"
|
||||||
|
case "${PROJECT:-}" in
|
||||||
|
""|escapepage|docker)
|
||||||
|
echo "Error: COMPOSE_PROJECT_NAME in docker/.env is '${PROJECT:-<unset>}'." >&2
|
||||||
|
echo "Refusing to run a test script against the production stack." >&2
|
||||||
|
echo "Set COMPOSE_PROJECT_NAME and STACK_NAME to e.g. 'escapepage-test' in docker/.env." >&2
|
||||||
|
exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- compose command -------------------------------------------------------
|
||||||
|
if docker compose version >/dev/null 2>&1; then
|
||||||
|
DOCKER_COMPOSE="docker compose"
|
||||||
|
elif command -v docker-compose >/dev/null 2>&1; then
|
||||||
|
DOCKER_COMPOSE="docker-compose"
|
||||||
|
else
|
||||||
|
echo "Error: neither 'docker compose' nor 'docker-compose' is available." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
command -v docker >/dev/null 2>&1 || { echo "Error: docker is required." >&2; exit 1; }
|
||||||
|
|
||||||
|
dc() { (cd "$DOCKER_DIR" && $DOCKER_COMPOSE -p "$PROJECT" -f compose.yaml -f compose.override.yaml "$@"); }
|
||||||
|
pexec() { dc exec -T php "$@"; } # as root (composer / npm)
|
||||||
|
pexecwww() { dc exec -T -u www-data php "$@"; } # as www-data (console / DB / cache)
|
||||||
|
|
||||||
|
REBUILD=1; RECREATE=0; DOWN_ONLY=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--no-build) REBUILD=0 ;;
|
||||||
|
--recreate) RECREATE=1 ;;
|
||||||
|
--down) DOWN_ONLY=1 ;;
|
||||||
|
*) echo "Unknown option: $arg" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Test stack: $STACK (compose project: $PROJECT)"
|
||||||
|
|
||||||
|
if [ "$DOWN_ONLY" -eq 1 ]; then
|
||||||
|
dc down --remove-orphans
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
BUILD_ARGS=()
|
||||||
|
[ "$REBUILD" -eq 1 ] && BUILD_ARGS+=("--build")
|
||||||
|
[ "$RECREATE" -eq 1 ] && BUILD_ARGS+=("--force-recreate")
|
||||||
|
|
||||||
|
dc up -d "${BUILD_ARGS[@]}"
|
||||||
|
|
||||||
|
# --- wait for the database ------------------------------------------------
|
||||||
|
printf "Waiting for database to be healthy..."
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
DB_ID=$(dc ps -q database 2>/dev/null || true)
|
||||||
|
if [ -n "$DB_ID" ] && [ "$(docker inspect -f '{{.State.Health.Status}}' "$DB_ID" 2>/dev/null || true)" = "healthy" ]; then
|
||||||
|
echo " OK"; break
|
||||||
|
fi
|
||||||
|
printf "."; sleep 2
|
||||||
|
[ "$i" -eq 60 ] && echo -e "\nWarning: database not healthy yet, continuing anyway."
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- dependencies (root: writes vendor/ and node_modules/) --------------
|
||||||
|
pexec composer install --no-interaction
|
||||||
|
|
||||||
|
# --- APP_SECRET: generate into .env.local if it's set nowhere -----------
|
||||||
|
if ! grep -qsE '^APP_SECRET=.+' "$ROOT_DIR/.env" "$ROOT_DIR/.env.local"; then
|
||||||
|
echo "Generating APP_SECRET in .env.local..."
|
||||||
|
printf 'APP_SECRET=%s\n' "$(openssl rand -hex 16)" >> "$ROOT_DIR/.env.local"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- database (www-data: keeps var/ writable by php-fpm) ---------------
|
||||||
|
echo "Creating database if it doesn't exist..."
|
||||||
|
pexecwww php bin/console doctrine:database:create --if-not-exists || {
|
||||||
|
echo "Error: database creation failed." >&2; dc logs database | tail -n 40; exit 1;
|
||||||
|
}
|
||||||
|
echo "Running migrations..."
|
||||||
|
pexecwww php bin/console doctrine:migrations:migrate -n || { echo "Error: migrations failed." >&2; exit 1; }
|
||||||
|
|
||||||
|
[ -f "$ROOT_DIR/importmap.php" ] && pexec php bin/console importmap:install || true
|
||||||
|
|
||||||
|
if [ -f "$ROOT_DIR/package.json" ]; then
|
||||||
|
echo "Installing npm dependencies..."; pexec npm ci || pexec npm install
|
||||||
|
echo "Building assets..."; pexec npm run build
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- repair var/ ownership for php-fpm (www-data), never var/volumes ----
|
||||||
|
echo "Fixing var/ ownership for php-fpm..."
|
||||||
|
pexec sh -lc 'mkdir -p var/cache var/log/php var/log/cron var/sessions && chown -R www-data:www-data var/cache var/log var/sessions'
|
||||||
|
|
||||||
|
pexecwww php bin/console cache:clear
|
||||||
|
|
||||||
|
NGINX_HTTPS="$(env_val NGINX_HTTPS_PORT)"
|
||||||
|
MAILPIT_UI="$(env_val MAILPIT_UI_PORT)"
|
||||||
|
|
||||||
|
cat <<EOT
|
||||||
|
|
||||||
|
Test stack '$PROJECT' is up.
|
||||||
|
|
||||||
|
NPM upstream: ${STACK}-nginx (scheme https, port 443, "Verify SSL" off)
|
||||||
|
Local check: curl -k https://${NGINX_HTTPS:-127.0.0.1:18443}/
|
||||||
|
Mailpit UI: http://${MAILPIT_UI:-127.0.0.1:18026}
|
||||||
|
|
||||||
|
Logs: docker logs -f ${STACK}-php
|
||||||
|
Shell: docker exec -it -u www-data ${STACK}-php sh
|
||||||
|
Restart: ./docker/restart.test.sh (add --fresh-db to wipe + re-init the DB)
|
||||||
|
|
||||||
|
Re-run this script any time. Use --no-build to skip the image rebuild.
|
||||||
|
EOT
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260810140000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create hint table and seed it with the previously-hardcoded mainframe hints for every existing game';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE hint (id INT AUTO_INCREMENT NOT NULL, game_id INT NOT NULL, hint_condition VARCHAR(30) NOT NULL, threshold_seconds INT NOT NULL, message LONGTEXT NOT NULL, sort_order INT NOT NULL, INDEX IDX_HINT_GAME (game_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||||
|
$this->addSql('ALTER TABLE hint ADD CONSTRAINT FK_HINT_GAME FOREIGN KEY (game_id) REFERENCES `game` (id) ON DELETE CASCADE');
|
||||||
|
|
||||||
|
// Seed every existing game with the hints that used to be hardcoded in
|
||||||
|
// SendMainframeHintsCommand, so behavior doesn't regress the moment the
|
||||||
|
// command switches to reading from this table.
|
||||||
|
$seed = [
|
||||||
|
['help_used', 120, 'Have you already checked which functions are available to you through the help command?', 10],
|
||||||
|
['broadcast_done', 300, 'You should establish communications with your fellow agents.', 20],
|
||||||
|
['contacted_all_privately', 420, 'The AI virus can see all communication if you are using open communication channels. Make sure you send private messages to all other agents as well, so I know you can.', 30],
|
||||||
|
['verified', 600, 'You need the help of your fellow agents to verify you. If both other agents have helped you in your verification, i can get you more rights. The help command might give you some more information.', 40],
|
||||||
|
['verified', 780, 'You are still not verified! Please get your verification codes from your colleagues so you can verify.', 50],
|
||||||
|
['left_home_directory', 900, 'You can change the folder you are working in. Check the help command for more information', 60],
|
||||||
|
['all_decoded', 1020, 'You should go to the rapports directory to check out the rapports.', 70],
|
||||||
|
['all_decoded', 1140, "Not all messages can be decoded by every agent. Every agent has their own personal decoding method. If you can't decode a message, maybe a colleague can.", 80],
|
||||||
|
['none', 1380, 'Have you checked out the help command to see what you can do?', 90],
|
||||||
|
['none', 1560, 'HURRY! The AI virus is almost done decoding the last files before it will send everything out!', 100],
|
||||||
|
['none', 1680, 'Please remove the files soon! The AI virus can not win! It will be a disaster if it does!', 110],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($seed as [$condition, $threshold, $message, $sortOrder]) {
|
||||||
|
$this->addSql(
|
||||||
|
'INSERT INTO hint (game_id, hint_condition, threshold_seconds, message, sort_order) '
|
||||||
|
. 'SELECT id, ?, ?, ?, ? FROM `game`',
|
||||||
|
[$condition, $threshold, $message, $sortOrder]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE hint DROP FOREIGN KEY FK_HINT_GAME');
|
||||||
|
$this->addSql('DROP TABLE hint');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260829120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create contact_message table for the public contact form';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE contact_message (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, message LONGTEXT NOT NULL, created_at DATETIME NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE contact_message');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260829130000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add read_at to contact_message so admin can track which messages have been read';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE contact_message ADD read_at DATETIME DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE contact_message DROP read_at');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260829140000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add deleted_at to contact_message for soft-deleting messages from the admin list';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE contact_message ADD deleted_at DATETIME DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE contact_message DROP deleted_at');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260829150000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add escape_room and escape_room_review tables for the physical escape room map and reviews';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE escape_room (
|
||||||
|
id INT AUTO_INCREMENT NOT NULL,
|
||||||
|
submitted_by_id INT DEFAULT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
venue VARCHAR(255) DEFAULT NULL,
|
||||||
|
street VARCHAR(255) DEFAULT NULL,
|
||||||
|
house_number VARCHAR(32) DEFAULT NULL,
|
||||||
|
postcode VARCHAR(32) DEFAULT NULL,
|
||||||
|
city VARCHAR(128) DEFAULT NULL,
|
||||||
|
country VARCHAR(2) DEFAULT NULL,
|
||||||
|
latitude NUMERIC(10, 7) NOT NULL,
|
||||||
|
longitude NUMERIC(10, 7) NOT NULL,
|
||||||
|
website VARCHAR(511) DEFAULT NULL,
|
||||||
|
phone VARCHAR(64) DEFAULT NULL,
|
||||||
|
source VARCHAR(16) NOT NULL,
|
||||||
|
osm_type VARCHAR(8) DEFAULT NULL,
|
||||||
|
osm_id BIGINT DEFAULT NULL,
|
||||||
|
status VARCHAR(16) NOT NULL,
|
||||||
|
locked TINYINT(1) DEFAULT 0 NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)',
|
||||||
|
updated_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)',
|
||||||
|
deleted_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)',
|
||||||
|
INDEX idx_escape_room_submitted_by (submitted_by_id),
|
||||||
|
UNIQUE INDEX uniq_escape_room_osm (osm_type, osm_id),
|
||||||
|
INDEX idx_escape_room_status (status),
|
||||||
|
INDEX idx_escape_room_bbox (latitude, longitude),
|
||||||
|
PRIMARY KEY(id)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE escape_room_review (
|
||||||
|
id INT AUTO_INCREMENT NOT NULL,
|
||||||
|
escape_room_id INT NOT NULL,
|
||||||
|
author_id INT DEFAULT NULL,
|
||||||
|
rating SMALLINT NOT NULL,
|
||||||
|
title VARCHAR(150) NOT NULL,
|
||||||
|
body LONGTEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)',
|
||||||
|
deleted_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)',
|
||||||
|
INDEX idx_escape_room_review_room (escape_room_id),
|
||||||
|
INDEX idx_escape_room_review_author (author_id),
|
||||||
|
PRIMARY KEY(id)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql('ALTER TABLE escape_room ADD CONSTRAINT fk_escape_room_submitted_by FOREIGN KEY (submitted_by_id) REFERENCES `user` (id) ON DELETE SET NULL');
|
||||||
|
$this->addSql('ALTER TABLE escape_room_review ADD CONSTRAINT fk_escape_room_review_room FOREIGN KEY (escape_room_id) REFERENCES escape_room (id) ON DELETE CASCADE');
|
||||||
|
$this->addSql('ALTER TABLE escape_room_review ADD CONSTRAINT fk_escape_room_review_author FOREIGN KEY (author_id) REFERENCES `user` (id) ON DELETE SET NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE escape_room_review DROP FOREIGN KEY fk_escape_room_review_room');
|
||||||
|
$this->addSql('ALTER TABLE escape_room_review DROP FOREIGN KEY fk_escape_room_review_author');
|
||||||
|
$this->addSql('ALTER TABLE escape_room DROP FOREIGN KEY fk_escape_room_submitted_by');
|
||||||
|
$this->addSql('DROP TABLE escape_room_review');
|
||||||
|
$this->addSql('DROP TABLE escape_room');
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1478
-4198
File diff suppressed because it is too large
Load Diff
+8
-5
@@ -12,21 +12,24 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.0",
|
"@babel/core": "^7.25.0",
|
||||||
"@babel/preset-env": "^7.25.0",
|
"@babel/preset-env": "^7.25.0",
|
||||||
"@symfony/webpack-encore": "^4.6.1",
|
"@symfony/webpack-encore": "^6.0.0",
|
||||||
"babel-loader": "^9.1.3",
|
"babel-loader": "^10.1.1",
|
||||||
"core-js": "^3.37.1",
|
"core-js": "^3.37.1",
|
||||||
"css-loader": "^7.1.2",
|
"css-loader": "^7.1.2",
|
||||||
"mini-css-extract-plugin": "^2.9.2",
|
"mini-css-extract-plugin": "^2.9.2",
|
||||||
"regenerator-runtime": "^0.14.1",
|
"regenerator-runtime": "^0.14.1",
|
||||||
"sass": "^1.101.0",
|
"sass": "^1.101.0",
|
||||||
"sass-loader": "^14.2.1",
|
"sass-loader": "^16.0.1",
|
||||||
"webpack": "^5.95.0",
|
"webpack": "^5.95.0",
|
||||||
"webpack-cli": "^5.1.4",
|
"webpack-cli": "^6.0.0",
|
||||||
"webpack-notifier": "^1.15.0"
|
"webpack-notifier": "^1.15.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"bootstrap-icons": "^1.13.1"
|
"bootstrap-icons": "^1.13.1",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"leaflet.markercluster": "^1.5.3",
|
||||||
|
"simple-datatables": "^10.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ namespace App\Command;
|
|||||||
use App\Game\Entity\Player;
|
use App\Game\Entity\Player;
|
||||||
use App\Game\Entity\Session;
|
use App\Game\Entity\Session;
|
||||||
use App\Game\Enum\GameSettingType;
|
use App\Game\Enum\GameSettingType;
|
||||||
|
use App\Game\Enum\HintCondition;
|
||||||
use App\Game\Enum\SessionSettingType;
|
use App\Game\Enum\SessionSettingType;
|
||||||
use App\Game\Enum\SessionStatus;
|
use App\Game\Enum\SessionStatus;
|
||||||
use App\Game\Event\PushMercureMessageEvent;
|
use App\Game\Event\PushMercureMessageEvent;
|
||||||
use App\Game\Repository\GameSettingRepository;
|
use App\Game\Repository\GameSettingRepository;
|
||||||
|
use App\Game\Repository\HintRepository;
|
||||||
use App\Game\Repository\SessionRepository;
|
use App\Game\Repository\SessionRepository;
|
||||||
use App\Game\Repository\SessionSettingRepository;
|
use App\Game\Repository\SessionSettingRepository;
|
||||||
use App\Game\Service\PlayerService;
|
use App\Game\Service\PlayerService;
|
||||||
@@ -28,22 +30,11 @@ final class SendMainframeHintsCommand extends Command
|
|||||||
{
|
{
|
||||||
private const DEFAULT_TOTAL_TIME = 3600;
|
private const DEFAULT_TOTAL_TIME = 3600;
|
||||||
|
|
||||||
private const HELP_THRESHOLD_SECONDS = 2 * 60;
|
|
||||||
private const BROADCAST_CHAT_THRESHOLD_SECONDS = 5 * 60;
|
|
||||||
private const PRIVATE_CHAT_THRESHOLD_SECONDS = 7 * 60;
|
|
||||||
private const VERIFY_THRESHOLD_SECONDS = 10 * 60;
|
|
||||||
private const VERIFY_ESCALATION_THRESHOLD_SECONDS = 13 * 60;
|
|
||||||
private const CD_THRESHOLD_SECONDS = 15 * 60;
|
|
||||||
private const RAPPORTS_THRESHOLD_SECONDS = 17 * 60;
|
|
||||||
private const RAPPORTS_ESCALATION_THRESHOLD_SECONDS = 19 * 60;
|
|
||||||
private const ENDGAME_NUDGE_THRESHOLD_SECONDS = 23 * 60;
|
|
||||||
private const ENDGAME_HURRY_THRESHOLD_SECONDS = 26 * 60;
|
|
||||||
private const ENDGAME_URGENT_THRESHOLD_SECONDS = 28 * 60;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly SessionRepository $sessionRepository,
|
private readonly SessionRepository $sessionRepository,
|
||||||
private readonly SessionSettingRepository $sessionSettingRepository,
|
private readonly SessionSettingRepository $sessionSettingRepository,
|
||||||
private readonly GameSettingRepository $gameSettingRepository,
|
private readonly GameSettingRepository $gameSettingRepository,
|
||||||
|
private readonly HintRepository $hintRepository,
|
||||||
private readonly PlayerService $playerService,
|
private readonly PlayerService $playerService,
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly EventDispatcherInterface $eventDispatcher,
|
private readonly EventDispatcherInterface $eventDispatcher,
|
||||||
@@ -110,90 +101,66 @@ final class SendMainframeHintsCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Walks this player's progress in strict dependency order and sends at most one
|
* Walks this game's admin-configured hints in order. For the first distinct
|
||||||
* hint: the one for the first step that's both unresolved and old enough to nudge
|
* condition that's still unresolved for this player, fires the most-escalated
|
||||||
* about. Later steps genuinely depend on earlier ones (e.g. /verify isn't even
|
* hint sharing that condition whose threshold has been reached, then stops -
|
||||||
* usable until a player has finished contacting everyone), so we stop at the
|
* later conditions are only reached once every one before them resolves, since
|
||||||
* first unresolved step instead of also evaluating - and possibly firing - hints
|
* they genuinely depend on it (e.g. /verify isn't even usable until a player has
|
||||||
* for steps that aren't actionable yet.
|
* finished contacting everyone).
|
||||||
*/
|
*/
|
||||||
private function sendNextHint(Session $session, Player $player, int $elapsed): bool
|
private function sendNextHint(Session $session, Player $player, int $elapsed): bool
|
||||||
{
|
{
|
||||||
if (!$this->hasUsedHelp($session, $player)) {
|
$hints = $this->hintRepository->findByGameOrdered($session->getGame());
|
||||||
if ($elapsed >= self::HELP_THRESHOLD_SECONDS) {
|
|
||||||
$this->publishHint($session, 'Have you already checked which functions are available to you through the help command?', $player);
|
$seenConditions = [];
|
||||||
|
foreach ($hints as $hint) {
|
||||||
|
$condition = $hint->getCondition();
|
||||||
|
|
||||||
|
if (in_array($condition, $seenConditions, true)) {
|
||||||
|
continue; // already handled this condition's group further up the list
|
||||||
|
}
|
||||||
|
$seenConditions[] = $condition;
|
||||||
|
|
||||||
|
if ($condition !== HintCondition::NONE && $this->isConditionResolved($condition, $session, $player)) {
|
||||||
|
continue; // move on to the next distinct condition
|
||||||
|
}
|
||||||
|
|
||||||
|
$group = array_filter($hints, static fn ($h) => $h->getCondition() === $condition);
|
||||||
|
|
||||||
|
$best = null;
|
||||||
|
foreach ($group as $candidate) {
|
||||||
|
if ($elapsed < $candidate->getThresholdSeconds()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($best === null || $candidate->getThresholdSeconds() > $best->getThresholdSeconds()) {
|
||||||
|
$best = $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($best !== null) {
|
||||||
|
$this->publishHint($session, $best->getMessage(), $player);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$hasBroadcast = $this->hasBroadcast($session, $player);
|
return false; // condition unresolved, but no threshold reached yet
|
||||||
$hasContactedEveryonePrivately = $this->hasContactedEveryonePrivately($session, $player);
|
|
||||||
|
|
||||||
if (!$hasBroadcast || !$hasContactedEveryonePrivately) {
|
|
||||||
if ($elapsed >= self::PRIVATE_CHAT_THRESHOLD_SECONDS) {
|
|
||||||
$this->publishHint($session, 'The AI virus can see all communication if you are using open communication channels. Contact each other privately as well.', $player);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (!$hasBroadcast && $elapsed >= self::BROADCAST_CHAT_THRESHOLD_SECONDS) {
|
|
||||||
$this->publishHint($session, 'You should establish communications with your fellow agents.', $player);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$this->isFullyVerified($session, $player)) {
|
|
||||||
if ($elapsed >= self::VERIFY_ESCALATION_THRESHOLD_SECONDS) {
|
|
||||||
$this->publishHint($session, 'You are still not verified! Please get your verification codes from your colleagues so you can verify.', $player);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if ($elapsed >= self::VERIFY_THRESHOLD_SECONDS) {
|
|
||||||
$this->publishHint($session, 'You need the help of your fellow agents to verify you. If both other agents have helped you in your verification, i can get you more rights. The help command might give you some more information.', $player);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->isStillInHomeDirectory($player)) {
|
|
||||||
if ($elapsed >= self::CD_THRESHOLD_SECONDS) {
|
|
||||||
$this->publishHint($session, 'You can change the folder you are working in. Check the help command for more information', $player);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$this->allMessagesDecoded($session, $player)) {
|
|
||||||
if ($elapsed >= self::RAPPORTS_ESCALATION_THRESHOLD_SECONDS) {
|
|
||||||
$this->publishHint($session, "Not all messages can be decoded by every agent. Every agent has their own personal decoding method. If you can't decode a message, maybe a colleague can.", $player);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if ($elapsed >= self::RAPPORTS_THRESHOLD_SECONDS && !$this->isInRapportsFolder($player)) {
|
|
||||||
$this->publishHint($session, 'You should go to the rapports directory to check out the rapports.', $player);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every earlier step is resolved for this player - only the endgame urgency
|
|
||||||
// nudges are left, gated purely on elapsed time.
|
|
||||||
if ($elapsed >= self::ENDGAME_URGENT_THRESHOLD_SECONDS) {
|
|
||||||
$this->publishHint($session, 'Please remove the files soon! The AI virus can not win! It will be a disaster if it does!', $player);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($elapsed >= self::ENDGAME_HURRY_THRESHOLD_SECONDS) {
|
|
||||||
$this->publishHint($session, 'HURRY! The AI virus is almost done decoding the last files before it will send everything out!', $player);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($elapsed >= self::ENDGAME_NUDGE_THRESHOLD_SECONDS) {
|
|
||||||
$this->publishHint($session, 'Have you checked out the help command to see what you can do?', $player);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isConditionResolved(HintCondition $condition, Session $session, Player $player): bool
|
||||||
|
{
|
||||||
|
return match ($condition) {
|
||||||
|
HintCondition::HELP_USED => $this->hasUsedHelp($session, $player),
|
||||||
|
HintCondition::BROADCAST_DONE => $this->hasBroadcast($session, $player),
|
||||||
|
HintCondition::CONTACTED_ALL_PRIVATELY => $this->hasContactedEveryonePrivately($session, $player),
|
||||||
|
HintCondition::VERIFIED => $this->isFullyVerified($session, $player),
|
||||||
|
HintCondition::LEFT_HOME_DIRECTORY => !$this->isStillInHomeDirectory($player),
|
||||||
|
HintCondition::ALL_DECODED => $this->allMessagesDecoded($session, $player),
|
||||||
|
HintCondition::NONE => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private function getElapsedPlayingSeconds(Session $session): ?int
|
private function getElapsedPlayingSeconds(Session $session): ?int
|
||||||
{
|
{
|
||||||
$timer = $session->getTimer();
|
$timer = $session->getTimer();
|
||||||
@@ -275,13 +242,6 @@ final class SendMainframeHintsCommand extends Command
|
|||||||
return $pwd === '/var/home/' . $player->getUser()->getUsername();
|
return $pwd === '/var/home/' . $player->getUser()->getUsername();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isInRapportsFolder(Player $player): bool
|
|
||||||
{
|
|
||||||
$pwd = $this->playerService->getCurrentPwdOfPlayer($player);
|
|
||||||
|
|
||||||
return $pwd !== null && str_starts_with($pwd, '/var/rapports');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True once all three agents have decoded their personal message - decoding
|
* True once all three agents have decoded their personal message - decoding
|
||||||
* player 1's grants 'sudo' to everyone, player 2's grants 'scan', player 3's
|
* player 1's grants 'sudo' to everyone, player 2's grants 'scan', player 3's
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Game\Controller;
|
||||||
|
|
||||||
|
use App\Website\Entity\ContactMessage;
|
||||||
|
use App\Website\Repository\ContactMessageRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
#[Route('/admin/contact')]
|
||||||
|
#[IsGranted('ROLE_ADMIN')]
|
||||||
|
final class GameAdminContactController extends AbstractController
|
||||||
|
{
|
||||||
|
#[Route('', name: 'game_admin_contact', methods: ['GET'])]
|
||||||
|
public function index(ContactMessageRepository $contactMessageRepository): Response
|
||||||
|
{
|
||||||
|
return $this->render('game/admin/contact/index.html.twig', [
|
||||||
|
'messages' => $contactMessageRepository->findActive(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/{id}', name: 'game_admin_contact_show', methods: ['GET'])]
|
||||||
|
public function show(ContactMessage $contactMessage, EntityManagerInterface $entityManager): Response
|
||||||
|
{
|
||||||
|
if (!$contactMessage->isRead()) {
|
||||||
|
$contactMessage->setReadAt(new \DateTimeImmutable());
|
||||||
|
$entityManager->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('game/admin/contact/show.html.twig', [
|
||||||
|
'message' => $contactMessage,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/{id}/delete', name: 'game_admin_contact_delete', methods: ['POST'])]
|
||||||
|
public function delete(ContactMessage $contactMessage, Request $request, EntityManagerInterface $entityManager): Response
|
||||||
|
{
|
||||||
|
if (!$this->isCsrfTokenValid('delete_contact_' . $contactMessage->getId(), $request->request->get('_token'))) {
|
||||||
|
$this->addFlash('danger', 'Invalid CSRF token.');
|
||||||
|
return $this->redirectToRoute('game_admin_contact');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$contactMessage->isDeleted()) {
|
||||||
|
$contactMessage->setDeletedAt(new \DateTimeImmutable());
|
||||||
|
$entityManager->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addFlash('success', sprintf('Message from "%s" deleted.', $contactMessage->getName()));
|
||||||
|
|
||||||
|
return $this->redirectToRoute('game_admin_contact');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ namespace App\Game\Controller;
|
|||||||
|
|
||||||
use App\Game\Entity\GameSetting;
|
use App\Game\Entity\GameSetting;
|
||||||
use App\Game\Enum\GameSettingType;
|
use App\Game\Enum\GameSettingType;
|
||||||
|
use App\Game\Enum\GameStatus;
|
||||||
use App\Game\Form\AdminGameType;
|
use App\Game\Form\AdminGameType;
|
||||||
use App\Game\Entity\Game;
|
use App\Game\Entity\Game;
|
||||||
use App\Game\Repository\GameRepository;
|
use App\Game\Repository\GameRepository;
|
||||||
@@ -29,6 +30,41 @@ final class GameAdminGameController extends AbstractController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Route('/new', name: 'game_admin_game_new', methods: ['GET', 'POST'])]
|
||||||
|
public function new(
|
||||||
|
Request $request,
|
||||||
|
EntityManagerInterface $em,
|
||||||
|
): Response {
|
||||||
|
$game = new Game();
|
||||||
|
$game->setStatus(GameStatus::IN_DEVELOPMENT);
|
||||||
|
|
||||||
|
$form = $this->createForm(AdminGameType::class, $game);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
$totalTime = $form->get('totalTime')->getData();
|
||||||
|
|
||||||
|
$em->persist($game);
|
||||||
|
|
||||||
|
if ($totalTime !== null) {
|
||||||
|
$totalTimeSetting = new GameSetting();
|
||||||
|
$totalTimeSetting->setGame($game);
|
||||||
|
$totalTimeSetting->setName(GameSettingType::TOTAL_TIME);
|
||||||
|
$totalTimeSetting->setValue((string) $totalTime);
|
||||||
|
$em->persist($totalTimeSetting);
|
||||||
|
}
|
||||||
|
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash('success', sprintf('Game "%s" created.', $game->getName()));
|
||||||
|
|
||||||
|
return $this->redirectToRoute('game_admin_games');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('game/admin/games/new.html.twig', [
|
||||||
|
'form' => $form,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/{id}/edit', name: 'game_admin_game_edit', methods: ['GET', 'POST'])]
|
#[Route('/{id}/edit', name: 'game_admin_game_edit', methods: ['GET', 'POST'])]
|
||||||
public function edit(
|
public function edit(
|
||||||
Game $game,
|
Game $game,
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Game\Controller;
|
||||||
|
|
||||||
|
use App\Game\Entity\Game;
|
||||||
|
use App\Game\Entity\Hint;
|
||||||
|
use App\Game\Form\AdminHintType;
|
||||||
|
use App\Game\Repository\HintRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
#[Route('/admin/games/{game}/hints')]
|
||||||
|
#[IsGranted('ROLE_ADMIN')]
|
||||||
|
final class GameAdminHintController extends AbstractController
|
||||||
|
{
|
||||||
|
#[Route('', name: 'game_admin_hints', methods: ['GET'])]
|
||||||
|
public function index(Game $game, HintRepository $hintRepository): Response
|
||||||
|
{
|
||||||
|
return $this->render('game/admin/hints/index.html.twig', [
|
||||||
|
'game' => $game,
|
||||||
|
'hints' => $hintRepository->findByGameOrdered($game),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/new', name: 'game_admin_hint_new', methods: ['GET', 'POST'])]
|
||||||
|
public function new(Game $game, Request $request, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
$hint = new Hint();
|
||||||
|
$hint->setGame($game);
|
||||||
|
|
||||||
|
$form = $this->createForm(AdminHintType::class, $hint);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
$em->persist($hint);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
$this->addFlash('success', 'Hint created.');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('game_admin_hints', ['game' => $game->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('game/admin/hints/new.html.twig', [
|
||||||
|
'game' => $game,
|
||||||
|
'form' => $form,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/{id}/edit', name: 'game_admin_hint_edit', methods: ['GET', 'POST'])]
|
||||||
|
public function edit(Game $game, Hint $hint, Request $request, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
$form = $this->createForm(AdminHintType::class, $hint);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
$this->addFlash('success', 'Hint updated.');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('game_admin_hints', ['game' => $game->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('game/admin/hints/edit.html.twig', [
|
||||||
|
'game' => $game,
|
||||||
|
'hint' => $hint,
|
||||||
|
'form' => $form,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/{id}/delete', name: 'game_admin_hint_delete', methods: ['POST'])]
|
||||||
|
public function delete(Game $game, Hint $hint, Request $request, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
if (!$this->isCsrfTokenValid('delete_hint_' . $hint->getId(), $request->request->get('_token'))) {
|
||||||
|
$this->addFlash('error', 'Invalid CSRF token.');
|
||||||
|
return $this->redirectToRoute('game_admin_hints', ['game' => $game->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$em->remove($hint);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
$this->addFlash('success', 'Hint deleted.');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('game_admin_hints', ['game' => $game->getId()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Game\Entity;
|
||||||
|
|
||||||
|
use App\Game\Enum\HintCondition;
|
||||||
|
use App\Game\Repository\HintRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: HintRepository::class)]
|
||||||
|
#[ORM\Table(name: 'hint')]
|
||||||
|
class Hint
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Game::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private ?Game $game = null;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'hint_condition', type: 'string', length: 30, enumType: HintCondition::class)]
|
||||||
|
private ?HintCondition $condition = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private int $thresholdSeconds = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text')]
|
||||||
|
private ?string $message = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private int $sortOrder = 0;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getGame(): ?Game
|
||||||
|
{
|
||||||
|
return $this->game;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setGame(?Game $game): static
|
||||||
|
{
|
||||||
|
$this->game = $game;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCondition(): ?HintCondition
|
||||||
|
{
|
||||||
|
return $this->condition;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCondition(HintCondition $condition): static
|
||||||
|
{
|
||||||
|
$this->condition = $condition;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getThresholdSeconds(): int
|
||||||
|
{
|
||||||
|
return $this->thresholdSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setThresholdSeconds(int $thresholdSeconds): static
|
||||||
|
{
|
||||||
|
$this->thresholdSeconds = $thresholdSeconds;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMessage(): ?string
|
||||||
|
{
|
||||||
|
return $this->message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMessage(string $message): static
|
||||||
|
{
|
||||||
|
$this->message = $message;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSortOrder(): int
|
||||||
|
{
|
||||||
|
return $this->sortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSortOrder(int $sortOrder): static
|
||||||
|
{
|
||||||
|
$this->sortOrder = $sortOrder;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,5 +6,5 @@ enum DecodeMessage: string
|
|||||||
{
|
{
|
||||||
case PLAYER_1 = 'Sudo is now available';
|
case PLAYER_1 = 'Sudo is now available';
|
||||||
case PLAYER_2 = 'AI virus protects its own files by replacing them';
|
case PLAYER_2 = 'AI virus protects its own files by replacing them';
|
||||||
case PLAYER_3 = 'The locked up bash files should be removed to lock it up';
|
case PLAYER_3 = 'The AI virus is having a lock on certain files. Remove these to disable it.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Game\Enum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fixed set of game-progress checks a hint can be gated behind. A hint fires
|
||||||
|
* only once its condition is still unresolved for that player - admins compose,
|
||||||
|
* order, and word hints freely, but the underlying checks are real game state and
|
||||||
|
* stay in code (see SendMainframeHintsCommand::isConditionResolved()).
|
||||||
|
*/
|
||||||
|
enum HintCondition: string
|
||||||
|
{
|
||||||
|
case HELP_USED = 'help_used';
|
||||||
|
case BROADCAST_DONE = 'broadcast_done';
|
||||||
|
case CONTACTED_ALL_PRIVATELY = 'contacted_all_privately';
|
||||||
|
case VERIFIED = 'verified';
|
||||||
|
case LEFT_HOME_DIRECTORY = 'left_home_directory';
|
||||||
|
case ALL_DECODED = 'all_decoded';
|
||||||
|
case NONE = 'none';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::HELP_USED => "Player hasn't used the help command yet",
|
||||||
|
self::BROADCAST_DONE => "Player hasn't sent an open (broadcast) chat message yet",
|
||||||
|
self::CONTACTED_ALL_PRIVATELY => "Player hasn't privately messaged every other agent yet",
|
||||||
|
self::VERIFIED => "Player isn't fully verified yet",
|
||||||
|
self::LEFT_HOME_DIRECTORY => "Player hasn't left their home directory",
|
||||||
|
self::ALL_DECODED => "Player hasn't decoded all messages",
|
||||||
|
self::NONE => 'No condition - always eligible once earlier hints are resolved (e.g. endgame nudges)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Game\Form;
|
||||||
|
|
||||||
|
use App\Game\Entity\Hint;
|
||||||
|
use App\Game\Enum\HintCondition;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||||
|
use Symfony\Component\Validator\Constraints\PositiveOrZero;
|
||||||
|
|
||||||
|
class AdminHintType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
->add('condition', ChoiceType::class, [
|
||||||
|
'label' => 'Fires while...',
|
||||||
|
'choices' => array_combine(
|
||||||
|
array_map(fn (HintCondition $c) => $c->label(), HintCondition::cases()),
|
||||||
|
HintCondition::cases(),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
->add('thresholdSeconds', IntegerType::class, [
|
||||||
|
'label' => 'Threshold (seconds since game start)',
|
||||||
|
'constraints' => [new NotBlank(), new PositiveOrZero()],
|
||||||
|
])
|
||||||
|
->add('message', TextareaType::class, [
|
||||||
|
'constraints' => [new NotBlank()],
|
||||||
|
'attr' => ['rows' => 4],
|
||||||
|
])
|
||||||
|
->add('sortOrder', IntegerType::class, [
|
||||||
|
'label' => 'Order (lower fires first)',
|
||||||
|
'constraints' => [new NotBlank()],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'data_class' => Hint::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Game\Repository;
|
namespace App\Game\Repository;
|
||||||
|
|
||||||
use App\Game\Entity\Game;
|
use App\Game\Entity\Game;
|
||||||
|
use App\Game\Enum\GameStatus;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
use Doctrine\Persistence\ManagerRegistry;
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
@@ -15,4 +16,9 @@ class GameRepository extends ServiceEntityRepository
|
|||||||
{
|
{
|
||||||
parent::__construct($registry, Game::class);
|
parent::__construct($registry, Game::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function hasOpenGame(): bool
|
||||||
|
{
|
||||||
|
return $this->count(['status' => GameStatus::OPEN]) > 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Game\Repository;
|
||||||
|
|
||||||
|
use App\Game\Entity\Game;
|
||||||
|
use App\Game\Entity\Hint;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Hint>
|
||||||
|
*/
|
||||||
|
class HintRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Hint::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Hint[]
|
||||||
|
*/
|
||||||
|
public function findByGameOrdered(Game $game): array
|
||||||
|
{
|
||||||
|
return $this->findBy(['game' => $game], ['sortOrder' => 'ASC']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -179,6 +179,14 @@ class GameResponseService
|
|||||||
|
|
||||||
$result = $this->handleVerifyMessage($message, $player);
|
$result = $this->handleVerifyMessage($message, $player);
|
||||||
return ['result' => [$result]];
|
return ['result' => [$result]];
|
||||||
|
case '/pwd':
|
||||||
|
case '/ls':
|
||||||
|
case '/scan':
|
||||||
|
case '/sudo':
|
||||||
|
case '/rm':
|
||||||
|
case '/cd':
|
||||||
|
case '/cat':
|
||||||
|
return $this->checkConsoleCommando(substr($message, 1), $player);
|
||||||
default:
|
default:
|
||||||
return ['result' => ['Unknown command']];
|
return ['result' => ['Unknown command']];
|
||||||
}
|
}
|
||||||
@@ -240,7 +248,9 @@ class GameResponseService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$filename = $messagePart[1];
|
$filename = $messagePart[1];
|
||||||
$fullPath = ($pwd === '/' ? '' : $pwd) . '/' . $filename;
|
$fullPath = str_starts_with($filename, '/')
|
||||||
|
? $filename
|
||||||
|
: ($pwd === '/' ? '' : $pwd) . '/' . $filename;
|
||||||
|
|
||||||
if(!$this->isAllowedToRemove($fullPath, $player, $sudo))
|
if(!$this->isAllowedToRemove($fullPath, $player, $sudo))
|
||||||
return ['result' => ['You are not allowed to remove this file.']];
|
return ['result' => ['You are not allowed to remove this file.']];
|
||||||
@@ -292,6 +302,10 @@ class GameResponseService
|
|||||||
$result[] = 'These files are protected by the AI virus. Use sudo rm {file} to remove them.';
|
$result[] = 'These files are protected by the AI virus. Use sudo rm {file} to remove them.';
|
||||||
|
|
||||||
return ['result' => $result];
|
return ['result' => $result];
|
||||||
|
case 'chat':
|
||||||
|
case 'verify':
|
||||||
|
case 'decode':
|
||||||
|
return $this->checkGameCommando('/'.$message, $player);
|
||||||
default:
|
default:
|
||||||
return ['result' => ['Unknown command']];
|
return ['result' => ['Unknown command']];
|
||||||
}
|
}
|
||||||
@@ -537,33 +551,7 @@ class GameResponseService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Grant verify right
|
// Grant verify right
|
||||||
$rightsSettingName = SessionSettingType::tryFrom('RightsForPlayer' . $screen);
|
$this->grantRights($player, ['verify', 'cat']);
|
||||||
|
|
||||||
if (!$rightsSettingName) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$setting = $this->sessionSettingRepository->getSetting($player->getSession(), $rightsSettingName, $player);
|
|
||||||
if (!$setting) {
|
|
||||||
return; // Should have been initialized
|
|
||||||
}
|
|
||||||
|
|
||||||
$rights = json_decode($setting->getValue() ?? '[]', true) ?? [];
|
|
||||||
$newRights = ['verify', 'cat'];
|
|
||||||
|
|
||||||
$updated = false;
|
|
||||||
foreach ($newRights as $newRight) {
|
|
||||||
if (!in_array($newRight, $rights)) {
|
|
||||||
$rights[] = $newRight;
|
|
||||||
$updated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($updated) {
|
|
||||||
$setting->setValue(json_encode($rights));
|
|
||||||
$this->entityManager->persist($setting);
|
|
||||||
$this->entityManager->flush();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleDecodeMessage(string $message, Player $player): string
|
private function handleDecodeMessage(string $message, Player $player): string
|
||||||
@@ -606,31 +594,59 @@ class GameResponseService
|
|||||||
|
|
||||||
private function grantRightToAllPlayers(Session $session, string $right): void
|
private function grantRightToAllPlayers(Session $session, string $right): void
|
||||||
{
|
{
|
||||||
$updated = false;
|
|
||||||
|
|
||||||
foreach ($session->getPlayers() as $sessionPlayer) {
|
foreach ($session->getPlayers() as $sessionPlayer) {
|
||||||
$rightsSettingName = SessionSettingType::tryFrom('RightsForPlayer' . $sessionPlayer->getScreen());
|
$this->grantRights($sessionPlayer, [$right]);
|
||||||
if (!$rightsSettingName) {
|
}
|
||||||
continue;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$setting = $this->sessionSettingRepository->getSetting($session, $rightsSettingName, $sessionPlayer);
|
/**
|
||||||
if (!$setting) {
|
* The single place a player's rights ever get granted. Persists any rights the
|
||||||
continue;
|
* player doesn't already have and, if anything actually changed, notifies that
|
||||||
}
|
* player (and only that player) over Mercure that they've gained access.
|
||||||
|
*
|
||||||
|
* @param string[] $newRights
|
||||||
|
* @return bool Whether any right was actually newly granted.
|
||||||
|
*/
|
||||||
|
private function grantRights(Player $player, array $newRights): bool
|
||||||
|
{
|
||||||
|
$screen = $player->getScreen();
|
||||||
|
$rightsSettingName = SessionSettingType::tryFrom('RightsForPlayer' . $screen);
|
||||||
|
|
||||||
$rights = json_decode($setting->getValue() ?? '[]', true) ?? [];
|
if (!$rightsSettingName) {
|
||||||
if (!in_array($right, $rights)) {
|
return false;
|
||||||
$rights[] = $right;
|
}
|
||||||
$setting->setValue(json_encode($rights));
|
|
||||||
$this->entityManager->persist($setting);
|
$setting = $this->sessionSettingRepository->getSetting($player->getSession(), $rightsSettingName, $player);
|
||||||
$updated = true;
|
if (!$setting) {
|
||||||
|
return false; // Should have been initialized
|
||||||
|
}
|
||||||
|
|
||||||
|
$rights = json_decode($setting->getValue() ?? '[]', true) ?? [];
|
||||||
|
$granted = [];
|
||||||
|
|
||||||
|
foreach ($newRights as $newRight) {
|
||||||
|
if (!in_array($newRight, $rights)) {
|
||||||
|
$rights[] = $newRight;
|
||||||
|
$granted[] = $newRight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($updated) {
|
if (empty($granted)) {
|
||||||
$this->entityManager->flush();
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$setting->setValue(json_encode($rights));
|
||||||
|
$this->entityManager->persist($setting);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->eventDispatcher->dispatch(new PushMercureMessageEvent(
|
||||||
|
$player->getSession(),
|
||||||
|
[$screen, 'MAINFRAME: You have received new access: ' . implode(', ', $granted) . '.', 'mainframe'],
|
||||||
|
'rights_granted',
|
||||||
|
$screen,
|
||||||
|
));
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -890,34 +906,7 @@ class GameResponseService
|
|||||||
|
|
||||||
private function grantVerificationRights(Player $player): void
|
private function grantVerificationRights(Player $player): void
|
||||||
{
|
{
|
||||||
$screen = $player->getScreen();
|
if ($this->grantRights($player, ['cd', 'decode'])) {
|
||||||
$rightsSettingName = SessionSettingType::tryFrom('RightsForPlayer' . $screen);
|
|
||||||
|
|
||||||
if (!$rightsSettingName) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$setting = $this->sessionSettingRepository->getSetting($player->getSession(), $rightsSettingName, $player);
|
|
||||||
if (!$setting) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rights = json_decode($setting->getValue() ?? '[]', true) ?? [];
|
|
||||||
$newRights = ['cd', 'decode'];
|
|
||||||
|
|
||||||
$updated = false;
|
|
||||||
foreach ($newRights as $newRight) {
|
|
||||||
if (!in_array($newRight, $rights)) {
|
|
||||||
$rights[] = $newRight;
|
|
||||||
$updated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($updated) {
|
|
||||||
$setting->setValue(json_encode($rights));
|
|
||||||
$this->entityManager->persist($setting);
|
|
||||||
$this->entityManager->flush();
|
|
||||||
|
|
||||||
$this->checkIfAllPlayersVerified($player);
|
$this->checkIfAllPlayersVerified($player);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Website\Command;
|
||||||
|
|
||||||
|
use App\Website\Entity\EscapeRoom;
|
||||||
|
use App\Website\Enum\EscapeRoomSource;
|
||||||
|
use App\Website\Enum\EscapeRoomStatus;
|
||||||
|
use App\Website\Repository\EscapeRoomRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:escaperooms:import-osm',
|
||||||
|
description: 'Import physical escape rooms (leisure=escape_game) from OpenStreetMap via the Overpass API',
|
||||||
|
)]
|
||||||
|
final class ImportEscapeRoomsFromOsmCommand extends Command
|
||||||
|
{
|
||||||
|
private const DEFAULT_ENDPOINT = 'https://overpass-api.de/api/interpreter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Continental-ish tiles covering inhabited land. Used with --tiled so no single
|
||||||
|
* Overpass response has to carry the whole planet at once.
|
||||||
|
*
|
||||||
|
* @var array<string, array{float, float, float, float}> name => [south, west, north, east]
|
||||||
|
*/
|
||||||
|
private const WORLD_TILES = [
|
||||||
|
'Europe' => [34.0, -25.0, 72.0, 45.0],
|
||||||
|
'Africa' => [-36.0, -20.0, 38.0, 52.0],
|
||||||
|
'Middle East' => [12.0, 26.0, 43.0, 63.0],
|
||||||
|
'North Asia' => [40.0, 45.0, 78.0, 180.0],
|
||||||
|
'South Asia' => [5.0, 63.0, 40.0, 93.0],
|
||||||
|
'East Asia' => [18.0, 93.0, 54.0, 146.0],
|
||||||
|
'SE Asia/Oceania' => [-48.0, 93.0, 18.0, 180.0],
|
||||||
|
'North America' => [10.0, -168.0, 72.0, -52.0],
|
||||||
|
'Central America' => [7.0, -92.0, 33.0, -58.0],
|
||||||
|
'South America' => [-56.0, -82.0, 13.0, -34.0],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly HttpClientInterface $httpClient,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly EscapeRoomRepository $rooms,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addOption('area', null, InputOption::VALUE_REQUIRED, 'Restrict to one country by ISO 3166-1 alpha-2 code, e.g. --area=NL')
|
||||||
|
->addOption('bbox', null, InputOption::VALUE_REQUIRED, 'Restrict to a bounding box: --bbox=south,west,north,east')
|
||||||
|
->addOption('tiled', null, InputOption::VALUE_NONE, 'Split a worldwide import into continental tiles (recommended for the first run)')
|
||||||
|
->addOption('endpoint', null, InputOption::VALUE_REQUIRED, 'Overpass API endpoint', self::DEFAULT_ENDPOINT)
|
||||||
|
->addOption('timeout', null, InputOption::VALUE_REQUIRED, 'Overpass server-side timeout in seconds', '600')
|
||||||
|
->addOption('sleep', null, InputOption::VALUE_REQUIRED, 'Seconds to wait between requests', '5')
|
||||||
|
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Fetch and parse but write nothing to the database');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$endpoint = (string) $input->getOption('endpoint');
|
||||||
|
$timeout = max(30, (int) $input->getOption('timeout'));
|
||||||
|
$sleep = max(0, (int) $input->getOption('sleep'));
|
||||||
|
$dryRun = (bool) $input->getOption('dry-run');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$queries = $this->buildQueries($input, $timeout);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
$io->error($e->getMessage());
|
||||||
|
|
||||||
|
return Command::INVALID;
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->title('Importing escape rooms from OpenStreetMap');
|
||||||
|
$io->writeln(sprintf('Endpoint: <info>%s</info> · batches: <info>%d</info>%s', $endpoint, \count($queries), $dryRun ? ' · <comment>dry run</comment>' : ''));
|
||||||
|
|
||||||
|
$totals = ['created' => 0, 'updated' => 0, 'locked' => 0, 'noname' => 0, 'nocoords' => 0];
|
||||||
|
$seen = 0;
|
||||||
|
$first = true;
|
||||||
|
|
||||||
|
foreach ($queries as $label => $ql) {
|
||||||
|
if (!$first && $sleep > 0) {
|
||||||
|
$io->writeln(sprintf(' <comment>sleeping %ds…</comment>', $sleep));
|
||||||
|
sleep($sleep);
|
||||||
|
}
|
||||||
|
$first = false;
|
||||||
|
|
||||||
|
$io->section((string) $label);
|
||||||
|
|
||||||
|
$elements = $this->fetch($endpoint, $ql, $timeout, $io);
|
||||||
|
if ($elements === null) {
|
||||||
|
$io->warning('Skipping this batch after a failed request.');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->writeln(sprintf(' %d element(s) returned', \count($elements)));
|
||||||
|
$batch = 0;
|
||||||
|
|
||||||
|
foreach ($elements as $element) {
|
||||||
|
++$seen;
|
||||||
|
$outcome = $this->upsert($element, $dryRun);
|
||||||
|
++$totals[$outcome];
|
||||||
|
|
||||||
|
if (!$dryRun && $outcome !== 'noname' && $outcome !== 'nocoords' && ++$batch % 400 === 0) {
|
||||||
|
$this->em->flush();
|
||||||
|
$this->em->clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$dryRun) {
|
||||||
|
$this->em->flush();
|
||||||
|
$this->em->clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->newLine();
|
||||||
|
$io->success(sprintf(
|
||||||
|
"%d element(s) processed\n created: %d\n updated: %d\n skipped (locked): %d\n skipped (no name): %d\n skipped (no coords): %d",
|
||||||
|
$seen,
|
||||||
|
$totals['created'],
|
||||||
|
$totals['updated'],
|
||||||
|
$totals['locked'],
|
||||||
|
$totals['noname'],
|
||||||
|
$totals['nocoords'],
|
||||||
|
));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string> label => Overpass QL
|
||||||
|
*/
|
||||||
|
private function buildQueries(InputInterface $input, int $timeout): array
|
||||||
|
{
|
||||||
|
$area = $input->getOption('area');
|
||||||
|
$bbox = $input->getOption('bbox');
|
||||||
|
$tiled = (bool) $input->getOption('tiled');
|
||||||
|
|
||||||
|
$header = sprintf('[out:json][timeout:%d];', $timeout);
|
||||||
|
$footer = 'out center tags;';
|
||||||
|
|
||||||
|
if ($area !== null) {
|
||||||
|
$code = strtoupper(trim((string) $area));
|
||||||
|
if (!preg_match('/^[A-Z]{2}$/', $code)) {
|
||||||
|
throw new \InvalidArgumentException('--area expects a two-letter ISO country code, e.g. NL');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'Country '.$code => sprintf('%s area["ISO3166-1"="%s"][admin_level=2]->.a; nwr["leisure"="escape_game"](area.a); %s', $header, $code, $footer),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($bbox !== null) {
|
||||||
|
$box = $this->parseBbox((string) $bbox);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'Bounding box' => sprintf('%s nwr["leisure"="escape_game"](%F,%F,%F,%F); %s', $header, $box[0], $box[1], $box[2], $box[3], $footer),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tiled) {
|
||||||
|
$queries = [];
|
||||||
|
foreach (self::WORLD_TILES as $name => $box) {
|
||||||
|
$queries[$name] = sprintf('%s nwr["leisure"="escape_game"](%F,%F,%F,%F); %s', $header, $box[0], $box[1], $box[2], $box[3], $footer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $queries;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'Worldwide' => sprintf('%s nwr["leisure"="escape_game"]; %s', $header, $footer),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{float, float, float, float} south, west, north, east
|
||||||
|
*/
|
||||||
|
private function parseBbox(string $raw): array
|
||||||
|
{
|
||||||
|
$parts = array_map('trim', explode(',', $raw));
|
||||||
|
if (\count($parts) !== 4 || array_filter($parts, static fn ($p) => !is_numeric($p))) {
|
||||||
|
throw new \InvalidArgumentException('--bbox expects four comma-separated numbers: south,west,north,east');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [(float) $parts[0], (float) $parts[1], (float) $parts[2], (float) $parts[3]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>|null
|
||||||
|
*/
|
||||||
|
private function fetch(string $endpoint, string $ql, int $timeout, SymfonyStyle $io): ?array
|
||||||
|
{
|
||||||
|
for ($attempt = 1; $attempt <= 2; ++$attempt) {
|
||||||
|
try {
|
||||||
|
$response = $this->httpClient->request('POST', $endpoint, [
|
||||||
|
'body' => ['data' => $ql],
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
'timeout' => $timeout + 60,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$data = $response->toArray();
|
||||||
|
|
||||||
|
if (isset($data['remark']) && str_contains((string) $data['remark'], 'error')) {
|
||||||
|
$io->warning('Overpass reported: '.$data['remark']);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data['elements'] ?? [];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$io->writeln(sprintf(' <comment>request failed (attempt %d/2): %s</comment>', $attempt, $e->getMessage()));
|
||||||
|
if ($attempt < 2) {
|
||||||
|
sleep(15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $element
|
||||||
|
*
|
||||||
|
* @return 'created'|'updated'|'locked'|'noname'|'nocoords'
|
||||||
|
*/
|
||||||
|
private function upsert(array $element, bool $dryRun): string
|
||||||
|
{
|
||||||
|
$tags = $element['tags'] ?? [];
|
||||||
|
$name = mb_substr(trim((string) ($tags['name'] ?? '')), 0, 255);
|
||||||
|
if ($name === '') {
|
||||||
|
return 'noname';
|
||||||
|
}
|
||||||
|
|
||||||
|
[$lat, $lng] = $this->coords($element);
|
||||||
|
if ($lat === null || $lng === null) {
|
||||||
|
return 'nocoords';
|
||||||
|
}
|
||||||
|
|
||||||
|
$osmType = (string) ($element['type'] ?? 'node');
|
||||||
|
$osmId = (string) ($element['id'] ?? '');
|
||||||
|
|
||||||
|
$room = $this->rooms->findOneByOsm($osmType, $osmId);
|
||||||
|
$isNew = $room === null;
|
||||||
|
|
||||||
|
if (!$isNew && $room->isLocked()) {
|
||||||
|
return 'locked';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isNew) {
|
||||||
|
$room = (new EscapeRoom())
|
||||||
|
->setSource(EscapeRoomSource::OSM)
|
||||||
|
->setStatus(EscapeRoomStatus::PUBLISHED)
|
||||||
|
->setOsmType($osmType)
|
||||||
|
->setOsmId($osmId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp each value to its column width - OSM address tags are free text
|
||||||
|
// and occasionally blow past these (e.g. addr:housenumber "12-14, 16, 18").
|
||||||
|
$room
|
||||||
|
->setName($name)
|
||||||
|
->setVenue($this->firstTag($tags, ['brand', 'operator'], 255))
|
||||||
|
->setStreet($this->firstTag($tags, ['addr:street'], 255))
|
||||||
|
->setHouseNumber($this->firstTag($tags, ['addr:housenumber'], 32))
|
||||||
|
->setPostcode($this->firstTag($tags, ['addr:postcode'], 32))
|
||||||
|
->setCity($this->firstTag($tags, ['addr:city', 'addr:town', 'addr:suburb'], 128))
|
||||||
|
->setCountry($this->normaliseCountry($this->firstTag($tags, ['addr:country'], 8)))
|
||||||
|
->setWebsite($this->firstTag($tags, ['website', 'contact:website', 'url'], 511))
|
||||||
|
->setPhone($this->firstTag($tags, ['phone', 'contact:phone'], 64))
|
||||||
|
->setLatitude($lat)
|
||||||
|
->setLongitude($lng);
|
||||||
|
|
||||||
|
if (!$isNew) {
|
||||||
|
$room->touch();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$dryRun) {
|
||||||
|
$this->em->persist($room);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $isNew ? 'created' : 'updated';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $element
|
||||||
|
*
|
||||||
|
* @return array{?float, ?float}
|
||||||
|
*/
|
||||||
|
private function coords(array $element): array
|
||||||
|
{
|
||||||
|
if (isset($element['lat'], $element['lon'])) {
|
||||||
|
return [(float) $element['lat'], (float) $element['lon']];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($element['center']['lat'], $element['center']['lon'])) {
|
||||||
|
return [(float) $element['center']['lat'], (float) $element['center']['lon']];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First non-empty value among $keys, trimmed and clamped to $maxLen chars.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $tags
|
||||||
|
* @param string[] $keys
|
||||||
|
*/
|
||||||
|
private function firstTag(array $tags, array $keys, int $maxLen = 255): ?string
|
||||||
|
{
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
$value = trim((string) ($tags[$key] ?? ''));
|
||||||
|
if ($value !== '') {
|
||||||
|
return mb_substr($value, 0, $maxLen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normaliseCountry(?string $value): ?string
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = strtoupper(trim($value));
|
||||||
|
|
||||||
|
return preg_match('/^[A-Z]{2}$/', $value) === 1 ? $value : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Website\Controller;
|
||||||
|
|
||||||
|
use App\Website\Entity\EscapeRoom;
|
||||||
|
use App\Website\Entity\EscapeRoomReview;
|
||||||
|
use App\Website\Enum\EscapeRoomStatus;
|
||||||
|
use App\Website\Form\AdminEscapeRoomType;
|
||||||
|
use App\Website\Repository\EscapeRoomRepository;
|
||||||
|
use App\Website\Repository\EscapeRoomReviewRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
#[Route('/admin/escape-rooms')]
|
||||||
|
#[IsGranted('ROLE_ADMIN')]
|
||||||
|
final class AdminEscapeRoomController extends AbstractController
|
||||||
|
{
|
||||||
|
#[Route('', name: 'website_admin_escaperooms', methods: ['GET'])]
|
||||||
|
public function index(Request $request, EscapeRoomRepository $rooms): Response
|
||||||
|
{
|
||||||
|
$filter = EscapeRoomStatus::tryFrom((string) $request->query->get('status'));
|
||||||
|
|
||||||
|
return $this->render('website/admin/escape_room/index.html.twig', [
|
||||||
|
'rooms' => $rooms->findForAdmin($filter),
|
||||||
|
'reviewCounts' => $rooms->reviewCounts(),
|
||||||
|
'filter' => $filter,
|
||||||
|
'counts' => [
|
||||||
|
'all' => \count($rooms->findForAdmin()),
|
||||||
|
EscapeRoomStatus::PENDING->value => $rooms->countByStatus(EscapeRoomStatus::PENDING),
|
||||||
|
EscapeRoomStatus::PUBLISHED->value => $rooms->countByStatus(EscapeRoomStatus::PUBLISHED),
|
||||||
|
EscapeRoomStatus::HIDDEN->value => $rooms->countByStatus(EscapeRoomStatus::HIDDEN),
|
||||||
|
EscapeRoomStatus::REJECTED->value => $rooms->countByStatus(EscapeRoomStatus::REJECTED),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/new', name: 'website_admin_escaperoom_new', methods: ['GET', 'POST'])]
|
||||||
|
public function new(Request $request, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
$room = new EscapeRoom();
|
||||||
|
$form = $this->createForm(AdminEscapeRoomType::class, $room);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
$em->persist($room);
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash('success', sprintf('"%s" added.', $room->getName()));
|
||||||
|
|
||||||
|
return $this->redirectToRoute('website_admin_escaperooms');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('website/admin/escape_room/form.html.twig', [
|
||||||
|
'form' => $form,
|
||||||
|
'room' => $room,
|
||||||
|
'heading' => 'New escape room',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/{id}/edit', name: 'website_admin_escaperoom_edit', methods: ['GET', 'POST'], requirements: ['id' => '\d+'])]
|
||||||
|
public function edit(EscapeRoom $room, Request $request, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
$form = $this->createForm(AdminEscapeRoomType::class, $room);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
$room->touch();
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash('success', 'Changes saved.');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('website_admin_escaperooms');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('website/admin/escape_room/form.html.twig', [
|
||||||
|
'form' => $form,
|
||||||
|
'room' => $room,
|
||||||
|
'heading' => 'Edit: '.$room->getName(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/{id}/status/{status}', name: 'website_admin_escaperoom_moderate', methods: ['POST'], requirements: ['id' => '\d+'])]
|
||||||
|
public function moderate(EscapeRoom $room, string $status, Request $request, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
if (!$this->isCsrfTokenValid('moderate_room_'.$room->getId(), (string) $request->request->get('_token'))) {
|
||||||
|
$this->addFlash('danger', 'Invalid CSRF token.');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('website_admin_escaperooms');
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = EscapeRoomStatus::tryFrom($status);
|
||||||
|
if ($target === null) {
|
||||||
|
throw $this->createNotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$room->setStatus($target)->touch();
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash('success', sprintf('"%s" is now %s.', $room->getName(), $target->label()));
|
||||||
|
|
||||||
|
return $this->redirectToRoute('website_admin_escaperooms', ['status' => $request->request->get('return_to')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/{id}/delete', name: 'website_admin_escaperoom_delete', methods: ['POST'], requirements: ['id' => '\d+'])]
|
||||||
|
public function delete(EscapeRoom $room, Request $request, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
if (!$this->isCsrfTokenValid('delete_room_'.$room->getId(), (string) $request->request->get('_token'))) {
|
||||||
|
$this->addFlash('danger', 'Invalid CSRF token.');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('website_admin_escaperooms');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$room->isDeleted()) {
|
||||||
|
$room->setDeletedAt(new \DateTimeImmutable());
|
||||||
|
$em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addFlash('success', sprintf('"%s" deleted.', $room->getName()));
|
||||||
|
|
||||||
|
return $this->redirectToRoute('website_admin_escaperooms');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/reviews', name: 'website_admin_escaperoom_reviews', methods: ['GET'])]
|
||||||
|
public function reviews(EscapeRoomReviewRepository $reviews): Response
|
||||||
|
{
|
||||||
|
return $this->render('website/admin/review/index.html.twig', [
|
||||||
|
'reviews' => $reviews->findRecent(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/reviews/{id}/delete', name: 'website_admin_escaperoom_review_delete', methods: ['POST'], requirements: ['id' => '\d+'])]
|
||||||
|
public function deleteReview(EscapeRoomReview $review, Request $request, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
if (!$this->isCsrfTokenValid('delete_review_'.$review->getId(), (string) $request->request->get('_token'))) {
|
||||||
|
$this->addFlash('danger', 'Invalid CSRF token.');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('website_admin_escaperoom_reviews');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$review->isDeleted()) {
|
||||||
|
$review->setDeletedAt(new \DateTimeImmutable());
|
||||||
|
$em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addFlash('success', 'Review removed.');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('website_admin_escaperoom_reviews');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Website\Controller;
|
||||||
|
|
||||||
|
use App\Website\Form\ContactFormType;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
|
|
||||||
|
final class ContactController extends AbstractController
|
||||||
|
{
|
||||||
|
#[Route(path: '/contact', name: 'website_contact', methods: ['GET', 'POST'])]
|
||||||
|
public function contact(Request $request, EntityManagerInterface $entityManager): Response
|
||||||
|
{
|
||||||
|
$form = $this->createForm(ContactFormType::class);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
$entityManager->persist($form->getData());
|
||||||
|
$entityManager->flush();
|
||||||
|
|
||||||
|
$this->addFlash('success', 'Thanks for reaching out — your message has been sent.');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('website_contact');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('website/contact.html.twig', [
|
||||||
|
'contactForm' => $form->createView(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Website\Controller;
|
||||||
|
|
||||||
|
use App\Tech\Entity\User;
|
||||||
|
use App\Website\Entity\EscapeRoom;
|
||||||
|
use App\Website\Entity\EscapeRoomReview;
|
||||||
|
use App\Website\Enum\EscapeRoomSource;
|
||||||
|
use App\Website\Enum\EscapeRoomStatus;
|
||||||
|
use App\Website\Form\EscapeRoomReviewType;
|
||||||
|
use App\Website\Form\SuggestEscapeRoomType;
|
||||||
|
use App\Website\Repository\EscapeRoomRepository;
|
||||||
|
use App\Website\Repository\EscapeRoomReviewRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\Form\FormInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
final class EscapeRoomController extends AbstractController
|
||||||
|
{
|
||||||
|
#[Route(path: '/escape-rooms', name: 'website_escaperooms', methods: ['GET'])]
|
||||||
|
public function index(EscapeRoomRepository $rooms): Response
|
||||||
|
{
|
||||||
|
return $this->render('website/escape_room/index.html.twig', [
|
||||||
|
'publishedCount' => $rooms->countByStatus(EscapeRoomStatus::PUBLISHED),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slim feed the map and the list are both built from. Cached at the edge for
|
||||||
|
* a few minutes; the dataset only changes when the importer runs or an admin
|
||||||
|
* edits a room.
|
||||||
|
*/
|
||||||
|
#[Route(path: '/escape-rooms/data.json', name: 'website_escaperooms_data', methods: ['GET'])]
|
||||||
|
public function data(EscapeRoomRepository $rooms): JsonResponse
|
||||||
|
{
|
||||||
|
$response = new JsonResponse([
|
||||||
|
'generatedAt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM),
|
||||||
|
'rooms' => $rooms->findMapData(),
|
||||||
|
]);
|
||||||
|
$response->setPublic();
|
||||||
|
$response->setMaxAge(600);
|
||||||
|
$response->setSharedMaxAge(600);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/escape-rooms/suggest', name: 'website_escaperoom_suggest', methods: ['GET', 'POST'])]
|
||||||
|
public function suggest(Request $request, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
$room = new EscapeRoom();
|
||||||
|
$form = $this->createForm(SuggestEscapeRoomType::class, $room);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
$room->setSource(EscapeRoomSource::USER)
|
||||||
|
->setStatus(EscapeRoomStatus::PENDING);
|
||||||
|
|
||||||
|
if ($this->getUser() instanceof User) {
|
||||||
|
$room->setSubmittedBy($this->getUser());
|
||||||
|
}
|
||||||
|
|
||||||
|
$em->persist($room);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
$this->addFlash('success', 'Thanks! Your suggestion has been sent for review and will appear on the map once approved.');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('website_escaperooms');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('website/escape_room/suggest.html.twig', [
|
||||||
|
'form' => $form,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/escape-rooms/{id}-{slug}', name: 'website_escaperoom_show', methods: ['GET'], requirements: ['id' => '\d+', 'slug' => '[a-z0-9-]*'])]
|
||||||
|
public function show(EscapeRoom $room, EscapeRoomReviewRepository $reviews): Response
|
||||||
|
{
|
||||||
|
$this->guardVisible($room);
|
||||||
|
|
||||||
|
return $this->renderRoom($room, $reviews, $this->createForm(EscapeRoomReviewType::class));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/escape-rooms/{id}/reviews', name: 'website_escaperoom_review', methods: ['POST'], requirements: ['id' => '\d+'])]
|
||||||
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
|
public function review(
|
||||||
|
EscapeRoom $room,
|
||||||
|
Request $request,
|
||||||
|
EntityManagerInterface $em,
|
||||||
|
EscapeRoomReviewRepository $reviews,
|
||||||
|
): Response {
|
||||||
|
$this->guardVisible($room);
|
||||||
|
|
||||||
|
$review = new EscapeRoomReview();
|
||||||
|
$form = $this->createForm(EscapeRoomReviewType::class, $review);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $this->getUser();
|
||||||
|
$review->setEscapeRoom($room)->setAuthor($user);
|
||||||
|
|
||||||
|
$em->persist($review);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
$this->addFlash('success', 'Thanks for your review!');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('website_escaperoom_show', [
|
||||||
|
'id' => $room->getId(),
|
||||||
|
'slug' => $room->getSlug(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->renderRoom($room, $reviews, $form);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function guardVisible(EscapeRoom $room): void
|
||||||
|
{
|
||||||
|
if ($room->isDeleted() || !$room->getStatus()->isPubliclyVisible()) {
|
||||||
|
throw $this->createNotFoundException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderRoom(EscapeRoom $room, EscapeRoomReviewRepository $reviews, FormInterface $reviewForm): Response
|
||||||
|
{
|
||||||
|
return $this->render('website/escape_room/show.html.twig', [
|
||||||
|
'room' => $room,
|
||||||
|
'reviews' => $reviews->findVisibleForRoom($room),
|
||||||
|
'rating' => $reviews->ratingSummary($room),
|
||||||
|
'reviewForm' => $reviewForm,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Website\Controller;
|
namespace App\Website\Controller;
|
||||||
|
|
||||||
|
use App\Game\Repository\GameRepository;
|
||||||
use App\Game\Service\FeedbackService;
|
use App\Game\Service\FeedbackService;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
@@ -11,17 +12,19 @@ use Symfony\Component\Routing\Annotation\Route;
|
|||||||
final class HomeController extends AbstractController
|
final class HomeController extends AbstractController
|
||||||
{
|
{
|
||||||
#[Route(path: '', name: 'website_home')]
|
#[Route(path: '', name: 'website_home')]
|
||||||
public function index(): Response
|
public function index(GameRepository $gameRepository): Response
|
||||||
{
|
{
|
||||||
return $this->render(
|
return $this->render('website/home/index.html.twig', [
|
||||||
'website/home/index.html.twig');
|
'showDevBanner' => !$gameRepository->hasOpenGame(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route(path: '/briefing', name: 'website_intro')]
|
#[Route(path: '/briefing', name: 'website_intro')]
|
||||||
public function intro(FeedbackService $feedbackService): Response
|
public function intro(FeedbackService $feedbackService, GameRepository $gameRepository): Response
|
||||||
{
|
{
|
||||||
return $this->render('website/home/intro.html.twig', [
|
return $this->render('website/home/intro.html.twig', [
|
||||||
'feedbackSummary' => $feedbackService->getFeedbackSummary(),
|
'feedbackSummary' => $feedbackService->getFeedbackSummary(),
|
||||||
|
'showDevBanner' => !$gameRepository->hasOpenGame(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +35,13 @@ final class HomeController extends AbstractController
|
|||||||
'website/home/help_wanted.html.twig');
|
'website/home/help_wanted.html.twig');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/terms', name: 'website_terms')]
|
||||||
|
public function terms(): Response
|
||||||
|
{
|
||||||
|
return $this->render(
|
||||||
|
'website/home/terms.html.twig');
|
||||||
|
}
|
||||||
|
|
||||||
#[Route(path: '/donation/cancel', name: 'website_donation_cancel')]
|
#[Route(path: '/donation/cancel', name: 'website_donation_cancel')]
|
||||||
public function donationCancel(): Response
|
public function donationCancel(): Response
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Website\Entity;
|
||||||
|
|
||||||
|
use App\Website\Repository\ContactMessageRepository;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: ContactMessageRepository::class)]
|
||||||
|
#[ORM\Table(name: 'contact_message')]
|
||||||
|
class ContactMessage
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private ?string $name = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private ?string $email = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT)]
|
||||||
|
private ?string $message = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||||
|
private ?\DateTimeImmutable $createdAt = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $readAt = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $deletedAt = 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): static
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEmail(): ?string
|
||||||
|
{
|
||||||
|
return $this->email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEmail(string $email): static
|
||||||
|
{
|
||||||
|
$this->email = $email;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMessage(): ?string
|
||||||
|
{
|
||||||
|
return $this->message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMessage(string $message): static
|
||||||
|
{
|
||||||
|
$this->message = $message;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCreatedAt(\DateTimeImmutable $createdAt): static
|
||||||
|
{
|
||||||
|
$this->createdAt = $createdAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getReadAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->readAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setReadAt(?\DateTimeImmutable $readAt): static
|
||||||
|
{
|
||||||
|
$this->readAt = $readAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isRead(): bool
|
||||||
|
{
|
||||||
|
return $this->readAt !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeletedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->deletedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDeletedAt(?\DateTimeImmutable $deletedAt): static
|
||||||
|
{
|
||||||
|
$this->deletedAt = $deletedAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDeleted(): bool
|
||||||
|
{
|
||||||
|
return $this->deletedAt !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Website\Entity;
|
||||||
|
|
||||||
|
use App\Tech\Entity\User;
|
||||||
|
use App\Website\Enum\EscapeRoomSource;
|
||||||
|
use App\Website\Enum\EscapeRoomStatus;
|
||||||
|
use App\Website\Repository\EscapeRoomRepository;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: EscapeRoomRepository::class)]
|
||||||
|
#[ORM\Table(name: 'escape_room')]
|
||||||
|
#[ORM\UniqueConstraint(name: 'uniq_escape_room_osm', columns: ['osm_type', 'osm_id'])]
|
||||||
|
#[ORM\Index(name: 'idx_escape_room_status', columns: ['status'])]
|
||||||
|
#[ORM\Index(name: 'idx_escape_room_bbox', columns: ['latitude', 'longitude'])]
|
||||||
|
#[ORM\Index(name: 'idx_escape_room_submitted_by', columns: ['submitted_by_id'])]
|
||||||
|
class EscapeRoom
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private ?string $name = null;
|
||||||
|
|
||||||
|
/** Operating company / brand, when it differs from the room name. */
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
private ?string $venue = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
private ?string $street = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 32, nullable: true)]
|
||||||
|
private ?string $houseNumber = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 32, nullable: true)]
|
||||||
|
private ?string $postcode = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 128, nullable: true)]
|
||||||
|
private ?string $city = null;
|
||||||
|
|
||||||
|
/** ISO 3166-1 alpha-2, upper case. */
|
||||||
|
#[ORM\Column(length: 2, nullable: true)]
|
||||||
|
private ?string $country = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 7)]
|
||||||
|
private ?string $latitude = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 7)]
|
||||||
|
private ?string $longitude = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 511, nullable: true)]
|
||||||
|
private ?string $website = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 64, nullable: true)]
|
||||||
|
private ?string $phone = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 16, enumType: EscapeRoomSource::class)]
|
||||||
|
private EscapeRoomSource $source = EscapeRoomSource::ADMIN;
|
||||||
|
|
||||||
|
/** OSM element type: node | way | relation. Set together with osmId for imported rooms. */
|
||||||
|
#[ORM\Column(length: 8, nullable: true)]
|
||||||
|
private ?string $osmType = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::BIGINT, nullable: true)]
|
||||||
|
private ?string $osmId = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 16, enumType: EscapeRoomStatus::class)]
|
||||||
|
private EscapeRoomStatus $status = EscapeRoomStatus::PENDING;
|
||||||
|
|
||||||
|
/** Kept for imported rooms so a re-import never clobbers manual corrections. */
|
||||||
|
#[ORM\Column(options: ['default' => false])]
|
||||||
|
private bool $locked = false;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||||
|
private ?User $submittedBy = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||||
|
private ?\DateTimeImmutable $createdAt = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $updatedAt = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $deletedAt = null;
|
||||||
|
|
||||||
|
/** @var Collection<int, EscapeRoomReview> */
|
||||||
|
#[ORM\OneToMany(mappedBy: 'escapeRoom', targetEntity: EscapeRoomReview::class, cascade: ['remove'])]
|
||||||
|
private Collection $reviews;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createdAt = new \DateTimeImmutable();
|
||||||
|
$this->reviews = new ArrayCollection();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(string $name): static
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getVenue(): ?string
|
||||||
|
{
|
||||||
|
return $this->venue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setVenue(?string $venue): static
|
||||||
|
{
|
||||||
|
$this->venue = $venue;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStreet(): ?string
|
||||||
|
{
|
||||||
|
return $this->street;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStreet(?string $street): static
|
||||||
|
{
|
||||||
|
$this->street = $street;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHouseNumber(): ?string
|
||||||
|
{
|
||||||
|
return $this->houseNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHouseNumber(?string $houseNumber): static
|
||||||
|
{
|
||||||
|
$this->houseNumber = $houseNumber;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPostcode(): ?string
|
||||||
|
{
|
||||||
|
return $this->postcode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPostcode(?string $postcode): static
|
||||||
|
{
|
||||||
|
$this->postcode = $postcode;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCity(): ?string
|
||||||
|
{
|
||||||
|
return $this->city;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCity(?string $city): static
|
||||||
|
{
|
||||||
|
$this->city = $city;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCountry(): ?string
|
||||||
|
{
|
||||||
|
return $this->country;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCountry(?string $country): static
|
||||||
|
{
|
||||||
|
$this->country = $country ? strtoupper($country) : null;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLatitude(): ?string
|
||||||
|
{
|
||||||
|
return $this->latitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLatitude(string|float|null $latitude): static
|
||||||
|
{
|
||||||
|
$this->latitude = $latitude === null ? null : (string) $latitude;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLongitude(): ?string
|
||||||
|
{
|
||||||
|
return $this->longitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLongitude(string|float|null $longitude): static
|
||||||
|
{
|
||||||
|
$this->longitude = $longitude === null ? null : (string) $longitude;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getWebsite(): ?string
|
||||||
|
{
|
||||||
|
return $this->website;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setWebsite(?string $website): static
|
||||||
|
{
|
||||||
|
$this->website = $website;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPhone(): ?string
|
||||||
|
{
|
||||||
|
return $this->phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPhone(?string $phone): static
|
||||||
|
{
|
||||||
|
$this->phone = $phone;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSource(): EscapeRoomSource
|
||||||
|
{
|
||||||
|
return $this->source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSource(EscapeRoomSource $source): static
|
||||||
|
{
|
||||||
|
$this->source = $source;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOsmType(): ?string
|
||||||
|
{
|
||||||
|
return $this->osmType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setOsmType(?string $osmType): static
|
||||||
|
{
|
||||||
|
$this->osmType = $osmType;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOsmId(): ?string
|
||||||
|
{
|
||||||
|
return $this->osmId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setOsmId(int|string|null $osmId): static
|
||||||
|
{
|
||||||
|
$this->osmId = $osmId === null ? null : (string) $osmId;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatus(): EscapeRoomStatus
|
||||||
|
{
|
||||||
|
return $this->status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStatus(EscapeRoomStatus $status): static
|
||||||
|
{
|
||||||
|
$this->status = $status;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isLocked(): bool
|
||||||
|
{
|
||||||
|
return $this->locked;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLocked(bool $locked): static
|
||||||
|
{
|
||||||
|
$this->locked = $locked;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSubmittedBy(): ?User
|
||||||
|
{
|
||||||
|
return $this->submittedBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSubmittedBy(?User $submittedBy): static
|
||||||
|
{
|
||||||
|
$this->submittedBy = $submittedBy;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUpdatedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function touch(): static
|
||||||
|
{
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeletedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->deletedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDeletedAt(?\DateTimeImmutable $deletedAt): static
|
||||||
|
{
|
||||||
|
$this->deletedAt = $deletedAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDeleted(): bool
|
||||||
|
{
|
||||||
|
return $this->deletedAt !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, EscapeRoomReview> */
|
||||||
|
public function getReviews(): Collection
|
||||||
|
{
|
||||||
|
return $this->reviews;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return EscapeRoomReview[] */
|
||||||
|
public function getVisibleReviews(): array
|
||||||
|
{
|
||||||
|
return $this->reviews
|
||||||
|
->filter(static fn (EscapeRoomReview $r) => !$r->isDeleted())
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSlug(): string
|
||||||
|
{
|
||||||
|
$slug = strtolower((string) $this->name);
|
||||||
|
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '';
|
||||||
|
|
||||||
|
return trim($slug, '-') ?: 'room';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDisplayAddress(): string
|
||||||
|
{
|
||||||
|
$line = trim(sprintf('%s %s', (string) $this->street, (string) $this->houseNumber));
|
||||||
|
$parts = array_filter([
|
||||||
|
$line !== '' ? $line : null,
|
||||||
|
trim(sprintf('%s %s', (string) $this->postcode, (string) $this->city)) ?: null,
|
||||||
|
$this->country,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return implode(', ', $parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Website\Entity;
|
||||||
|
|
||||||
|
use App\Tech\Entity\User;
|
||||||
|
use App\Website\Repository\EscapeRoomReviewRepository;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: EscapeRoomReviewRepository::class)]
|
||||||
|
#[ORM\Table(name: 'escape_room_review')]
|
||||||
|
#[ORM\Index(name: 'idx_escape_room_review_room', columns: ['escape_room_id'])]
|
||||||
|
#[ORM\Index(name: 'idx_escape_room_review_author', columns: ['author_id'])]
|
||||||
|
class EscapeRoomReview
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: EscapeRoom::class, inversedBy: 'reviews')]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private ?EscapeRoom $escapeRoom = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||||
|
private ?User $author = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::SMALLINT)]
|
||||||
|
#[Assert\Range(min: 1, max: 5)]
|
||||||
|
private int $rating = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 150)]
|
||||||
|
#[Assert\NotBlank(message: 'Give your review a short title')]
|
||||||
|
#[Assert\Length(max: 150)]
|
||||||
|
private ?string $title = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT)]
|
||||||
|
#[Assert\NotBlank(message: 'Write a few words about your experience')]
|
||||||
|
#[Assert\Length(max: 5000)]
|
||||||
|
private ?string $body = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||||
|
private ?\DateTimeImmutable $createdAt = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $deletedAt = null;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createdAt = new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEscapeRoom(): ?EscapeRoom
|
||||||
|
{
|
||||||
|
return $this->escapeRoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEscapeRoom(?EscapeRoom $escapeRoom): static
|
||||||
|
{
|
||||||
|
$this->escapeRoom = $escapeRoom;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAuthor(): ?User
|
||||||
|
{
|
||||||
|
return $this->author;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAuthor(?User $author): static
|
||||||
|
{
|
||||||
|
$this->author = $author;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRating(): int
|
||||||
|
{
|
||||||
|
return $this->rating;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRating(int $rating): static
|
||||||
|
{
|
||||||
|
$this->rating = $rating;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTitle(): ?string
|
||||||
|
{
|
||||||
|
return $this->title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTitle(?string $title): static
|
||||||
|
{
|
||||||
|
$this->title = $title;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBody(): ?string
|
||||||
|
{
|
||||||
|
return $this->body;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBody(?string $body): static
|
||||||
|
{
|
||||||
|
$this->body = $body;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeletedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->deletedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDeletedAt(?\DateTimeImmutable $deletedAt): static
|
||||||
|
{
|
||||||
|
$this->deletedAt = $deletedAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDeleted(): bool
|
||||||
|
{
|
||||||
|
return $this->deletedAt !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Website\Enum;
|
||||||
|
|
||||||
|
enum EscapeRoomSource: string
|
||||||
|
{
|
||||||
|
case OSM = 'osm';
|
||||||
|
case USER = 'user';
|
||||||
|
case ADMIN = 'admin';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::OSM => 'OpenStreetMap',
|
||||||
|
self::USER => 'Visitor submission',
|
||||||
|
self::ADMIN => 'Added by admin',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Website\Enum;
|
||||||
|
|
||||||
|
enum EscapeRoomStatus: string
|
||||||
|
{
|
||||||
|
/** Submitted by a visitor, awaiting admin moderation. */
|
||||||
|
case PENDING = 'pending';
|
||||||
|
/** Live: shown on the public map and list. */
|
||||||
|
case PUBLISHED = 'published';
|
||||||
|
/** Reviewed by an admin and turned down; kept for de-duplication. */
|
||||||
|
case REJECTED = 'rejected';
|
||||||
|
/** Was public, hidden again by an admin (e.g. room closed down). */
|
||||||
|
case HIDDEN = 'hidden';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::PENDING => 'Pending review',
|
||||||
|
self::PUBLISHED => 'Published',
|
||||||
|
self::REJECTED => 'Rejected',
|
||||||
|
self::HIDDEN => 'Hidden',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPubliclyVisible(): bool
|
||||||
|
{
|
||||||
|
return $this === self::PUBLISHED;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Website\Form;
|
||||||
|
|
||||||
|
use App\Website\Entity\EscapeRoom;
|
||||||
|
use App\Website\Enum\EscapeRoomSource;
|
||||||
|
use App\Website\Enum\EscapeRoomStatus;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\CountryType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\NumberType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TelType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\UrlType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||||
|
use Symfony\Component\Validator\Constraints\Range;
|
||||||
|
|
||||||
|
class AdminEscapeRoomType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
->add('name', TextType::class, ['constraints' => [new NotBlank()]])
|
||||||
|
->add('venue', TextType::class, ['required' => false])
|
||||||
|
->add('street', TextType::class, ['required' => false])
|
||||||
|
->add('houseNumber', TextType::class, ['label' => 'House number', 'required' => false])
|
||||||
|
->add('postcode', TextType::class, ['required' => false])
|
||||||
|
->add('city', TextType::class, ['required' => false])
|
||||||
|
->add('country', CountryType::class, ['placeholder' => '—', 'required' => false])
|
||||||
|
->add('latitude', NumberType::class, [
|
||||||
|
'scale' => 7,
|
||||||
|
'html5' => true,
|
||||||
|
'constraints' => [new NotBlank(), new Range(min: -90, max: 90)],
|
||||||
|
])
|
||||||
|
->add('longitude', NumberType::class, [
|
||||||
|
'scale' => 7,
|
||||||
|
'html5' => true,
|
||||||
|
'constraints' => [new NotBlank(), new Range(min: -180, max: 180)],
|
||||||
|
])
|
||||||
|
->add('website', UrlType::class, ['required' => false, 'default_protocol' => 'https'])
|
||||||
|
->add('phone', TelType::class, ['required' => false])
|
||||||
|
->add('status', EnumType::class, [
|
||||||
|
'class' => EscapeRoomStatus::class,
|
||||||
|
'choice_label' => fn (EscapeRoomStatus $s) => $s->label(),
|
||||||
|
])
|
||||||
|
->add('source', EnumType::class, [
|
||||||
|
'class' => EscapeRoomSource::class,
|
||||||
|
'choice_label' => fn (EscapeRoomSource $s) => $s->label(),
|
||||||
|
])
|
||||||
|
->add('locked', CheckboxType::class, [
|
||||||
|
'required' => false,
|
||||||
|
'label' => 'Locked (protect from OSM re-import)',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'data_class' => EscapeRoom::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Website\Form;
|
||||||
|
|
||||||
|
use App\Website\Entity\ContactMessage;
|
||||||
|
use Karser\Recaptcha3Bundle\Form\Recaptcha3Type;
|
||||||
|
use Karser\Recaptcha3Bundle\Validator\Constraints\Recaptcha3;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
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\Length;
|
||||||
|
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||||
|
|
||||||
|
class ContactFormType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
->add('name', TextType::class, [
|
||||||
|
'constraints' => [
|
||||||
|
new NotBlank(message: 'Please enter your name'),
|
||||||
|
new Length(max: 255, maxMessage: 'Your name cannot be longer than {{ limit }} characters'),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->add('email', EmailType::class, [
|
||||||
|
'constraints' => [
|
||||||
|
new NotBlank(message: 'Please enter your email address'),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->add('message', TextareaType::class, [
|
||||||
|
'attr' => ['rows' => 6],
|
||||||
|
'constraints' => [
|
||||||
|
new NotBlank(message: 'Please enter a message'),
|
||||||
|
new Length(max: 5000, maxMessage: 'Your message cannot be longer than {{ limit }} characters'),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->add('captcha', Recaptcha3Type::class, [
|
||||||
|
'constraints' => new Recaptcha3(),
|
||||||
|
'action_name' => 'contact',
|
||||||
|
])
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'data_class' => ContactMessage::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Website\Form;
|
||||||
|
|
||||||
|
use App\Website\Entity\EscapeRoomReview;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
|
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\NotBlank;
|
||||||
|
|
||||||
|
class EscapeRoomReviewType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
->add('rating', ChoiceType::class, [
|
||||||
|
'placeholder' => 'Choose a rating',
|
||||||
|
'choices' => [
|
||||||
|
'★★★★★ — Outstanding' => 5,
|
||||||
|
'★★★★ — Very good' => 4,
|
||||||
|
'★★★ — Decent' => 3,
|
||||||
|
'★★ — Poor' => 2,
|
||||||
|
'★ — Terrible' => 1,
|
||||||
|
],
|
||||||
|
'constraints' => [new NotBlank(message: 'Pick a rating')],
|
||||||
|
])
|
||||||
|
->add('title', TextType::class, [
|
||||||
|
'attr' => ['maxlength' => 150, 'placeholder' => 'Sum it up in a few words'],
|
||||||
|
])
|
||||||
|
->add('body', TextareaType::class, [
|
||||||
|
'attr' => ['rows' => 5, 'maxlength' => 5000, 'placeholder' => 'What worked, what didn\'t, would you recommend it?'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'data_class' => EscapeRoomReview::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Website\Form;
|
||||||
|
|
||||||
|
use App\Website\Entity\EscapeRoom;
|
||||||
|
use Karser\Recaptcha3Bundle\Form\Recaptcha3Type;
|
||||||
|
use Karser\Recaptcha3Bundle\Validator\Constraints\Recaptcha3;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\CountryType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TelType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\UrlType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
use Symfony\Component\Validator\Constraints\Length;
|
||||||
|
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||||
|
use Symfony\Component\Validator\Constraints\Range;
|
||||||
|
use Symfony\Component\Validator\Constraints\Regex;
|
||||||
|
|
||||||
|
class SuggestEscapeRoomType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
->add('name', TextType::class, [
|
||||||
|
'label' => 'Room / venue name',
|
||||||
|
'constraints' => [new NotBlank(message: 'Enter the room name'), new Length(max: 255)],
|
||||||
|
])
|
||||||
|
->add('venue', TextType::class, [
|
||||||
|
'label' => 'Operating company (optional)',
|
||||||
|
'required' => false,
|
||||||
|
'constraints' => [new Length(max: 255)],
|
||||||
|
])
|
||||||
|
->add('street', TextType::class, ['required' => false, 'constraints' => [new Length(max: 255)]])
|
||||||
|
->add('houseNumber', TextType::class, ['label' => 'No.', 'required' => false, 'constraints' => [new Length(max: 32)]])
|
||||||
|
->add('postcode', TextType::class, ['required' => false, 'constraints' => [new Length(max: 32)]])
|
||||||
|
->add('city', TextType::class, ['required' => false, 'constraints' => [new Length(max: 128)]])
|
||||||
|
->add('country', CountryType::class, [
|
||||||
|
'placeholder' => 'Select a country',
|
||||||
|
'required' => false,
|
||||||
|
])
|
||||||
|
->add('website', UrlType::class, [
|
||||||
|
'required' => false,
|
||||||
|
'default_protocol' => 'https',
|
||||||
|
'constraints' => [new Length(max: 511)],
|
||||||
|
])
|
||||||
|
->add('phone', TelType::class, ['required' => false, 'constraints' => [new Length(max: 64)]])
|
||||||
|
->add('latitude', HiddenType::class, [
|
||||||
|
'constraints' => [
|
||||||
|
new NotBlank(message: 'Drop a pin on the map to set the location'),
|
||||||
|
new Regex(pattern: '/^-?\d{1,3}(\.\d+)?$/', message: 'Invalid latitude'),
|
||||||
|
new Range(min: -90, max: 90),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->add('longitude', HiddenType::class, [
|
||||||
|
'constraints' => [
|
||||||
|
new NotBlank(message: 'Drop a pin on the map to set the location'),
|
||||||
|
new Regex(pattern: '/^-?\d{1,3}(\.\d+)?$/', message: 'Invalid longitude'),
|
||||||
|
new Range(min: -180, max: 180),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->add('captcha', Recaptcha3Type::class, [
|
||||||
|
'mapped' => false,
|
||||||
|
'constraints' => new Recaptcha3(),
|
||||||
|
'action_name' => 'escaperoom_suggest',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'data_class' => EscapeRoom::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Website\Repository;
|
||||||
|
|
||||||
|
use App\Website\Entity\ContactMessage;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<ContactMessage>
|
||||||
|
*/
|
||||||
|
class ContactMessageRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, ContactMessage::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return ContactMessage[]
|
||||||
|
*/
|
||||||
|
public function findActive(): array
|
||||||
|
{
|
||||||
|
return $this->findBy(['deletedAt' => null], ['createdAt' => 'DESC']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Website\Repository;
|
||||||
|
|
||||||
|
use App\Website\Entity\EscapeRoom;
|
||||||
|
use App\Website\Enum\EscapeRoomStatus;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<EscapeRoom>
|
||||||
|
*/
|
||||||
|
class EscapeRoomRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, EscapeRoom::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(EscapeRoom $room, bool $flush = true): void
|
||||||
|
{
|
||||||
|
$this->getEntityManager()->persist($room);
|
||||||
|
|
||||||
|
if ($flush) {
|
||||||
|
$this->getEntityManager()->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findOneByOsm(string $osmType, string $osmId): ?EscapeRoom
|
||||||
|
{
|
||||||
|
return $this->findOneBy(['osmType' => $osmType, 'osmId' => $osmId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight rows for the public map / list. One row per published room with
|
||||||
|
* its aggregated rating. Optionally clipped to a lat/lng bounding box.
|
||||||
|
*
|
||||||
|
* @param array{south: float, west: float, north: float, east: float}|null $bbox
|
||||||
|
*
|
||||||
|
* @return list<array{
|
||||||
|
* id: int, name: string, city: ?string, country: ?string,
|
||||||
|
* lat: float, lng: float, avgRating: ?float, reviewCount: int
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public function findMapData(?array $bbox = null, int $limit = 20000): array
|
||||||
|
{
|
||||||
|
$qb = $this->createQueryBuilder('r')
|
||||||
|
->select('r.id, r.name, r.city, r.country, r.latitude, r.longitude')
|
||||||
|
->addSelect('AVG(rev.rating) AS avgRating')
|
||||||
|
->addSelect('COUNT(rev.id) AS reviewCount')
|
||||||
|
->leftJoin('r.reviews', 'rev', 'WITH', 'rev.deletedAt IS NULL')
|
||||||
|
->where('r.deletedAt IS NULL')
|
||||||
|
->andWhere('r.status = :published')
|
||||||
|
->setParameter('published', EscapeRoomStatus::PUBLISHED)
|
||||||
|
->groupBy('r.id')
|
||||||
|
->setMaxResults($limit);
|
||||||
|
|
||||||
|
if ($bbox !== null) {
|
||||||
|
$qb->andWhere('r.latitude BETWEEN :south AND :north')
|
||||||
|
->andWhere('r.longitude BETWEEN :west AND :east')
|
||||||
|
->setParameter('south', $bbox['south'])
|
||||||
|
->setParameter('north', $bbox['north'])
|
||||||
|
->setParameter('west', $bbox['west'])
|
||||||
|
->setParameter('east', $bbox['east']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $qb->getQuery()->getArrayResult();
|
||||||
|
|
||||||
|
return array_map(static function (array $row): array {
|
||||||
|
return [
|
||||||
|
'id' => (int) $row['id'],
|
||||||
|
'name' => (string) $row['name'],
|
||||||
|
'city' => $row['city'],
|
||||||
|
'country' => $row['country'],
|
||||||
|
'lat' => (float) $row['latitude'],
|
||||||
|
'lng' => (float) $row['longitude'],
|
||||||
|
'avgRating' => $row['reviewCount'] > 0 ? round((float) $row['avgRating'], 1) : null,
|
||||||
|
'reviewCount' => (int) $row['reviewCount'],
|
||||||
|
];
|
||||||
|
}, $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function countByStatus(EscapeRoomStatus $status): int
|
||||||
|
{
|
||||||
|
return (int) $this->createQueryBuilder('r')
|
||||||
|
->select('COUNT(r.id)')
|
||||||
|
->where('r.deletedAt IS NULL')
|
||||||
|
->andWhere('r.status = :status')
|
||||||
|
->setParameter('status', $status)
|
||||||
|
->getQuery()
|
||||||
|
->getSingleScalarResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All non-deleted rooms, newest first. For the admin list.
|
||||||
|
*
|
||||||
|
* @return EscapeRoom[]
|
||||||
|
*/
|
||||||
|
public function findForAdmin(?EscapeRoomStatus $status = null): array
|
||||||
|
{
|
||||||
|
$qb = $this->createQueryBuilder('r')
|
||||||
|
->where('r.deletedAt IS NULL')
|
||||||
|
->orderBy('r.createdAt', 'DESC');
|
||||||
|
|
||||||
|
if ($status !== null) {
|
||||||
|
$qb->andWhere('r.status = :status')->setParameter('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $qb->getQuery()->getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visible review count per room, in one query, for the admin list.
|
||||||
|
*
|
||||||
|
* @return array<int, int> roomId => count
|
||||||
|
*/
|
||||||
|
public function reviewCounts(): array
|
||||||
|
{
|
||||||
|
$rows = $this->getEntityManager()
|
||||||
|
->createQuery(
|
||||||
|
'SELECT IDENTITY(rev.escapeRoom) AS rid, COUNT(rev.id) AS c '
|
||||||
|
.'FROM App\Website\Entity\EscapeRoomReview rev '
|
||||||
|
.'WHERE rev.deletedAt IS NULL GROUP BY rid'
|
||||||
|
)
|
||||||
|
->getScalarResult();
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$map[(int) $row['rid']] = (int) $row['c'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Website\Repository;
|
||||||
|
|
||||||
|
use App\Website\Entity\EscapeRoom;
|
||||||
|
use App\Website\Entity\EscapeRoomReview;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<EscapeRoomReview>
|
||||||
|
*/
|
||||||
|
class EscapeRoomReviewRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, EscapeRoomReview::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(EscapeRoomReview $review, bool $flush = true): void
|
||||||
|
{
|
||||||
|
$this->getEntityManager()->persist($review);
|
||||||
|
|
||||||
|
if ($flush) {
|
||||||
|
$this->getEntityManager()->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return EscapeRoomReview[]
|
||||||
|
*/
|
||||||
|
public function findVisibleForRoom(EscapeRoom $room): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('rev')
|
||||||
|
->where('rev.escapeRoom = :room')
|
||||||
|
->andWhere('rev.deletedAt IS NULL')
|
||||||
|
->setParameter('room', $room)
|
||||||
|
->orderBy('rev.createdAt', 'DESC')
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{avg: ?float, count: int}
|
||||||
|
*/
|
||||||
|
public function ratingSummary(EscapeRoom $room): array
|
||||||
|
{
|
||||||
|
$row = $this->createQueryBuilder('rev')
|
||||||
|
->select('AVG(rev.rating) AS avg, COUNT(rev.id) AS count')
|
||||||
|
->where('rev.escapeRoom = :room')
|
||||||
|
->andWhere('rev.deletedAt IS NULL')
|
||||||
|
->setParameter('room', $room)
|
||||||
|
->getQuery()
|
||||||
|
->getSingleResult();
|
||||||
|
|
||||||
|
$count = (int) $row['count'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'avg' => $count > 0 ? round((float) $row['avg'], 1) : null,
|
||||||
|
'count' => $count,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recent reviews across all rooms, for admin moderation.
|
||||||
|
*
|
||||||
|
* @return EscapeRoomReview[]
|
||||||
|
*/
|
||||||
|
public function findRecent(int $limit = 200): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('rev')
|
||||||
|
->addSelect('room')
|
||||||
|
->join('rev.escapeRoom', 'room')
|
||||||
|
->where('rev.deletedAt IS NULL')
|
||||||
|
->orderBy('rev.createdAt', 'DESC')
|
||||||
|
->setMaxResults($limit)
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,40 @@
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* simple-datatables theming to match the admin panel */
|
||||||
|
.datatable-top,
|
||||||
|
.datatable-bottom {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.datatable-input {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.datatable-selector {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.datatable-pagination a,
|
||||||
|
.datatable-pagination button {
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
.datatable-pagination a:hover,
|
||||||
|
.datatable-pagination button:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
.datatable-pagination .datatable-active a,
|
||||||
|
.datatable-pagination .datatable-active button {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 767.98px) {
|
@media (max-width: 767.98px) {
|
||||||
.admin-shell {
|
.admin-shell {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -90,6 +124,7 @@
|
|||||||
<div class="admin-shell">
|
<div class="admin-shell">
|
||||||
|
|
||||||
{# ── Sidebar ──────────────────────────────────────────────────────── #}
|
{# ── Sidebar ──────────────────────────────────────────────────────── #}
|
||||||
|
{% block admin_sidebar %}
|
||||||
<nav class="admin-sidebar">
|
<nav class="admin-sidebar">
|
||||||
<div class="admin-sidebar-title">Game Admin</div>
|
<div class="admin-sidebar-title">Game Admin</div>
|
||||||
|
|
||||||
@@ -102,6 +137,7 @@
|
|||||||
{ route: 'game_admin_games', label: 'Games', icon: '🎮' },
|
{ route: 'game_admin_games', label: 'Games', icon: '🎮' },
|
||||||
{ route: 'game_admin_sessions', label: 'Sessions', icon: '▶' },
|
{ route: 'game_admin_sessions', label: 'Sessions', icon: '▶' },
|
||||||
{ route: 'game_admin_feedback', label: 'Feedback', icon: '💬' },
|
{ route: 'game_admin_feedback', label: 'Feedback', icon: '💬' },
|
||||||
|
{ route: 'game_admin_contact', label: 'Contact', icon: '📨' },
|
||||||
{ route: 'game_admin_email_log', label: 'Email Log', icon: '✉' },
|
{ route: 'game_admin_email_log', label: 'Email Log', icon: '✉' },
|
||||||
] %}
|
] %}
|
||||||
|
|
||||||
@@ -121,11 +157,15 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="admin-sidebar-footer">
|
<div class="admin-sidebar-footer">
|
||||||
|
<a href="{{ path('website_admin_escaperooms') }}" style="color: #64748b; font-size: 0.8rem; text-decoration: none; display: block; margin-bottom: 0.4rem;">
|
||||||
|
Website admin →
|
||||||
|
</a>
|
||||||
<a href="{{ path('game_dashboard') }}" style="color: #64748b; font-size: 0.8rem; text-decoration: none;">
|
<a href="{{ path('game_dashboard') }}" style="color: #64748b; font-size: 0.8rem; text-decoration: none;">
|
||||||
← Back to game
|
← Back to game
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{# ── Content ──────────────────────────────────────────────────────── #}
|
{# ── Content ──────────────────────────────────────────────────────── #}
|
||||||
<main class="admin-main">
|
<main class="admin-main">
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{% extends 'game/admin/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Contact Messages — Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_body %}
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
|
||||||
|
<h1 style="margin: 0; font-size: 1.5rem; color: #0f172a;">Contact Messages</h1>
|
||||||
|
<span style="color: #64748b; font-size: 0.9rem;">{{ messages|length }} message{{ messages|length != 1 ? 's' : '' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
||||||
|
<table class="admin-datatable" style="width: 100%; min-width: 760px; border-collapse: collapse; font-size: 0.9rem;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
||||||
|
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Received</th>
|
||||||
|
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Name</th>
|
||||||
|
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Email</th>
|
||||||
|
<th data-type="boolean" style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Read</th>
|
||||||
|
<th data-sortable="false" style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in messages %}
|
||||||
|
<tr style="border-bottom: 1px solid #f1f5f9;">
|
||||||
|
<td style="padding: 0.75rem 1rem; color: #475569; white-space: nowrap;">{{ entry.createdAt|date('Y-m-d H:i:s') }}</td>
|
||||||
|
<td style="padding: 0.75rem 1rem; font-weight: 500; color: #0f172a;">{{ entry.name }}</td>
|
||||||
|
<td style="padding: 0.75rem 1rem; color: #475569;">
|
||||||
|
<a href="mailto:{{ entry.email }}" style="color: #3b82f6; text-decoration: none;">{{ entry.email }}</a>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 0.75rem 1rem; color: #16a34a; font-weight: 600;">{{ entry.read ? '✓' : '' }}</td>
|
||||||
|
<td style="padding: 0.75rem 1rem;">
|
||||||
|
<a href="{{ path('game_admin_contact_show', {id: entry.id}) }}" style="color: #3b82f6; text-decoration: none; font-size: 0.85rem; margin-right: 0.75rem;">View</a>
|
||||||
|
<form method="post" action="{{ path('game_admin_contact_delete', {id: entry.id}) }}" style="display: inline;" onsubmit="return confirm('Delete message from {{ entry.name }}?')">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('delete_contact_' ~ entry.id) }}">
|
||||||
|
<button type="submit" style="
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #ef4444;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0;
|
||||||
|
">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" style="padding: 2rem; text-align: center; color: #94a3b8;">No messages yet.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{% extends 'game/admin/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Message from {{ message.name }} — Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_body %}
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
|
||||||
|
<h1 style="margin: 0; font-size: 1.5rem; color: #0f172a;">Message from {{ message.name }}</h1>
|
||||||
|
<a href="{{ path('game_admin_contact') }}" style="color: #3b82f6; text-decoration: none; font-size: 0.85rem;">← Back to Contact Messages</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); padding: 1.5rem; max-width: 720px;">
|
||||||
|
<dl style="display: grid; grid-template-columns: 140px 1fr; row-gap: 0.75rem; margin: 0 0 1.5rem;">
|
||||||
|
<dt style="color: #64748b; font-weight: 600;">Name</dt>
|
||||||
|
<dd style="margin: 0; color: #0f172a;">{{ message.name }}</dd>
|
||||||
|
|
||||||
|
<dt style="color: #64748b; font-weight: 600;">Email</dt>
|
||||||
|
<dd style="margin: 0;">
|
||||||
|
<a href="mailto:{{ message.email }}" style="color: #3b82f6; text-decoration: none;">{{ message.email }}</a>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt style="color: #64748b; font-weight: 600;">Received</dt>
|
||||||
|
<dd style="margin: 0; color: #0f172a;">{{ message.createdAt|date('Y-m-d H:i:s') }}</dd>
|
||||||
|
|
||||||
|
<dt style="color: #64748b; font-weight: 600;">Read at</dt>
|
||||||
|
<dd style="margin: 0; color: #0f172a;">
|
||||||
|
{% if message.readAt %}
|
||||||
|
{{ message.readAt|date('Y-m-d H:i:s') }}
|
||||||
|
{% else %}
|
||||||
|
<span style="color: #94a3b8;">Just now</span>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<hr style="border-color: #f1f5f9; margin-bottom: 1.5rem;">
|
||||||
|
|
||||||
|
<div style="white-space: pre-wrap; color: #0f172a; line-height: 1.6; margin-bottom: 1.5rem;">{{ message.message }}</div>
|
||||||
|
|
||||||
|
<hr style="border-color: #f1f5f9; margin-bottom: 1.5rem;">
|
||||||
|
|
||||||
|
<form method="post" action="{{ path('game_admin_contact_delete', {id: message.id}) }}" onsubmit="return confirm('Delete message from {{ message.name }}?')">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('delete_contact_' ~ message.id) }}">
|
||||||
|
<button type="submit" style="
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #fca5a5;
|
||||||
|
color: #ef4444;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
">Delete Message</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
||||||
<table style="width: 100%; min-width: 620px; border-collapse: collapse; font-size: 0.9rem;">
|
<table class="admin-datatable" style="width: 100%; min-width: 620px; border-collapse: collapse; font-size: 0.9rem;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
||||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th>
|
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
||||||
<table style="width: 100%; min-width: 760px; border-collapse: collapse; font-size: 0.9rem;">
|
<table class="admin-datatable" style="width: 100%; min-width: 760px; border-collapse: collapse; font-size: 0.9rem;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
||||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Game</th>
|
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Game</th>
|
||||||
|
|||||||
@@ -5,7 +5,18 @@
|
|||||||
{% block admin_body %}
|
{% block admin_body %}
|
||||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
|
||||||
<h1 style="margin: 0; font-size: 1.5rem; color: #0f172a;">Games</h1>
|
<h1 style="margin: 0; font-size: 1.5rem; color: #0f172a;">Games</h1>
|
||||||
<span style="color: #64748b; font-size: 0.9rem;">{{ games|length }} total</span>
|
<div style="display: flex; align-items: center; gap: 1rem;">
|
||||||
|
<span style="color: #64748b; font-size: 0.9rem;">{{ games|length }} total</span>
|
||||||
|
<a href="{{ path('game_admin_game_new') }}" style="
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
">+ New Game</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
||||||
@@ -46,7 +57,8 @@
|
|||||||
</td>
|
</td>
|
||||||
<td style="padding: 0.75rem 1rem; color: #475569;">{{ game.sessions|length }}</td>
|
<td style="padding: 0.75rem 1rem; color: #475569;">{{ game.sessions|length }}</td>
|
||||||
<td style="padding: 0.75rem 1rem;">
|
<td style="padding: 0.75rem 1rem;">
|
||||||
<a href="{{ path('game_admin_game_edit', {id: game.id}) }}" style="color: #3b82f6; text-decoration: none; font-size: 0.85rem;">Edit</a>
|
<a href="{{ path('game_admin_game_edit', {id: game.id}) }}" style="color: #3b82f6; text-decoration: none; font-size: 0.85rem; margin-right: 0.75rem;">Edit</a>
|
||||||
|
<a href="{{ path('game_admin_hints', {game: game.id}) }}" style="color: #3b82f6; text-decoration: none; font-size: 0.85rem;">Hints</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{% extends 'game/admin/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}New Game — Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_body %}
|
||||||
|
<div style="margin-bottom: 1.5rem;">
|
||||||
|
<a href="{{ path('game_admin_games') }}" style="color: #64748b; text-decoration: none; font-size: 0.9rem;">← Games</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 style="margin: 0 0 1.5rem; font-size: 1.5rem; color: #0f172a;">New game</h1>
|
||||||
|
|
||||||
|
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); padding: 1.5rem; max-width: 500px;">
|
||||||
|
{{ form_start(form, {attr: {style: 'display: flex; flex-direction: column; gap: 1.25rem;'}}) }}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{ form_label(form.name, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
|
||||||
|
{{ form_widget(form.name, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
|
||||||
|
{{ form_errors(form.name) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{ form_label(form.numberOfPlayers, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
|
||||||
|
{{ form_widget(form.numberOfPlayers, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
|
||||||
|
{{ form_errors(form.numberOfPlayers) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{ form_label(form.status, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
|
||||||
|
{{ form_widget(form.status, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
|
||||||
|
{{ form_errors(form.status) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{ form_label(form.totalTime, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
|
||||||
|
{{ form_widget(form.totalTime, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
|
||||||
|
<small style="color: #64748b; font-size: 0.8rem;">In seconds — e.g. 3600 = 1 hour</small>
|
||||||
|
{{ form_errors(form.totalTime) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 0.75rem; margin-top: 0.5rem;">
|
||||||
|
<button type="submit" style="
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
">Create game</button>
|
||||||
|
<a href="{{ path('game_admin_games') }}" style="
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
">Cancel</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ form_end(form) }}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{% extends 'game/admin/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Edit Hint — {{ game.name }} — Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_body %}
|
||||||
|
<div style="margin-bottom: 1.5rem;">
|
||||||
|
<a href="{{ path('game_admin_hints', {game: game.id}) }}" style="color: #64748b; text-decoration: none; font-size: 0.9rem;">← Hints — {{ game.name }}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 style="margin: 0 0 1.5rem; font-size: 1.5rem; color: #0f172a;">Edit hint</h1>
|
||||||
|
|
||||||
|
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); padding: 1.5rem; max-width: 560px;">
|
||||||
|
{{ form_start(form, {attr: {style: 'display: flex; flex-direction: column; gap: 1.25rem;'}}) }}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{ form_label(form.condition, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
|
||||||
|
{{ form_widget(form.condition, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
|
||||||
|
{{ form_errors(form.condition) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{ form_label(form.thresholdSeconds, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
|
||||||
|
{{ form_widget(form.thresholdSeconds, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
|
||||||
|
<small style="color: #64748b; font-size: 0.8rem;">Seconds since the game started — e.g. 300 = 5 minutes</small>
|
||||||
|
{{ form_errors(form.thresholdSeconds) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{ form_label(form.message, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
|
||||||
|
{{ form_widget(form.message, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
|
||||||
|
{{ form_errors(form.message) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{ form_label(form.sortOrder, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
|
||||||
|
{{ form_widget(form.sortOrder, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
|
||||||
|
<small style="color: #64748b; font-size: 0.8rem;">Hints are evaluated in ascending order — use gaps (10, 20, 30…) to leave room to insert later</small>
|
||||||
|
{{ form_errors(form.sortOrder) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 0.75rem; margin-top: 0.5rem;">
|
||||||
|
<button type="submit" style="
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
">Save changes</button>
|
||||||
|
<a href="{{ path('game_admin_hints', {game: game.id}) }}" style="
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
">Cancel</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ form_end(form) }}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
{% extends 'game/admin/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Hints — {{ game.name }} — Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_body %}
|
||||||
|
<div style="margin-bottom: 1.5rem;">
|
||||||
|
<a href="{{ path('game_admin_games') }}" style="color: #64748b; text-decoration: none; font-size: 0.9rem;">← Games</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
|
||||||
|
<h1 style="margin: 0; font-size: 1.5rem; color: #0f172a;">Hints — {{ game.name }}</h1>
|
||||||
|
<div style="display: flex; align-items: center; gap: 1rem;">
|
||||||
|
<span style="color: #64748b; font-size: 0.9rem;">{{ hints|length }} total</span>
|
||||||
|
<a href="{{ path('game_admin_hint_new', {game: game.id}) }}" style="
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
">+ New Hint</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="color: #64748b; font-size: 0.85rem; margin-top: -1rem; margin-bottom: 1.5rem;">
|
||||||
|
Hints are checked in order. For each row, if its condition is still unresolved for a player, the
|
||||||
|
highest threshold reached among hints sharing that same condition fires and the check stops there
|
||||||
|
for that pass — later rows are only reached once every condition before them is resolved.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
||||||
|
<table style="width: 100%; min-width: 760px; border-collapse: collapse; font-size: 0.9rem;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
||||||
|
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Order</th>
|
||||||
|
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Condition</th>
|
||||||
|
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Threshold</th>
|
||||||
|
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Message</th>
|
||||||
|
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for hint in hints %}
|
||||||
|
<tr style="border-bottom: 1px solid #f1f5f9;">
|
||||||
|
<td style="padding: 0.75rem 1rem; color: #94a3b8;">{{ hint.sortOrder }}</td>
|
||||||
|
<td style="padding: 0.75rem 1rem; color: #0f172a;">{{ hint.condition.label }}</td>
|
||||||
|
<td style="padding: 0.75rem 1rem; color: #475569; white-space: nowrap;">{{ (hint.thresholdSeconds / 60)|round(1, 'floor') }} min</td>
|
||||||
|
<td style="padding: 0.75rem 1rem; color: #475569; max-width: 420px;">{{ hint.message|length > 90 ? hint.message|slice(0, 90) ~ '…' : hint.message }}</td>
|
||||||
|
<td style="padding: 0.75rem 1rem; white-space: nowrap;">
|
||||||
|
<a href="{{ path('game_admin_hint_edit', {game: game.id, id: hint.id}) }}" style="
|
||||||
|
color: #3b82f6;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
">Edit</a>
|
||||||
|
<form method="post" action="{{ path('game_admin_hint_delete', {game: game.id, id: hint.id}) }}" style="display: inline;" onsubmit="return confirm('Delete this hint?')">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('delete_hint_' ~ hint.id) }}">
|
||||||
|
<button type="submit" style="
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #ef4444;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0;
|
||||||
|
">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" style="padding: 2rem; text-align: center; color: #94a3b8;">No hints configured for this game yet.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{% extends 'game/admin/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}New Hint — {{ game.name }} — Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_body %}
|
||||||
|
<div style="margin-bottom: 1.5rem;">
|
||||||
|
<a href="{{ path('game_admin_hints', {game: game.id}) }}" style="color: #64748b; text-decoration: none; font-size: 0.9rem;">← Hints — {{ game.name }}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 style="margin: 0 0 1.5rem; font-size: 1.5rem; color: #0f172a;">New hint</h1>
|
||||||
|
|
||||||
|
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); padding: 1.5rem; max-width: 560px;">
|
||||||
|
{{ form_start(form, {attr: {style: 'display: flex; flex-direction: column; gap: 1.25rem;'}}) }}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{ form_label(form.condition, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
|
||||||
|
{{ form_widget(form.condition, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
|
||||||
|
{{ form_errors(form.condition) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{ form_label(form.thresholdSeconds, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
|
||||||
|
{{ form_widget(form.thresholdSeconds, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
|
||||||
|
<small style="color: #64748b; font-size: 0.8rem;">Seconds since the game started — e.g. 300 = 5 minutes</small>
|
||||||
|
{{ form_errors(form.thresholdSeconds) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{ form_label(form.message, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
|
||||||
|
{{ form_widget(form.message, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
|
||||||
|
{{ form_errors(form.message) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{ form_label(form.sortOrder, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
|
||||||
|
{{ form_widget(form.sortOrder, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
|
||||||
|
<small style="color: #64748b; font-size: 0.8rem;">Hints are evaluated in ascending order — use gaps (10, 20, 30…) to leave room to insert later</small>
|
||||||
|
{{ form_errors(form.sortOrder) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 0.75rem; margin-top: 0.5rem;">
|
||||||
|
<button type="submit" style="
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
">Create hint</button>
|
||||||
|
<a href="{{ path('game_admin_hints', {game: game.id}) }}" style="
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
">Cancel</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ form_end(form) }}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
||||||
<table style="width: 100%; min-width: 700px; border-collapse: collapse; font-size: 0.9rem;">
|
<table class="admin-datatable" style="width: 100%; min-width: 700px; border-collapse: collapse; font-size: 0.9rem;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
||||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th>
|
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
||||||
<table style="width: 100%; min-width: 760px; border-collapse: collapse; font-size: 0.9rem;">
|
<table class="admin-datatable" style="width: 100%; min-width: 760px; border-collapse: collapse; font-size: 0.9rem;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
||||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th>
|
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th>
|
||||||
|
|||||||
@@ -20,8 +20,15 @@
|
|||||||
<input type="hidden" name="_token" value="{{ csrf_token('create_session') }}">
|
<input type="hidden" name="_token" value="{{ csrf_token('create_session') }}">
|
||||||
<select name="game_id" class="form-select mb-3">
|
<select name="game_id" class="form-select mb-3">
|
||||||
{% for game in availableGames %}
|
{% for game in availableGames %}
|
||||||
|
{% set totalTime = null %}
|
||||||
|
{% for setting in game.settings %}
|
||||||
|
{% if setting.name.value == 'totalTime' %}
|
||||||
|
{% set totalTime = setting.value %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
<option value="{{ game.id }}">
|
<option value="{{ game.id }}">
|
||||||
{{ game.name }} ({{ game.numberOfPlayers }} players)
|
{{ game.name }} ({{ game.numberOfPlayers }} players{% if totalTime %}, {{ (totalTime / 60)|round }} min{% endif %})
|
||||||
{% if is_granted('ROLE_ADMIN') %}
|
{% if is_granted('ROLE_ADMIN') %}
|
||||||
[{{ game.status.value }}]
|
[{{ game.status.value }}]
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
<h4>{{ session.game.name }}</h4>
|
<h4>{{ session.game.name }}</h4>
|
||||||
<p>Welcome to the game! Please wait for all players to join and signal they are ready to start.</p>
|
<p>Welcome to the game! Please wait for all players to join and signal they are ready to start.</p>
|
||||||
|
|
||||||
|
<p><strong>This game will end when you disable the virus by having its source files deleted from the server.</strong></p>
|
||||||
|
|
||||||
<div class="game-info">
|
<div class="game-info">
|
||||||
Please keep the following things in mind:
|
Please keep the following things in mind:
|
||||||
<ul>
|
<ul>
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{{ path('game_dashboard') }}">{{ 'nav.game'|trans }}</a>
|
<a class="nav-link" href="{{ path('game_dashboard') }}">{{ 'nav.game'|trans }}</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ path('website_escaperooms') }}">{{ 'nav.escaperooms'|trans }}</a>
|
||||||
|
</li>
|
||||||
{% if app.user %}
|
{% if app.user %}
|
||||||
{% if is_granted('ROLE_ADMIN') %}
|
{% if is_granted('ROLE_ADMIN') %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
@@ -67,7 +70,11 @@
|
|||||||
<footer class="site-footer py-4">
|
<footer class="site-footer py-4">
|
||||||
<div class="container text-center small">
|
<div class="container text-center small">
|
||||||
<div class="mb-1">
|
<div class="mb-1">
|
||||||
|
<a href="{{ path('website_contact') }}" class="link-light">{{ 'footer.contact'|trans }}</a>
|
||||||
|
·
|
||||||
<a href="{{ path('website_help_wanted') }}" class="link-light">{{ 'footer.help_wanted'|trans }}</a>
|
<a href="{{ path('website_help_wanted') }}" class="link-light">{{ 'footer.help_wanted'|trans }}</a>
|
||||||
|
·
|
||||||
|
<a href="{{ path('website_terms') }}" class="link-light">{{ 'footer.terms'|trans }}</a>
|
||||||
</div>
|
</div>
|
||||||
© {{ "now"|date("Y") }} {{ 'site.name'|trans }}
|
© {{ "now"|date("Y") }} {{ 'site.name'|trans }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
{{ form_row(registrationForm.username) }}
|
{{ form_row(registrationForm.username) }}
|
||||||
{{ form_row(registrationForm.plainPassword) }}
|
{{ form_row(registrationForm.plainPassword) }}
|
||||||
{{ form_row(registrationForm.agreeTerms) }}
|
{{ form_row(registrationForm.agreeTerms) }}
|
||||||
|
<p class="small text-muted mt-n2 mb-3">
|
||||||
|
<a href="{{ path('website_terms') }}" target="_blank" rel="noopener">Read the Terms and Conditions</a>
|
||||||
|
</p>
|
||||||
{{ form_row(registrationForm.marketingOptIn) }}
|
{{ form_row(registrationForm.marketingOptIn) }}
|
||||||
{{ form_row(registrationForm.captcha) }}
|
{{ form_row(registrationForm.captcha) }}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
{% extends 'game/admin/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block stylesheets %}
|
||||||
|
{{ parent() }}
|
||||||
|
<style>
|
||||||
|
.er-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1rem 1.25rem;
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
.er-form .er-field-wide { grid-column: 1 / -1; }
|
||||||
|
.er-form label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.er-form input[type=text],
|
||||||
|
.er-form input[type=number],
|
||||||
|
.er-form input[type=url],
|
||||||
|
.er-form input[type=tel],
|
||||||
|
.er-form select,
|
||||||
|
.er-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.er-form .form-error,
|
||||||
|
.er-form ul { color: #b91c1c; font-size: 0.8rem; margin: 0.25rem 0 0; padding-left: 1rem; }
|
||||||
|
.er-btn {
|
||||||
|
padding: 0.55rem 1.2rem;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.er-btn--ghost { background: #f1f5f9; color: #475569; }
|
||||||
|
.er-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.er-badge--published { background: #dcfce7; color: #166534; }
|
||||||
|
.er-badge--pending { background: #fef9c3; color: #854d0e; }
|
||||||
|
.er-badge--rejected { background: #fee2e2; color: #991b1b; }
|
||||||
|
.er-badge--hidden { background: #e2e8f0; color: #475569; }
|
||||||
|
.er-actions form { display: inline; }
|
||||||
|
.er-actions button,
|
||||||
|
.er-actions a {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin-right: 0.6rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #3b82f6;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.er-actions button.er-danger { color: #ef4444; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_sidebar %}
|
||||||
|
<nav class="admin-sidebar">
|
||||||
|
<div class="admin-sidebar-title">Website Admin</div>
|
||||||
|
|
||||||
|
<ul class="admin-nav-list">
|
||||||
|
{% set current = app.request.attributes.get('_route') %}
|
||||||
|
{% set navItems = [
|
||||||
|
{ route: 'website_admin_escaperooms', label: 'Escape Rooms', icon: '📍' },
|
||||||
|
{ route: 'website_admin_escaperoom_reviews', label: 'Reviews', icon: '⭐' },
|
||||||
|
] %}
|
||||||
|
|
||||||
|
{% for item in navItems %}
|
||||||
|
{% set isActive = current starts with item.route %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ path(item.route) }}" class="admin-nav-link" style="
|
||||||
|
color: {{ isActive ? '#f1f5f9' : '#94a3b8' }};
|
||||||
|
background: {{ isActive ? '#334155' : 'transparent' }};
|
||||||
|
border-left: 3px solid {{ isActive ? '#3b82f6' : 'transparent' }};
|
||||||
|
">
|
||||||
|
<span>{{ item.icon }}</span>
|
||||||
|
{{ item.label }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="admin-sidebar-footer">
|
||||||
|
<a href="{{ path('game_admin_dashboard') }}" style="color: #64748b; font-size: 0.8rem; text-decoration: none; display: block; margin-bottom: 0.4rem;">
|
||||||
|
← Game admin
|
||||||
|
</a>
|
||||||
|
<a href="{{ path('website_home') }}" style="color: #64748b; font-size: 0.8rem; text-decoration: none;">
|
||||||
|
← Back to site
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{% extends 'website/admin/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}{{ heading }} — Website Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_body %}
|
||||||
|
<div style="margin-bottom: 1.25rem;">
|
||||||
|
<a href="{{ path('website_admin_escaperooms') }}" style="color: #64748b; text-decoration: none; font-size: 0.9rem;">← Escape Rooms</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 style="margin: 0 0 1.5rem; font-size: 1.5rem; color: #0f172a;">{{ heading }}</h1>
|
||||||
|
|
||||||
|
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); padding: 1.5rem;">
|
||||||
|
{{ form_start(form, {attr: {class: 'er-form'}}) }}
|
||||||
|
<div class="er-field-wide">{{ form_row(form.name) }}</div>
|
||||||
|
<div class="er-field-wide">{{ form_row(form.venue) }}</div>
|
||||||
|
|
||||||
|
{{ form_row(form.street) }}
|
||||||
|
{{ form_row(form.houseNumber) }}
|
||||||
|
{{ form_row(form.postcode) }}
|
||||||
|
{{ form_row(form.city) }}
|
||||||
|
{{ form_row(form.country) }}
|
||||||
|
<div></div>
|
||||||
|
|
||||||
|
{{ form_row(form.latitude) }}
|
||||||
|
{{ form_row(form.longitude) }}
|
||||||
|
|
||||||
|
{{ form_row(form.website) }}
|
||||||
|
{{ form_row(form.phone) }}
|
||||||
|
|
||||||
|
{{ form_row(form.status) }}
|
||||||
|
{{ form_row(form.source) }}
|
||||||
|
|
||||||
|
<div class="er-field-wide">{{ form_row(form.locked) }}</div>
|
||||||
|
|
||||||
|
<div class="er-field-wide" style="display: flex; gap: 0.75rem; margin-top: 0.5rem;">
|
||||||
|
<button type="submit" class="er-btn">Save</button>
|
||||||
|
<a href="{{ path('website_admin_escaperooms') }}" class="er-btn er-btn--ghost">Cancel</a>
|
||||||
|
</div>
|
||||||
|
{{ form_end(form) }}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
{% extends 'website/admin/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Escape Rooms — Website Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_body %}
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem;">
|
||||||
|
<h1 style="margin: 0; font-size: 1.5rem; color: #0f172a;">Escape Rooms</h1>
|
||||||
|
<a href="{{ path('website_admin_escaperoom_new') }}" class="er-btn">+ New room</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% set tabs = {
|
||||||
|
'': 'All (' ~ counts.all ~ ')',
|
||||||
|
'pending': 'Pending (' ~ counts.pending ~ ')',
|
||||||
|
'published': 'Published (' ~ counts.published ~ ')',
|
||||||
|
'hidden': 'Hidden (' ~ counts.hidden ~ ')',
|
||||||
|
'rejected': 'Rejected (' ~ counts.rejected ~ ')',
|
||||||
|
} %}
|
||||||
|
<div style="margin-bottom: 1rem; display: flex; gap: 0.4rem; flex-wrap: wrap;">
|
||||||
|
{% for value, label in tabs %}
|
||||||
|
{% set active = (filter is null and value == '') or (filter and filter.value == value) %}
|
||||||
|
<a href="{{ path('website_admin_escaperooms', value ? {status: value} : {}) }}"
|
||||||
|
style="padding: 0.3rem 0.7rem; border-radius: 6px; font-size: 0.82rem; text-decoration: none;
|
||||||
|
background: {{ active ? '#3b82f6' : '#e2e8f0' }}; color: {{ active ? '#fff' : '#475569' }};">
|
||||||
|
{{ label }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
||||||
|
<table class="admin-datatable" style="width: 100%; min-width: 900px; border-collapse: collapse; font-size: 0.88rem;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
||||||
|
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Name</th>
|
||||||
|
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">City</th>
|
||||||
|
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Country</th>
|
||||||
|
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Source</th>
|
||||||
|
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Status</th>
|
||||||
|
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Reviews</th>
|
||||||
|
<th data-sortable="false" style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for room in rooms %}
|
||||||
|
<tr style="border-bottom: 1px solid #f1f5f9;">
|
||||||
|
<td style="padding: 0.7rem 0.9rem; font-weight: 500; color: #0f172a;">
|
||||||
|
{% if room.status.isPubliclyVisible %}
|
||||||
|
<a href="{{ path('website_escaperoom_show', {id: room.id, slug: room.slug}) }}" target="_blank" rel="noopener" style="color: #0f172a;">{{ room.name }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ room.name }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="padding: 0.7rem 0.9rem; color: #475569;">{{ room.city }}</td>
|
||||||
|
<td style="padding: 0.7rem 0.9rem; color: #475569;">{{ room.country }}</td>
|
||||||
|
<td style="padding: 0.7rem 0.9rem; color: #475569;">{{ room.source.value }}</td>
|
||||||
|
<td style="padding: 0.7rem 0.9rem;">
|
||||||
|
<span class="er-badge er-badge--{{ room.status.value }}">{{ room.status.label }}</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 0.7rem 0.9rem; color: #475569;">{{ reviewCounts[room.id]|default(0) }}</td>
|
||||||
|
<td style="padding: 0.7rem 0.9rem;" class="er-actions">
|
||||||
|
<a href="{{ path('website_admin_escaperoom_edit', {id: room.id}) }}">Edit</a>
|
||||||
|
{% if not room.status.isPubliclyVisible %}
|
||||||
|
<form method="post" action="{{ path('website_admin_escaperoom_moderate', {id: room.id, status: 'published'}) }}">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('moderate_room_' ~ room.id) }}">
|
||||||
|
<input type="hidden" name="return_to" value="{{ filter ? filter.value : '' }}">
|
||||||
|
<button type="submit">Publish</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="{{ path('website_admin_escaperoom_moderate', {id: room.id, status: 'hidden'}) }}">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('moderate_room_' ~ room.id) }}">
|
||||||
|
<input type="hidden" name="return_to" value="{{ filter ? filter.value : '' }}">
|
||||||
|
<button type="submit">Hide</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if room.status.value != 'rejected' %}
|
||||||
|
<form method="post" action="{{ path('website_admin_escaperoom_moderate', {id: room.id, status: 'rejected'}) }}">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('moderate_room_' ~ room.id) }}">
|
||||||
|
<input type="hidden" name="return_to" value="{{ filter ? filter.value : '' }}">
|
||||||
|
<button type="submit">Reject</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="{{ path('website_admin_escaperoom_delete', {id: room.id}) }}" onsubmit="return confirm('Delete {{ room.name|e('js') }}?')">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('delete_room_' ~ room.id) }}">
|
||||||
|
<button type="submit" class="er-danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="7" style="padding: 2rem; text-align: center; color: #94a3b8;">No rooms.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends 'website/admin/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Reviews — Website Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_body %}
|
||||||
|
<h1 style="margin: 0 0 1.5rem; font-size: 1.5rem; color: #0f172a;">Recent reviews</h1>
|
||||||
|
|
||||||
|
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
||||||
|
<table class="admin-datatable" style="width: 100%; min-width: 820px; border-collapse: collapse; font-size: 0.88rem;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
||||||
|
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Date</th>
|
||||||
|
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Room</th>
|
||||||
|
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Rating</th>
|
||||||
|
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Title</th>
|
||||||
|
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Author</th>
|
||||||
|
<th data-sortable="false" style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for review in reviews %}
|
||||||
|
<tr style="border-bottom: 1px solid #f1f5f9;">
|
||||||
|
<td style="padding: 0.7rem 0.9rem; color: #475569; white-space: nowrap;">{{ review.createdAt|date('Y-m-d') }}</td>
|
||||||
|
<td style="padding: 0.7rem 0.9rem; color: #0f172a;">
|
||||||
|
<a href="{{ path('website_admin_escaperoom_edit', {id: review.escapeRoom.id}) }}" style="color: #0f172a;">{{ review.escapeRoom.name }}</a>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 0.7rem 0.9rem;" data-order="{{ review.rating }}">{{ review.rating }} ★</td>
|
||||||
|
<td style="padding: 0.7rem 0.9rem; color: #475569;">{{ review.title }}</td>
|
||||||
|
<td style="padding: 0.7rem 0.9rem; color: #475569;">{{ review.author ? review.author.username : '—' }}</td>
|
||||||
|
<td style="padding: 0.7rem 0.9rem;" class="er-actions">
|
||||||
|
<form method="post" action="{{ path('website_admin_escaperoom_review_delete', {id: review.id}) }}" onsubmit="return confirm('Remove this review?')">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('delete_review_' ~ review.id) }}">
|
||||||
|
<button type="submit" class="er-danger">Remove</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="6" style="padding: 2rem; text-align: center; color: #94a3b8;">No reviews yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{% extends 'layout/site.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Contact | {{ 'site.name'|trans }}{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<h1 class="display-6 fw-bold">Contact</h1>
|
||||||
|
<p class="lead text-secondary">Got a question, a bug to report, or just want to say hi?</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
{{ form_errors(contactForm) }}
|
||||||
|
|
||||||
|
{{ form_start(contactForm) }}
|
||||||
|
{{ form_row(contactForm.name) }}
|
||||||
|
{{ form_row(contactForm.email) }}
|
||||||
|
{{ form_row(contactForm.message) }}
|
||||||
|
{{ form_row(contactForm.captcha) }}
|
||||||
|
|
||||||
|
<div class="d-grid mt-3">
|
||||||
|
<button type="submit" class="btn btn-primary">Send Message</button>
|
||||||
|
</div>
|
||||||
|
{{ form_end(contactForm) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
{% extends 'layout/site.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Escape room map | {{ 'site.name'|trans }}{% endblock %}
|
||||||
|
|
||||||
|
{% block stylesheets %}
|
||||||
|
{{ parent() }}
|
||||||
|
{{ encore_entry_link_tags('escaperoom-map') }}
|
||||||
|
<style>
|
||||||
|
#escaperoom-map {
|
||||||
|
height: 70vh;
|
||||||
|
min-height: 420px;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
.escaperoom-list-wrap {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, .07);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.escaperoom-list-wrap table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
.escaperoom-list-wrap th,
|
||||||
|
.escaperoom-list-wrap td {
|
||||||
|
padding: .6rem .9rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
{% set detailTemplate = path('website_escaperoom_show', {id: 987654321, slug: 'zzslugzz'})|replace({'987654321': '__ID__', 'zzslugzz': '__SLUG__'}) %}
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap align-items-baseline justify-content-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="display-6 fw-bold mb-1">Physical escape rooms</h1>
|
||||||
|
<p class="text-secondary mb-0">Zoom in to find rooms near you, then open one to read and leave reviews.</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<div id="escaperoom-status" class="small text-secondary">{{ publishedCount }} rooms on the map</div>
|
||||||
|
<a href="{{ path('website_escaperoom_suggest') }}" class="btn btn-sm btn-outline-primary mt-1">Suggest a room</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="escaperoom-map"
|
||||||
|
data-endpoint="{{ path('website_escaperooms_data') }}"
|
||||||
|
data-detail-template="{{ detailTemplate }}"
|
||||||
|
class="mb-4"></div>
|
||||||
|
|
||||||
|
<div class="escaperoom-list-wrap">
|
||||||
|
<table id="escaperoom-list">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>City</th>
|
||||||
|
<th>Country</th>
|
||||||
|
<th>Rating</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td colspan="4" class="text-center text-secondary py-4">Loading rooms…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block javascripts %}
|
||||||
|
{{ parent() }}
|
||||||
|
{{ encore_entry_script_tags('escaperoom-map') }}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
{% extends 'layout/site.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}{{ room.name }} | {{ 'site.name'|trans }}{% endblock %}
|
||||||
|
|
||||||
|
{% block stylesheets %}
|
||||||
|
{{ parent() }}
|
||||||
|
{{ encore_entry_link_tags('escaperoom-detail') }}
|
||||||
|
<style>
|
||||||
|
#escaperoom-detail-map {
|
||||||
|
height: 260px;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb small">
|
||||||
|
<li class="breadcrumb-item"><a href="{{ path('website_escaperooms') }}">Escape room map</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">{{ room.name }}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<h1 class="h3 fw-bold mb-1">{{ room.name }}</h1>
|
||||||
|
{% if room.venue %}<p class="text-secondary mb-2">{{ room.venue }}</p>{% endif %}
|
||||||
|
|
||||||
|
<p class="mb-1">
|
||||||
|
{% if rating.count > 0 %}
|
||||||
|
<span class="fw-semibold">{{ rating.avg }} ★</span>
|
||||||
|
<span class="text-secondary">· {{ rating.count }} review{{ rating.count != 1 ? 's' : '' }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-secondary">No reviews yet</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if room.displayAddress %}
|
||||||
|
<p class="mb-1">{{ room.displayAddress }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<p class="mb-3 small">
|
||||||
|
{% if room.website %}<a href="{{ room.website }}" target="_blank" rel="noopener nofollow">Website</a> · {% endif %}
|
||||||
|
{% if room.phone %}{{ room.phone }} · {% endif %}
|
||||||
|
<a href="https://www.openstreetmap.org/?mlat={{ room.latitude }}&mlon={{ room.longitude }}#map=17/{{ room.latitude }}/{{ room.longitude }}"
|
||||||
|
target="_blank" rel="noopener">Open in OpenStreetMap</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2 class="h5 mb-3">Reviews</h2>
|
||||||
|
|
||||||
|
{% if app.user %}
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="h6 mb-3">Leave a review</h3>
|
||||||
|
{{ form_start(reviewForm, {action: path('website_escaperoom_review', {id: room.id})}) }}
|
||||||
|
<div class="mb-2">
|
||||||
|
{{ form_label(reviewForm.rating) }}
|
||||||
|
{{ form_widget(reviewForm.rating, {attr: {class: 'form-select'}}) }}
|
||||||
|
{{ form_errors(reviewForm.rating) }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
{{ form_label(reviewForm.title) }}
|
||||||
|
{{ form_widget(reviewForm.title, {attr: {class: 'form-control'}}) }}
|
||||||
|
{{ form_errors(reviewForm.title) }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form_label(reviewForm.body) }}
|
||||||
|
{{ form_widget(reviewForm.body, {attr: {class: 'form-control'}}) }}
|
||||||
|
{{ form_errors(reviewForm.body) }}
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Post review</button>
|
||||||
|
{{ form_end(reviewForm) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="small"><a href="{{ path('app_login') }}">Log in</a> to leave a review.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for review in reviews %}
|
||||||
|
<article class="mb-3 pb-3 border-bottom">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<strong>{{ review.title }}</strong>
|
||||||
|
<span>
|
||||||
|
{%- for i in 1..5 -%}
|
||||||
|
<span class="{{ i <= review.rating ? 'text-warning' : 'text-secondary' }}">★</span>
|
||||||
|
{%- endfor -%}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="small text-secondary mb-1">
|
||||||
|
{{ review.author ? review.author.username : 'Anonymous' }} · {{ review.createdAt|date('Y-m-d') }}
|
||||||
|
</div>
|
||||||
|
<p class="mb-0">{{ review.body|nl2br }}</p>
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-secondary">Be the first to review this room.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div id="escaperoom-detail-map"
|
||||||
|
data-lat="{{ room.latitude }}"
|
||||||
|
data-lng="{{ room.longitude }}"
|
||||||
|
class="mb-3"></div>
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body small">
|
||||||
|
<div class="text-secondary">Coordinates</div>
|
||||||
|
<div class="mb-2">{{ room.latitude }}, {{ room.longitude }}</div>
|
||||||
|
<div class="text-secondary">Listing source</div>
|
||||||
|
<div>{{ room.source.label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block javascripts %}
|
||||||
|
{{ parent() }}
|
||||||
|
{{ encore_entry_script_tags('escaperoom-detail') }}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
{% extends 'layout/site.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Suggest an escape room | {{ 'site.name'|trans }}{% endblock %}
|
||||||
|
|
||||||
|
{% block stylesheets %}
|
||||||
|
{{ parent() }}
|
||||||
|
{{ encore_entry_link_tags('escaperoom-detail') }}
|
||||||
|
<style>
|
||||||
|
#escaperoom-suggest-map {
|
||||||
|
height: 340px;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb small">
|
||||||
|
<li class="breadcrumb-item"><a href="{{ path('website_escaperooms') }}">Escape room map</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">Suggest a room</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1 class="h3 fw-bold mb-1">Suggest an escape room</h1>
|
||||||
|
<p class="text-secondary">Know a physical escape room that isn't on the map? Add it here — an admin will review it before it goes live.</p>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
{{ form_errors(form) }}
|
||||||
|
{{ form_start(form) }}
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form_label(form.name) }}
|
||||||
|
{{ form_widget(form.name, {attr: {class: 'form-control'}}) }}
|
||||||
|
{{ form_errors(form.name) }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form_label(form.venue) }}
|
||||||
|
{{ form_widget(form.venue, {attr: {class: 'form-control'}}) }}
|
||||||
|
{{ form_errors(form.venue) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-9 mb-3">
|
||||||
|
{{ form_label(form.street) }}
|
||||||
|
{{ form_widget(form.street, {attr: {class: 'form-control'}}) }}
|
||||||
|
</div>
|
||||||
|
<div class="col-3 mb-3">
|
||||||
|
{{ form_label(form.houseNumber) }}
|
||||||
|
{{ form_widget(form.houseNumber, {attr: {class: 'form-control'}}) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-4 mb-3">
|
||||||
|
{{ form_label(form.postcode) }}
|
||||||
|
{{ form_widget(form.postcode, {attr: {class: 'form-control'}}) }}
|
||||||
|
</div>
|
||||||
|
<div class="col-8 mb-3">
|
||||||
|
{{ form_label(form.city) }}
|
||||||
|
{{ form_widget(form.city, {attr: {class: 'form-control'}}) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form_label(form.country) }}
|
||||||
|
{{ form_widget(form.country, {attr: {class: 'form-select'}}) }}
|
||||||
|
</div>
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-sm-6 mb-3">
|
||||||
|
{{ form_label(form.website) }}
|
||||||
|
{{ form_widget(form.website, {attr: {class: 'form-control'}}) }}
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6 mb-3">
|
||||||
|
{{ form_label(form.phone) }}
|
||||||
|
{{ form_widget(form.phone, {attr: {class: 'form-control'}}) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="form-label">Location <span class="text-secondary fw-normal">— click the map to drop a pin</span></label>
|
||||||
|
<div id="escaperoom-suggest-map" class="mb-2"></div>
|
||||||
|
{{ form_widget(form.latitude) }}
|
||||||
|
{{ form_widget(form.longitude) }}
|
||||||
|
{{ form_errors(form.latitude) }}
|
||||||
|
{{ form_errors(form.longitude) }}
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
{{ form_row(form.captcha) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid mt-3">
|
||||||
|
<button type="submit" class="btn btn-primary">Submit for review</button>
|
||||||
|
</div>
|
||||||
|
{{ form_end(form) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block javascripts %}
|
||||||
|
{{ parent() }}
|
||||||
|
{{ encore_entry_script_tags('escaperoom-detail') }}
|
||||||
|
{% endblock %}
|
||||||
@@ -44,6 +44,40 @@
|
|||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, .35);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, .35);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
.how-it-works-kicker {
|
||||||
|
letter-spacing: .1em;
|
||||||
|
color: var(--brand-teal-dark, #1c5a70);
|
||||||
|
}
|
||||||
|
.how-step-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(47, 143, 174, .12);
|
||||||
|
color: var(--brand-teal-dark, #1c5a70);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
.how-step-connector {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.how-step-connector:not(:last-child)::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 32px;
|
||||||
|
left: calc(50% + 44px);
|
||||||
|
right: calc(-50% + 44px);
|
||||||
|
height: 2px;
|
||||||
|
background: rgba(47, 143, 174, .25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.world-map-strip {
|
||||||
|
background: rgba(47, 143, 174, .06);
|
||||||
|
border: 1px solid rgba(47, 143, 174, .2);
|
||||||
|
border-radius: .75rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -66,14 +100,69 @@
|
|||||||
<div class="col-lg-5 text-center">
|
<div class="col-lg-5 text-center">
|
||||||
<div class="dev-watermark-wrap">
|
<div class="dev-watermark-wrap">
|
||||||
<img src="{{ asset('images/AI_virus.png') }}" alt="AI Virus" class="img-fluid" style="max-height: 260px;">
|
<img src="{{ asset('images/AI_virus.png') }}" alt="AI Virus" class="img-fluid" style="max-height: 260px;">
|
||||||
<span class="dev-watermark-badge">In Development</span>
|
{% if showDevBanner %}
|
||||||
|
<span class="dev-watermark-badge">In Development</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-center py-4">
|
<div class="mb-5">
|
||||||
<img src="{{ asset('images/logo.png') }}" alt="{{ 'site.name'|trans }}" height="100" class="mb-3">
|
<div class="text-center mb-4">
|
||||||
<p class="col-lg-8 mx-auto text-secondary">{{ 'home.description'|trans }}</p>
|
<span class="text-uppercase small fw-semibold how-it-works-kicker">How it works</span>
|
||||||
|
<h2 class="fw-bold">One team — even if you're not in the same room</h2>
|
||||||
|
<p class="col-lg-8 mx-auto text-secondary">
|
||||||
|
Every game on EscapePage is built to be played online, together. You don't need to travel,
|
||||||
|
book a room, or even hop on a call — everyone plays from their own screen, and the
|
||||||
|
game has a built-in chat to keep your team talking.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-md-3 col-6 how-step-connector">
|
||||||
|
<div class="text-center h-100 p-3">
|
||||||
|
<div class="how-step-icon mx-auto mb-3"><i class="bi bi-people-fill"></i></div>
|
||||||
|
<h3 class="h6 fw-bold">1. Invite your team</h3>
|
||||||
|
<p class="small text-secondary mb-0">
|
||||||
|
Get two or three friends to join the same game — no matter where in the
|
||||||
|
world they are.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-6 how-step-connector">
|
||||||
|
<div class="text-center h-100 p-3">
|
||||||
|
<div class="how-step-icon mx-auto mb-3"><i class="bi bi-display"></i></div>
|
||||||
|
<h3 class="h6 fw-bold">2. Everyone logs in</h3>
|
||||||
|
<p class="small text-secondary mb-0">
|
||||||
|
Each player opens the game in their own browser, from wherever they are. No app,
|
||||||
|
no download, no travel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-6 how-step-connector">
|
||||||
|
<div class="text-center h-100 p-3">
|
||||||
|
<div class="how-step-icon mx-auto mb-3"><i class="bi bi-chat-dots-fill"></i></div>
|
||||||
|
<h3 class="h6 fw-bold">3. Chat in the game</h3>
|
||||||
|
<p class="small text-secondary mb-0">
|
||||||
|
Each of you sees different clues. Use the built-in chat to share what you find and
|
||||||
|
connect the dots — no separate call needed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-6 how-step-connector">
|
||||||
|
<div class="text-center h-100 p-3">
|
||||||
|
<div class="how-step-icon mx-auto mb-3"><i class="bi bi-hourglass-split"></i></div>
|
||||||
|
<h3 class="h6 fw-bold">4. Beat the clock</h3>
|
||||||
|
<p class="small text-secondary mb-0">
|
||||||
|
Work together in real time to crack it before the timer runs out — you win
|
||||||
|
or lose as one team.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="world-map-strip text-center p-3 p-md-4 mt-4">
|
||||||
|
<i class="bi bi-globe-americas me-2" style="color: var(--brand-teal-dark, #1c5a70);"></i>
|
||||||
|
<span class="text-secondary">Amsterdam, Auckland, or anywhere in between — as long as you're online, you can play together.</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -81,7 +81,9 @@
|
|||||||
<div class="text-center mb-4">
|
<div class="text-center mb-4">
|
||||||
<div class="dev-watermark-wrap">
|
<div class="dev-watermark-wrap">
|
||||||
<img src="{{ asset('images/AI_virus.png') }}" alt="AI Virus" class="img-fluid" style="max-height: 220px;">
|
<img src="{{ asset('images/AI_virus.png') }}" alt="AI Virus" class="img-fluid" style="max-height: 220px;">
|
||||||
<span class="dev-watermark-badge">In Development</span>
|
{% if showDevBanner %}
|
||||||
|
<span class="dev-watermark-badge">In Development</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
{% extends 'layout/site.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Terms and Conditions | {{ 'site.name'|trans }}{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-9">
|
||||||
|
<div class="mb-4">
|
||||||
|
<h1 class="display-6 fw-bold">Terms and Conditions</h1>
|
||||||
|
<p class="text-secondary">Last updated: 29 August 2026</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<p>
|
||||||
|
Welcome to EscapePage. These Terms and Conditions ("Terms") govern your use of this
|
||||||
|
website and the online escape-room game(s) offered on it (together, the "Service"). By
|
||||||
|
creating an account or otherwise using the Service, you agree to these Terms.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="h5 mt-4">1. The Service</h2>
|
||||||
|
<p>
|
||||||
|
EscapePage is a hobby project offering collaborative, browser-based escape-room
|
||||||
|
experiences. The Service is under active development: games, features, and content may
|
||||||
|
change, be added, or be removed at any time, without notice.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="h5 mt-4">2. Accounts</h2>
|
||||||
|
<p>
|
||||||
|
You need an account to play. You are responsible for keeping your login details
|
||||||
|
confidential and for all activity under your account. You must provide accurate
|
||||||
|
information when registering, and you must be at least 16 years old, or the minimum age
|
||||||
|
required in your country to consent to online services without parental approval,
|
||||||
|
whichever is higher.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="h5 mt-4">3. Acceptable Use</h2>
|
||||||
|
<p>You agree not to:</p>
|
||||||
|
<ul>
|
||||||
|
<li>attempt to cheat, exploit bugs, or bypass the intended puzzle mechanics in a way that spoils the game for other players;</li>
|
||||||
|
<li>harass, abuse, or send unlawful or offensive content to other players or the operator;</li>
|
||||||
|
<li>use automated tools to scrape, overload, or attack the Service;</li>
|
||||||
|
<li>use the Service for any unlawful purpose.</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Accounts that break these rules may be suspended or removed at the operator's discretion.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="h5 mt-4">4. Feedback and Submitted Content</h2>
|
||||||
|
<p>
|
||||||
|
If you submit feedback, ratings, or written comments through the Service, you grant the
|
||||||
|
operator a non-exclusive, royalty-free right to use, store, and display that content
|
||||||
|
(for example, as aggregate feedback scores or anonymised testimonials) in connection with
|
||||||
|
the Service.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="h5 mt-4">5. Donations</h2>
|
||||||
|
<p>
|
||||||
|
The Service may offer an optional way to support its development through a third-party
|
||||||
|
payment provider (currently PayPal). Donations are voluntary, are not required to play,
|
||||||
|
do not unlock any in-game advantage, and are non-refundable except where required by law.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="h5 mt-4">6. Availability and "As Is" Service</h2>
|
||||||
|
<p>
|
||||||
|
The Service is provided free of charge, "as is" and "as available," without warranties of
|
||||||
|
any kind, express or implied. As a small, self-hosted hobby project, uptime, data
|
||||||
|
retention, and long-term availability are not guaranteed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="h5 mt-4">7. Intellectual Property</h2>
|
||||||
|
<p>
|
||||||
|
The game stories, puzzles, artwork, and source code are the property of the operator or
|
||||||
|
used under licence, except where third-party or open-source components state otherwise.
|
||||||
|
You may not copy, redistribute, or republish game content without permission.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="h5 mt-4">8. Limitation of Liability</h2>
|
||||||
|
<p>
|
||||||
|
To the fullest extent permitted by law, the operator is not liable for any indirect,
|
||||||
|
incidental, or consequential damages arising from your use of, or inability to use, the
|
||||||
|
Service.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="h5 mt-4">9. Changes to These Terms</h2>
|
||||||
|
<p>
|
||||||
|
These Terms may be updated from time to time; the "Last updated" date above will reflect
|
||||||
|
the most recent change. Continuing to use the Service after a change means you accept the
|
||||||
|
updated Terms.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="h5 mt-4">10. Governing Law</h2>
|
||||||
|
<p>
|
||||||
|
These Terms are governed by the laws of the Netherlands, without regard to its conflict
|
||||||
|
of law principles.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="h5 mt-4">11. Contact</h2>
|
||||||
|
<p class="mb-0">
|
||||||
|
Questions about these Terms are welcome. Please reach out to the operator through the
|
||||||
|
contact details available on this site.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
site.name: EscapePage
|
site.name: EscapePage
|
||||||
nav.home: Home
|
nav.home: Home
|
||||||
nav.game: Game
|
nav.game: Game
|
||||||
|
nav.escaperooms: Rooms
|
||||||
nav.admin: Admin
|
nav.admin: Admin
|
||||||
nav.profile: Profile
|
nav.profile: Profile
|
||||||
nav.login: Login
|
nav.login: Login
|
||||||
@@ -9,12 +10,13 @@ nav.register: Register
|
|||||||
nav.logout: Logout
|
nav.logout: Logout
|
||||||
link.enter_game: Enter the Game Area
|
link.enter_game: Enter the Game Area
|
||||||
link.back_to_website: Back to Website
|
link.back_to_website: Back to Website
|
||||||
|
footer.contact: Contact
|
||||||
footer.help_wanted: Looking for Help
|
footer.help_wanted: Looking for Help
|
||||||
|
footer.terms: Terms and Conditions
|
||||||
|
|
||||||
# Home page
|
# Home page
|
||||||
home.title: "Welcome | EscapePage"
|
home.title: "Welcome | EscapePage"
|
||||||
home.h1: "Welcome to EscapePage"
|
home.h1: "Welcome to EscapePage"
|
||||||
home.description: "This is the public website. Minimal interaction, information about the game, and links."
|
|
||||||
|
|
||||||
# Game hub
|
# Game hub
|
||||||
game.title: "Game Hub | EscapePage"
|
game.title: "Game Hub | EscapePage"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
site.name: EscapePage
|
site.name: EscapePage
|
||||||
nav.home: Start
|
nav.home: Start
|
||||||
nav.game: Spel
|
nav.game: Spel
|
||||||
|
nav.escaperooms: Rooms
|
||||||
nav.admin: Beheer
|
nav.admin: Beheer
|
||||||
nav.profile: Profiel
|
nav.profile: Profiel
|
||||||
nav.login: Inloggen
|
nav.login: Inloggen
|
||||||
@@ -9,12 +10,13 @@ nav.register: Registreren
|
|||||||
nav.logout: Uitloggen
|
nav.logout: Uitloggen
|
||||||
link.enter_game: Ga naar het Speelgedeelte
|
link.enter_game: Ga naar het Speelgedeelte
|
||||||
link.back_to_website: Terug naar Website
|
link.back_to_website: Terug naar Website
|
||||||
|
footer.contact: Contact
|
||||||
footer.help_wanted: Op zoek naar hulp
|
footer.help_wanted: Op zoek naar hulp
|
||||||
|
footer.terms: Algemene Voorwaarden
|
||||||
|
|
||||||
# Home pagina
|
# Home pagina
|
||||||
home.title: "Welkom | %{site}%"
|
home.title: "Welkom | %{site}%"
|
||||||
home.h1: "Welkom bij %{site}%"
|
home.h1: "Welkom bij %{site}%"
|
||||||
home.description: "Dit is de publieke website. Beperkte interactie, informatie over het spel en links."
|
|
||||||
|
|
||||||
# Spel hub
|
# Spel hub
|
||||||
game.title: "Spel Hub | %{site}%"
|
game.title: "Spel Hub | %{site}%"
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ Encore
|
|||||||
.addEntry('app', './assets/app.js')
|
.addEntry('app', './assets/app.js')
|
||||||
.addEntry('game1', './assets/game1.js')
|
.addEntry('game1', './assets/game1.js')
|
||||||
.addEntry('pregame', './assets/pregame.js')
|
.addEntry('pregame', './assets/pregame.js')
|
||||||
|
.addEntry('escaperoom-map', './assets/escaperoom-map.js')
|
||||||
|
.addEntry('escaperoom-detail', './assets/escaperoom-detail.js')
|
||||||
// split entry chunks for better caching
|
// split entry chunks for better caching
|
||||||
.splitEntryChunks()
|
.splitEntryChunks()
|
||||||
// will require an extra script tag for runtime.js
|
// will require an extra script tag for runtime.js
|
||||||
|
|||||||
Reference in New Issue
Block a user