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,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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user