added frontend and made it usable
This commit is contained in:
1 parent
96408f00d8
commit
b9b15152bc
50 files changed
+7974
-1
No files matched your search
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Repository\CategoryRepository;
|
||||
use App\Repository\EventRepository;
|
||||
use App\Service\EventMapSerializer;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class EventController extends AbstractController
|
||||
{
|
||||
private const PER_PAGE = 24;
|
||||
|
||||
#[Route('/events', name: 'event_list')]
|
||||
public function list(Request $request, EventRepository $events, CategoryRepository $categories): Response
|
||||
{
|
||||
$page = max(1, $request->query->getInt('page', 1));
|
||||
$category = $request->query->get('category') ?: null;
|
||||
$offset = (($page ?: 1) - 1) * self::PER_PAGE;
|
||||
|
||||
$items = $events->findPage(self::PER_PAGE, $offset, $category);
|
||||
$total = $events->countPage($category);
|
||||
|
||||
return $this->render('events/list.html.twig', [
|
||||
'activeNav' => 'events',
|
||||
'events' => $items,
|
||||
'categories' => $categories->findUsedNames(),
|
||||
'activeCategory' => $category,
|
||||
'page' => $page,
|
||||
'totalPages' => max(1, (int) ceil($total / self::PER_PAGE)),
|
||||
'total' => $total,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/events/{id}', name: 'event_show', requirements: ['id' => '\d+'])]
|
||||
public function show(int $id, EventRepository $events, EventMapSerializer $mapSerializer): Response
|
||||
{
|
||||
$event = $events->findOneNotDeleted($id);
|
||||
if (!$event) {
|
||||
throw $this->createNotFoundException('Event nicht gefunden.');
|
||||
}
|
||||
|
||||
return $this->render('events/show.html.twig', [
|
||||
'activeNav' => 'events',
|
||||
'event' => $event,
|
||||
'mapEventJson' => $event->hasCoordinates() ? $mapSerializer->toJson([$event]) : '[]',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Repository\CategoryRepository;
|
||||
use App\Repository\EventRepository;
|
||||
use App\Service\DefaultLocation;
|
||||
use App\Service\EventMapSerializer;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class HomeController extends AbstractController
|
||||
{
|
||||
// Matches /map's MAX_RANGE_KM/POOL_LIMIT: the light map only shows the
|
||||
// nearest 20, but the candidate pool it picks from needs to actually
|
||||
// cover that area, not just be an arbitrary unordered chunk of events.
|
||||
private const POOL_RADIUS_KM = 100;
|
||||
private const POOL_LIMIT = 3000;
|
||||
|
||||
#[Route('/', name: 'home')]
|
||||
public function index(
|
||||
EventRepository $events,
|
||||
CategoryRepository $categories,
|
||||
EventMapSerializer $mapSerializer
|
||||
): Response {
|
||||
// Same fallback as /map: default to Köln until real geolocation
|
||||
// (checked client-side in map.js) says otherwise.
|
||||
$lat = DefaultLocation::LAT;
|
||||
$lon = DefaultLocation::LON;
|
||||
|
||||
$pool = $events->findWithinRadius($lat, $lon, self::POOL_RADIUS_KM, self::POOL_LIMIT);
|
||||
|
||||
return $this->render('home.html.twig', [
|
||||
'activeNav' => 'home',
|
||||
'upcomingEvents' => $events->findRecent(6),
|
||||
'mapEventsJson' => $mapSerializer->toJson($pool),
|
||||
'popularCategories' => $categories->findMostUsedNames(12),
|
||||
'centerLat' => $lat,
|
||||
'centerLon' => $lon,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Repository\CategoryRepository;
|
||||
use App\Repository\EventRepository;
|
||||
use App\Service\DefaultLocation;
|
||||
use App\Service\EventMapSerializer;
|
||||
use App\Service\RangeDefaults;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class MapController extends AbstractController
|
||||
{
|
||||
// Safety cap on the pool shipped to the client, not a practical limit:
|
||||
// even the densest 100km radius seen (NRW around Köln) comes in under
|
||||
// 3000, so this should never actually truncate in normal use.
|
||||
private const POOL_LIMIT = 3000;
|
||||
|
||||
#[Route('/map', name: 'map')]
|
||||
public function index(
|
||||
Request $request,
|
||||
EventRepository $events,
|
||||
CategoryRepository $categories,
|
||||
EventMapSerializer $mapSerializer
|
||||
): Response {
|
||||
$lat = (float) ($request->query->get('lat') ?: DefaultLocation::LAT);
|
||||
$lon = (float) ($request->query->get('lon') ?: DefaultLocation::LON);
|
||||
|
||||
$rangeKm = (int) $request->query->get('range', RangeDefaults::DEFAULT_KM);
|
||||
if ($rangeKm <= 0 || $rangeKm > RangeDefaults::MAX_KM) {
|
||||
$rangeKm = RangeDefaults::DEFAULT_KM;
|
||||
}
|
||||
|
||||
// Ship the pool sized to the *max* selectable range so the range
|
||||
// dropdown and small map clicks/drags can be handled entirely
|
||||
// client-side, without another page load.
|
||||
$pool = $events->findWithinRadius($lat, $lon, RangeDefaults::MAX_KM, self::POOL_LIMIT);
|
||||
|
||||
return $this->render('map.html.twig', [
|
||||
'activeNav' => 'map',
|
||||
'mapEventsJson' => $mapSerializer->toJson($pool),
|
||||
'categories' => $categories->findUsedNames(),
|
||||
'centerLat' => $lat,
|
||||
'centerLon' => $lon,
|
||||
'rangeKm' => $rangeKm,
|
||||
'rangeOptions' => RangeDefaults::OPTIONS_KM,
|
||||
'maxRangeKm' => RangeDefaults::MAX_KM,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Repository\CategoryRepository;
|
||||
use App\Repository\EventRepository;
|
||||
use App\Service\RangeDefaults;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class SearchController extends AbstractController
|
||||
{
|
||||
private const PER_PAGE = 24;
|
||||
|
||||
#[Route('/search', name: 'search')]
|
||||
public function index(Request $request, EventRepository $events, CategoryRepository $categories): Response
|
||||
{
|
||||
$q = trim((string) $request->query->get('q', ''));
|
||||
$category = $request->query->get('category') ?: null;
|
||||
$when = $request->query->get('when') ?: 'any';
|
||||
$page = max(1, $request->query->getInt('page', 1));
|
||||
|
||||
// Location filtering is opt-in — plain text/category search behaves
|
||||
// exactly as before unless a place/"use my location" actually set
|
||||
// lat/lon, unlike /map which always has *some* center.
|
||||
$latParam = $request->query->get('lat');
|
||||
$lonParam = $request->query->get('lon');
|
||||
$hasLocation = null !== $latParam && null !== $lonParam && '' !== $latParam && '' !== $lonParam;
|
||||
$lat = $hasLocation ? (float) $latParam : null;
|
||||
$lon = $hasLocation ? (float) $lonParam : null;
|
||||
|
||||
$rangeKm = (int) $request->query->get('range', RangeDefaults::DEFAULT_KM);
|
||||
if ($rangeKm <= 0 || $rangeKm > RangeDefaults::MAX_KM) {
|
||||
$rangeKm = RangeDefaults::DEFAULT_KM;
|
||||
}
|
||||
|
||||
$distances = [];
|
||||
$locationIds = null;
|
||||
if ($hasLocation) {
|
||||
$distances = $events->distancesWithinRadius($lat, $lon, $rangeKm, 3000);
|
||||
$locationIds = array_keys($distances);
|
||||
}
|
||||
|
||||
$offset = ($page - 1) * self::PER_PAGE;
|
||||
$results = $events->search($q ?: null, $category, $when, self::PER_PAGE, $offset, $locationIds);
|
||||
$total = $events->countSearch($q ?: null, $category, $when, $locationIds);
|
||||
|
||||
return $this->render('search.html.twig', [
|
||||
'activeNav' => 'search',
|
||||
'q' => $q,
|
||||
'events' => $results,
|
||||
'categories' => $categories->findUsedNames(),
|
||||
'activeCategory' => $category,
|
||||
'when' => $when,
|
||||
'hasLocation' => $hasLocation,
|
||||
'lat' => $lat,
|
||||
'lon' => $lon,
|
||||
'rangeKm' => $rangeKm,
|
||||
'rangeOptions' => RangeDefaults::OPTIONS_KM,
|
||||
'distances' => $distances,
|
||||
'page' => $page,
|
||||
'totalPages' => max(1, (int) ceil($total / self::PER_PAGE)),
|
||||
'total' => $total,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\CategoryRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: CategoryRepository::class)]
|
||||
#[ORM\Table(name: 'categories')]
|
||||
class Category
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $name = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\EventRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: EventRepository::class)]
|
||||
#[ORM\Table(name: 'events')]
|
||||
class Event
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(name: 'external_id', type: 'string', length: 255, nullable: true)]
|
||||
private ?string $externalId = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $title = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $city = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $street = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $country = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $type = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $zip = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $phone = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $web = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $email = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $author = null;
|
||||
|
||||
#[ORM\Column(type: 'float', nullable: true)]
|
||||
private ?float $latitude = null;
|
||||
|
||||
#[ORM\Column(type: 'float', nullable: true)]
|
||||
private ?float $longitude = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $teaser = null;
|
||||
|
||||
#[ORM\Column(name: 'start_date', type: 'string', length: 255, nullable: true)]
|
||||
private ?string $startDate = null;
|
||||
|
||||
#[ORM\Column(name: 'end_date', type: 'string', length: 255, nullable: true)]
|
||||
private ?string $endDate = null;
|
||||
|
||||
#[ORM\Column(name: 'deleted_at', type: 'datetime', nullable: true)]
|
||||
private ?\DateTimeInterface $deletedAt = null;
|
||||
|
||||
#[ORM\ManyToMany(targetEntity: Category::class)]
|
||||
#[ORM\JoinTable(name: 'event_categories')]
|
||||
#[ORM\JoinColumn(name: 'event_id', referencedColumnName: 'id')]
|
||||
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id')]
|
||||
private Collection $categories;
|
||||
|
||||
#[ORM\ManyToMany(targetEntity: MediaObject::class)]
|
||||
#[ORM\JoinTable(name: 'event_media_objects')]
|
||||
#[ORM\JoinColumn(name: 'event_id', referencedColumnName: 'id')]
|
||||
#[ORM\InverseJoinColumn(name: 'media_object_id', referencedColumnName: 'id')]
|
||||
private Collection $mediaObjects;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->categories = new ArrayCollection();
|
||||
$this->mediaObjects = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getExternalId(): ?string
|
||||
{
|
||||
return $this->externalId;
|
||||
}
|
||||
|
||||
public function getTitle(): ?string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function getCity(): ?string
|
||||
{
|
||||
return $this->city;
|
||||
}
|
||||
|
||||
public function getStreet(): ?string
|
||||
{
|
||||
return $this->street;
|
||||
}
|
||||
|
||||
public function getCountry(): ?string
|
||||
{
|
||||
return $this->country;
|
||||
}
|
||||
|
||||
public function getType(): ?string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function getZip(): ?string
|
||||
{
|
||||
return $this->zip;
|
||||
}
|
||||
|
||||
public function getPhone(): ?string
|
||||
{
|
||||
return $this->phone;
|
||||
}
|
||||
|
||||
public function getWeb(): ?string
|
||||
{
|
||||
return $this->web;
|
||||
}
|
||||
|
||||
public function getEmail(): ?string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function getAuthor(): ?string
|
||||
{
|
||||
return $this->author;
|
||||
}
|
||||
|
||||
public function getLatitude(): ?float
|
||||
{
|
||||
return $this->latitude;
|
||||
}
|
||||
|
||||
public function getLongitude(): ?float
|
||||
{
|
||||
return $this->longitude;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function getTeaser(): ?string
|
||||
{
|
||||
return $this->teaser;
|
||||
}
|
||||
|
||||
public function getStartDate(): ?string
|
||||
{
|
||||
return $this->startDate;
|
||||
}
|
||||
|
||||
public function getEndDate(): ?string
|
||||
{
|
||||
return $this->endDate;
|
||||
}
|
||||
|
||||
public function getDeletedAt(): ?\DateTimeInterface
|
||||
{
|
||||
return $this->deletedAt;
|
||||
}
|
||||
|
||||
/** @return Collection<int, Category> */
|
||||
public function getCategories(): Collection
|
||||
{
|
||||
return $this->categories;
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
public function getCategoryNames(): array
|
||||
{
|
||||
return array_values(array_filter(array_map(
|
||||
static fn (Category $category) => $category->getName(),
|
||||
$this->categories->toArray()
|
||||
)));
|
||||
}
|
||||
|
||||
/** @return Collection<int, MediaObject> */
|
||||
public function getMediaObjects(): Collection
|
||||
{
|
||||
return $this->mediaObjects;
|
||||
}
|
||||
|
||||
public function getPrimaryImage(): ?MediaObject
|
||||
{
|
||||
foreach ($this->mediaObjects as $mediaObject) {
|
||||
if ($mediaObject->isImage()) {
|
||||
return $mediaObject;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function hasCoordinates(): bool
|
||||
{
|
||||
return null !== $this->latitude
|
||||
&& null !== $this->longitude
|
||||
&& (0.0 !== $this->latitude || 0.0 !== $this->longitude);
|
||||
}
|
||||
|
||||
public function getStartDateTime(): ?\DateTimeImmutable
|
||||
{
|
||||
if (!$this->startDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new \DateTimeImmutable($this->startDate);
|
||||
} catch (\Exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function getEndDateTime(): ?\DateTimeImmutable
|
||||
{
|
||||
if (!$this->endDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new \DateTimeImmutable($this->endDate);
|
||||
} catch (\Exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'media_objects')]
|
||||
class MediaObject
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $url = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $alt = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $rel = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $name = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $type = null;
|
||||
|
||||
#[ORM\Column(name: 'ref_id', type: 'string', length: 255, nullable: true)]
|
||||
private ?string $refId = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $source = null;
|
||||
|
||||
#[ORM\Column(type: 'integer', nullable: true)]
|
||||
private ?int $prio = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $license = null;
|
||||
|
||||
#[ORM\Column(name: 'ai_optimized', type: 'boolean', nullable: true)]
|
||||
private ?bool $aiOptimized = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $ext = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $hash = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUrl(): ?string
|
||||
{
|
||||
return $this->url;
|
||||
}
|
||||
|
||||
public function getAlt(): ?string
|
||||
{
|
||||
return $this->alt;
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function getType(): ?string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function getSource(): ?string
|
||||
{
|
||||
return $this->source;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function getLicense(): ?string
|
||||
{
|
||||
return $this->license;
|
||||
}
|
||||
|
||||
public function getExt(): ?string
|
||||
{
|
||||
return $this->ext;
|
||||
}
|
||||
|
||||
public function getHash(): ?string
|
||||
{
|
||||
return $this->hash;
|
||||
}
|
||||
|
||||
public function isImage(): bool
|
||||
{
|
||||
return str_starts_with($this->type ?? '', 'image/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches the path EventRepository.saveMediaFile writes to in the Go
|
||||
* importer: ./public/storage/<mediatype>/<hash><ext>.
|
||||
*/
|
||||
public function getStoragePath(): ?string
|
||||
{
|
||||
if (!$this->hash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mediaType = strtolower((string) (strstr($this->type ?? '', '/', true) ?: 'other'));
|
||||
|
||||
return sprintf('/storage/%s/%s%s', $mediaType, $this->hash, $this->ext);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||
|
||||
class Kernel extends BaseKernel
|
||||
{
|
||||
use MicroKernelTrait;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Category;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class CategoryRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Category::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Category names actually attached to at least one non-deleted event —
|
||||
* the plain categories table can contain names no current event uses.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function findUsedNames(): array
|
||||
{
|
||||
$sql = 'SELECT DISTINCT c.name FROM categories c
|
||||
INNER JOIN event_categories ec ON ec.category_id = c.id
|
||||
INNER JOIN events e ON e.id = ec.event_id AND e.deleted_at IS NULL
|
||||
WHERE c.name IS NOT NULL AND c.name <> \'\'
|
||||
ORDER BY c.name ASC';
|
||||
|
||||
return $this->getEntityManager()->getConnection()->executeQuery($sql)->fetchFirstColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Category names ordered by how many non-deleted events use them —
|
||||
* for a curated "browse by category" section, where alphabetical order
|
||||
* would surface obscure tags ahead of common ones.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function findMostUsedNames(int $limit = 12): array
|
||||
{
|
||||
$sql = 'SELECT c.name FROM categories c
|
||||
INNER JOIN event_categories ec ON ec.category_id = c.id
|
||||
INNER JOIN events e ON e.id = ec.event_id AND e.deleted_at IS NULL
|
||||
WHERE c.name IS NOT NULL AND c.name <> \'\'
|
||||
GROUP BY c.name
|
||||
ORDER BY COUNT(*) DESC, c.name ASC
|
||||
LIMIT :limit';
|
||||
|
||||
return $this->getEntityManager()->getConnection()
|
||||
->executeQuery($sql, ['limit' => $limit], ['limit' => ParameterType::INTEGER])
|
||||
->fetchFirstColumn();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Event;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class EventRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Event::class);
|
||||
}
|
||||
|
||||
public function findOneNotDeleted(int $id): ?Event
|
||||
{
|
||||
return $this->notDeleted()
|
||||
->andWhere('e.id = :id')
|
||||
->setParameter('id', $id)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/** @return Event[] */
|
||||
public function findRecent(int $limit): array
|
||||
{
|
||||
return $this->orderByStartDate($this->notDeleted())
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/** @return Event[] */
|
||||
public function findPage(int $limit, int $offset, ?string $category = null): array
|
||||
{
|
||||
$qb = $this->orderByStartDate($this->notDeleted())
|
||||
->setMaxResults($limit)
|
||||
->setFirstResult($offset);
|
||||
|
||||
$this->filterByCategory($qb, $category);
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function countPage(?string $category = null): int
|
||||
{
|
||||
$qb = $this->notDeleted()->select('COUNT(DISTINCT e.id)');
|
||||
$this->filterByCategory($qb, $category);
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/** @return Event[] */
|
||||
public function findWithCoordinates(int $limit = 500): array
|
||||
{
|
||||
return $this->notDeleted()
|
||||
->andWhere('e.latitude IS NOT NULL AND e.longitude IS NOT NULL')
|
||||
->andWhere('e.latitude <> 0 OR e.longitude <> 0')
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance in km to every event within `radiusKm` of (lat, lon),
|
||||
* nearest first, keyed by event id — the location+range based
|
||||
* replacement for a flat "first N" cap. No PostGIS here, so distance
|
||||
* is a plain Haversine formula in raw SQL (DQL has no trig
|
||||
* functions); a computed column can't be filtered in WHERE directly,
|
||||
* hence the subquery.
|
||||
*
|
||||
* @return array<int, float>
|
||||
*/
|
||||
public function distancesWithinRadius(float $lat, float $lon, float $radiusKm, int $limit = 500): array
|
||||
{
|
||||
$sql = 'SELECT id, distance_km FROM (
|
||||
SELECT e.id,
|
||||
(6371 * acos(least(1, greatest(-1,
|
||||
cos(radians(:lat)) * cos(radians(e.latitude)) * cos(radians(e.longitude) - radians(:lon))
|
||||
+ sin(radians(:lat)) * sin(radians(e.latitude))
|
||||
)))) AS distance_km
|
||||
FROM events e
|
||||
WHERE e.deleted_at IS NULL
|
||||
AND e.latitude IS NOT NULL AND e.longitude IS NOT NULL
|
||||
AND (e.latitude <> 0 OR e.longitude <> 0)
|
||||
) sub
|
||||
WHERE distance_km <= :radius
|
||||
ORDER BY distance_km ASC
|
||||
LIMIT :limit';
|
||||
|
||||
$rows = $this->getEntityManager()->getConnection()->executeQuery(
|
||||
$sql,
|
||||
['lat' => $lat, 'lon' => $lon, 'radius' => $radiusKm, 'limit' => $limit],
|
||||
['lat' => ParameterType::STRING, 'lon' => ParameterType::STRING, 'radius' => ParameterType::STRING, 'limit' => ParameterType::INTEGER]
|
||||
)->fetchAllAssociative();
|
||||
|
||||
$distances = [];
|
||||
foreach ($rows as $row) {
|
||||
$distances[(int) $row['id']] = (float) $row['distance_km'];
|
||||
}
|
||||
|
||||
return $distances;
|
||||
}
|
||||
|
||||
/**
|
||||
* Events within `radiusKm` of (lat, lon), nearest first.
|
||||
*
|
||||
* @return Event[]
|
||||
*/
|
||||
public function findWithinRadius(float $lat, float $lon, float $radiusKm, int $limit = 500): array
|
||||
{
|
||||
return $this->hydrateInOrder(array_keys($this->distancesWithinRadius($lat, $lon, $radiusKm, $limit)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Text/category search, paginated, optionally restricted to (and
|
||||
* ordered by) a pre-computed set of location-filtered ids — e.g. from
|
||||
* distancesWithinRadius() — so "markets near Düsseldorf" combines a
|
||||
* normal LIKE/category filter with the radius query rather than
|
||||
* needing a second, separate search path. Without $locationIds this
|
||||
* behaves like a plain text/category search, ordered chronologically.
|
||||
*
|
||||
* Distance order only exists in PHP (the $locationIds array), not as
|
||||
* a sortable DB column, so paging has to slice that ordered id list
|
||||
* *after* intersecting it with the text/category matches — pushing
|
||||
* LIMIT/OFFSET into SQL here would paginate an arbitrary DB-ordered
|
||||
* subset instead of the correct "25th-50th nearest" slice.
|
||||
*
|
||||
* @param int[]|null $locationIds
|
||||
*
|
||||
* @return Event[]
|
||||
*/
|
||||
public function search(?string $q, ?string $category, string $when, int $limit, int $offset, ?array $locationIds = null): array
|
||||
{
|
||||
if (null === $locationIds) {
|
||||
return $this->orderByStartDate($this->baseSearchQb($q, $category, $when))
|
||||
->setMaxResults($limit)
|
||||
->setFirstResult($offset)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
if (!$locationIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$matchingIds = array_map('intval', array_column(
|
||||
$this->baseSearchQb($q, $category, $when)
|
||||
->select('e.id')
|
||||
->andWhere('e.id IN (:locationIds)')
|
||||
->setParameter('locationIds', $locationIds)
|
||||
->getQuery()
|
||||
->getScalarResult(),
|
||||
'id'
|
||||
));
|
||||
|
||||
$matchingIdSet = array_flip($matchingIds);
|
||||
$orderedIds = array_values(array_filter($locationIds, static fn ($id) => isset($matchingIdSet[$id])));
|
||||
|
||||
return $this->hydrateInOrder(array_slice($orderedIds, $offset, $limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* Total count matching the same filters as search(), for pagination.
|
||||
*
|
||||
* @param int[]|null $locationIds
|
||||
*/
|
||||
public function countSearch(?string $q, ?string $category, string $when, ?array $locationIds = null): int
|
||||
{
|
||||
if (null !== $locationIds && !$locationIds) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$qb = $this->baseSearchQb($q, $category, $when)->select('COUNT(DISTINCT e.id)');
|
||||
|
||||
if (null !== $locationIds) {
|
||||
$qb->andWhere('e.id IN (:locationIds)')->setParameter('locationIds', $locationIds);
|
||||
}
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
private function baseSearchQb(?string $q, ?string $category, string $when): QueryBuilder
|
||||
{
|
||||
$qb = $this->notDeleted();
|
||||
|
||||
if ($q) {
|
||||
$qb->andWhere('LOWER(e.title) LIKE :q OR LOWER(e.city) LIKE :q OR LOWER(e.street) LIKE :q')
|
||||
->setParameter('q', '%'.mb_strtolower($q).'%');
|
||||
}
|
||||
|
||||
$this->filterByCategory($qb, $category);
|
||||
$this->filterByWhen($qb, $when);
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* startDate is a raw ISO-8601-ish string ("2026-09-12T18:00:00+02:00"),
|
||||
* consistently formatted across the imported dataset, so a plain
|
||||
* lexicographic string comparison against a "Y-m-d" boundary sorts
|
||||
* correctly without needing to parse it as a real date/time column.
|
||||
* Applied in SQL (not PHP, post-fetch) so it composes correctly with
|
||||
* pagination and COUNT — filtering after LIMIT/OFFSET would make the
|
||||
* page size and total count lie.
|
||||
*/
|
||||
private function filterByWhen(QueryBuilder $qb, string $when): void
|
||||
{
|
||||
if ('any' === $when) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Explicit timezone, not the container's (UTC): the imported dates
|
||||
// and the site's audience are both German-local, so "today" needs
|
||||
// to mean the German calendar day, not whatever day it happens to
|
||||
// be in UTC — those disagree for two hours after every UTC
|
||||
// midnight.
|
||||
$start = new \DateTimeImmutable('today', new \DateTimeZone('Europe/Berlin'));
|
||||
$end = match ($when) {
|
||||
'today' => $start->modify('+1 day'),
|
||||
'week' => $start->modify('+7 days'),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (!$end) {
|
||||
return;
|
||||
}
|
||||
|
||||
$qb->andWhere('e.startDate >= :whenStart AND e.startDate < :whenEnd')
|
||||
->setParameter('whenStart', $start->format('Y-m-d'))
|
||||
->setParameter('whenEnd', $end->format('Y-m-d'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrates entities for the given ids and returns them in that exact
|
||||
* order — Doctrine's IN(...) doesn't preserve it.
|
||||
*
|
||||
* @param int[] $ids
|
||||
*
|
||||
* @return Event[]
|
||||
*/
|
||||
private function hydrateInOrder(array $ids): array
|
||||
{
|
||||
if (!$ids) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$events = $this->createQueryBuilder('e')
|
||||
->andWhere('e.id IN (:ids)')
|
||||
->setParameter('ids', $ids)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$order = array_flip($ids);
|
||||
usort($events, static fn (Event $a, Event $b) => $order[$a->getId()] <=> $order[$b->getId()]);
|
||||
|
||||
return $events;
|
||||
}
|
||||
|
||||
private function filterByCategory(QueryBuilder $qb, ?string $category): void
|
||||
{
|
||||
if (!$category) {
|
||||
return;
|
||||
}
|
||||
|
||||
$qb->join('e.categories', 'c')
|
||||
->andWhere('c.name = :category')
|
||||
->setParameter('category', $category);
|
||||
}
|
||||
|
||||
private function notDeleted(): QueryBuilder
|
||||
{
|
||||
return $this->createQueryBuilder('e')
|
||||
->andWhere('e.deletedAt IS NULL');
|
||||
}
|
||||
|
||||
/**
|
||||
* startDate is a raw ISO-ish string column (may be empty when the
|
||||
* source had no time interval); push undated events to the end
|
||||
* instead of letting '' sort first.
|
||||
*/
|
||||
private function orderByStartDate(QueryBuilder $qb): QueryBuilder
|
||||
{
|
||||
return $qb->addSelect("CASE WHEN e.startDate = '' THEN 1 ELSE 0 END AS HIDDEN has_no_date")
|
||||
->orderBy('has_no_date', 'ASC')
|
||||
->addOrderBy('e.startDate', 'ASC');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
/**
|
||||
* Köln — the site-wide fallback map center used whenever no real location
|
||||
* (browser geolocation, a place search, or a URL) is available yet. Shared
|
||||
* by HomeController and MapController so the "nearest 20" light map and
|
||||
* the full /map explorer agree on the same default.
|
||||
*/
|
||||
final class DefaultLocation
|
||||
{
|
||||
public const LAT = 50.9375;
|
||||
public const LON = 6.9603;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Event;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
/**
|
||||
* Serializes Event entities into the plain JSON array the Leaflet stage
|
||||
* (public/js/map.js) reads — used by Home, Map, and Event::show, so pulled
|
||||
* out once rather than duplicated per controller.
|
||||
*/
|
||||
class EventMapSerializer
|
||||
{
|
||||
public function __construct(private UrlGeneratorInterface $urlGenerator)
|
||||
{
|
||||
}
|
||||
|
||||
/** @param Event[] $events */
|
||||
public function toJson(array $events): string
|
||||
{
|
||||
return json_encode(
|
||||
array_map($this->toPoint(...), $events),
|
||||
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
|
||||
);
|
||||
}
|
||||
|
||||
private function toPoint(Event $event): array
|
||||
{
|
||||
return [
|
||||
'id' => $event->getId(),
|
||||
'title' => $event->getTitle(),
|
||||
'venue' => $event->getCity(),
|
||||
'category' => $event->getCategoryNames()[0] ?? null,
|
||||
'lat' => $event->getLatitude(),
|
||||
'lon' => $event->getLongitude(),
|
||||
'url' => $this->urlGenerator->generate('event_show', ['id' => $event->getId()]),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
/**
|
||||
* Shared radius options for every location-based filter (the /map
|
||||
* explorer and /search's "near a location" filter) — one set of
|
||||
* dropdown choices and bounds instead of each controller inventing its
|
||||
* own.
|
||||
*/
|
||||
final class RangeDefaults
|
||||
{
|
||||
public const DEFAULT_KM = 25;
|
||||
public const MAX_KM = 100;
|
||||
public const OPTIONS_KM = [5, 10, 25, 50, 100];
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Twig;
|
||||
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFilter;
|
||||
|
||||
/**
|
||||
* Real ODH category names are German tourism-taxonomy strings, not a fixed
|
||||
* set of slugs, so colors are derived deterministically from the name
|
||||
* instead of a hardcoded lookup table.
|
||||
*/
|
||||
class CategoryColorExtension extends AbstractExtension
|
||||
{
|
||||
public function getFilters(): array
|
||||
{
|
||||
return [
|
||||
new TwigFilter('category_color', $this->categoryColor(...)),
|
||||
new TwigFilter('category_bg', $this->categoryBg(...)),
|
||||
];
|
||||
}
|
||||
|
||||
public function categoryColor(?string $name): string
|
||||
{
|
||||
if (!$name) {
|
||||
return 'hsl(220, 10%, 55%)';
|
||||
}
|
||||
|
||||
return sprintf('hsl(%d, 62%%, 42%%)', $this->hue($name));
|
||||
}
|
||||
|
||||
public function categoryBg(?string $name): string
|
||||
{
|
||||
if (!$name) {
|
||||
return 'hsl(220, 15%, 92%)';
|
||||
}
|
||||
|
||||
return sprintf('hsl(%d, 55%%, 92%%)', $this->hue($name));
|
||||
}
|
||||
|
||||
private function hue(string $name): int
|
||||
{
|
||||
return crc32($name) % 360;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user