Files
Escapepage/assets/escaperoom-map.js
T
FrankandClaude Sonnet 5 008887ab60 Add physical escape room map, reviews, and website admin
Public:
- /escape-rooms: Leaflet + OpenStreetMap map with marker clustering and a
  searchable list, both built from a cached slim JSON feed
- /escape-rooms/{id}-{slug}: room detail with a mini-map and user reviews
  (rating + title + body, login required)
- /escape-rooms/suggest: visitor submission form with click-to-drop-pin map
  and reCAPTCHA v3; new rooms land as pending

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 00:24:03 +02:00

152 lines
5.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* Public "physical escape rooms" map + list.
*
* One JSON fetch (website_escaperooms_data) feeds both the Leaflet map (with
* marker clustering, so a worldwide dataset stays responsive) and the searchable
* table underneath it.
*/
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import 'leaflet.markercluster';
import 'leaflet.markercluster/dist/MarkerCluster.css';
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
import { DataTable } from 'simple-datatables';
import 'simple-datatables/dist/style.css';
import iconUrl from 'leaflet/dist/images/marker-icon.png';
import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
// Webpack rewrites the image paths, so hand them to Leaflet explicitly.
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl, shadowUrl });
function slugify(name) {
return (name || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'room';
}
function escapeHtml(value) {
const div = document.createElement('div');
div.textContent = value == null ? '' : String(value);
return div.innerHTML;
}
function ratingLabel(avg, count) {
if (!count) {
return '—';
}
return `${avg.toFixed(1)} ★ (${count})`;
}
function detailUrl(template, room) {
return template.replace('__ID__', room.id).replace('__SLUG__', slugify(room.name));
}
function buildMap(el, rooms, detailTemplate) {
const map = L.map(el, { worldCopyJump: true }).setView([20, 0], 2);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
const cluster = L.markerClusterGroup({ chunkedLoading: true });
const bounds = [];
rooms.forEach((room) => {
if (typeof room.lat !== 'number' || typeof room.lng !== 'number') {
return;
}
const where = [room.city, room.country].filter(Boolean).join(', ');
const popup = `
<strong>${escapeHtml(room.name)}</strong><br>
${where ? `${escapeHtml(where)}<br>` : ''}
<span>${escapeHtml(ratingLabel(room.avgRating, room.reviewCount))}</span><br>
<a href="${detailUrl(detailTemplate, room)}">View &amp; review &raquo;</a>
`;
const marker = L.marker([room.lat, room.lng]).bindPopup(popup);
cluster.addLayer(marker);
bounds.push([room.lat, room.lng]);
});
map.addLayer(cluster);
if (bounds.length) {
map.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
}
}
function buildList(table, rooms, detailTemplate) {
const tbody = table.querySelector('tbody');
const frag = document.createDocumentFragment();
rooms.forEach((room) => {
const tr = document.createElement('tr');
const where = room.city ? escapeHtml(room.city) : '';
const country = room.country ? escapeHtml(room.country) : '';
const ratingValue = room.reviewCount ? room.avgRating : 0;
tr.innerHTML = `
<td><a href="${detailUrl(detailTemplate, room)}">${escapeHtml(room.name)}</a></td>
<td>${where}</td>
<td>${country}</td>
<td data-order="${ratingValue}">${escapeHtml(ratingLabel(room.avgRating, room.reviewCount))}</td>
`;
frag.appendChild(tr);
});
tbody.replaceChildren(frag);
new DataTable(table, {
perPage: 25,
perPageSelect: [25, 50, 100],
searchable: true,
sortable: true,
labels: {
placeholder: 'Search rooms…',
perPage: '{select} per page',
noRows: 'No matching rooms',
info: 'Showing {start}–{end} of {rows} rooms',
},
});
}
document.addEventListener('DOMContentLoaded', () => {
const mapEl = document.getElementById('escaperoom-map');
if (!mapEl) {
return;
}
const endpoint = mapEl.dataset.endpoint;
const detailTemplate = mapEl.dataset.detailTemplate;
const listEl = document.getElementById('escaperoom-list');
const statusEl = document.getElementById('escaperoom-status');
fetch(endpoint, { headers: { Accept: 'application/json' } })
.then((res) => {
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json();
})
.then((payload) => {
const rooms = Array.isArray(payload.rooms) ? payload.rooms : [];
buildMap(mapEl, rooms, detailTemplate);
if (listEl) {
buildList(listEl, rooms, detailTemplate);
}
if (statusEl) {
statusEl.textContent = `${rooms.length.toLocaleString()} rooms on the map`;
}
})
.catch((err) => {
if (statusEl) {
statusEl.textContent = 'Could not load escape rooms right now.';
}
console.error('escaperoom-map:', err);
});
});