Add physical escape room map, reviews, and website admin

Public:
- /escape-rooms: Leaflet + OpenStreetMap map with marker clustering and a
  searchable list, both built from a cached slim JSON feed
- /escape-rooms/{id}-{slug}: room detail with a mini-map and user reviews
  (rating + title + body, login required)
- /escape-rooms/suggest: visitor submission form with click-to-drop-pin map
  and reCAPTCHA v3; new rooms land as pending

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Frank
2026-08-29 23:33:06 +02:00
co-authored by Claude Sonnet 5
parent 07e8e68742
commit 779c02b743
29 changed files with 2532 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
/*
* Small single-purpose Leaflet maps:
* - #escaperoom-detail-map : read-only pin on a room's detail page
* - #escaperoom-suggest-map : click to drop / move a pin on the "suggest a room"
* form, writing the coordinates into the hidden latitude/longitude fields.
*/
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import iconUrl from 'leaflet/dist/images/marker-icon.png';
import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl, shadowUrl });
const OSM = {
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
opts: {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
},
};
function initDetailMap(el) {
const lat = parseFloat(el.dataset.lat);
const lng = parseFloat(el.dataset.lng);
if (Number.isNaN(lat) || Number.isNaN(lng)) {
return;
}
const map = L.map(el, { scrollWheelZoom: false }).setView([lat, lng], 15);
L.tileLayer(OSM.url, OSM.opts).addTo(map);
L.marker([lat, lng]).addTo(map);
}
function initSuggestMap(el) {
const form = el.closest('form');
const latField = form && form.querySelector('input[name$="[latitude]"]');
const lngField = form && form.querySelector('input[name$="[longitude]"]');
if (!latField || !lngField) {
return;
}
const startLat = parseFloat(latField.value);
const startLng = parseFloat(lngField.value);
const hasStart = !Number.isNaN(startLat) && !Number.isNaN(startLng);
const map = L.map(el).setView(hasStart ? [startLat, startLng] : [20, 0], hasStart ? 15 : 2);
L.tileLayer(OSM.url, OSM.opts).addTo(map);
let marker = hasStart ? L.marker([startLat, startLng], { draggable: true }).addTo(map) : null;
const write = (latlng) => {
latField.value = latlng.lat.toFixed(7);
lngField.value = latlng.lng.toFixed(7);
latField.dispatchEvent(new Event('change', { bubbles: true }));
};
if (marker) {
marker.on('dragend', () => write(marker.getLatLng()));
}
map.on('click', (e) => {
if (marker) {
marker.setLatLng(e.latlng);
} else {
marker = L.marker(e.latlng, { draggable: true }).addTo(map);
marker.on('dragend', () => write(marker.getLatLng()));
}
write(e.latlng);
});
}
document.addEventListener('DOMContentLoaded', () => {
const detail = document.getElementById('escaperoom-detail-map');
if (detail) {
initDetailMap(detail);
}
const suggest = document.getElementById('escaperoom-suggest-map');
if (suggest) {
initSuggestMap(suggest);
}
});
+151
View File
@@ -0,0 +1,151 @@
/*
* Public "physical escape rooms" map + list.
*
* One JSON fetch (website_escaperooms_data) feeds both the Leaflet map (with
* marker clustering, so a worldwide dataset stays responsive) and the searchable
* table underneath it.
*/
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import 'leaflet.markercluster';
import 'leaflet.markercluster/dist/MarkerCluster.css';
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
import { DataTable } from 'simple-datatables';
import 'simple-datatables/dist/style.css';
import iconUrl from 'leaflet/dist/images/marker-icon.png';
import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
// Webpack rewrites the image paths, so hand them to Leaflet explicitly.
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl, shadowUrl });
function slugify(name) {
return (name || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'room';
}
function escapeHtml(value) {
const div = document.createElement('div');
div.textContent = value == null ? '' : String(value);
return div.innerHTML;
}
function ratingLabel(avg, count) {
if (!count) {
return '—';
}
return `${avg.toFixed(1)} ★ (${count})`;
}
function detailUrl(template, room) {
return template.replace('__ID__', room.id).replace('__SLUG__', slugify(room.name));
}
function buildMap(el, rooms, detailTemplate) {
const map = L.map(el, { worldCopyJump: true }).setView([20, 0], 2);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
const cluster = L.markerClusterGroup({ chunkedLoading: true });
const bounds = [];
rooms.forEach((room) => {
if (typeof room.lat !== 'number' || typeof room.lng !== 'number') {
return;
}
const where = [room.city, room.country].filter(Boolean).join(', ');
const popup = `
<strong>${escapeHtml(room.name)}</strong><br>
${where ? `${escapeHtml(where)}<br>` : ''}
<span>${escapeHtml(ratingLabel(room.avgRating, room.reviewCount))}</span><br>
<a href="${detailUrl(detailTemplate, room)}">View &amp; review &raquo;</a>
`;
const marker = L.marker([room.lat, room.lng]).bindPopup(popup);
cluster.addLayer(marker);
bounds.push([room.lat, room.lng]);
});
map.addLayer(cluster);
if (bounds.length) {
map.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
}
}
function buildList(table, rooms, detailTemplate) {
const tbody = table.querySelector('tbody');
const frag = document.createDocumentFragment();
rooms.forEach((room) => {
const tr = document.createElement('tr');
const where = room.city ? escapeHtml(room.city) : '';
const country = room.country ? escapeHtml(room.country) : '';
const ratingValue = room.reviewCount ? room.avgRating : 0;
tr.innerHTML = `
<td><a href="${detailUrl(detailTemplate, room)}">${escapeHtml(room.name)}</a></td>
<td>${where}</td>
<td>${country}</td>
<td data-order="${ratingValue}">${escapeHtml(ratingLabel(room.avgRating, room.reviewCount))}</td>
`;
frag.appendChild(tr);
});
tbody.replaceChildren(frag);
new DataTable(table, {
perPage: 25,
perPageSelect: [25, 50, 100],
searchable: true,
sortable: true,
labels: {
placeholder: 'Search rooms…',
perPage: '{select} per page',
noRows: 'No matching rooms',
info: 'Showing {start}–{end} of {rows} rooms',
},
});
}
document.addEventListener('DOMContentLoaded', () => {
const mapEl = document.getElementById('escaperoom-map');
if (!mapEl) {
return;
}
const endpoint = mapEl.dataset.endpoint;
const detailTemplate = mapEl.dataset.detailTemplate;
const listEl = document.getElementById('escaperoom-list');
const statusEl = document.getElementById('escaperoom-status');
fetch(endpoint, { headers: { Accept: 'application/json' } })
.then((res) => {
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json();
})
.then((payload) => {
const rooms = Array.isArray(payload.rooms) ? payload.rooms : [];
buildMap(mapEl, rooms, detailTemplate);
if (listEl) {
buildList(listEl, rooms, detailTemplate);
}
if (statusEl) {
statusEl.textContent = `${rooms.length.toLocaleString()} rooms on the map`;
}
})
.catch((err) => {
if (statusEl) {
statusEl.textContent = 'Could not load escape rooms right now.';
}
console.error('escaperoom-map:', err);
});
});
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260829150000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add escape_room and escape_room_review tables for the physical escape room map and reviews';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE escape_room (
id INT AUTO_INCREMENT NOT NULL,
submitted_by_id INT DEFAULT NULL,
name VARCHAR(255) NOT NULL,
venue VARCHAR(255) DEFAULT NULL,
street VARCHAR(255) DEFAULT NULL,
house_number VARCHAR(32) DEFAULT NULL,
postcode VARCHAR(32) DEFAULT NULL,
city VARCHAR(128) DEFAULT NULL,
country VARCHAR(2) DEFAULT NULL,
latitude NUMERIC(10, 7) NOT NULL,
longitude NUMERIC(10, 7) NOT NULL,
website VARCHAR(511) DEFAULT NULL,
phone VARCHAR(64) DEFAULT NULL,
source VARCHAR(16) NOT NULL,
osm_type VARCHAR(8) DEFAULT NULL,
osm_id BIGINT DEFAULT NULL,
status VARCHAR(16) NOT NULL,
locked TINYINT(1) DEFAULT 0 NOT NULL,
created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)',
updated_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)',
deleted_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)',
INDEX idx_escape_room_submitted_by (submitted_by_id),
UNIQUE INDEX uniq_escape_room_osm (osm_type, osm_id),
INDEX idx_escape_room_status (status),
INDEX idx_escape_room_bbox (latitude, longitude),
PRIMARY KEY(id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE escape_room_review (
id INT AUTO_INCREMENT NOT NULL,
escape_room_id INT NOT NULL,
author_id INT DEFAULT NULL,
rating SMALLINT NOT NULL,
title VARCHAR(150) NOT NULL,
body LONGTEXT NOT NULL,
created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)',
deleted_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)',
INDEX idx_escape_room_review_room (escape_room_id),
INDEX idx_escape_room_review_author (author_id),
PRIMARY KEY(id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql('ALTER TABLE escape_room ADD CONSTRAINT fk_escape_room_submitted_by FOREIGN KEY (submitted_by_id) REFERENCES `user` (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE escape_room_review ADD CONSTRAINT fk_escape_room_review_room FOREIGN KEY (escape_room_id) REFERENCES escape_room (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE escape_room_review ADD CONSTRAINT fk_escape_room_review_author FOREIGN KEY (author_id) REFERENCES `user` (id) ON DELETE SET NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE escape_room_review DROP FOREIGN KEY fk_escape_room_review_room');
$this->addSql('ALTER TABLE escape_room_review DROP FOREIGN KEY fk_escape_room_review_author');
$this->addSql('ALTER TABLE escape_room DROP FOREIGN KEY fk_escape_room_submitted_by');
$this->addSql('DROP TABLE escape_room_review');
$this->addSql('DROP TABLE escape_room');
}
}
+17
View File
@@ -12,6 +12,8 @@
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
"simple-datatables": "^10.3.0"
},
"devDependencies": {
@@ -3863,6 +3865,21 @@
"node": ">=0.10.0"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/leaflet.markercluster": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
"integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==",
"license": "MIT",
"peerDependencies": {
"leaflet": "^1.3.1"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+2
View File
@@ -28,6 +28,8 @@
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
"simple-datatables": "^10.3.0"
}
}
@@ -0,0 +1,334 @@
<?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 = trim((string) ($tags['name'] ?? ''));
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);
}
$room
->setName($name)
->setVenue($this->firstTag($tags, ['brand', 'operator']))
->setStreet($this->firstTag($tags, ['addr:street']))
->setHouseNumber($this->firstTag($tags, ['addr:housenumber']))
->setPostcode($this->firstTag($tags, ['addr:postcode']))
->setCity($this->firstTag($tags, ['addr:city', 'addr:town', 'addr:suburb']))
->setCountry($this->normaliseCountry($this->firstTag($tags, ['addr:country'])))
->setWebsite($this->firstTag($tags, ['website', 'contact:website', 'url']))
->setPhone($this->firstTag($tags, ['phone', 'contact:phone']))
->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];
}
/**
* @param array<string, mixed> $tags
* @param string[] $keys
*/
private function firstTag(array $tags, array $keys): ?string
{
foreach ($keys as $key) {
$value = trim((string) ($tags[$key] ?? ''));
if ($value !== '') {
return mb_substr($value, 0, 500);
}
}
return null;
}
private function normaliseCountry(?string $value): ?string
{
if ($value === null) {
return null;
}
$value = strtoupper(trim($value));
return preg_match('/^[A-Z]{2}$/', $value) === 1 ? $value : null;
}
}
@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Website\Controller;
use App\Website\Entity\EscapeRoom;
use App\Website\Entity\EscapeRoomReview;
use App\Website\Enum\EscapeRoomStatus;
use App\Website\Form\AdminEscapeRoomType;
use App\Website\Repository\EscapeRoomRepository;
use App\Website\Repository\EscapeRoomReviewRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/admin/escape-rooms')]
#[IsGranted('ROLE_ADMIN')]
final class AdminEscapeRoomController extends AbstractController
{
#[Route('', name: 'website_admin_escaperooms', methods: ['GET'])]
public function index(Request $request, EscapeRoomRepository $rooms): Response
{
$filter = EscapeRoomStatus::tryFrom((string) $request->query->get('status'));
return $this->render('website/admin/escape_room/index.html.twig', [
'rooms' => $rooms->findForAdmin($filter),
'reviewCounts' => $rooms->reviewCounts(),
'filter' => $filter,
'counts' => [
'all' => \count($rooms->findForAdmin()),
EscapeRoomStatus::PENDING->value => $rooms->countByStatus(EscapeRoomStatus::PENDING),
EscapeRoomStatus::PUBLISHED->value => $rooms->countByStatus(EscapeRoomStatus::PUBLISHED),
EscapeRoomStatus::HIDDEN->value => $rooms->countByStatus(EscapeRoomStatus::HIDDEN),
EscapeRoomStatus::REJECTED->value => $rooms->countByStatus(EscapeRoomStatus::REJECTED),
],
]);
}
#[Route('/new', name: 'website_admin_escaperoom_new', methods: ['GET', 'POST'])]
public function new(Request $request, EntityManagerInterface $em): Response
{
$room = new EscapeRoom();
$form = $this->createForm(AdminEscapeRoomType::class, $room);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->persist($room);
$em->flush();
$this->addFlash('success', sprintf('"%s" added.', $room->getName()));
return $this->redirectToRoute('website_admin_escaperooms');
}
return $this->render('website/admin/escape_room/form.html.twig', [
'form' => $form,
'room' => $room,
'heading' => 'New escape room',
]);
}
#[Route('/{id}/edit', name: 'website_admin_escaperoom_edit', methods: ['GET', 'POST'], requirements: ['id' => '\d+'])]
public function edit(EscapeRoom $room, Request $request, EntityManagerInterface $em): Response
{
$form = $this->createForm(AdminEscapeRoomType::class, $room);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$room->touch();
$em->flush();
$this->addFlash('success', 'Changes saved.');
return $this->redirectToRoute('website_admin_escaperooms');
}
return $this->render('website/admin/escape_room/form.html.twig', [
'form' => $form,
'room' => $room,
'heading' => 'Edit: '.$room->getName(),
]);
}
#[Route('/{id}/status/{status}', name: 'website_admin_escaperoom_moderate', methods: ['POST'], requirements: ['id' => '\d+'])]
public function moderate(EscapeRoom $room, string $status, Request $request, EntityManagerInterface $em): Response
{
if (!$this->isCsrfTokenValid('moderate_room_'.$room->getId(), (string) $request->request->get('_token'))) {
$this->addFlash('danger', 'Invalid CSRF token.');
return $this->redirectToRoute('website_admin_escaperooms');
}
$target = EscapeRoomStatus::tryFrom($status);
if ($target === null) {
throw $this->createNotFoundException();
}
$room->setStatus($target)->touch();
$em->flush();
$this->addFlash('success', sprintf('"%s" is now %s.', $room->getName(), $target->label()));
return $this->redirectToRoute('website_admin_escaperooms', ['status' => $request->request->get('return_to')]);
}
#[Route('/{id}/delete', name: 'website_admin_escaperoom_delete', methods: ['POST'], requirements: ['id' => '\d+'])]
public function delete(EscapeRoom $room, Request $request, EntityManagerInterface $em): Response
{
if (!$this->isCsrfTokenValid('delete_room_'.$room->getId(), (string) $request->request->get('_token'))) {
$this->addFlash('danger', 'Invalid CSRF token.');
return $this->redirectToRoute('website_admin_escaperooms');
}
if (!$room->isDeleted()) {
$room->setDeletedAt(new \DateTimeImmutable());
$em->flush();
}
$this->addFlash('success', sprintf('"%s" deleted.', $room->getName()));
return $this->redirectToRoute('website_admin_escaperooms');
}
#[Route('/reviews', name: 'website_admin_escaperoom_reviews', methods: ['GET'])]
public function reviews(EscapeRoomReviewRepository $reviews): Response
{
return $this->render('website/admin/review/index.html.twig', [
'reviews' => $reviews->findRecent(),
]);
}
#[Route('/reviews/{id}/delete', name: 'website_admin_escaperoom_review_delete', methods: ['POST'], requirements: ['id' => '\d+'])]
public function deleteReview(EscapeRoomReview $review, Request $request, EntityManagerInterface $em): Response
{
if (!$this->isCsrfTokenValid('delete_review_'.$review->getId(), (string) $request->request->get('_token'))) {
$this->addFlash('danger', 'Invalid CSRF token.');
return $this->redirectToRoute('website_admin_escaperoom_reviews');
}
if (!$review->isDeleted()) {
$review->setDeletedAt(new \DateTimeImmutable());
$em->flush();
}
$this->addFlash('success', 'Review removed.');
return $this->redirectToRoute('website_admin_escaperoom_reviews');
}
}
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Website\Controller;
use App\Tech\Entity\User;
use App\Website\Entity\EscapeRoom;
use App\Website\Entity\EscapeRoomReview;
use App\Website\Enum\EscapeRoomSource;
use App\Website\Enum\EscapeRoomStatus;
use App\Website\Form\EscapeRoomReviewType;
use App\Website\Form\SuggestEscapeRoomType;
use App\Website\Repository\EscapeRoomRepository;
use App\Website\Repository\EscapeRoomReviewRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
final class EscapeRoomController extends AbstractController
{
#[Route(path: '/escape-rooms', name: 'website_escaperooms', methods: ['GET'])]
public function index(EscapeRoomRepository $rooms): Response
{
return $this->render('website/escape_room/index.html.twig', [
'publishedCount' => $rooms->countByStatus(EscapeRoomStatus::PUBLISHED),
]);
}
/**
* Slim feed the map and the list are both built from. Cached at the edge for
* a few minutes; the dataset only changes when the importer runs or an admin
* edits a room.
*/
#[Route(path: '/escape-rooms/data.json', name: 'website_escaperooms_data', methods: ['GET'])]
public function data(EscapeRoomRepository $rooms): JsonResponse
{
$response = new JsonResponse([
'generatedAt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM),
'rooms' => $rooms->findMapData(),
]);
$response->setPublic();
$response->setMaxAge(600);
$response->setSharedMaxAge(600);
return $response;
}
#[Route(path: '/escape-rooms/suggest', name: 'website_escaperoom_suggest', methods: ['GET', 'POST'])]
public function suggest(Request $request, EntityManagerInterface $em): Response
{
$room = new EscapeRoom();
$form = $this->createForm(SuggestEscapeRoomType::class, $room);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$room->setSource(EscapeRoomSource::USER)
->setStatus(EscapeRoomStatus::PENDING);
if ($this->getUser() instanceof User) {
$room->setSubmittedBy($this->getUser());
}
$em->persist($room);
$em->flush();
$this->addFlash('success', 'Thanks! Your suggestion has been sent for review and will appear on the map once approved.');
return $this->redirectToRoute('website_escaperooms');
}
return $this->render('website/escape_room/suggest.html.twig', [
'form' => $form,
]);
}
#[Route(path: '/escape-rooms/{id}-{slug}', name: 'website_escaperoom_show', methods: ['GET'], requirements: ['id' => '\d+', 'slug' => '[a-z0-9-]*'])]
public function show(EscapeRoom $room, EscapeRoomReviewRepository $reviews): Response
{
$this->guardVisible($room);
return $this->renderRoom($room, $reviews, $this->createForm(EscapeRoomReviewType::class));
}
#[Route(path: '/escape-rooms/{id}/reviews', name: 'website_escaperoom_review', methods: ['POST'], requirements: ['id' => '\d+'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function review(
EscapeRoom $room,
Request $request,
EntityManagerInterface $em,
EscapeRoomReviewRepository $reviews,
): Response {
$this->guardVisible($room);
$review = new EscapeRoomReview();
$form = $this->createForm(EscapeRoomReviewType::class, $review);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var User $user */
$user = $this->getUser();
$review->setEscapeRoom($room)->setAuthor($user);
$em->persist($review);
$em->flush();
$this->addFlash('success', 'Thanks for your review!');
return $this->redirectToRoute('website_escaperoom_show', [
'id' => $room->getId(),
'slug' => $room->getSlug(),
]);
}
return $this->renderRoom($room, $reviews, $form);
}
private function guardVisible(EscapeRoom $room): void
{
if ($room->isDeleted() || !$room->getStatus()->isPubliclyVisible()) {
throw $this->createNotFoundException();
}
}
private function renderRoom(EscapeRoom $room, EscapeRoomReviewRepository $reviews, FormInterface $reviewForm): Response
{
return $this->render('website/escape_room/show.html.twig', [
'room' => $room,
'reviews' => $reviews->findVisibleForRoom($room),
'rating' => $reviews->ratingSummary($room),
'reviewForm' => $reviewForm,
]);
}
}
+378
View File
@@ -0,0 +1,378 @@
<?php
namespace App\Website\Entity;
use App\Tech\Entity\User;
use App\Website\Enum\EscapeRoomSource;
use App\Website\Enum\EscapeRoomStatus;
use App\Website\Repository\EscapeRoomRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: EscapeRoomRepository::class)]
#[ORM\Table(name: 'escape_room')]
#[ORM\UniqueConstraint(name: 'uniq_escape_room_osm', columns: ['osm_type', 'osm_id'])]
#[ORM\Index(name: 'idx_escape_room_status', columns: ['status'])]
#[ORM\Index(name: 'idx_escape_room_bbox', columns: ['latitude', 'longitude'])]
#[ORM\Index(name: 'idx_escape_room_submitted_by', columns: ['submitted_by_id'])]
class EscapeRoom
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $name = null;
/** Operating company / brand, when it differs from the room name. */
#[ORM\Column(length: 255, nullable: true)]
private ?string $venue = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $street = null;
#[ORM\Column(length: 32, nullable: true)]
private ?string $houseNumber = null;
#[ORM\Column(length: 32, nullable: true)]
private ?string $postcode = null;
#[ORM\Column(length: 128, nullable: true)]
private ?string $city = null;
/** ISO 3166-1 alpha-2, upper case. */
#[ORM\Column(length: 2, nullable: true)]
private ?string $country = null;
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 7)]
private ?string $latitude = null;
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 7)]
private ?string $longitude = null;
#[ORM\Column(length: 511, nullable: true)]
private ?string $website = null;
#[ORM\Column(length: 64, nullable: true)]
private ?string $phone = null;
#[ORM\Column(length: 16, enumType: EscapeRoomSource::class)]
private EscapeRoomSource $source = EscapeRoomSource::ADMIN;
/** OSM element type: node | way | relation. Set together with osmId for imported rooms. */
#[ORM\Column(length: 8, nullable: true)]
private ?string $osmType = null;
#[ORM\Column(type: Types::BIGINT, nullable: true)]
private ?string $osmId = null;
#[ORM\Column(length: 16, enumType: EscapeRoomStatus::class)]
private EscapeRoomStatus $status = EscapeRoomStatus::PENDING;
/** Kept for imported rooms so a re-import never clobbers manual corrections. */
#[ORM\Column(options: ['default' => false])]
private bool $locked = false;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?User $submittedBy = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $deletedAt = null;
/** @var Collection<int, EscapeRoomReview> */
#[ORM\OneToMany(mappedBy: 'escapeRoom', targetEntity: EscapeRoomReview::class, cascade: ['remove'])]
private Collection $reviews;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
$this->reviews = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getVenue(): ?string
{
return $this->venue;
}
public function setVenue(?string $venue): static
{
$this->venue = $venue;
return $this;
}
public function getStreet(): ?string
{
return $this->street;
}
public function setStreet(?string $street): static
{
$this->street = $street;
return $this;
}
public function getHouseNumber(): ?string
{
return $this->houseNumber;
}
public function setHouseNumber(?string $houseNumber): static
{
$this->houseNumber = $houseNumber;
return $this;
}
public function getPostcode(): ?string
{
return $this->postcode;
}
public function setPostcode(?string $postcode): static
{
$this->postcode = $postcode;
return $this;
}
public function getCity(): ?string
{
return $this->city;
}
public function setCity(?string $city): static
{
$this->city = $city;
return $this;
}
public function getCountry(): ?string
{
return $this->country;
}
public function setCountry(?string $country): static
{
$this->country = $country ? strtoupper($country) : null;
return $this;
}
public function getLatitude(): ?string
{
return $this->latitude;
}
public function setLatitude(string|float|null $latitude): static
{
$this->latitude = $latitude === null ? null : (string) $latitude;
return $this;
}
public function getLongitude(): ?string
{
return $this->longitude;
}
public function setLongitude(string|float|null $longitude): static
{
$this->longitude = $longitude === null ? null : (string) $longitude;
return $this;
}
public function getWebsite(): ?string
{
return $this->website;
}
public function setWebsite(?string $website): static
{
$this->website = $website;
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(?string $phone): static
{
$this->phone = $phone;
return $this;
}
public function getSource(): EscapeRoomSource
{
return $this->source;
}
public function setSource(EscapeRoomSource $source): static
{
$this->source = $source;
return $this;
}
public function getOsmType(): ?string
{
return $this->osmType;
}
public function setOsmType(?string $osmType): static
{
$this->osmType = $osmType;
return $this;
}
public function getOsmId(): ?string
{
return $this->osmId;
}
public function setOsmId(int|string|null $osmId): static
{
$this->osmId = $osmId === null ? null : (string) $osmId;
return $this;
}
public function getStatus(): EscapeRoomStatus
{
return $this->status;
}
public function setStatus(EscapeRoomStatus $status): static
{
$this->status = $status;
return $this;
}
public function isLocked(): bool
{
return $this->locked;
}
public function setLocked(bool $locked): static
{
$this->locked = $locked;
return $this;
}
public function getSubmittedBy(): ?User
{
return $this->submittedBy;
}
public function setSubmittedBy(?User $submittedBy): static
{
$this->submittedBy = $submittedBy;
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): ?\DateTimeImmutable
{
return $this->updatedAt;
}
public function touch(): static
{
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function getDeletedAt(): ?\DateTimeImmutable
{
return $this->deletedAt;
}
public function setDeletedAt(?\DateTimeImmutable $deletedAt): static
{
$this->deletedAt = $deletedAt;
return $this;
}
public function isDeleted(): bool
{
return $this->deletedAt !== null;
}
/** @return Collection<int, EscapeRoomReview> */
public function getReviews(): Collection
{
return $this->reviews;
}
/** @return EscapeRoomReview[] */
public function getVisibleReviews(): array
{
return $this->reviews
->filter(static fn (EscapeRoomReview $r) => !$r->isDeleted())
->toArray();
}
public function getSlug(): string
{
$slug = strtolower((string) $this->name);
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '';
return trim($slug, '-') ?: 'room';
}
public function getDisplayAddress(): string
{
$line = trim(sprintf('%s %s', (string) $this->street, (string) $this->houseNumber));
$parts = array_filter([
$line !== '' ? $line : null,
trim(sprintf('%s %s', (string) $this->postcode, (string) $this->city)) ?: null,
$this->country,
]);
return implode(', ', $parts);
}
}
+141
View File
@@ -0,0 +1,141 @@
<?php
namespace App\Website\Entity;
use App\Tech\Entity\User;
use App\Website\Repository\EscapeRoomReviewRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: EscapeRoomReviewRepository::class)]
#[ORM\Table(name: 'escape_room_review')]
#[ORM\Index(name: 'idx_escape_room_review_room', columns: ['escape_room_id'])]
#[ORM\Index(name: 'idx_escape_room_review_author', columns: ['author_id'])]
class EscapeRoomReview
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: EscapeRoom::class, inversedBy: 'reviews')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?EscapeRoom $escapeRoom = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?User $author = null;
#[ORM\Column(type: Types::SMALLINT)]
#[Assert\Range(min: 1, max: 5)]
private int $rating = 0;
#[ORM\Column(length: 150)]
#[Assert\NotBlank(message: 'Give your review a short title')]
#[Assert\Length(max: 150)]
private ?string $title = null;
#[ORM\Column(type: Types::TEXT)]
#[Assert\NotBlank(message: 'Write a few words about your experience')]
#[Assert\Length(max: 5000)]
private ?string $body = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $deletedAt = null;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getEscapeRoom(): ?EscapeRoom
{
return $this->escapeRoom;
}
public function setEscapeRoom(?EscapeRoom $escapeRoom): static
{
$this->escapeRoom = $escapeRoom;
return $this;
}
public function getAuthor(): ?User
{
return $this->author;
}
public function setAuthor(?User $author): static
{
$this->author = $author;
return $this;
}
public function getRating(): int
{
return $this->rating;
}
public function setRating(int $rating): static
{
$this->rating = $rating;
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(?string $title): static
{
$this->title = $title;
return $this;
}
public function getBody(): ?string
{
return $this->body;
}
public function setBody(?string $body): static
{
$this->body = $body;
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function getDeletedAt(): ?\DateTimeImmutable
{
return $this->deletedAt;
}
public function setDeletedAt(?\DateTimeImmutable $deletedAt): static
{
$this->deletedAt = $deletedAt;
return $this;
}
public function isDeleted(): bool
{
return $this->deletedAt !== null;
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Website\Enum;
enum EscapeRoomSource: string
{
case OSM = 'osm';
case USER = 'user';
case ADMIN = 'admin';
public function label(): string
{
return match ($this) {
self::OSM => 'OpenStreetMap',
self::USER => 'Visitor submission',
self::ADMIN => 'Added by admin',
};
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Website\Enum;
enum EscapeRoomStatus: string
{
/** Submitted by a visitor, awaiting admin moderation. */
case PENDING = 'pending';
/** Live: shown on the public map and list. */
case PUBLISHED = 'published';
/** Reviewed by an admin and turned down; kept for de-duplication. */
case REJECTED = 'rejected';
/** Was public, hidden again by an admin (e.g. room closed down). */
case HIDDEN = 'hidden';
public function label(): string
{
return match ($this) {
self::PENDING => 'Pending review',
self::PUBLISHED => 'Published',
self::REJECTED => 'Rejected',
self::HIDDEN => 'Hidden',
};
}
public function isPubliclyVisible(): bool
{
return $this === self::PUBLISHED;
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Website\Form;
use App\Website\Entity\EscapeRoom;
use App\Website\Enum\EscapeRoomSource;
use App\Website\Enum\EscapeRoomStatus;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\CountryType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TelType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Range;
class AdminEscapeRoomType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, ['constraints' => [new NotBlank()]])
->add('venue', TextType::class, ['required' => false])
->add('street', TextType::class, ['required' => false])
->add('houseNumber', TextType::class, ['label' => 'House number', 'required' => false])
->add('postcode', TextType::class, ['required' => false])
->add('city', TextType::class, ['required' => false])
->add('country', CountryType::class, ['placeholder' => '—', 'required' => false])
->add('latitude', NumberType::class, [
'scale' => 7,
'html5' => true,
'constraints' => [new NotBlank(), new Range(min: -90, max: 90)],
])
->add('longitude', NumberType::class, [
'scale' => 7,
'html5' => true,
'constraints' => [new NotBlank(), new Range(min: -180, max: 180)],
])
->add('website', UrlType::class, ['required' => false, 'default_protocol' => 'https'])
->add('phone', TelType::class, ['required' => false])
->add('status', EnumType::class, [
'class' => EscapeRoomStatus::class,
'choice_label' => fn (EscapeRoomStatus $s) => $s->label(),
])
->add('source', EnumType::class, [
'class' => EscapeRoomSource::class,
'choice_label' => fn (EscapeRoomSource $s) => $s->label(),
])
->add('locked', CheckboxType::class, [
'required' => false,
'label' => 'Locked (protect from OSM re-import)',
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => EscapeRoom::class,
]);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Website\Form;
use App\Website\Entity\EscapeRoomReview;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;
class EscapeRoomReviewType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('rating', ChoiceType::class, [
'placeholder' => 'Choose a rating',
'choices' => [
'★★★★★ — Outstanding' => 5,
'★★★★ — Very good' => 4,
'★★★ — Decent' => 3,
'★★ — Poor' => 2,
'★ — Terrible' => 1,
],
'constraints' => [new NotBlank(message: 'Pick a rating')],
])
->add('title', TextType::class, [
'attr' => ['maxlength' => 150, 'placeholder' => 'Sum it up in a few words'],
])
->add('body', TextareaType::class, [
'attr' => ['rows' => 5, 'maxlength' => 5000, 'placeholder' => 'What worked, what didn\'t, would you recommend it?'],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => EscapeRoomReview::class,
]);
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Website\Form;
use App\Website\Entity\EscapeRoom;
use Karser\Recaptcha3Bundle\Form\Recaptcha3Type;
use Karser\Recaptcha3Bundle\Validator\Constraints\Recaptcha3;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CountryType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\TelType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Range;
use Symfony\Component\Validator\Constraints\Regex;
class SuggestEscapeRoomType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'label' => 'Room / venue name',
'constraints' => [new NotBlank(message: 'Enter the room name'), new Length(max: 255)],
])
->add('venue', TextType::class, [
'label' => 'Operating company (optional)',
'required' => false,
'constraints' => [new Length(max: 255)],
])
->add('street', TextType::class, ['required' => false, 'constraints' => [new Length(max: 255)]])
->add('houseNumber', TextType::class, ['label' => 'No.', 'required' => false, 'constraints' => [new Length(max: 32)]])
->add('postcode', TextType::class, ['required' => false, 'constraints' => [new Length(max: 32)]])
->add('city', TextType::class, ['required' => false, 'constraints' => [new Length(max: 128)]])
->add('country', CountryType::class, [
'placeholder' => 'Select a country',
'required' => false,
])
->add('website', UrlType::class, [
'required' => false,
'default_protocol' => 'https',
'constraints' => [new Length(max: 511)],
])
->add('phone', TelType::class, ['required' => false, 'constraints' => [new Length(max: 64)]])
->add('latitude', HiddenType::class, [
'constraints' => [
new NotBlank(message: 'Drop a pin on the map to set the location'),
new Regex(pattern: '/^-?\d{1,3}(\.\d+)?$/', message: 'Invalid latitude'),
new Range(min: -90, max: 90),
],
])
->add('longitude', HiddenType::class, [
'constraints' => [
new NotBlank(message: 'Drop a pin on the map to set the location'),
new Regex(pattern: '/^-?\d{1,3}(\.\d+)?$/', message: 'Invalid longitude'),
new Range(min: -180, max: 180),
],
])
->add('captcha', Recaptcha3Type::class, [
'mapped' => false,
'constraints' => new Recaptcha3(),
'action_name' => 'escaperoom_suggest',
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => EscapeRoom::class,
]);
}
}
@@ -0,0 +1,134 @@
<?php
namespace App\Website\Repository;
use App\Website\Entity\EscapeRoom;
use App\Website\Enum\EscapeRoomStatus;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<EscapeRoom>
*/
class EscapeRoomRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, EscapeRoom::class);
}
public function save(EscapeRoom $room, bool $flush = true): void
{
$this->getEntityManager()->persist($room);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function findOneByOsm(string $osmType, string $osmId): ?EscapeRoom
{
return $this->findOneBy(['osmType' => $osmType, 'osmId' => $osmId]);
}
/**
* Lightweight rows for the public map / list. One row per published room with
* its aggregated rating. Optionally clipped to a lat/lng bounding box.
*
* @param array{south: float, west: float, north: float, east: float}|null $bbox
*
* @return list<array{
* id: int, name: string, city: ?string, country: ?string,
* lat: float, lng: float, avgRating: ?float, reviewCount: int
* }>
*/
public function findMapData(?array $bbox = null, int $limit = 20000): array
{
$qb = $this->createQueryBuilder('r')
->select('r.id, r.name, r.city, r.country, r.latitude, r.longitude')
->addSelect('AVG(rev.rating) AS avgRating')
->addSelect('COUNT(rev.id) AS reviewCount')
->leftJoin('r.reviews', 'rev', 'WITH', 'rev.deletedAt IS NULL')
->where('r.deletedAt IS NULL')
->andWhere('r.status = :published')
->setParameter('published', EscapeRoomStatus::PUBLISHED)
->groupBy('r.id')
->setMaxResults($limit);
if ($bbox !== null) {
$qb->andWhere('r.latitude BETWEEN :south AND :north')
->andWhere('r.longitude BETWEEN :west AND :east')
->setParameter('south', $bbox['south'])
->setParameter('north', $bbox['north'])
->setParameter('west', $bbox['west'])
->setParameter('east', $bbox['east']);
}
$rows = $qb->getQuery()->getArrayResult();
return array_map(static function (array $row): array {
return [
'id' => (int) $row['id'],
'name' => (string) $row['name'],
'city' => $row['city'],
'country' => $row['country'],
'lat' => (float) $row['latitude'],
'lng' => (float) $row['longitude'],
'avgRating' => $row['reviewCount'] > 0 ? round((float) $row['avgRating'], 1) : null,
'reviewCount' => (int) $row['reviewCount'],
];
}, $rows);
}
public function countByStatus(EscapeRoomStatus $status): int
{
return (int) $this->createQueryBuilder('r')
->select('COUNT(r.id)')
->where('r.deletedAt IS NULL')
->andWhere('r.status = :status')
->setParameter('status', $status)
->getQuery()
->getSingleScalarResult();
}
/**
* All non-deleted rooms, newest first. For the admin list.
*
* @return EscapeRoom[]
*/
public function findForAdmin(?EscapeRoomStatus $status = null): array
{
$qb = $this->createQueryBuilder('r')
->where('r.deletedAt IS NULL')
->orderBy('r.createdAt', 'DESC');
if ($status !== null) {
$qb->andWhere('r.status = :status')->setParameter('status', $status);
}
return $qb->getQuery()->getResult();
}
/**
* Visible review count per room, in one query, for the admin list.
*
* @return array<int, int> roomId => count
*/
public function reviewCounts(): array
{
$rows = $this->getEntityManager()
->createQuery(
'SELECT IDENTITY(rev.escapeRoom) AS rid, COUNT(rev.id) AS c '
.'FROM App\Website\Entity\EscapeRoomReview rev '
.'WHERE rev.deletedAt IS NULL GROUP BY rid'
)
->getScalarResult();
$map = [];
foreach ($rows as $row) {
$map[(int) $row['rid']] = (int) $row['c'];
}
return $map;
}
}
@@ -0,0 +1,80 @@
<?php
namespace App\Website\Repository;
use App\Website\Entity\EscapeRoom;
use App\Website\Entity\EscapeRoomReview;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<EscapeRoomReview>
*/
class EscapeRoomReviewRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, EscapeRoomReview::class);
}
public function save(EscapeRoomReview $review, bool $flush = true): void
{
$this->getEntityManager()->persist($review);
if ($flush) {
$this->getEntityManager()->flush();
}
}
/**
* @return EscapeRoomReview[]
*/
public function findVisibleForRoom(EscapeRoom $room): array
{
return $this->createQueryBuilder('rev')
->where('rev.escapeRoom = :room')
->andWhere('rev.deletedAt IS NULL')
->setParameter('room', $room)
->orderBy('rev.createdAt', 'DESC')
->getQuery()
->getResult();
}
/**
* @return array{avg: ?float, count: int}
*/
public function ratingSummary(EscapeRoom $room): array
{
$row = $this->createQueryBuilder('rev')
->select('AVG(rev.rating) AS avg, COUNT(rev.id) AS count')
->where('rev.escapeRoom = :room')
->andWhere('rev.deletedAt IS NULL')
->setParameter('room', $room)
->getQuery()
->getSingleResult();
$count = (int) $row['count'];
return [
'avg' => $count > 0 ? round((float) $row['avg'], 1) : null,
'count' => $count,
];
}
/**
* Recent reviews across all rooms, for admin moderation.
*
* @return EscapeRoomReview[]
*/
public function findRecent(int $limit = 200): array
{
return $this->createQueryBuilder('rev')
->addSelect('room')
->join('rev.escapeRoom', 'room')
->where('rev.deletedAt IS NULL')
->orderBy('rev.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
}
+5
View File
@@ -124,6 +124,7 @@
<div class="admin-shell">
{# ── Sidebar ──────────────────────────────────────────────────────── #}
{% block admin_sidebar %}
<nav class="admin-sidebar">
<div class="admin-sidebar-title">Game Admin</div>
@@ -156,11 +157,15 @@
</ul>
<div class="admin-sidebar-footer">
<a href="{{ path('website_admin_escaperooms') }}" style="color: #64748b; font-size: 0.8rem; text-decoration: none; display: block; margin-bottom: 0.4rem;">
Website admin →
</a>
<a href="{{ path('game_dashboard') }}" style="color: #64748b; font-size: 0.8rem; text-decoration: none;">
← Back to game
</a>
</div>
</nav>
{% endblock %}
{# ── Content ──────────────────────────────────────────────────────── #}
<main class="admin-main">
+3
View File
@@ -20,6 +20,9 @@
<li class="nav-item">
<a class="nav-link" href="{{ path('game_dashboard') }}">{{ 'nav.game'|trans }}</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ path('website_escaperooms') }}">{{ 'nav.escaperooms'|trans }}</a>
</li>
{% if app.user %}
{% if is_granted('ROLE_ADMIN') %}
<li class="nav-item">
+110
View File
@@ -0,0 +1,110 @@
{% extends 'game/admin/base.html.twig' %}
{% block stylesheets %}
{{ parent() }}
<style>
.er-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem 1.25rem;
max-width: 720px;
}
.er-form .er-field-wide { grid-column: 1 / -1; }
.er-form label {
display: block;
font-weight: 600;
margin-bottom: 0.3rem;
color: #374151;
font-size: 0.8rem;
}
.er-form input[type=text],
.er-form input[type=number],
.er-form input[type=url],
.er-form input[type=tel],
.er-form select,
.er-form textarea {
width: 100%;
padding: 0.5rem 0.7rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.9rem;
box-sizing: border-box;
}
.er-form .form-error,
.er-form ul { color: #b91c1c; font-size: 0.8rem; margin: 0.25rem 0 0; padding-left: 1rem; }
.er-btn {
padding: 0.55rem 1.2rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
.er-btn--ghost { background: #f1f5f9; color: #475569; }
.er-badge {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
}
.er-badge--published { background: #dcfce7; color: #166534; }
.er-badge--pending { background: #fef9c3; color: #854d0e; }
.er-badge--rejected { background: #fee2e2; color: #991b1b; }
.er-badge--hidden { background: #e2e8f0; color: #475569; }
.er-actions form { display: inline; }
.er-actions button,
.er-actions a {
background: none;
border: none;
padding: 0;
margin-right: 0.6rem;
font-size: 0.82rem;
color: #3b82f6;
cursor: pointer;
text-decoration: none;
}
.er-actions button.er-danger { color: #ef4444; }
</style>
{% endblock %}
{% block admin_sidebar %}
<nav class="admin-sidebar">
<div class="admin-sidebar-title">Website Admin</div>
<ul class="admin-nav-list">
{% set current = app.request.attributes.get('_route') %}
{% set navItems = [
{ route: 'website_admin_escaperooms', label: 'Escape Rooms', icon: '📍' },
{ route: 'website_admin_escaperoom_reviews', label: 'Reviews', icon: '⭐' },
] %}
{% for item in navItems %}
{% set isActive = current starts with item.route %}
<li>
<a href="{{ path(item.route) }}" class="admin-nav-link" style="
color: {{ isActive ? '#f1f5f9' : '#94a3b8' }};
background: {{ isActive ? '#334155' : 'transparent' }};
border-left: 3px solid {{ isActive ? '#3b82f6' : 'transparent' }};
">
<span>{{ item.icon }}</span>
{{ item.label }}
</a>
</li>
{% endfor %}
</ul>
<div class="admin-sidebar-footer">
<a href="{{ path('game_admin_dashboard') }}" style="color: #64748b; font-size: 0.8rem; text-decoration: none; display: block; margin-bottom: 0.4rem;">
← Game admin
</a>
<a href="{{ path('website_home') }}" style="color: #64748b; font-size: 0.8rem; text-decoration: none;">
← Back to site
</a>
</div>
</nav>
{% endblock %}
@@ -0,0 +1,41 @@
{% extends 'website/admin/base.html.twig' %}
{% block title %}{{ heading }} — Website Admin{% endblock %}
{% block admin_body %}
<div style="margin-bottom: 1.25rem;">
<a href="{{ path('website_admin_escaperooms') }}" style="color: #64748b; text-decoration: none; font-size: 0.9rem;">← Escape Rooms</a>
</div>
<h1 style="margin: 0 0 1.5rem; font-size: 1.5rem; color: #0f172a;">{{ heading }}</h1>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); padding: 1.5rem;">
{{ form_start(form, {attr: {class: 'er-form'}}) }}
<div class="er-field-wide">{{ form_row(form.name) }}</div>
<div class="er-field-wide">{{ form_row(form.venue) }}</div>
{{ form_row(form.street) }}
{{ form_row(form.houseNumber) }}
{{ form_row(form.postcode) }}
{{ form_row(form.city) }}
{{ form_row(form.country) }}
<div></div>
{{ form_row(form.latitude) }}
{{ form_row(form.longitude) }}
{{ form_row(form.website) }}
{{ form_row(form.phone) }}
{{ form_row(form.status) }}
{{ form_row(form.source) }}
<div class="er-field-wide">{{ form_row(form.locked) }}</div>
<div class="er-field-wide" style="display: flex; gap: 0.75rem; margin-top: 0.5rem;">
<button type="submit" class="er-btn">Save</button>
<a href="{{ path('website_admin_escaperooms') }}" class="er-btn er-btn--ghost">Cancel</a>
</div>
{{ form_end(form) }}
</div>
{% endblock %}
@@ -0,0 +1,93 @@
{% extends 'website/admin/base.html.twig' %}
{% block title %}Escape Rooms — Website Admin{% endblock %}
{% block admin_body %}
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem;">
<h1 style="margin: 0; font-size: 1.5rem; color: #0f172a;">Escape Rooms</h1>
<a href="{{ path('website_admin_escaperoom_new') }}" class="er-btn">+ New room</a>
</div>
{% set tabs = {
'': 'All (' ~ counts.all ~ ')',
'pending': 'Pending (' ~ counts.pending ~ ')',
'published': 'Published (' ~ counts.published ~ ')',
'hidden': 'Hidden (' ~ counts.hidden ~ ')',
'rejected': 'Rejected (' ~ counts.rejected ~ ')',
} %}
<div style="margin-bottom: 1rem; display: flex; gap: 0.4rem; flex-wrap: wrap;">
{% for value, label in tabs %}
{% set active = (filter is null and value == '') or (filter and filter.value == value) %}
<a href="{{ path('website_admin_escaperooms', value ? {status: value} : {}) }}"
style="padding: 0.3rem 0.7rem; border-radius: 6px; font-size: 0.82rem; text-decoration: none;
background: {{ active ? '#3b82f6' : '#e2e8f0' }}; color: {{ active ? '#fff' : '#475569' }};">
{{ label }}
</a>
{% endfor %}
</div>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
<table class="admin-datatable" style="width: 100%; min-width: 900px; border-collapse: collapse; font-size: 0.88rem;">
<thead>
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Name</th>
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">City</th>
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Country</th>
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Source</th>
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Status</th>
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Reviews</th>
<th data-sortable="false" style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Actions</th>
</tr>
</thead>
<tbody>
{% for room in rooms %}
<tr style="border-bottom: 1px solid #f1f5f9;">
<td style="padding: 0.7rem 0.9rem; font-weight: 500; color: #0f172a;">
{% if room.status.isPubliclyVisible %}
<a href="{{ path('website_escaperoom_show', {id: room.id, slug: room.slug}) }}" target="_blank" rel="noopener" style="color: #0f172a;">{{ room.name }}</a>
{% else %}
{{ room.name }}
{% endif %}
</td>
<td style="padding: 0.7rem 0.9rem; color: #475569;">{{ room.city }}</td>
<td style="padding: 0.7rem 0.9rem; color: #475569;">{{ room.country }}</td>
<td style="padding: 0.7rem 0.9rem; color: #475569;">{{ room.source.value }}</td>
<td style="padding: 0.7rem 0.9rem;">
<span class="er-badge er-badge--{{ room.status.value }}">{{ room.status.label }}</span>
</td>
<td style="padding: 0.7rem 0.9rem; color: #475569;">{{ reviewCounts[room.id]|default(0) }}</td>
<td style="padding: 0.7rem 0.9rem;" class="er-actions">
<a href="{{ path('website_admin_escaperoom_edit', {id: room.id}) }}">Edit</a>
{% if not room.status.isPubliclyVisible %}
<form method="post" action="{{ path('website_admin_escaperoom_moderate', {id: room.id, status: 'published'}) }}">
<input type="hidden" name="_token" value="{{ csrf_token('moderate_room_' ~ room.id) }}">
<input type="hidden" name="return_to" value="{{ filter ? filter.value : '' }}">
<button type="submit">Publish</button>
</form>
{% else %}
<form method="post" action="{{ path('website_admin_escaperoom_moderate', {id: room.id, status: 'hidden'}) }}">
<input type="hidden" name="_token" value="{{ csrf_token('moderate_room_' ~ room.id) }}">
<input type="hidden" name="return_to" value="{{ filter ? filter.value : '' }}">
<button type="submit">Hide</button>
</form>
{% endif %}
{% if room.status.value != 'rejected' %}
<form method="post" action="{{ path('website_admin_escaperoom_moderate', {id: room.id, status: 'rejected'}) }}">
<input type="hidden" name="_token" value="{{ csrf_token('moderate_room_' ~ room.id) }}">
<input type="hidden" name="return_to" value="{{ filter ? filter.value : '' }}">
<button type="submit">Reject</button>
</form>
{% endif %}
<form method="post" action="{{ path('website_admin_escaperoom_delete', {id: room.id}) }}" onsubmit="return confirm('Delete {{ room.name|e('js') }}?')">
<input type="hidden" name="_token" value="{{ csrf_token('delete_room_' ~ room.id) }}">
<button type="submit" class="er-danger">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="7" style="padding: 2rem; text-align: center; color: #94a3b8;">No rooms.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
@@ -0,0 +1,43 @@
{% extends 'website/admin/base.html.twig' %}
{% block title %}Reviews — Website Admin{% endblock %}
{% block admin_body %}
<h1 style="margin: 0 0 1.5rem; font-size: 1.5rem; color: #0f172a;">Recent reviews</h1>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
<table class="admin-datatable" style="width: 100%; min-width: 820px; border-collapse: collapse; font-size: 0.88rem;">
<thead>
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Date</th>
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Room</th>
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Rating</th>
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Title</th>
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Author</th>
<th data-sortable="false" style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Actions</th>
</tr>
</thead>
<tbody>
{% for review in reviews %}
<tr style="border-bottom: 1px solid #f1f5f9;">
<td style="padding: 0.7rem 0.9rem; color: #475569; white-space: nowrap;">{{ review.createdAt|date('Y-m-d') }}</td>
<td style="padding: 0.7rem 0.9rem; color: #0f172a;">
<a href="{{ path('website_admin_escaperoom_edit', {id: review.escapeRoom.id}) }}" style="color: #0f172a;">{{ review.escapeRoom.name }}</a>
</td>
<td style="padding: 0.7rem 0.9rem;" data-order="{{ review.rating }}">{{ review.rating }} ★</td>
<td style="padding: 0.7rem 0.9rem; color: #475569;">{{ review.title }}</td>
<td style="padding: 0.7rem 0.9rem; color: #475569;">{{ review.author ? review.author.username : '—' }}</td>
<td style="padding: 0.7rem 0.9rem;" class="er-actions">
<form method="post" action="{{ path('website_admin_escaperoom_review_delete', {id: review.id}) }}" onsubmit="return confirm('Remove this review?')">
<input type="hidden" name="_token" value="{{ csrf_token('delete_review_' ~ review.id) }}">
<button type="submit" class="er-danger">Remove</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="6" style="padding: 2rem; text-align: center; color: #94a3b8;">No reviews yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
@@ -0,0 +1,74 @@
{% extends 'layout/site.html.twig' %}
{% block title %}Escape room map | {{ 'site.name'|trans }}{% endblock %}
{% block stylesheets %}
{{ parent() }}
{{ encore_entry_link_tags('escaperoom-map') }}
<style>
#escaperoom-map {
height: 70vh;
min-height: 420px;
width: 100%;
border-radius: 8px;
z-index: 0;
}
.escaperoom-list-wrap {
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, .07);
overflow: hidden;
}
.escaperoom-list-wrap table {
width: 100%;
border-collapse: collapse;
font-size: .9rem;
}
.escaperoom-list-wrap th,
.escaperoom-list-wrap td {
padding: .6rem .9rem;
text-align: left;
}
</style>
{% endblock %}
{% block body %}
{% set detailTemplate = path('website_escaperoom_show', {id: 987654321, slug: 'zzslugzz'})|replace({'987654321': '__ID__', 'zzslugzz': '__SLUG__'}) %}
<div class="d-flex flex-wrap align-items-baseline justify-content-between mb-3">
<div>
<h1 class="display-6 fw-bold mb-1">Physical escape rooms</h1>
<p class="text-secondary mb-0">Zoom in to find rooms near you, then open one to read and leave reviews.</p>
</div>
<div class="text-end">
<div id="escaperoom-status" class="small text-secondary">{{ publishedCount }} rooms on the map</div>
<a href="{{ path('website_escaperoom_suggest') }}" class="btn btn-sm btn-outline-primary mt-1">Suggest a room</a>
</div>
</div>
<div id="escaperoom-map"
data-endpoint="{{ path('website_escaperooms_data') }}"
data-detail-template="{{ detailTemplate }}"
class="mb-4"></div>
<div class="escaperoom-list-wrap">
<table id="escaperoom-list">
<thead>
<tr>
<th>Name</th>
<th>City</th>
<th>Country</th>
<th>Rating</th>
</tr>
</thead>
<tbody>
<tr><td colspan="4" class="text-center text-secondary py-4">Loading rooms…</td></tr>
</tbody>
</table>
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
{{ encore_entry_script_tags('escaperoom-map') }}
{% endblock %}
@@ -0,0 +1,122 @@
{% extends 'layout/site.html.twig' %}
{% block title %}{{ room.name }} | {{ 'site.name'|trans }}{% endblock %}
{% block stylesheets %}
{{ parent() }}
{{ encore_entry_link_tags('escaperoom-detail') }}
<style>
#escaperoom-detail-map {
height: 260px;
width: 100%;
border-radius: 8px;
z-index: 0;
}
</style>
{% endblock %}
{% block body %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb small">
<li class="breadcrumb-item"><a href="{{ path('website_escaperooms') }}">Escape room map</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ room.name }}</li>
</ol>
</nav>
<div class="row g-4">
<div class="col-lg-7">
<h1 class="h3 fw-bold mb-1">{{ room.name }}</h1>
{% if room.venue %}<p class="text-secondary mb-2">{{ room.venue }}</p>{% endif %}
<p class="mb-1">
{% if rating.count > 0 %}
<span class="fw-semibold">{{ rating.avg }} ★</span>
<span class="text-secondary">· {{ rating.count }} review{{ rating.count != 1 ? 's' : '' }}</span>
{% else %}
<span class="text-secondary">No reviews yet</span>
{% endif %}
</p>
{% if room.displayAddress %}
<p class="mb-1">{{ room.displayAddress }}</p>
{% endif %}
<p class="mb-3 small">
{% if room.website %}<a href="{{ room.website }}" target="_blank" rel="noopener nofollow">Website</a> · {% endif %}
{% if room.phone %}{{ room.phone }} · {% endif %}
<a href="https://www.openstreetmap.org/?mlat={{ room.latitude }}&mlon={{ room.longitude }}#map=17/{{ room.latitude }}/{{ room.longitude }}"
target="_blank" rel="noopener">Open in OpenStreetMap</a>
</p>
<hr>
<h2 class="h5 mb-3">Reviews</h2>
{% if app.user %}
<div class="card shadow-sm mb-4">
<div class="card-body">
<h3 class="h6 mb-3">Leave a review</h3>
{{ form_start(reviewForm, {action: path('website_escaperoom_review', {id: room.id})}) }}
<div class="mb-2">
{{ form_label(reviewForm.rating) }}
{{ form_widget(reviewForm.rating, {attr: {class: 'form-select'}}) }}
{{ form_errors(reviewForm.rating) }}
</div>
<div class="mb-2">
{{ form_label(reviewForm.title) }}
{{ form_widget(reviewForm.title, {attr: {class: 'form-control'}}) }}
{{ form_errors(reviewForm.title) }}
</div>
<div class="mb-3">
{{ form_label(reviewForm.body) }}
{{ form_widget(reviewForm.body, {attr: {class: 'form-control'}}) }}
{{ form_errors(reviewForm.body) }}
</div>
<button type="submit" class="btn btn-primary btn-sm">Post review</button>
{{ form_end(reviewForm) }}
</div>
</div>
{% else %}
<p class="small"><a href="{{ path('app_login') }}">Log in</a> to leave a review.</p>
{% endif %}
{% for review in reviews %}
<article class="mb-3 pb-3 border-bottom">
<div class="d-flex justify-content-between">
<strong>{{ review.title }}</strong>
<span>
{%- for i in 1..5 -%}
<span class="{{ i <= review.rating ? 'text-warning' : 'text-secondary' }}">★</span>
{%- endfor -%}
</span>
</div>
<div class="small text-secondary mb-1">
{{ review.author ? review.author.username : 'Anonymous' }} · {{ review.createdAt|date('Y-m-d') }}
</div>
<p class="mb-0">{{ review.body|nl2br }}</p>
</article>
{% else %}
<p class="text-secondary">Be the first to review this room.</p>
{% endfor %}
</div>
<div class="col-lg-5">
<div id="escaperoom-detail-map"
data-lat="{{ room.latitude }}"
data-lng="{{ room.longitude }}"
class="mb-3"></div>
<div class="card shadow-sm">
<div class="card-body small">
<div class="text-secondary">Coordinates</div>
<div class="mb-2">{{ room.latitude }}, {{ room.longitude }}</div>
<div class="text-secondary">Listing source</div>
<div>{{ room.source.label }}</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
{{ encore_entry_script_tags('escaperoom-detail') }}
{% endblock %}
@@ -0,0 +1,105 @@
{% extends 'layout/site.html.twig' %}
{% block title %}Suggest an escape room | {{ 'site.name'|trans }}{% endblock %}
{% block stylesheets %}
{{ parent() }}
{{ encore_entry_link_tags('escaperoom-detail') }}
<style>
#escaperoom-suggest-map {
height: 340px;
width: 100%;
border-radius: 8px;
z-index: 0;
}
</style>
{% endblock %}
{% block body %}
<div class="row justify-content-center">
<div class="col-lg-8">
<nav aria-label="breadcrumb">
<ol class="breadcrumb small">
<li class="breadcrumb-item"><a href="{{ path('website_escaperooms') }}">Escape room map</a></li>
<li class="breadcrumb-item active" aria-current="page">Suggest a room</li>
</ol>
</nav>
<h1 class="h3 fw-bold mb-1">Suggest an escape room</h1>
<p class="text-secondary">Know a physical escape room that isn't on the map? Add it here — an admin will review it before it goes live.</p>
<div class="card shadow-sm">
<div class="card-body">
{{ form_errors(form) }}
{{ form_start(form) }}
<div class="mb-3">
{{ form_label(form.name) }}
{{ form_widget(form.name, {attr: {class: 'form-control'}}) }}
{{ form_errors(form.name) }}
</div>
<div class="mb-3">
{{ form_label(form.venue) }}
{{ form_widget(form.venue, {attr: {class: 'form-control'}}) }}
{{ form_errors(form.venue) }}
</div>
<div class="row g-2">
<div class="col-9 mb-3">
{{ form_label(form.street) }}
{{ form_widget(form.street, {attr: {class: 'form-control'}}) }}
</div>
<div class="col-3 mb-3">
{{ form_label(form.houseNumber) }}
{{ form_widget(form.houseNumber, {attr: {class: 'form-control'}}) }}
</div>
</div>
<div class="row g-2">
<div class="col-4 mb-3">
{{ form_label(form.postcode) }}
{{ form_widget(form.postcode, {attr: {class: 'form-control'}}) }}
</div>
<div class="col-8 mb-3">
{{ form_label(form.city) }}
{{ form_widget(form.city, {attr: {class: 'form-control'}}) }}
</div>
</div>
<div class="mb-3">
{{ form_label(form.country) }}
{{ form_widget(form.country, {attr: {class: 'form-select'}}) }}
</div>
<div class="row g-2">
<div class="col-sm-6 mb-3">
{{ form_label(form.website) }}
{{ form_widget(form.website, {attr: {class: 'form-control'}}) }}
</div>
<div class="col-sm-6 mb-3">
{{ form_label(form.phone) }}
{{ form_widget(form.phone, {attr: {class: 'form-control'}}) }}
</div>
</div>
<label class="form-label">Location <span class="text-secondary fw-normal">— click the map to drop a pin</span></label>
<div id="escaperoom-suggest-map" class="mb-2"></div>
{{ form_widget(form.latitude) }}
{{ form_widget(form.longitude) }}
{{ form_errors(form.latitude) }}
{{ form_errors(form.longitude) }}
<div class="mt-3">
{{ form_row(form.captcha) }}
</div>
<div class="d-grid mt-3">
<button type="submit" class="btn btn-primary">Submit for review</button>
</div>
{{ form_end(form) }}
</div>
</div>
</div>
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
{{ encore_entry_script_tags('escaperoom-detail') }}
{% endblock %}
+1
View File
@@ -2,6 +2,7 @@
site.name: EscapePage
nav.home: Home
nav.game: Game
nav.escaperooms: Escape rooms
nav.admin: Admin
nav.profile: Profile
nav.login: Login
+1
View File
@@ -2,6 +2,7 @@
site.name: EscapePage
nav.home: Start
nav.game: Spel
nav.escaperooms: Escaperooms
nav.admin: Beheer
nav.profile: Profiel
nav.login: Inloggen
+2
View File
@@ -15,6 +15,8 @@ Encore
.addEntry('app', './assets/app.js')
.addEntry('game1', './assets/game1.js')
.addEntry('pregame', './assets/pregame.js')
.addEntry('escaperoom-map', './assets/escaperoom-map.js')
.addEntry('escaperoom-detail', './assets/escaperoom-detail.js')
// split entry chunks for better caching
.splitEntryChunks()
// will require an extra script tag for runtime.js