From 008887ab60df8348fb0ac8a49c06d4bb16e69027 Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 29 Aug 2026 23:33:06 +0200 Subject: [PATCH] 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 --- assets/escaperoom-detail.js | 85 ++++ assets/escaperoom-map.js | 151 +++++++ migrations/Version20260829150000.php | 79 ++++ package-lock.json | 17 + package.json | 2 + .../ImportEscapeRoomsFromOsmCommand.php | 334 ++++++++++++++++ .../Controller/AdminEscapeRoomController.php | 152 +++++++ .../Controller/EscapeRoomController.php | 139 +++++++ src/Website/Entity/EscapeRoom.php | 378 ++++++++++++++++++ src/Website/Entity/EscapeRoomReview.php | 141 +++++++ src/Website/Enum/EscapeRoomSource.php | 19 + src/Website/Enum/EscapeRoomStatus.php | 30 ++ src/Website/Form/AdminEscapeRoomType.php | 67 ++++ src/Website/Form/EscapeRoomReviewType.php | 46 +++ src/Website/Form/SuggestEscapeRoomType.php | 78 ++++ .../Repository/EscapeRoomRepository.php | 134 +++++++ .../Repository/EscapeRoomReviewRepository.php | 80 ++++ templates/game/admin/base.html.twig | 5 + templates/layout/site.html.twig | 3 + templates/website/admin/base.html.twig | 110 +++++ .../website/admin/escape_room/form.html.twig | 41 ++ .../website/admin/escape_room/index.html.twig | 93 +++++ .../website/admin/review/index.html.twig | 43 ++ templates/website/escape_room/index.html.twig | 74 ++++ templates/website/escape_room/show.html.twig | 122 ++++++ .../website/escape_room/suggest.html.twig | 105 +++++ translations/messages.en.yaml | 1 + translations/messages.nl.yaml | 1 + webpack.config.js | 2 + 29 files changed, 2532 insertions(+) create mode 100644 assets/escaperoom-detail.js create mode 100644 assets/escaperoom-map.js create mode 100644 migrations/Version20260829150000.php create mode 100644 src/Website/Command/ImportEscapeRoomsFromOsmCommand.php create mode 100644 src/Website/Controller/AdminEscapeRoomController.php create mode 100644 src/Website/Controller/EscapeRoomController.php create mode 100644 src/Website/Entity/EscapeRoom.php create mode 100644 src/Website/Entity/EscapeRoomReview.php create mode 100644 src/Website/Enum/EscapeRoomSource.php create mode 100644 src/Website/Enum/EscapeRoomStatus.php create mode 100644 src/Website/Form/AdminEscapeRoomType.php create mode 100644 src/Website/Form/EscapeRoomReviewType.php create mode 100644 src/Website/Form/SuggestEscapeRoomType.php create mode 100644 src/Website/Repository/EscapeRoomRepository.php create mode 100644 src/Website/Repository/EscapeRoomReviewRepository.php create mode 100644 templates/website/admin/base.html.twig create mode 100644 templates/website/admin/escape_room/form.html.twig create mode 100644 templates/website/admin/escape_room/index.html.twig create mode 100644 templates/website/admin/review/index.html.twig create mode 100644 templates/website/escape_room/index.html.twig create mode 100644 templates/website/escape_room/show.html.twig create mode 100644 templates/website/escape_room/suggest.html.twig diff --git a/assets/escaperoom-detail.js b/assets/escaperoom-detail.js new file mode 100644 index 0000000..5d4cc07 --- /dev/null +++ b/assets/escaperoom-detail.js @@ -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: '© OpenStreetMap 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); + } +}); diff --git a/assets/escaperoom-map.js b/assets/escaperoom-map.js new file mode 100644 index 0000000..66d9fb2 --- /dev/null +++ b/assets/escaperoom-map.js @@ -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: '© OpenStreetMap 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 = ` + ${escapeHtml(room.name)}
+ ${where ? `${escapeHtml(where)}
` : ''} + ${escapeHtml(ratingLabel(room.avgRating, room.reviewCount))}
+ View & review » + `; + 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 = ` + ${escapeHtml(room.name)} + ${where} + ${country} + ${escapeHtml(ratingLabel(room.avgRating, room.reviewCount))} + `; + 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); + }); +}); diff --git a/migrations/Version20260829150000.php b/migrations/Version20260829150000.php new file mode 100644 index 0000000..e291d4a --- /dev/null +++ b/migrations/Version20260829150000.php @@ -0,0 +1,79 @@ +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'); + } +} diff --git a/package-lock.json b/package-lock.json index ded21df..56f12f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 123ba80..4797278 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/Website/Command/ImportEscapeRoomsFromOsmCommand.php b/src/Website/Command/ImportEscapeRoomsFromOsmCommand.php new file mode 100644 index 0000000..cf92ede --- /dev/null +++ b/src/Website/Command/ImportEscapeRoomsFromOsmCommand.php @@ -0,0 +1,334 @@ + 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: %s · batches: %d%s', $endpoint, \count($queries), $dryRun ? ' · dry run' : '')); + + $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(' sleeping %ds…', $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 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>|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(' request failed (attempt %d/2): %s', $attempt, $e->getMessage())); + if ($attempt < 2) { + sleep(15); + } + } + } + + return null; + } + + /** + * @param array $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 $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 $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; + } +} diff --git a/src/Website/Controller/AdminEscapeRoomController.php b/src/Website/Controller/AdminEscapeRoomController.php new file mode 100644 index 0000000..52b70df --- /dev/null +++ b/src/Website/Controller/AdminEscapeRoomController.php @@ -0,0 +1,152 @@ +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'); + } +} diff --git a/src/Website/Controller/EscapeRoomController.php b/src/Website/Controller/EscapeRoomController.php new file mode 100644 index 0000000..e4ab3e0 --- /dev/null +++ b/src/Website/Controller/EscapeRoomController.php @@ -0,0 +1,139 @@ +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, + ]); + } +} diff --git a/src/Website/Entity/EscapeRoom.php b/src/Website/Entity/EscapeRoom.php new file mode 100644 index 0000000..90d7715 --- /dev/null +++ b/src/Website/Entity/EscapeRoom.php @@ -0,0 +1,378 @@ + 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 */ + #[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 */ + 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); + } +} diff --git a/src/Website/Entity/EscapeRoomReview.php b/src/Website/Entity/EscapeRoomReview.php new file mode 100644 index 0000000..cc6783b --- /dev/null +++ b/src/Website/Entity/EscapeRoomReview.php @@ -0,0 +1,141 @@ +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; + } +} diff --git a/src/Website/Enum/EscapeRoomSource.php b/src/Website/Enum/EscapeRoomSource.php new file mode 100644 index 0000000..60fd943 --- /dev/null +++ b/src/Website/Enum/EscapeRoomSource.php @@ -0,0 +1,19 @@ + 'OpenStreetMap', + self::USER => 'Visitor submission', + self::ADMIN => 'Added by admin', + }; + } +} diff --git a/src/Website/Enum/EscapeRoomStatus.php b/src/Website/Enum/EscapeRoomStatus.php new file mode 100644 index 0000000..cce0f42 --- /dev/null +++ b/src/Website/Enum/EscapeRoomStatus.php @@ -0,0 +1,30 @@ + 'Pending review', + self::PUBLISHED => 'Published', + self::REJECTED => 'Rejected', + self::HIDDEN => 'Hidden', + }; + } + + public function isPubliclyVisible(): bool + { + return $this === self::PUBLISHED; + } +} diff --git a/src/Website/Form/AdminEscapeRoomType.php b/src/Website/Form/AdminEscapeRoomType.php new file mode 100644 index 0000000..e87f46c --- /dev/null +++ b/src/Website/Form/AdminEscapeRoomType.php @@ -0,0 +1,67 @@ +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, + ]); + } +} diff --git a/src/Website/Form/EscapeRoomReviewType.php b/src/Website/Form/EscapeRoomReviewType.php new file mode 100644 index 0000000..e3e9898 --- /dev/null +++ b/src/Website/Form/EscapeRoomReviewType.php @@ -0,0 +1,46 @@ +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, + ]); + } +} diff --git a/src/Website/Form/SuggestEscapeRoomType.php b/src/Website/Form/SuggestEscapeRoomType.php new file mode 100644 index 0000000..cd3a79a --- /dev/null +++ b/src/Website/Form/SuggestEscapeRoomType.php @@ -0,0 +1,78 @@ +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, + ]); + } +} diff --git a/src/Website/Repository/EscapeRoomRepository.php b/src/Website/Repository/EscapeRoomRepository.php new file mode 100644 index 0000000..01c237d --- /dev/null +++ b/src/Website/Repository/EscapeRoomRepository.php @@ -0,0 +1,134 @@ + + */ +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 + */ + 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 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; + } +} diff --git a/src/Website/Repository/EscapeRoomReviewRepository.php b/src/Website/Repository/EscapeRoomReviewRepository.php new file mode 100644 index 0000000..ade2cdd --- /dev/null +++ b/src/Website/Repository/EscapeRoomReviewRepository.php @@ -0,0 +1,80 @@ + + */ +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(); + } +} diff --git a/templates/game/admin/base.html.twig b/templates/game/admin/base.html.twig index 08d1266..b897c7f 100644 --- a/templates/game/admin/base.html.twig +++ b/templates/game/admin/base.html.twig @@ -124,6 +124,7 @@
{# ── Sidebar ──────────────────────────────────────────────────────── #} + {% block admin_sidebar %} + {% endblock %} {# ── Content ──────────────────────────────────────────────────────── #}
diff --git a/templates/layout/site.html.twig b/templates/layout/site.html.twig index c88c628..7afa5ee 100644 --- a/templates/layout/site.html.twig +++ b/templates/layout/site.html.twig @@ -20,6 +20,9 @@ + {% if app.user %} {% if is_granted('ROLE_ADMIN') %}