Compare commits
6
Commits
6e5b5978e8
...
ab0976751f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab0976751f | ||
|
|
28f7e9809e | ||
|
|
008887ab60 | ||
|
|
0edaa9d8cc | ||
|
|
97d35f8fcb | ||
|
|
b565825cd9 |
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Small single-purpose Leaflet maps:
|
||||
* - #escaperoom-detail-map : read-only pin on a room's detail page
|
||||
* - #escaperoom-suggest-map : click to drop / move a pin on the "suggest a room"
|
||||
* form, writing the coordinates into the hidden latitude/longitude fields.
|
||||
*/
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
import iconUrl from 'leaflet/dist/images/marker-icon.png';
|
||||
import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
|
||||
import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
|
||||
|
||||
delete L.Icon.Default.prototype._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl, shadowUrl });
|
||||
|
||||
const OSM = {
|
||||
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
opts: {
|
||||
maxZoom: 19,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
},
|
||||
};
|
||||
|
||||
function initDetailMap(el) {
|
||||
const lat = parseFloat(el.dataset.lat);
|
||||
const lng = parseFloat(el.dataset.lng);
|
||||
if (Number.isNaN(lat) || Number.isNaN(lng)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const map = L.map(el, { scrollWheelZoom: false }).setView([lat, lng], 15);
|
||||
L.tileLayer(OSM.url, OSM.opts).addTo(map);
|
||||
L.marker([lat, lng]).addTo(map);
|
||||
}
|
||||
|
||||
function initSuggestMap(el) {
|
||||
const form = el.closest('form');
|
||||
const latField = form && form.querySelector('input[name$="[latitude]"]');
|
||||
const lngField = form && form.querySelector('input[name$="[longitude]"]');
|
||||
if (!latField || !lngField) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startLat = parseFloat(latField.value);
|
||||
const startLng = parseFloat(lngField.value);
|
||||
const hasStart = !Number.isNaN(startLat) && !Number.isNaN(startLng);
|
||||
|
||||
const map = L.map(el).setView(hasStart ? [startLat, startLng] : [20, 0], hasStart ? 15 : 2);
|
||||
L.tileLayer(OSM.url, OSM.opts).addTo(map);
|
||||
|
||||
let marker = hasStart ? L.marker([startLat, startLng], { draggable: true }).addTo(map) : null;
|
||||
|
||||
const write = (latlng) => {
|
||||
latField.value = latlng.lat.toFixed(7);
|
||||
lngField.value = latlng.lng.toFixed(7);
|
||||
latField.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
};
|
||||
|
||||
if (marker) {
|
||||
marker.on('dragend', () => write(marker.getLatLng()));
|
||||
}
|
||||
|
||||
map.on('click', (e) => {
|
||||
if (marker) {
|
||||
marker.setLatLng(e.latlng);
|
||||
} else {
|
||||
marker = L.marker(e.latlng, { draggable: true }).addTo(map);
|
||||
marker.on('dragend', () => write(marker.getLatLng()));
|
||||
}
|
||||
write(e.latlng);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const detail = document.getElementById('escaperoom-detail-map');
|
||||
if (detail) {
|
||||
initDetailMap(detail);
|
||||
}
|
||||
|
||||
const suggest = document.getElementById('escaperoom-suggest-map');
|
||||
if (suggest) {
|
||||
initSuggestMap(suggest);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Public "physical escape rooms" map + list.
|
||||
*
|
||||
* One JSON fetch (website_escaperooms_data) feeds both the Leaflet map (with
|
||||
* marker clustering, so a worldwide dataset stays responsive) and the searchable
|
||||
* table underneath it.
|
||||
*/
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import 'leaflet.markercluster';
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.css';
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
|
||||
import { DataTable } from 'simple-datatables';
|
||||
import 'simple-datatables/dist/style.css';
|
||||
|
||||
import iconUrl from 'leaflet/dist/images/marker-icon.png';
|
||||
import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
|
||||
import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
|
||||
|
||||
// Webpack rewrites the image paths, so hand them to Leaflet explicitly.
|
||||
delete L.Icon.Default.prototype._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl, shadowUrl });
|
||||
|
||||
function slugify(name) {
|
||||
return (name || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'room';
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = value == null ? '' : String(value);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function ratingLabel(avg, count) {
|
||||
if (!count) {
|
||||
return '—';
|
||||
}
|
||||
return `${avg.toFixed(1)} ★ (${count})`;
|
||||
}
|
||||
|
||||
function detailUrl(template, room) {
|
||||
return template.replace('__ID__', room.id).replace('__SLUG__', slugify(room.name));
|
||||
}
|
||||
|
||||
function buildMap(el, rooms, detailTemplate) {
|
||||
const map = L.map(el, { worldCopyJump: true }).setView([20, 0], 2);
|
||||
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
}).addTo(map);
|
||||
|
||||
const cluster = L.markerClusterGroup({ chunkedLoading: true });
|
||||
const bounds = [];
|
||||
|
||||
rooms.forEach((room) => {
|
||||
if (typeof room.lat !== 'number' || typeof room.lng !== 'number') {
|
||||
return;
|
||||
}
|
||||
const where = [room.city, room.country].filter(Boolean).join(', ');
|
||||
const popup = `
|
||||
<strong>${escapeHtml(room.name)}</strong><br>
|
||||
${where ? `${escapeHtml(where)}<br>` : ''}
|
||||
<span>${escapeHtml(ratingLabel(room.avgRating, room.reviewCount))}</span><br>
|
||||
<a href="${detailUrl(detailTemplate, room)}">View & review »</a>
|
||||
`;
|
||||
const marker = L.marker([room.lat, room.lng]).bindPopup(popup);
|
||||
cluster.addLayer(marker);
|
||||
bounds.push([room.lat, room.lng]);
|
||||
});
|
||||
|
||||
map.addLayer(cluster);
|
||||
|
||||
if (bounds.length) {
|
||||
map.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
||||
}
|
||||
}
|
||||
|
||||
function buildList(table, rooms, detailTemplate) {
|
||||
const tbody = table.querySelector('tbody');
|
||||
const frag = document.createDocumentFragment();
|
||||
|
||||
rooms.forEach((room) => {
|
||||
const tr = document.createElement('tr');
|
||||
const where = room.city ? escapeHtml(room.city) : '';
|
||||
const country = room.country ? escapeHtml(room.country) : '';
|
||||
const ratingValue = room.reviewCount ? room.avgRating : 0;
|
||||
|
||||
tr.innerHTML = `
|
||||
<td><a href="${detailUrl(detailTemplate, room)}">${escapeHtml(room.name)}</a></td>
|
||||
<td>${where}</td>
|
||||
<td>${country}</td>
|
||||
<td data-order="${ratingValue}">${escapeHtml(ratingLabel(room.avgRating, room.reviewCount))}</td>
|
||||
`;
|
||||
frag.appendChild(tr);
|
||||
});
|
||||
|
||||
tbody.replaceChildren(frag);
|
||||
|
||||
new DataTable(table, {
|
||||
perPage: 25,
|
||||
perPageSelect: [25, 50, 100],
|
||||
searchable: true,
|
||||
sortable: true,
|
||||
labels: {
|
||||
placeholder: 'Search rooms…',
|
||||
perPage: '{select} per page',
|
||||
noRows: 'No matching rooms',
|
||||
info: 'Showing {start}–{end} of {rows} rooms',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const mapEl = document.getElementById('escaperoom-map');
|
||||
if (!mapEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const endpoint = mapEl.dataset.endpoint;
|
||||
const detailTemplate = mapEl.dataset.detailTemplate;
|
||||
const listEl = document.getElementById('escaperoom-list');
|
||||
const statusEl = document.getElementById('escaperoom-status');
|
||||
|
||||
fetch(endpoint, { headers: { Accept: 'application/json' } })
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then((payload) => {
|
||||
const rooms = Array.isArray(payload.rooms) ? payload.rooms : [];
|
||||
buildMap(mapEl, rooms, detailTemplate);
|
||||
if (listEl) {
|
||||
buildList(listEl, rooms, detailTemplate);
|
||||
}
|
||||
if (statusEl) {
|
||||
statusEl.textContent = `${rooms.length.toLocaleString()} rooms on the map`;
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'Could not load escape rooms right now.';
|
||||
}
|
||||
console.error('escaperoom-map:', err);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
# Test / staging environment (`test.escapepage.com`)
|
||||
|
||||
Runs a second copy of the full Docker stack on the same server as production,
|
||||
behind the same Nginx Proxy Manager (NPM). Production is untouched: with no
|
||||
`docker/.env` overrides, `compose.yaml` behaves exactly as before.
|
||||
|
||||
## How isolation works
|
||||
|
||||
`docker/compose.yaml` derives everything instance-specific from `docker/.env`:
|
||||
|
||||
| Concern | Mechanism | prod (no overrides) | test |
|
||||
|---|---|---|---|
|
||||
| Container names | `${STACK_NAME:-escapepage}-*` | `escapepage-php` … | `escapepage-test-php` … |
|
||||
| Compose project (networks, labels) | `COMPOSE_PROJECT_NAME` | `docker` (unchanged) | `escapepage-test` |
|
||||
| Published ports | `${NGINX_HTTP_PORT:-8080}` etc. | `0.0.0.0:8080/8443/3306/8090/8025` | `127.0.0.1:8081/8444/3307/8091/8026` |
|
||||
| Database files | bind mount `../var/volumes/db` | per checkout | per checkout |
|
||||
| Web reachability | `nginx` joined to external `nginx_default` | via NPM | via NPM |
|
||||
|
||||
`STACK_NAME` is a plain variable (not the Compose-managed `COMPOSE_PROJECT_NAME`),
|
||||
so its `:-escapepage` default is reliable and **production needs no `docker/.env`
|
||||
change** to keep its `escapepage-*` container names.
|
||||
|
||||
`nginx`, `mercure` and `mailer` all sit on the external `nginx_default` network,
|
||||
so NPM forwards straight to `escapepage-test-nginx` / `-mercure` by name — no
|
||||
public host port needed.
|
||||
|
||||
## Production checkout — optional
|
||||
|
||||
Nothing is required. Two optional tidy-ups, each needing a `docker compose up -d`
|
||||
to recreate the affected container:
|
||||
|
||||
- The `nginx` service now also attaches to `nginx_default`. If you'd rather have
|
||||
NPM forward to `escapepage-nginx` by name instead of `host:8080`, recreate it
|
||||
and repoint the NPM proxy host. Otherwise the published `8080/8443` still work
|
||||
as before and you can ignore this.
|
||||
- Add `STACK_NAME=escapepage` and `COMPOSE_PROJECT_NAME=escapepage` to the
|
||||
production `docker/.env` to move it off the implicit `docker` project name.
|
||||
Cosmetic; do it only if you want the two stacks named symmetrically.
|
||||
|
||||
## Standing up the test stack
|
||||
|
||||
### 1. DNS
|
||||
`test.escapepage.com` → server IP. (`mercure-test.escapepage.com` too if you want
|
||||
live game features.) On Cloudflare, DNS-only ("grey cloud") keeps it low-profile.
|
||||
|
||||
### 2. Separate checkout
|
||||
```bash
|
||||
git clone <repo> /opt/escapepage-test
|
||||
cd /opt/escapepage-test
|
||||
git checkout <branch-to-test>
|
||||
```
|
||||
Use a **separate clone**, not `git worktree` — the Docker build context is the
|
||||
checkout directory and full isolation avoids surprises.
|
||||
|
||||
### 3. Compose env
|
||||
```bash
|
||||
cp docker/.env.test.example docker/.env
|
||||
# edit: real passwords, fresh MERCURE_JWT_SECRET (openssl rand -hex 32), reCAPTCHA keys
|
||||
```
|
||||
|
||||
### 4. Symfony env overrides
|
||||
Create `/opt/escapepage-test/.env.local` in the checkout (git-ignored):
|
||||
```
|
||||
APP_SECRET=<openssl rand -hex 16>
|
||||
# Behind NPM the forwarded client IP lands from a Docker-private range; trust them
|
||||
# all on staging so URL generation / HTTPS detection work.
|
||||
TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
|
||||
```
|
||||
`APP_ENV=prod`, `DATABASE_URL`, `SITE_BASE_URL`, `MERCURE_*` and `MAILER_DSN` are
|
||||
already supplied to the containers by `docker/.env`.
|
||||
|
||||
### 5. Build & start
|
||||
```bash
|
||||
./docker/setup.test.sh
|
||||
```
|
||||
The test-specific script (not `setup.sh`): it refuses to run unless
|
||||
`COMPOSE_PROJECT_NAME` in `docker/.env` is a non-prod value, scopes every compose
|
||||
call to that project, runs the DB/cache/console steps as `www-data`, and repairs
|
||||
`var/cache` / `var/log` / `var/sessions` ownership at the end (bare `docker exec`
|
||||
runs as root, which otherwise leaves php-fpm unable to read its own cache — a
|
||||
silent 500). It never touches `var/volumes/db`.
|
||||
|
||||
Builds `escapepage-test-*` images, starts the containers, creates + migrates
|
||||
`escapepage_test`, builds assets. Re-run any time; `--no-build` skips the rebuild.
|
||||
|
||||
### 6. Nginx Proxy Manager — proxy host
|
||||
- Domain: `test.escapepage.com`
|
||||
- Forward to: `escapepage-test-nginx`, port **443**, scheme **https**
|
||||
(the app nginx force-redirects 80→443 and serves a self-signed cert; leave
|
||||
"Verify SSL" off — same arrangement as production)
|
||||
- SSL: request a Let's Encrypt cert, Force SSL, HTTP/2
|
||||
- Optional: add response header `X-Robots-Tag: noindex`
|
||||
|
||||
If you want live game screens, add a second proxy host
|
||||
`mercure-test.escapepage.com` → `escapepage-test-mercure` port `80` (http).
|
||||
|
||||
### 7. Restrict access to your IP — NPM Access List
|
||||
A host firewall on 80/443 can't help: prod and test share those ports on NPM.
|
||||
Restrict at the proxy instead.
|
||||
|
||||
- **NPM → Access Lists → Add Access List**
|
||||
- Name: e.g. `staging-allowlist`
|
||||
- Authorisation: leave empty
|
||||
- Access: `Allow <your.public.ip>` then a final `Deny all`
|
||||
- Satisfy: **Any**
|
||||
- Edit the `test.escapepage.com` proxy host → **Access List** → select it.
|
||||
- Do the same on `mercure-test.escapepage.com` if you added it.
|
||||
|
||||
Everyone else gets `403` at the proxy. Update the IP in one place when it changes.
|
||||
|
||||
Defence in depth (optional): in `/opt/escapepage-test/docker/nginx/default.conf`,
|
||||
inside the `server { listen 443 ... }` block:
|
||||
```nginx
|
||||
set_real_ip_from 172.16.0.0/12; # NPM's docker network
|
||||
real_ip_header X-Forwarded-For;
|
||||
allow <your.public.ip>;
|
||||
deny all;
|
||||
```
|
||||
|
||||
## Deploying a new version to test
|
||||
|
||||
```bash
|
||||
cd /var/sites/escapepage-test
|
||||
git fetch && git checkout <branch> && git pull
|
||||
./docker/setup.test.sh --no-build # composer install, migrate, build assets, fix perms
|
||||
```
|
||||
|
||||
`--no-build` skips the image rebuild; drop it if the Dockerfile changed.
|
||||
|
||||
## Restarting
|
||||
|
||||
```bash
|
||||
./docker/restart.test.sh # down + up, keeps the DB and images
|
||||
./docker/restart.test.sh --build # also rebuild images
|
||||
./docker/restart.test.sh --fresh-db # also wipe var/volumes/db and re-init MySQL
|
||||
```
|
||||
|
||||
## Safety notes
|
||||
|
||||
- Use the **`*.test.sh`** scripts on this checkout, not `setup.sh` / `restart.sh`.
|
||||
Both refuse to run unless `docker/.env` names a non-prod
|
||||
`COMPOSE_PROJECT_NAME`, and both scope every action to that project — they
|
||||
can't reach the production stack.
|
||||
- `restart.test.sh` **keeps the database** by default (you loaded prod data into
|
||||
it); `--fresh-db` is the only thing that wipes it. It never runs a host-wide
|
||||
`docker system prune` / `docker builder prune`, and it only repairs ownership
|
||||
of `var/cache` / `var/log` / `var/sessions` — **never `var/volumes/db`**
|
||||
(chowning the MySQL data dir is what corrupted it earlier this build).
|
||||
- The test `docker/.env` uses its own `DB_NAME` and passwords so a config slip
|
||||
can't reach the production database.
|
||||
- `MAILER_DSN=smtp://mailer:1026` keeps staging mail inside Mailpit instead of
|
||||
sending through Mailgun.
|
||||
@@ -0,0 +1,77 @@
|
||||
# =============================================================================
|
||||
# docker/.env for a TEST / STAGING stack (e.g. test.escapepage.com)
|
||||
# =============================================================================
|
||||
# Copy this to docker/.env inside the *test* checkout and fill in the blanks.
|
||||
# docker/.env is git-ignored, so it never leaves the server.
|
||||
#
|
||||
# Compose reads this file automatically. Anything unique per stack is derived
|
||||
# from COMPOSE_PROJECT_NAME + the *_PORT vars below, so the same compose.yaml
|
||||
# serves both production and this copy.
|
||||
#
|
||||
# Two config layers, don't mix them up:
|
||||
# - THIS file -> consumed by `docker compose` (container names, ports, and the
|
||||
# runtime env it injects into the php containers).
|
||||
# - <checkout>/.env(.local) -> consumed by Symfony itself (APP_SECRET,
|
||||
# TRUSTED_PROXIES, messenger DSN, CLI database access, ...).
|
||||
# =============================================================================
|
||||
|
||||
## --- Instance identity -------------------------------------------------------
|
||||
# Both must be unique on the host.
|
||||
# STACK_NAME -> prefix for every container_name (escapepage-test-php …)
|
||||
# COMPOSE_PROJECT_NAME -> compose project: isolates networks, volumes and the
|
||||
# labels that `docker compose down` / *.test.sh act on.
|
||||
# Must be a non-prod value or setup.test.sh / restart.test.sh
|
||||
# refuse to run.
|
||||
STACK_NAME=escapepage-test
|
||||
COMPOSE_PROJECT_NAME=escapepage-test
|
||||
|
||||
## --- Published host ports ---------------------------------------------------
|
||||
# Bound to localhost only: the sole public entrypoint is Nginx Proxy Manager,
|
||||
# which reaches the containers over the shared `nginx_default` network by name.
|
||||
# Pick ports that don't clash with the production stack (8080/8443/3306/8090/8025/1025).
|
||||
NGINX_HTTP_PORT=127.0.0.1:8081
|
||||
NGINX_HTTPS_PORT=127.0.0.1:8444
|
||||
DB_HOST_PORT=127.0.0.1:3307
|
||||
MERCURE_HTTP_PORT=127.0.0.1:8091
|
||||
MAILPIT_UI_PORT=127.0.0.1:8026
|
||||
MAILPIT_SMTP_PORT=127.0.0.1:1026
|
||||
|
||||
## --- PHP image build ------------------------------------------------------
|
||||
USER_ID=1000
|
||||
GROUP_ID=1000
|
||||
|
||||
## --- Symfony runtime (injected into php / php-worker / php-cron) ------------
|
||||
APP_ENV=prod
|
||||
SITE_BASE_URL=https://test.escapepage.com
|
||||
|
||||
# Staging should NOT send real mail. Point at the bundled Mailpit and read it
|
||||
# at http://127.0.0.1:8026 on the server (or via an NPM host if you expose it).
|
||||
MAILER_DSN=smtp://mailer:1026
|
||||
MAILER_FROM=mailer@test.escapepage.com
|
||||
|
||||
## --- Database -------------------------------------------------------------
|
||||
# `database` is the compose *service* name and resolves inside this stack's
|
||||
# own network - keep it as-is. Use its own name + fresh credentials so a mistake
|
||||
# here can never point at the production database.
|
||||
DB_NAME=escapepage_test
|
||||
DB_USER=escapepage
|
||||
DB_PASSWORD=CHANGE_ME_test_db_password
|
||||
MYSQL_ROOT_PASSWORD=CHANGE_ME_test_root_password
|
||||
DATABASE_URL=pdo_mysql://escapepage:CHANGE_ME_test_db_password@database:3306/escapepage_test?serverVersion=8.0.32&charset=utf8mb4
|
||||
|
||||
## --- Mercure -----------------------------------------------------------------
|
||||
# Internal hub URL (service name, stays the same). Public URL + CORS must be the
|
||||
# test domain. Add a `mercure-test.escapepage.com` proxy host in NPM ->
|
||||
# <project>-mercure:80.
|
||||
MERCURE_URL=http://mercure/.well-known/mercure
|
||||
MERCURE_PUBLIC_URL=https://mercure-test.escapepage.com/.well-known/mercure
|
||||
MERCURE_JWT_SECRET=CHANGE_ME_generate_with_openssl_rand_hex_32
|
||||
MERCURE_CORS_ALLOWED_ORIGINS="https://test.escapepage.com"
|
||||
MERCURE_TOPIC_BASE=https://test.escapepage.com
|
||||
|
||||
## --- reCAPTCHA v3 ----------------------------------------------------------
|
||||
# Register test.escapepage.com in the reCAPTCHA admin console and paste its keys,
|
||||
# or leave the placeholders and expect the contact / "suggest a room" forms to
|
||||
# fail captcha validation on staging.
|
||||
RECAPTCHA3_KEY=CHANGE_ME_or_reuse_prod_if_domain_added
|
||||
RECAPTCHA3_SECRET=CHANGE_ME_or_reuse_prod_if_domain_added
|
||||
@@ -17,8 +17,8 @@ services:
|
||||
mailer:
|
||||
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
@@ -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
@@ -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
|
||||
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Restart the TEST (staging) stack. Scoped entirely to this checkout's compose
|
||||
# project, so it can never touch the production stack. Unlike docker/restart.sh
|
||||
# it:
|
||||
# - keeps the database by default (you loaded prod data into it) — pass
|
||||
# --fresh-db to wipe var/volumes/db and let MySQL re-initialise
|
||||
# - never runs a host-wide `docker system/builder prune`
|
||||
# - only repairs ownership of var/cache, var/log, var/sessions — NEVER
|
||||
# var/volumes/db (that dir belongs to the mysql process; chowning it is
|
||||
# what corrupts the data directory)
|
||||
#
|
||||
# Usage:
|
||||
# ./docker/restart.test.sh # down + up (keeps DB and images)
|
||||
# ./docker/restart.test.sh --build # also rebuild images
|
||||
# ./docker/restart.test.sh --fresh-db # also wipe + re-initialise the database
|
||||
|
||||
DOCKER_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
ROOT_DIR=$(cd "$DOCKER_DIR/.." && pwd)
|
||||
|
||||
env_val() { [ -f "$DOCKER_DIR/.env" ] && grep -E "^$1=" "$DOCKER_DIR/.env" | tail -n1 | cut -d= -f2- | tr -d "\"' " || true; }
|
||||
|
||||
if [ ! -f "$DOCKER_DIR/.env" ]; then
|
||||
echo "Error: $DOCKER_DIR/.env not found. Copy docker/.env.test.example to docker/.env first." >&2
|
||||
exit 1
|
||||
fi
|
||||
PROJECT="$(env_val COMPOSE_PROJECT_NAME)"
|
||||
STACK="$(env_val STACK_NAME)"; STACK="${STACK:-$PROJECT}"
|
||||
case "${PROJECT:-}" in
|
||||
""|escapepage|docker)
|
||||
echo "Error: COMPOSE_PROJECT_NAME in docker/.env is '${PROJECT:-<unset>}'." >&2
|
||||
echo "Refusing to run a test script against the production stack." >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
DOCKER_COMPOSE="docker compose"
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
DOCKER_COMPOSE="docker-compose"
|
||||
else
|
||||
echo "Error: neither 'docker compose' nor 'docker-compose' is available." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FRESH_DB=0; BUILD=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--fresh-db) FRESH_DB=1 ;;
|
||||
--build) BUILD=1 ;;
|
||||
*) echo "Unknown option: $arg" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
dc() { (cd "$DOCKER_DIR" && $DOCKER_COMPOSE -p "$PROJECT" -f compose.yaml -f compose.override.yaml "$@"); }
|
||||
|
||||
echo "Restarting test stack: $STACK (compose project: $PROJECT)"
|
||||
|
||||
echo "Stopping containers..."
|
||||
dc down --remove-orphans || true
|
||||
# scoped fallback — only this stack
|
||||
docker rm -f \
|
||||
"${STACK}-db" "${STACK}-php" "${STACK}-nginx" "${STACK}-mercure" \
|
||||
"${STACK}-mailer" "${STACK}-php-worker" "${STACK}-php-cron" 2>/dev/null || true
|
||||
for net in $(docker network ls -q --filter "name=^${PROJECT}_" 2>/dev/null); do
|
||||
docker network rm "$net" || true
|
||||
done
|
||||
|
||||
if [ "$FRESH_DB" -eq 1 ]; then
|
||||
echo "Wiping the test database (var/volumes/db)..."
|
||||
sudo rm -rf "$ROOT_DIR/var/volumes/db"
|
||||
fi
|
||||
|
||||
echo "Repairing var/ ownership (cache/log/sessions only)..."
|
||||
sudo mkdir -p "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log/php" "$ROOT_DIR/var/log/cron" "$ROOT_DIR/var/sessions"
|
||||
sudo chown -R 1000:1000 "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log" "$ROOT_DIR/var/sessions"
|
||||
sudo chmod -R u+rwX,g+rwX "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log" "$ROOT_DIR/var/sessions"
|
||||
|
||||
echo "Bringing the stack back up..."
|
||||
if [ "$BUILD" -eq 1 ]; then
|
||||
"$DOCKER_DIR/setup.test.sh"
|
||||
else
|
||||
"$DOCKER_DIR/setup.test.sh" --no-build
|
||||
fi
|
||||
Executable
+134
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Bootstrap / re-provision a TEST (staging) stack — e.g. test.escapepage.com.
|
||||
# Same job as docker/setup.sh, but:
|
||||
# - refuses to run unless docker/.env marks this as a non-prod stack
|
||||
# - scopes every compose call to COMPOSE_PROJECT_NAME
|
||||
# - runs DB / cache / console steps as www-data and repairs var/ ownership at
|
||||
# the end, so php-fpm (www-data) can actually read the compiled container
|
||||
# (bare `docker exec` runs as root and would leave var/cache root-owned)
|
||||
# - NEVER touches var/volumes/db (MySQL owns that)
|
||||
#
|
||||
# Usage:
|
||||
# ./docker/setup.test.sh # full setup (build images)
|
||||
# ./docker/setup.test.sh --no-build # skip image rebuild
|
||||
# ./docker/setup.test.sh --recreate # force-recreate containers
|
||||
# ./docker/setup.test.sh --down # stop and remove this stack's containers
|
||||
|
||||
DOCKER_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
ROOT_DIR=$(cd "$DOCKER_DIR/.." && pwd)
|
||||
|
||||
env_val() { [ -f "$DOCKER_DIR/.env" ] && grep -E "^$1=" "$DOCKER_DIR/.env" | tail -n1 | cut -d= -f2- | tr -d "\"' " || true; }
|
||||
|
||||
# --- safety gate: never let a *.test.sh script act on the production stack ----
|
||||
if [ ! -f "$DOCKER_DIR/.env" ]; then
|
||||
echo "Error: $DOCKER_DIR/.env not found. Copy docker/.env.test.example to docker/.env and fill it in." >&2
|
||||
exit 1
|
||||
fi
|
||||
PROJECT="$(env_val COMPOSE_PROJECT_NAME)"
|
||||
STACK="$(env_val STACK_NAME)"; STACK="${STACK:-$PROJECT}"
|
||||
case "${PROJECT:-}" in
|
||||
""|escapepage|docker)
|
||||
echo "Error: COMPOSE_PROJECT_NAME in docker/.env is '${PROJECT:-<unset>}'." >&2
|
||||
echo "Refusing to run a test script against the production stack." >&2
|
||||
echo "Set COMPOSE_PROJECT_NAME and STACK_NAME to e.g. 'escapepage-test' in docker/.env." >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
|
||||
# --- compose command -------------------------------------------------------
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
DOCKER_COMPOSE="docker compose"
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
DOCKER_COMPOSE="docker-compose"
|
||||
else
|
||||
echo "Error: neither 'docker compose' nor 'docker-compose' is available." >&2
|
||||
exit 1
|
||||
fi
|
||||
command -v docker >/dev/null 2>&1 || { echo "Error: docker is required." >&2; exit 1; }
|
||||
|
||||
dc() { (cd "$DOCKER_DIR" && $DOCKER_COMPOSE -p "$PROJECT" -f compose.yaml -f compose.override.yaml "$@"); }
|
||||
pexec() { dc exec -T php "$@"; } # as root (composer / npm)
|
||||
pexecwww() { dc exec -T -u www-data php "$@"; } # as www-data (console / DB / cache)
|
||||
|
||||
REBUILD=1; RECREATE=0; DOWN_ONLY=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-build) REBUILD=0 ;;
|
||||
--recreate) RECREATE=1 ;;
|
||||
--down) DOWN_ONLY=1 ;;
|
||||
*) echo "Unknown option: $arg" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "Test stack: $STACK (compose project: $PROJECT)"
|
||||
|
||||
if [ "$DOWN_ONLY" -eq 1 ]; then
|
||||
dc down --remove-orphans
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BUILD_ARGS=()
|
||||
[ "$REBUILD" -eq 1 ] && BUILD_ARGS+=("--build")
|
||||
[ "$RECREATE" -eq 1 ] && BUILD_ARGS+=("--force-recreate")
|
||||
|
||||
dc up -d "${BUILD_ARGS[@]}"
|
||||
|
||||
# --- wait for the database ------------------------------------------------
|
||||
printf "Waiting for database to be healthy..."
|
||||
for i in $(seq 1 60); do
|
||||
DB_ID=$(dc ps -q database 2>/dev/null || true)
|
||||
if [ -n "$DB_ID" ] && [ "$(docker inspect -f '{{.State.Health.Status}}' "$DB_ID" 2>/dev/null || true)" = "healthy" ]; then
|
||||
echo " OK"; break
|
||||
fi
|
||||
printf "."; sleep 2
|
||||
[ "$i" -eq 60 ] && echo -e "\nWarning: database not healthy yet, continuing anyway."
|
||||
done
|
||||
|
||||
# --- dependencies (root: writes vendor/ and node_modules/) --------------
|
||||
pexec composer install --no-interaction
|
||||
|
||||
# --- APP_SECRET: generate into .env.local if it's set nowhere -----------
|
||||
if ! grep -qsE '^APP_SECRET=.+' "$ROOT_DIR/.env" "$ROOT_DIR/.env.local"; then
|
||||
echo "Generating APP_SECRET in .env.local..."
|
||||
printf 'APP_SECRET=%s\n' "$(openssl rand -hex 16)" >> "$ROOT_DIR/.env.local"
|
||||
fi
|
||||
|
||||
# --- database (www-data: keeps var/ writable by php-fpm) ---------------
|
||||
echo "Creating database if it doesn't exist..."
|
||||
pexecwww php bin/console doctrine:database:create --if-not-exists || {
|
||||
echo "Error: database creation failed." >&2; dc logs database | tail -n 40; exit 1;
|
||||
}
|
||||
echo "Running migrations..."
|
||||
pexecwww php bin/console doctrine:migrations:migrate -n || { echo "Error: migrations failed." >&2; exit 1; }
|
||||
|
||||
[ -f "$ROOT_DIR/importmap.php" ] && pexec php bin/console importmap:install || true
|
||||
|
||||
if [ -f "$ROOT_DIR/package.json" ]; then
|
||||
echo "Installing npm dependencies..."; pexec npm ci || pexec npm install
|
||||
echo "Building assets..."; pexec npm run build
|
||||
fi
|
||||
|
||||
# --- repair var/ ownership for php-fpm (www-data), never var/volumes ----
|
||||
echo "Fixing var/ ownership for php-fpm..."
|
||||
pexec sh -lc 'mkdir -p var/cache var/log/php var/log/cron var/sessions && chown -R www-data:www-data var/cache var/log var/sessions'
|
||||
|
||||
pexecwww php bin/console cache:clear
|
||||
|
||||
NGINX_HTTPS="$(env_val NGINX_HTTPS_PORT)"
|
||||
MAILPIT_UI="$(env_val MAILPIT_UI_PORT)"
|
||||
|
||||
cat <<EOT
|
||||
|
||||
Test stack '$PROJECT' is up.
|
||||
|
||||
NPM upstream: ${STACK}-nginx (scheme https, port 443, "Verify SSL" off)
|
||||
Local check: curl -k https://${NGINX_HTTPS:-127.0.0.1:18443}/
|
||||
Mailpit UI: http://${MAILPIT_UI:-127.0.0.1:18026}
|
||||
|
||||
Logs: docker logs -f ${STACK}-php
|
||||
Shell: docker exec -it -u www-data ${STACK}-php sh
|
||||
Restart: ./docker/restart.test.sh (add --fresh-db to wipe + re-init the DB)
|
||||
|
||||
Re-run this script any time. Use --no-build to skip the image rebuild.
|
||||
EOT
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260829150000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add escape_room and escape_room_review tables for the physical escape room map and reviews';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE escape_room (
|
||||
id INT AUTO_INCREMENT NOT NULL,
|
||||
submitted_by_id INT DEFAULT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
venue VARCHAR(255) DEFAULT NULL,
|
||||
street VARCHAR(255) DEFAULT NULL,
|
||||
house_number VARCHAR(32) DEFAULT NULL,
|
||||
postcode VARCHAR(32) DEFAULT NULL,
|
||||
city VARCHAR(128) DEFAULT NULL,
|
||||
country VARCHAR(2) DEFAULT NULL,
|
||||
latitude NUMERIC(10, 7) NOT NULL,
|
||||
longitude NUMERIC(10, 7) NOT NULL,
|
||||
website VARCHAR(511) DEFAULT NULL,
|
||||
phone VARCHAR(64) DEFAULT NULL,
|
||||
source VARCHAR(16) NOT NULL,
|
||||
osm_type VARCHAR(8) DEFAULT NULL,
|
||||
osm_id BIGINT DEFAULT NULL,
|
||||
status VARCHAR(16) NOT NULL,
|
||||
locked TINYINT(1) DEFAULT 0 NOT NULL,
|
||||
created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)',
|
||||
updated_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)',
|
||||
deleted_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)',
|
||||
INDEX idx_escape_room_submitted_by (submitted_by_id),
|
||||
UNIQUE INDEX uniq_escape_room_osm (osm_type, osm_id),
|
||||
INDEX idx_escape_room_status (status),
|
||||
INDEX idx_escape_room_bbox (latitude, longitude),
|
||||
PRIMARY KEY(id)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE escape_room_review (
|
||||
id INT AUTO_INCREMENT NOT NULL,
|
||||
escape_room_id INT NOT NULL,
|
||||
author_id INT DEFAULT NULL,
|
||||
rating SMALLINT NOT NULL,
|
||||
title VARCHAR(150) NOT NULL,
|
||||
body LONGTEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)',
|
||||
deleted_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)',
|
||||
INDEX idx_escape_room_review_room (escape_room_id),
|
||||
INDEX idx_escape_room_review_author (author_id),
|
||||
PRIMARY KEY(id)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
|
||||
SQL);
|
||||
|
||||
$this->addSql('ALTER TABLE escape_room ADD CONSTRAINT fk_escape_room_submitted_by FOREIGN KEY (submitted_by_id) REFERENCES `user` (id) ON DELETE SET NULL');
|
||||
$this->addSql('ALTER TABLE escape_room_review ADD CONSTRAINT fk_escape_room_review_room FOREIGN KEY (escape_room_id) REFERENCES escape_room (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE escape_room_review ADD CONSTRAINT fk_escape_room_review_author FOREIGN KEY (author_id) REFERENCES `user` (id) ON DELETE SET NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE escape_room_review DROP FOREIGN KEY fk_escape_room_review_room');
|
||||
$this->addSql('ALTER TABLE escape_room_review DROP FOREIGN KEY fk_escape_room_review_author');
|
||||
$this->addSql('ALTER TABLE escape_room DROP FOREIGN KEY fk_escape_room_submitted_by');
|
||||
$this->addSql('DROP TABLE escape_room_review');
|
||||
$this->addSql('DROP TABLE escape_room');
|
||||
}
|
||||
}
|
||||
Generated
+17
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
<?php
|
||||
|
||||
namespace App\Website\Entity;
|
||||
|
||||
use App\Tech\Entity\User;
|
||||
use App\Website\Enum\EscapeRoomSource;
|
||||
use App\Website\Enum\EscapeRoomStatus;
|
||||
use App\Website\Repository\EscapeRoomRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: EscapeRoomRepository::class)]
|
||||
#[ORM\Table(name: 'escape_room')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_escape_room_osm', columns: ['osm_type', 'osm_id'])]
|
||||
#[ORM\Index(name: 'idx_escape_room_status', columns: ['status'])]
|
||||
#[ORM\Index(name: 'idx_escape_room_bbox', columns: ['latitude', 'longitude'])]
|
||||
#[ORM\Index(name: 'idx_escape_room_submitted_by', columns: ['submitted_by_id'])]
|
||||
class EscapeRoom
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private ?string $name = null;
|
||||
|
||||
/** Operating company / brand, when it differs from the room name. */
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $venue = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $street = null;
|
||||
|
||||
#[ORM\Column(length: 32, nullable: true)]
|
||||
private ?string $houseNumber = null;
|
||||
|
||||
#[ORM\Column(length: 32, nullable: true)]
|
||||
private ?string $postcode = null;
|
||||
|
||||
#[ORM\Column(length: 128, nullable: true)]
|
||||
private ?string $city = null;
|
||||
|
||||
/** ISO 3166-1 alpha-2, upper case. */
|
||||
#[ORM\Column(length: 2, nullable: true)]
|
||||
private ?string $country = null;
|
||||
|
||||
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 7)]
|
||||
private ?string $latitude = null;
|
||||
|
||||
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 7)]
|
||||
private ?string $longitude = null;
|
||||
|
||||
#[ORM\Column(length: 511, nullable: true)]
|
||||
private ?string $website = null;
|
||||
|
||||
#[ORM\Column(length: 64, nullable: true)]
|
||||
private ?string $phone = null;
|
||||
|
||||
#[ORM\Column(length: 16, enumType: EscapeRoomSource::class)]
|
||||
private EscapeRoomSource $source = EscapeRoomSource::ADMIN;
|
||||
|
||||
/** OSM element type: node | way | relation. Set together with osmId for imported rooms. */
|
||||
#[ORM\Column(length: 8, nullable: true)]
|
||||
private ?string $osmType = null;
|
||||
|
||||
#[ORM\Column(type: Types::BIGINT, nullable: true)]
|
||||
private ?string $osmId = null;
|
||||
|
||||
#[ORM\Column(length: 16, enumType: EscapeRoomStatus::class)]
|
||||
private EscapeRoomStatus $status = EscapeRoomStatus::PENDING;
|
||||
|
||||
/** Kept for imported rooms so a re-import never clobbers manual corrections. */
|
||||
#[ORM\Column(options: ['default' => false])]
|
||||
private bool $locked = false;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?User $submittedBy = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
private ?\DateTimeImmutable $createdAt = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $updatedAt = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $deletedAt = null;
|
||||
|
||||
/** @var Collection<int, EscapeRoomReview> */
|
||||
#[ORM\OneToMany(mappedBy: 'escapeRoom', targetEntity: EscapeRoomReview::class, cascade: ['remove'])]
|
||||
private Collection $reviews;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
$this->reviews = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): static
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getVenue(): ?string
|
||||
{
|
||||
return $this->venue;
|
||||
}
|
||||
|
||||
public function setVenue(?string $venue): static
|
||||
{
|
||||
$this->venue = $venue;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStreet(): ?string
|
||||
{
|
||||
return $this->street;
|
||||
}
|
||||
|
||||
public function setStreet(?string $street): static
|
||||
{
|
||||
$this->street = $street;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getHouseNumber(): ?string
|
||||
{
|
||||
return $this->houseNumber;
|
||||
}
|
||||
|
||||
public function setHouseNumber(?string $houseNumber): static
|
||||
{
|
||||
$this->houseNumber = $houseNumber;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPostcode(): ?string
|
||||
{
|
||||
return $this->postcode;
|
||||
}
|
||||
|
||||
public function setPostcode(?string $postcode): static
|
||||
{
|
||||
$this->postcode = $postcode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCity(): ?string
|
||||
{
|
||||
return $this->city;
|
||||
}
|
||||
|
||||
public function setCity(?string $city): static
|
||||
{
|
||||
$this->city = $city;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCountry(): ?string
|
||||
{
|
||||
return $this->country;
|
||||
}
|
||||
|
||||
public function setCountry(?string $country): static
|
||||
{
|
||||
$this->country = $country ? strtoupper($country) : null;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLatitude(): ?string
|
||||
{
|
||||
return $this->latitude;
|
||||
}
|
||||
|
||||
public function setLatitude(string|float|null $latitude): static
|
||||
{
|
||||
$this->latitude = $latitude === null ? null : (string) $latitude;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLongitude(): ?string
|
||||
{
|
||||
return $this->longitude;
|
||||
}
|
||||
|
||||
public function setLongitude(string|float|null $longitude): static
|
||||
{
|
||||
$this->longitude = $longitude === null ? null : (string) $longitude;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getWebsite(): ?string
|
||||
{
|
||||
return $this->website;
|
||||
}
|
||||
|
||||
public function setWebsite(?string $website): static
|
||||
{
|
||||
$this->website = $website;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPhone(): ?string
|
||||
{
|
||||
return $this->phone;
|
||||
}
|
||||
|
||||
public function setPhone(?string $phone): static
|
||||
{
|
||||
$this->phone = $phone;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSource(): EscapeRoomSource
|
||||
{
|
||||
return $this->source;
|
||||
}
|
||||
|
||||
public function setSource(EscapeRoomSource $source): static
|
||||
{
|
||||
$this->source = $source;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getOsmType(): ?string
|
||||
{
|
||||
return $this->osmType;
|
||||
}
|
||||
|
||||
public function setOsmType(?string $osmType): static
|
||||
{
|
||||
$this->osmType = $osmType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getOsmId(): ?string
|
||||
{
|
||||
return $this->osmId;
|
||||
}
|
||||
|
||||
public function setOsmId(int|string|null $osmId): static
|
||||
{
|
||||
$this->osmId = $osmId === null ? null : (string) $osmId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStatus(): EscapeRoomStatus
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function setStatus(EscapeRoomStatus $status): static
|
||||
{
|
||||
$this->status = $status;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isLocked(): bool
|
||||
{
|
||||
return $this->locked;
|
||||
}
|
||||
|
||||
public function setLocked(bool $locked): static
|
||||
{
|
||||
$this->locked = $locked;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSubmittedBy(): ?User
|
||||
{
|
||||
return $this->submittedBy;
|
||||
}
|
||||
|
||||
public function setSubmittedBy(?User $submittedBy): static
|
||||
{
|
||||
$this->submittedBy = $submittedBy;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
public function touch(): static
|
||||
{
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDeletedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->deletedAt;
|
||||
}
|
||||
|
||||
public function setDeletedAt(?\DateTimeImmutable $deletedAt): static
|
||||
{
|
||||
$this->deletedAt = $deletedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isDeleted(): bool
|
||||
{
|
||||
return $this->deletedAt !== null;
|
||||
}
|
||||
|
||||
/** @return Collection<int, EscapeRoomReview> */
|
||||
public function getReviews(): Collection
|
||||
{
|
||||
return $this->reviews;
|
||||
}
|
||||
|
||||
/** @return EscapeRoomReview[] */
|
||||
public function getVisibleReviews(): array
|
||||
{
|
||||
return $this->reviews
|
||||
->filter(static fn (EscapeRoomReview $r) => !$r->isDeleted())
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getSlug(): string
|
||||
{
|
||||
$slug = strtolower((string) $this->name);
|
||||
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '';
|
||||
|
||||
return trim($slug, '-') ?: 'room';
|
||||
}
|
||||
|
||||
public function getDisplayAddress(): string
|
||||
{
|
||||
$line = trim(sprintf('%s %s', (string) $this->street, (string) $this->houseNumber));
|
||||
$parts = array_filter([
|
||||
$line !== '' ? $line : null,
|
||||
trim(sprintf('%s %s', (string) $this->postcode, (string) $this->city)) ?: null,
|
||||
$this->country,
|
||||
]);
|
||||
|
||||
return implode(', ', $parts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Website\Entity;
|
||||
|
||||
use App\Tech\Entity\User;
|
||||
use App\Website\Repository\EscapeRoomReviewRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: EscapeRoomReviewRepository::class)]
|
||||
#[ORM\Table(name: 'escape_room_review')]
|
||||
#[ORM\Index(name: 'idx_escape_room_review_room', columns: ['escape_room_id'])]
|
||||
#[ORM\Index(name: 'idx_escape_room_review_author', columns: ['author_id'])]
|
||||
class EscapeRoomReview
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: EscapeRoom::class, inversedBy: 'reviews')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private ?EscapeRoom $escapeRoom = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?User $author = null;
|
||||
|
||||
#[ORM\Column(type: Types::SMALLINT)]
|
||||
#[Assert\Range(min: 1, max: 5)]
|
||||
private int $rating = 0;
|
||||
|
||||
#[ORM\Column(length: 150)]
|
||||
#[Assert\NotBlank(message: 'Give your review a short title')]
|
||||
#[Assert\Length(max: 150)]
|
||||
private ?string $title = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT)]
|
||||
#[Assert\NotBlank(message: 'Write a few words about your experience')]
|
||||
#[Assert\Length(max: 5000)]
|
||||
private ?string $body = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
private ?\DateTimeImmutable $createdAt = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $deletedAt = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getEscapeRoom(): ?EscapeRoom
|
||||
{
|
||||
return $this->escapeRoom;
|
||||
}
|
||||
|
||||
public function setEscapeRoom(?EscapeRoom $escapeRoom): static
|
||||
{
|
||||
$this->escapeRoom = $escapeRoom;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAuthor(): ?User
|
||||
{
|
||||
return $this->author;
|
||||
}
|
||||
|
||||
public function setAuthor(?User $author): static
|
||||
{
|
||||
$this->author = $author;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRating(): int
|
||||
{
|
||||
return $this->rating;
|
||||
}
|
||||
|
||||
public function setRating(int $rating): static
|
||||
{
|
||||
$this->rating = $rating;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTitle(): ?string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function setTitle(?string $title): static
|
||||
{
|
||||
$this->title = $title;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBody(): ?string
|
||||
{
|
||||
return $this->body;
|
||||
}
|
||||
|
||||
public function setBody(?string $body): static
|
||||
{
|
||||
$this->body = $body;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getDeletedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->deletedAt;
|
||||
}
|
||||
|
||||
public function setDeletedAt(?\DateTimeImmutable $deletedAt): static
|
||||
{
|
||||
$this->deletedAt = $deletedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isDeleted(): bool
|
||||
{
|
||||
return $this->deletedAt !== null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Website\Enum;
|
||||
|
||||
enum EscapeRoomSource: string
|
||||
{
|
||||
case OSM = 'osm';
|
||||
case USER = 'user';
|
||||
case ADMIN = 'admin';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::OSM => 'OpenStreetMap',
|
||||
self::USER => 'Visitor submission',
|
||||
self::ADMIN => 'Added by admin',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Website\Enum;
|
||||
|
||||
enum EscapeRoomStatus: string
|
||||
{
|
||||
/** Submitted by a visitor, awaiting admin moderation. */
|
||||
case PENDING = 'pending';
|
||||
/** Live: shown on the public map and list. */
|
||||
case PUBLISHED = 'published';
|
||||
/** Reviewed by an admin and turned down; kept for de-duplication. */
|
||||
case REJECTED = 'rejected';
|
||||
/** Was public, hidden again by an admin (e.g. room closed down). */
|
||||
case HIDDEN = 'hidden';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::PENDING => 'Pending review',
|
||||
self::PUBLISHED => 'Published',
|
||||
self::REJECTED => 'Rejected',
|
||||
self::HIDDEN => 'Hidden',
|
||||
};
|
||||
}
|
||||
|
||||
public function isPubliclyVisible(): bool
|
||||
{
|
||||
return $this === self::PUBLISHED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Website\Form;
|
||||
|
||||
use App\Website\Entity\EscapeRoom;
|
||||
use App\Website\Enum\EscapeRoomSource;
|
||||
use App\Website\Enum\EscapeRoomStatus;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CountryType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\NumberType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TelType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\UrlType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Constraints\Range;
|
||||
|
||||
class AdminEscapeRoomType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('name', TextType::class, ['constraints' => [new NotBlank()]])
|
||||
->add('venue', TextType::class, ['required' => false])
|
||||
->add('street', TextType::class, ['required' => false])
|
||||
->add('houseNumber', TextType::class, ['label' => 'House number', 'required' => false])
|
||||
->add('postcode', TextType::class, ['required' => false])
|
||||
->add('city', TextType::class, ['required' => false])
|
||||
->add('country', CountryType::class, ['placeholder' => '—', 'required' => false])
|
||||
->add('latitude', NumberType::class, [
|
||||
'scale' => 7,
|
||||
'html5' => true,
|
||||
'constraints' => [new NotBlank(), new Range(min: -90, max: 90)],
|
||||
])
|
||||
->add('longitude', NumberType::class, [
|
||||
'scale' => 7,
|
||||
'html5' => true,
|
||||
'constraints' => [new NotBlank(), new Range(min: -180, max: 180)],
|
||||
])
|
||||
->add('website', UrlType::class, ['required' => false, 'default_protocol' => 'https'])
|
||||
->add('phone', TelType::class, ['required' => false])
|
||||
->add('status', EnumType::class, [
|
||||
'class' => EscapeRoomStatus::class,
|
||||
'choice_label' => fn (EscapeRoomStatus $s) => $s->label(),
|
||||
])
|
||||
->add('source', EnumType::class, [
|
||||
'class' => EscapeRoomSource::class,
|
||||
'choice_label' => fn (EscapeRoomSource $s) => $s->label(),
|
||||
])
|
||||
->add('locked', CheckboxType::class, [
|
||||
'required' => false,
|
||||
'label' => 'Locked (protect from OSM re-import)',
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => EscapeRoom::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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();
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user