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:
Frank
2026-08-31 00:24:03 +02:00
co-authored by Claude Sonnet 5
parent 0edaa9d8cc
commit 008887ab60
29 changed files with 2532 additions and 0 deletions
@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Website\Controller;
use App\Website\Entity\EscapeRoom;
use App\Website\Entity\EscapeRoomReview;
use App\Website\Enum\EscapeRoomStatus;
use App\Website\Form\AdminEscapeRoomType;
use App\Website\Repository\EscapeRoomRepository;
use App\Website\Repository\EscapeRoomReviewRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/admin/escape-rooms')]
#[IsGranted('ROLE_ADMIN')]
final class AdminEscapeRoomController extends AbstractController
{
#[Route('', name: 'website_admin_escaperooms', methods: ['GET'])]
public function index(Request $request, EscapeRoomRepository $rooms): Response
{
$filter = EscapeRoomStatus::tryFrom((string) $request->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');
}
}