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