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>
152 lines
5.0 KiB
JavaScript
152 lines
5.0 KiB
JavaScript
/*
|
||
* 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);
|
||
});
|
||
});
|