diff --git a/frontend/public/js/map.js b/frontend/public/js/map.js index 39daae2..8238632 100644 --- a/frontend/public/js/map.js +++ b/frontend/public/js/map.js @@ -29,12 +29,20 @@ return null; } - function saveLastLocation(lat, lon) { + function saveLastLocation(lat, lon, name) { try { - if (window.localStorage) localStorage.setItem(LAST_LOCATION_KEY, JSON.stringify({ lat: lat, lon: lon })); + if (window.localStorage) localStorage.setItem(LAST_LOCATION_KEY, JSON.stringify({ lat: lat, lon: lon, name: name || null })); } catch (e) { /* localStorage disabled/unavailable — nothing to persist to */ } } + // Mirrors whatever setUserLocation/setSource just resolved (a searched + // place name, or nothing for GPS/a map click) into the location search + // field, so it doesn't just sit there blank after the fact. + function setLocationInputValue(name) { + var input = document.getElementById('locationSearch'); + if (input) input.value = name || ''; + } + function setLocateStatus(statusEl, text, isError) { if (!statusEl) return; statusEl.textContent = text; @@ -482,9 +490,10 @@ }); } - function setUserLocation(lat, lon, statusText) { + function setUserLocation(lat, lon, statusText, placeName) { state.userLoc = [lat, lon]; - saveLastLocation(lat, lon); + saveLastLocation(lat, lon, placeName); + setLocationInputValue(placeName); if (limit) rebuildMarkers(nearest([lat, lon], allEvents.slice(), limit)); @@ -512,7 +521,7 @@ // A manual search/click may have already happened while this // (silent, permission-check-triggered) lookup was still resolving. if (isAutoCheck && manualLocationSet) return; - setUserLocation(pos.coords.latitude, pos.coords.longitude, limit ? ('Showing the ' + limit + ' nearest events') : 'Showing events near you'); + setUserLocation(pos.coords.latitude, pos.coords.longitude, limit ? ('Showing the ' + limit + ' nearest events') : 'Showing events near you', 'Your location'); }, function () { setLocateStatus(statusEl, "Couldn't get your location", true); }, { timeout: 8000 }); @@ -540,7 +549,7 @@ setLocateStatus(statusEl, 'Could not find "' + query + '"', true); return; } - setUserLocation(parseFloat(results[0].lat), parseFloat(results[0].lon), limit ? ('Showing the ' + limit + ' nearest events') : 'Showing events near you'); + setUserLocation(parseFloat(results[0].lat), parseFloat(results[0].lon), limit ? ('Showing the ' + limit + ' nearest events') : 'Showing events near you', results[0].display_name); }) .catch(function () { setLocateStatus(statusEl, 'Location search failed', true); @@ -554,7 +563,7 @@ var lastLocation = loadLastLocation(); if (lastLocation) { manualLocationSet = true; - setUserLocation(lastLocation.lat, lastLocation.lon, limit ? ('Showing the ' + limit + ' nearest events') : 'Showing events near you'); + setUserLocation(lastLocation.lat, lastLocation.lon, limit ? ('Showing the ' + limit + ' nearest events') : 'Showing events near you', lastLocation.name); } else if (navigator.permissions && navigator.permissions.query) { // If the user already granted location access (on this or an earlier // visit), center on it automatically — this only *reads* the current @@ -603,10 +612,6 @@ attributionControl: true, center: state.source, zoom: opts.zoom || 11, - // This map already owns the whole viewport (no surrounding page to - // protect from trapped scroll/pinch), so gesture handling would only - // make one-finger dragging feel unresponsive for no benefit. - gestureHandling: false, onMarkerClick: function (ev) { highlightRow(ev.id); } }); var map = result ? result.map : null; @@ -724,8 +729,9 @@ // new point itself is inside it, so the check is deliberately // conservative: reload whenever full coverage isn't guaranteed, // rather than risk silently showing an incomplete result. - function setSource(lat, lon) { - saveLastLocation(lat, lon); + function setSource(lat, lon, placeName) { + saveLastLocation(lat, lon, placeName); + setLocationInputValue(placeName); var distFromOriginal = distanceKm(originalCenter[0], originalCenter[1], lat, lon); if (distFromOriginal + state.rangeKm > maxRangeKm) { var url = new URL(window.location.href); @@ -743,13 +749,15 @@ } if (map) { - map.on('click', function (e) { manualLocationSet = true; setSource(e.latlng.lat, e.latlng.lng); }); + // A clicked point has no place name — clears any stale searched name + // that would otherwise sit next to coordinates it no longer describes. + map.on('click', function (e) { manualLocationSet = true; setSource(e.latlng.lat, e.latlng.lng, null); }); } if (sourceMarker) { sourceMarker.on('dragend', function () { manualLocationSet = true; var ll = sourceMarker.getLatLng(); - setSource(ll.lat, ll.lng); + setSource(ll.lat, ll.lng, null); }); } @@ -792,7 +800,7 @@ setLocateStatus(statusEl, 'Could not find "' + query + '"', true); return; } - setSource(parseFloat(results[0].lat), parseFloat(results[0].lon)); + setSource(parseFloat(results[0].lat), parseFloat(results[0].lon), results[0].display_name); }) .catch(function () { setLocateStatus(statusEl, 'Location search failed', true); @@ -811,7 +819,7 @@ // A manual search/click/drag may have already happened while this // (silent, permission-check-triggered) lookup was still resolving. if (isAutoCheck && manualLocationSet) return; - setSource(pos.coords.latitude, pos.coords.longitude); + setSource(pos.coords.latitude, pos.coords.longitude, 'Your location'); }, function () { setLocateStatus(statusEl, "Couldn't get your location", true); }, { timeout: 8000 }); @@ -820,6 +828,13 @@ var locateBtn = document.getElementById('locateBtn'); if (locateBtn) locateBtn.addEventListener('click', function () { manualLocationSet = true; requestLocation(false); }); + // A page load landing with an explicit ?lat= (e.g. after setSource's + // own reload-threshold redirect, or a shared link) can't carry the + // place name in the URL — fill the field back in from localStorage + // regardless of whether that load also needs the redirect below. + var savedForName = loadLastLocation(); + if (savedForName && savedForName.name) setLocationInputValue(savedForName.name); + // An explicit ?lat=/&lon= in the URL (e.g. a shared link) always wins. // Otherwise, a remembered search/geolocation takes priority over asking // the browser again — restoring it counts as the user's own manual @@ -827,10 +842,10 @@ // clobber it. If it's far enough from this page's own server-resolved // center, setSource's own reload-threshold logic kicks in as usual. var urlHasExplicitLocation = /[?&]lat=/.test(window.location.search); - var lastLocation = urlHasExplicitLocation ? null : loadLastLocation(); + var lastLocation = urlHasExplicitLocation ? null : savedForName; if (lastLocation) { manualLocationSet = true; - setSource(lastLocation.lat, lastLocation.lon); + setSource(lastLocation.lat, lastLocation.lon, lastLocation.name); } else if (navigator.permissions && navigator.permissions.query) { navigator.permissions.query({ name: 'geolocation' }).then(function (status) { if (status.state === 'granted') requestLocation(true); diff --git a/frontend/public/styles.css b/frontend/public/styles.css index 62da7fd..0e2fc75 100644 --- a/frontend/public/styles.css +++ b/frontend/public/styles.css @@ -352,13 +352,14 @@ h1,h2,h3{ font-family: var(--f-display); font-weight:700; letter-spacing:-0.01em pointer-events:auto; } .search-card{ padding:14px 16px; flex:1; max-width:560px; } -.search-card form{ display:flex; gap:10px; margin-bottom:12px; } -.search-card input{ flex:1; padding:11px 14px; border:1px solid var(--line-strong); border-radius:var(--r-pill); font-size:14px; } .chip-row-tight{ display:flex; gap:8px; flex-wrap:wrap; max-height:64px; overflow-y:auto; } .chip-sm{ font-family:var(--f-mono); font-size:11px; padding:6px 12px; border-radius:var(--r-pill); border:1px solid var(--line-strong); background:#fff; color:var(--ink-soft); } .chip-sm.is-active{ background:var(--ink); color:var(--paper); border-color:var(--ink); } -.locate-card{ padding:10px; flex:none; width:220px; } -#locationSearchForm{ display:flex; gap:6px; margin-bottom:8px; } +/* margin-left:auto anchors it to the right within map-overlay-top's flex + row regardless of whether .search-card (hidden in compact mode) precedes + it — space-between alone only works when both cards are present. */ +.locate-card{ padding:10px; flex:none; width:220px; margin-left:auto; } +#locationSearchForm{ display:flex; gap:6px; } #locationSearchForm input{ flex:1; min-width:0; padding:9px 12px; border:1px solid var(--line-strong); border-radius:var(--r-pill); font-size:12px; } .locate-btn{ display:flex; align-items:center; gap:8px; background:var(--ink); color:var(--paper); diff --git a/frontend/src/Controller/EventController.php b/frontend/src/Controller/EventController.php index dbcef64..3404ed2 100644 --- a/frontend/src/Controller/EventController.php +++ b/frontend/src/Controller/EventController.php @@ -5,6 +5,7 @@ namespace App\Controller; use App\Repository\CategoryRepository; use App\Repository\EventRepository; use App\Service\EventMapSerializer; +use App\Service\RangeDefaults; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -13,22 +14,79 @@ use Symfony\Component\Routing\Attribute\Route; class EventController extends AbstractController { private const PER_PAGE = 24; + private const SORTS = ['soonest', 'nearest', 'az', 'za']; + // Browse events and the old standalone /search page did the same job + // (text/category/when/location filtering over the same event set) — + // merged here rather than kept as two near-duplicate controllers. #[Route('/events', name: 'event_list')] public function list(Request $request, EventRepository $events, CategoryRepository $categories): Response { - $page = max(1, $request->query->getInt('page', 1)); + $q = trim((string) $request->query->get('q', '')); $category = $request->query->get('category') ?: null; - $offset = (($page ?: 1) - 1) * self::PER_PAGE; + $when = $request->query->get('when') ?: 'any'; + $page = max(1, $request->query->getInt('page', 1)); - $items = $events->findPage(self::PER_PAGE, $offset, $category); - $total = $events->countPage($category); + // Location filtering is opt-in — plain text/category browsing + // behaves exactly as before unless a place/"use my location" + // actually set lat/lon. + $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); + } + + // "Nearest" only means anything with a location active — default to + // it there (matches this page's previous, sort-less behavior), + // otherwise fall back to chronological. + $defaultSort = $hasLocation ? 'nearest' : 'soonest'; + $sort = $request->query->get('sort') ?: $defaultSort; + if (!\in_array($sort, self::SORTS, true)) { + $sort = $defaultSort; + } + + $offset = ($page - 1) * self::PER_PAGE; + $items = $events->search($q ?: null, $category, $when, self::PER_PAGE, $offset, $locationIds, $sort); + $total = $events->countSearch($q ?: null, $category, $when, $locationIds); return $this->render('events/list.html.twig', [ 'activeNav' => 'events', 'events' => $items, 'categories' => $categories->findUsedNames(), 'activeCategory' => $category, + 'q' => $q, + 'when' => $when, + 'hasLocation' => $hasLocation, + 'lat' => $lat, + 'lon' => $lon, + 'rangeKm' => $rangeKm, + 'rangeOptions' => RangeDefaults::OPTIONS_KM, + 'distances' => $distances, + 'sort' => $sort, + // Other active filters, ready to |merge with a one-field + // override in a link — e.g. filters|merge({category: 'Markt'}) + // keeps q/when/location while only changing the category. + 'filters' => [ + 'q' => $q ?: null, + 'category' => $category, + 'when' => 'any' !== $when ? $when : null, + 'lat' => $hasLocation ? $lat : null, + 'lon' => $hasLocation ? $lon : null, + 'range' => $hasLocation ? $rangeKm : null, + 'sort' => $sort !== $defaultSort ? $sort : null, + ], 'page' => $page, 'totalPages' => max(1, (int) ceil($total / self::PER_PAGE)), 'total' => $total, diff --git a/frontend/src/Controller/SearchController.php b/frontend/src/Controller/SearchController.php deleted file mode 100644 index 9a4f1fe..0000000 --- a/frontend/src/Controller/SearchController.php +++ /dev/null @@ -1,68 +0,0 @@ -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, - ]); - } -} diff --git a/frontend/src/Repository/EventRepository.php b/frontend/src/Repository/EventRepository.php index 77a361e..b9aa460 100644 --- a/frontend/src/Repository/EventRepository.php +++ b/frontend/src/Repository/EventRepository.php @@ -33,26 +33,6 @@ class EventRepository extends ServiceEntityRepository ->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 { @@ -127,40 +107,63 @@ class EventRepository extends ServiceEntityRepository * 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. + * subset instead of the correct "25th-50th nearest" slice. That trick + * only applies to $sort === 'nearest' though; the other sorts are real + * DB columns, so they go through a normal ORDER BY + LIMIT/OFFSET + * query instead, restricted to $locationIds the same way but without + * needing to preserve that array's own order. * * @param int[]|null $locationIds * * @return Event[] */ - public function search(?string $q, ?string $category, string $when, int $limit, int $offset, ?array $locationIds = null): array + public function search(?string $q, ?string $category, string $when, int $limit, int $offset, ?array $locationIds = null, string $sort = 'soonest'): array { - if (null === $locationIds) { - return $this->orderByStartDate($this->baseSearchQb($q, $category, $when)) - ->setMaxResults($limit) - ->setFirstResult($offset) - ->getQuery() - ->getResult(); + // "Nearest" only means anything once a location narrowed the pool. + if ('nearest' === $sort && null === $locationIds) { + $sort = 'soonest'; } - if (!$locationIds) { + if ('nearest' === $sort) { + 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)); + } + + if (null !== $locationIds && !$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' - )); + $qb = $this->baseSearchQb($q, $category, $when); + if (null !== $locationIds) { + $qb->andWhere('e.id IN (:locationIds)')->setParameter('locationIds', $locationIds); + } - $matchingIdSet = array_flip($matchingIds); - $orderedIds = array_values(array_filter($locationIds, static fn ($id) => isset($matchingIdSet[$id]))); + match ($sort) { + 'az' => $qb->orderBy('e.title', 'ASC'), + 'za' => $qb->orderBy('e.title', 'DESC'), + default => $this->orderByStartDate($qb), + }; - return $this->hydrateInOrder(array_slice($orderedIds, $offset, $limit)); + return $qb->setMaxResults($limit) + ->setFirstResult($offset) + ->getQuery() + ->getResult(); } /** diff --git a/frontend/templates/_map_stage.html.twig b/frontend/templates/_map_stage.html.twig index 96f4c86..18019ef 100644 --- a/frontend/templates/_map_stage.html.twig +++ b/frontend/templates/_map_stage.html.twig @@ -1,34 +1,29 @@ {# - Reusable interactive map stage: pin map, event search box, "use my + Reusable interactive map stage: pin map, category filter, "use my location" button, and a nearby-events panel. Include on any page that has defined `mapEventsJson` and `categories` in scope, then call Vindn.initMapStage() (home) or Vindn.initRadiusMapStage() (/map) from that page's scripts block. Pass `compact: true` for the lighter home-page hero variant: hides the - category chips/legend and the location-search/range controls, and uses - a contained card height instead of the full-viewport /map takeover. + category filter/legend and the range control, and uses a contained card + height instead of the full-viewport /map takeover. #} {% set compact = compact|default(false) %}
-
-
- - - -
- {% if not compact %} + {% if not compact %} +
{% for category in categories %} {% endfor %}
- {% endif %} -
+
+ {% endif %}
diff --git a/frontend/templates/base.html.twig b/frontend/templates/base.html.twig index bde23fd..39230c2 100644 --- a/frontend/templates/base.html.twig +++ b/frontend/templates/base.html.twig @@ -19,10 +19,9 @@ Home Browse events Map - Search diff --git a/frontend/templates/events/list.html.twig b/frontend/templates/events/list.html.twig index 37cfe4d..4f2eddc 100644 --- a/frontend/templates/events/list.html.twig +++ b/frontend/templates/events/list.html.twig @@ -5,6 +5,18 @@ {% block head_extra %} {% endblock %} @@ -13,21 +25,81 @@

All events

-

What's on{{ activeCategory ? ' — ' ~ activeCategory : '' }}

-

{{ total }} event{{ total == 1 ? '' : 's' }}

+

{{ q ? 'Searching: "' ~ q ~ '"' : "What's on" }}{{ activeCategory ? ' — ' ~ activeCategory : '' }}

+
+

{{ total }} event{{ total == 1 ? '' : 's' }}

+ + +
+ Search + + {% if activeCategory %}{% endif %} + {% if 'any' != when %}{% endif %} + {% if hasLocation %} + + + + {% endif %} + + + + + + Category
- All + All {% for category in categories %} - {{ category }} + {{ category }} {% endfor %}
-
@@ -35,11 +107,11 @@
{% for event in events %} - {% include 'events/_card.html.twig' with { event: event } %} + {% include 'events/_card.html.twig' with { event: event, dist: hasLocation ? distances[event.id]|default(null) : null } %} {% else %}

No events

-

Nothing matches this filter yet — try "All" or check back once the importer has run.

+

Nothing matches those filters yet — try clearing one, or check back once the importer has run.

{% endfor %}
@@ -48,8 +120,155 @@ currentPage: page, totalPages: totalPages, routeName: 'event_list', - routeParams: { category: activeCategory } + routeParams: filters } %}
{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/frontend/templates/home.html.twig b/frontend/templates/home.html.twig index b706119..0266740 100644 --- a/frontend/templates/home.html.twig +++ b/frontend/templates/home.html.twig @@ -72,7 +72,6 @@

Explore

diff --git a/frontend/templates/search.html.twig b/frontend/templates/search.html.twig deleted file mode 100644 index abc2f39..0000000 --- a/frontend/templates/search.html.twig +++ /dev/null @@ -1,230 +0,0 @@ -{% extends 'base.html.twig' %} - -{% block title %}Search — VINDN{% endblock %} - -{% block head_extra %} - -{% endblock %} - -{% block body %} -
-
-

Search

-

- {{ q ? 'Searching: "' ~ q ~ '"' : 'Search events' }} -

- -
- {% if activeCategory %}{% endif %} - {% if hasLocation %} - - - - {% endif %} - - - -
-
-
- -
-
-
- - - - - -
-
-

- {{ total }} EVENT{{ total == 1 ? '' : 'S' }} MATCH{{ hasLocation ? ' · SORTED BY DISTANCE' : '' }} -

-
- - {% if events|length %} - - {% else %} -
-

No matches

-

Nothing fits those filters yet. Try clearing a filter or a different search term.

-
- {% endif %} - - {% include '_pager.html.twig' with { - currentPage: page, - totalPages: totalPages, - routeName: 'search', - routeParams: { - q: q ?: null, - category: activeCategory, - when: when != 'any' ? when : null, - lat: hasLocation ? lat : null, - lon: hasLocation ? lon : null, - range: hasLocation ? rangeKm : null - } - } %} -
-
-
-
-{% endblock %} - -{% block scripts %} - -{% endblock %}