6 Commits
Author SHA1 Message Date
FrankandClaude Sonnet 5 ab0976751f Rename the nav link "Escape rooms" to "Rooms"
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 00:24:03 +02:00
FrankandClaude Sonnet 5 28f7e9809e Clamp OSM import field values to their column widths
addr:housenumber / addr:postcode etc. are free-text in OSM and occasionally
exceed the entity's VARCHAR limits (e.g. housenumber "12-14, 16, 18"), which
aborted the whole --tiled run with a 1406 "Data too long" error. firstTag()
now takes a max length and each field is clamped to its real column size.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 00:24:03 +02:00
FrankandClaude Sonnet 5 008887ab60 Add physical escape room map, reviews, and website admin
Public:
- /escape-rooms: Leaflet + OpenStreetMap map with marker clustering and a
  searchable list, both built from a cached slim JSON feed
- /escape-rooms/{id}-{slug}: room detail with a mini-map and user reviews
  (rating + title + body, login required)
- /escape-rooms/suggest: visitor submission form with click-to-drop-pin map
  and reCAPTCHA v3; new rooms land as pending

Data:
- EscapeRoom / EscapeRoomReview entities (soft-delete), migration
- app:escaperooms:import-osm console command pulls leisure=escape_game from
  the Overpass API, upserts by (osmType, osmId), skips locked rows;
  --tiled / --area / --bbox options

Admin (new "Website Admin" area, separate from Game Admin):
- src/Website/Controller/AdminEscapeRoomController.php at /admin/escape-rooms:
  status-filtered list, new/edit, publish/hide/reject, delete, review removal
- game admin sidebar extracted into a block and cross-linked

Assets: leaflet + leaflet.markercluster; escaperoom-map and escaperoom-detail
Encore entries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 00:24:03 +02:00
Frank 0edaa9d8cc Merge branch 'docker-test-environment': multi-instance Docker stack + test.escapepage.com scripts 2026-08-31 00:23:05 +02:00
FrankandClaude Sonnet 5 97d35f8fcb Add dedicated setup.test.sh / restart.test.sh for the staging stack
Both refuse to run unless docker/.env names a non-prod COMPOSE_PROJECT_NAME and
scope every compose call to that project.

setup.test.sh: like setup.sh but runs the DB/cache/console steps as www-data and
repairs var/cache|log|sessions ownership at the end, so php-fpm can read its own
compiled cache (bare `docker exec` runs as root -> silent 500s). Never touches
var/volumes/db. Test-appropriate final message (NPM upstream, local curl check).

restart.test.sh: keeps the database by default (--fresh-db to wipe + re-init),
never runs a host-wide docker prune, and only chowns var/cache|log|sessions
(chowning var/volumes/db is what corrupted the MySQL data dir).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 00:19:13 +02:00
FrankandClaude Sonnet 5 b565825cd9 docker: make the stack multi-instance so a test.escapepage.com copy can run alongside prod
compose.yaml / compose.override.yaml:
- container_name is now ${STACK_NAME:-escapepage}-* (STACK_NAME is a plain var,
  not COMPOSE_PROJECT_NAME, so prod keeps its escapepage-* names with no config change)
- every published host port is ${*_PORT:-<current default>}, so prod is unchanged
  and a second stack can bind its own (localhost-only) ports
- nginx joins the external nginx_default network so Nginx Proxy Manager can
  forward to <stack>-nginx by name

restart.sh:
- scoped to STACK_NAME / COMPOSE_PROJECT_NAME read from docker/.env, so running it
  from the test checkout can't touch the prod stack
- host-wide `docker system prune` / `docker builder prune` moved behind --prune-all

Adds docker/.env.test.example and doc/test-environment.md (separate checkout,
env layers, NPM proxy host + Access List IP allowlist, Mercure on test).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 23:52:53 +02:00
36 changed files with 3050 additions and 28 deletions
+85
View File
@@ -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: '&copy; <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);
}
});
+151
View File
@@ -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: '&copy; <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 &amp; review &raquo;</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);
});
});
+152
View File
@@ -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.
+77
View File
@@ -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
+2 -2
View File
@@ -17,8 +17,8 @@ services:
mailer:
image: axllent/mailpit
ports:
- "1025:1025"
- "8025:8025"
- "${MAILPIT_SMTP_PORT:-1025}:1025"
- "${MAILPIT_UI_PORT:-8025}:8025"
environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
+26 -15
View File
@@ -1,5 +1,14 @@
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:
php:
build:
@@ -8,7 +17,7 @@ services:
args:
USER_ID: ${USER_ID}
GROUP_ID: ${GROUP_ID}
container_name: escapepage-php
container_name: ${STACK_NAME:-escapepage}-php
volumes:
- ../:/var/www/html:delegated
- /etc/hosts:/etc/hosts:ro
@@ -40,7 +49,7 @@ services:
args:
USER_ID: ${USER_ID}
GROUP_ID: ${GROUP_ID}
container_name: escapepage-php-worker
container_name: ${STACK_NAME:-escapepage}-php-worker
volumes:
- ../:/var/www/html:delegated
- /etc/hosts:/etc/hosts:ro
@@ -73,7 +82,7 @@ services:
args:
USER_ID: ${USER_ID}
GROUP_ID: ${GROUP_ID}
container_name: escapepage-php-cron
container_name: ${STACK_NAME:-escapepage}-php-cron
volumes:
- ../:/var/www/html:delegated
- /etc/hosts:/etc/hosts:ro
@@ -101,10 +110,10 @@ services:
nginx:
image: nginx:1.29.4-alpine
container_name: escapepage-nginx
container_name: ${STACK_NAME:-escapepage}-nginx
ports:
- "8080:80"
- "8443:443"
- "${NGINX_HTTP_PORT:-8080}:80"
- "${NGINX_HTTPS_PORT:-8443}:443"
volumes:
- ../:/var/www/html:ro
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
@@ -112,16 +121,18 @@ services:
- /etc/hosts:/etc/hosts:ro
depends_on:
- php
# networks:
# backend:
# ipv4_address: 172.23.0.12
# Joined to the Nginx Proxy Manager network so NPM can forward straight to
# "<project>-nginx" without going back out to a published host port.
networks:
- default
- nginx_proxy
restart: unless-stopped
mailer:
image: axllent/mailpit:latest
container_name: escapepage-mailer
container_name: ${STACK_NAME:-escapepage}-mailer
ports:
- "8025:8025"
- "${MAILPIT_UI_PORT:-8025}:8025"
volumes:
- /etc/hosts:/etc/hosts:ro
networks:
@@ -131,7 +142,7 @@ services:
mercure:
image: dunglas/mercure:v0.21
container_name: escapepage-mercure
container_name: ${STACK_NAME:-escapepage}-mercure
environment:
SERVER_NAME: "http://:80"
MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_JWT_SECRET}
@@ -143,7 +154,7 @@ services:
publish_origins ${MERCURE_CORS_ALLOWED_ORIGINS}
anonymous
ports:
- "8090:80"
- "${MERCURE_HTTP_PORT:-8090}:80"
volumes:
- /etc/hosts:/etc/hosts:ro
networks:
@@ -154,7 +165,7 @@ services:
###> doctrine/doctrine-bundle ###
database:
image: mysql:8.0
container_name: escapepage-db
container_name: ${STACK_NAME:-escapepage}-db
environment:
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
@@ -173,7 +184,7 @@ services:
- /etc/hosts:/etc/hosts:ro
# Uncomment the two lines below if you need to access MySQL from your host (workbench, etc.)
ports:
- "3306:3306"
- "${DB_HOST_PORT:-3306}:3306"
# networks:
# backend:
# ipv4_address: 172.23.0.15
+39 -11
View File
@@ -1,22 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
# Script to completely restart the project as requested
# Can be run from any directory
# Completely restart ONE project stack (prod or a test/staging copy).
# 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)
ROOT_DIR=$(cd "$DOCKER_DIR/.." && pwd)
echo "Stopping and removing containers..."
(cd "$DOCKER_DIR" && docker compose -f compose.yaml -f compose.override.yaml down -v --remove-orphans) || true
docker network rm escapepage_network || true
docker network rm $(docker network ls -q --filter name=escapepage) || true
docker network prune -f || true
docker rm -f escapepage-db escapepage-php escapepage-nginx escapepage-mercure escapepage-mailer escapepage-php-worker escapepage-php-cron || true
docker system prune -f || true
PRUNE_ALL=0
for arg in "$@"; do
case "$arg" in
--prune-all) PRUNE_ALL=1 ;;
*) echo "Unknown option: $arg" >&2; exit 1 ;;
esac
done
echo "Clearing Docker build cache..."
docker builder prune -af
# Read the identifiers from docker/.env (same file Compose uses). STACK_NAME is
# 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..."
sudo chown -R 1000:1000 "$ROOT_DIR/var/volumes/db" || true
+84
View File
@@ -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
+134
View File
@@ -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
+79
View File
@@ -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');
}
}
+17
View File
@@ -12,6 +12,8 @@
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
"simple-datatables": "^10.3.0"
},
"devDependencies": {
@@ -3863,6 +3865,21 @@
"node": ">=0.10.0"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/leaflet.markercluster": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
"integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==",
"license": "MIT",
"peerDependencies": {
"leaflet": "^1.3.1"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+2
View File
@@ -28,6 +28,8 @@
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
"simple-datatables": "^10.3.0"
}
}
@@ -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,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,
]);
}
}
+378
View File
@@ -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);
}
}
+141
View File
@@ -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;
}
}
+19
View File
@@ -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',
};
}
}
+30
View File
@@ -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;
}
}
+67
View File
@@ -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,
]);
}
}
+46
View File
@@ -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,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();
}
}
+5
View File
@@ -124,6 +124,7 @@
<div class="admin-shell">
{# ── Sidebar ──────────────────────────────────────────────────────── #}
{% block admin_sidebar %}
<nav class="admin-sidebar">
<div class="admin-sidebar-title">Game Admin</div>
@@ -156,11 +157,15 @@
</ul>
<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;">
← Back to game
</a>
</div>
</nav>
{% endblock %}
{# ── Content ──────────────────────────────────────────────────────── #}
<main class="admin-main">
+3
View File
@@ -20,6 +20,9 @@
<li class="nav-item">
<a class="nav-link" href="{{ path('game_dashboard') }}">{{ 'nav.game'|trans }}</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ path('website_escaperooms') }}">{{ 'nav.escaperooms'|trans }}</a>
</li>
{% if app.user %}
{% if is_granted('ROLE_ADMIN') %}
<li class="nav-item">
+110
View File
@@ -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,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 %}
+1
View File
@@ -2,6 +2,7 @@
site.name: EscapePage
nav.home: Home
nav.game: Game
nav.escaperooms: Rooms
nav.admin: Admin
nav.profile: Profile
nav.login: Login
+1
View File
@@ -2,6 +2,7 @@
site.name: EscapePage
nav.home: Start
nav.game: Spel
nav.escaperooms: Rooms
nav.admin: Beheer
nav.profile: Profiel
nav.login: Inloggen
+2
View File
@@ -15,6 +15,8 @@ Encore
.addEntry('app', './assets/app.js')
.addEntry('game1', './assets/game1.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
.splitEntryChunks()
// will require an extra script tag for runtime.js