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:
@@ -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,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,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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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: Escape 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: Escaperooms
|
||||
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