improved maps and browse events, removed search
This commit is contained in:
1 parent
55e28fd0af
commit
75d5d0bb0f
11 files changed
+372
-377
No files matched your search
+34
-19
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<?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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,22 +107,24 @@ 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 ('nearest' === $sort) {
|
||||
if (!$locationIds) {
|
||||
return [];
|
||||
}
|
||||
@@ -163,6 +145,27 @@ class EventRepository extends ServiceEntityRepository
|
||||
return $this->hydrateInOrder(array_slice($orderedIds, $offset, $limit));
|
||||
}
|
||||
|
||||
if (null !== $locationIds && !$locationIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$qb = $this->baseSearchQb($q, $category, $when);
|
||||
if (null !== $locationIds) {
|
||||
$qb->andWhere('e.id IN (:locationIds)')->setParameter('locationIds', $locationIds);
|
||||
}
|
||||
|
||||
match ($sort) {
|
||||
'az' => $qb->orderBy('e.title', 'ASC'),
|
||||
'za' => $qb->orderBy('e.title', 'DESC'),
|
||||
default => $this->orderByStartDate($qb),
|
||||
};
|
||||
|
||||
return $qb->setMaxResults($limit)
|
||||
->setFirstResult($offset)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Total count matching the same filters as search(), for pagination.
|
||||
*
|
||||
|
||||
@@ -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) %}
|
||||
<section class="stage{{ compact ? ' stage-compact' : '' }}">
|
||||
<div id="mapCanvas" role="application" aria-label="Map of events"></div>
|
||||
|
||||
<div class="map-overlay-top">
|
||||
<div class="float-card search-card">
|
||||
<form action="{{ path('search') }}">
|
||||
<label for="mapSearch" style="display:none;">Search events</label>
|
||||
<input id="mapSearch" name="q" type="text" placeholder="Search events…">
|
||||
<button class="btn btn-primary btn-sm" type="submit">Search</button>
|
||||
</form>
|
||||
{% if not compact %}
|
||||
<div class="float-card search-card">
|
||||
<div class="chip-row-tight" id="catChips">
|
||||
<button class="chip-sm is-active" data-cat="all">All</button>
|
||||
{% for category in categories %}
|
||||
<button class="chip-sm" data-cat="{{ category }}">{{ category }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="float-card locate-card">
|
||||
<form id="locationSearchForm">
|
||||
<label for="locationSearch" style="display:none;">Search a place</label>
|
||||
|
||||
@@ -19,10 +19,9 @@
|
||||
<a href="{{ path('home') }}" class="{{ activeNav == 'home' ? 'is-active' : '' }}">Home</a>
|
||||
<a href="{{ path('event_list') }}" class="{{ activeNav == 'events' ? 'is-active' : '' }}">Browse events</a>
|
||||
<a href="{{ path('map') }}" class="{{ activeNav == 'map' ? 'is-active' : '' }}">Map</a>
|
||||
<a href="{{ path('search') }}" class="{{ activeNav == 'search' ? 'is-active' : '' }}">Search</a>
|
||||
</nav>
|
||||
<div class="nav-actions">
|
||||
<form class="nav-search" action="{{ path('search') }}" role="search">
|
||||
<form class="nav-search" action="{{ path('event_list') }}" role="search">
|
||||
<button type="submit" aria-label="Search">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||
</button>
|
||||
@@ -37,9 +36,8 @@
|
||||
<a href="{{ path('home') }}" class="{{ activeNav == 'home' ? 'is-active' : '' }}">Home</a>
|
||||
<a href="{{ path('event_list') }}" class="{{ activeNav == 'events' ? 'is-active' : '' }}">Browse events</a>
|
||||
<a href="{{ path('map') }}" class="{{ activeNav == 'map' ? 'is-active' : '' }}">Map</a>
|
||||
<a href="{{ path('search') }}" class="{{ activeNav == 'search' ? 'is-active' : '' }}">Search</a>
|
||||
</nav>
|
||||
<form class="mobile-search" action="{{ path('search') }}" role="search">
|
||||
<form class="mobile-search" action="{{ path('event_list') }}" role="search">
|
||||
<label for="mobileSearchInput" style="display:none;">Search events</label>
|
||||
<input id="mobileSearchInput" type="text" name="q" placeholder="Search events…" autocomplete="off" value="{{ app.request.query.get('q') }}">
|
||||
<button type="submit">Search</button>
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
{% endif %}
|
||||
<h3>{{ event.title }}</h3>
|
||||
<p class="when">{{ event.startDateTime ? event.startDateTime|date('D, M j · H:i') : 'Date TBD' }}</p>
|
||||
<p class="place">{{ [event.street, event.city]|filter(v => v)|join(', ') ?: event.country }}</p>
|
||||
<p class="place">
|
||||
{{ [event.street, event.city]|filter(v => v)|join(', ') ?: event.country }}
|
||||
{% if dist is defined and dist is not null %}
|
||||
· {{ dist < 1 ? (dist * 1000)|round ~ ' m' : (dist < 10 ? dist|round(1) : dist|round) ~ ' km' }} away
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
@@ -5,6 +5,18 @@
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.toolbar{ display:flex; justify-content:space-between; align-items:center; gap:16px; flex-wrap:wrap; margin-bottom:20px; }
|
||||
.search-input-bar{ display:flex; gap:10px; margin-bottom:20px; }
|
||||
.search-input-bar input{ flex:1; padding:16px 20px; border:1px solid var(--line-strong); border-radius: var(--r-pill); font-size:15px; }
|
||||
.filter-label{
|
||||
display:block; font-family:var(--f-mono); font-size:11px; text-transform:uppercase;
|
||||
letter-spacing:.06em; color:var(--muted); margin:0 0 8px;
|
||||
}
|
||||
.filter-row{ display:flex; align-items:flex-start; gap:24px; flex-wrap:wrap; margin-bottom:18px; }
|
||||
.filter-group-inline{ display:flex; flex-direction:column; }
|
||||
.location-filter{ display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||
.location-filter form{ display:flex; gap:6px; }
|
||||
.location-filter input[type=text]{ min-width:0; width:180px; padding:9px 12px; border:1px solid var(--line-strong); border-radius:var(--r-pill); font-size:13px; }
|
||||
.location-filter select{ padding:7px 8px; border:1px solid var(--line-strong); border-radius:6px; font-family:var(--f-mono); font-size:12px; background:#fff; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -13,21 +25,81 @@
|
||||
<div class="wrap">
|
||||
<p class="eyebrow" style="margin-bottom:10px;">All events</p>
|
||||
<div class="toolbar">
|
||||
<h1 style="font-size:clamp(28px,4vw,40px);">What's on{{ activeCategory ? ' — ' ~ activeCategory : '' }}</h1>
|
||||
<p class="mono" style="font-size:13px; color:var(--muted);">{{ total }} event{{ total == 1 ? '' : 's' }}</p>
|
||||
<h1 style="font-size:clamp(28px,4vw,40px);">{{ q ? 'Searching: "' ~ q ~ '"' : "What's on" }}{{ activeCategory ? ' — ' ~ activeCategory : '' }}</h1>
|
||||
<div style="display:flex; align-items:center; gap:12px;">
|
||||
<p class="mono" style="font-size:13px; color:var(--muted); margin:0;">{{ total }} event{{ total == 1 ? '' : 's' }}</p>
|
||||
<label for="sortSelect" style="display:none;">Sort by</label>
|
||||
<select id="sortSelect" style="padding:7px 8px; border:1px solid var(--line-strong); border-radius:6px; font-family:var(--f-mono); font-size:12px; background:#fff;">
|
||||
<option value="soonest" data-href="{{ path('event_list', filters|merge({sort: 'soonest'})) }}" {{ 'soonest' == sort ? 'selected' : '' }}>Sort: Soonest</option>
|
||||
{% if hasLocation %}
|
||||
<option value="nearest" data-href="{{ path('event_list', filters|merge({sort: 'nearest'})) }}" {{ 'nearest' == sort ? 'selected' : '' }}>Sort: Nearest</option>
|
||||
{% endif %}
|
||||
<option value="az" data-href="{{ path('event_list', filters|merge({sort: 'az'})) }}" {{ 'az' == sort ? 'selected' : '' }}>Sort: A–Z</option>
|
||||
<option value="za" data-href="{{ path('event_list', filters|merge({sort: 'za'})) }}" {{ 'za' == sort ? 'selected' : '' }}>Sort: Z–A</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="filter-label">Search</span>
|
||||
<form class="search-input-bar" method="get" action="{{ path('event_list') }}">
|
||||
{% if activeCategory %}<input type="hidden" name="category" value="{{ activeCategory }}">{% endif %}
|
||||
{% if 'any' != when %}<input type="hidden" name="when" value="{{ when }}">{% endif %}
|
||||
{% if hasLocation %}
|
||||
<input type="hidden" name="lat" value="{{ lat }}">
|
||||
<input type="hidden" name="lon" value="{{ lon }}">
|
||||
<input type="hidden" name="range" value="{{ rangeKm }}">
|
||||
{% endif %}
|
||||
<label for="q" style="display:none;">Search events</label>
|
||||
<input id="q" name="q" type="text" value="{{ q }}" placeholder="Search by event, artist or venue…">
|
||||
<button class="btn btn-primary" type="submit">Search</button>
|
||||
</form>
|
||||
|
||||
<span class="filter-label">Category</span>
|
||||
<input type="checkbox" id="catsToggle" class="chip-toggle-checkbox">
|
||||
<div class="chip-row chip-row-collapsible">
|
||||
<a href="{{ path('event_list') }}" class="chip {{ activeCategory is null ? 'is-active' : '' }}">All</a>
|
||||
<a href="{{ path('event_list', filters|merge({category: null})) }}" class="chip {{ activeCategory is null ? 'is-active' : '' }}">All</a>
|
||||
{% for category in categories %}
|
||||
<a href="{{ path('event_list', {category: category}) }}" class="chip {{ activeCategory == category ? 'is-active' : '' }}">{{ category }}</a>
|
||||
<a href="{{ path('event_list', filters|merge({category: category})) }}" class="chip {{ activeCategory == category ? 'is-active' : '' }}">{{ category }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<label for="catsToggle" class="chip-toggle-btn" style="margin-bottom:32px;">
|
||||
<label for="catsToggle" class="chip-toggle-btn" style="margin-bottom:20px;">
|
||||
<span class="show-more">Show all categories ↑</span>
|
||||
<span class="show-less">Show fewer categories ↑</span>
|
||||
</label>
|
||||
|
||||
<div class="filter-row">
|
||||
<div class="filter-group-inline">
|
||||
<span class="filter-label">When</span>
|
||||
<div class="chip-row-tight">
|
||||
<a href="{{ path('event_list', filters|merge({when: null})) }}" class="chip-sm {{ 'any' == when ? 'is-active' : '' }}">Any date</a>
|
||||
<a href="{{ path('event_list', filters|merge({when: 'today'})) }}" class="chip-sm {{ 'today' == when ? 'is-active' : '' }}">Today</a>
|
||||
<a href="{{ path('event_list', filters|merge({when: 'week'})) }}" class="chip-sm {{ 'week' == when ? 'is-active' : '' }}">Next 7 days</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group-inline">
|
||||
<span class="filter-label">Near a location</span>
|
||||
<div class="location-filter">
|
||||
<form id="locationSearchForm">
|
||||
<label for="locationSearchInput" style="display:none;">Search a place</label>
|
||||
<input id="locationSearchInput" type="text" placeholder="Search a place…" autocomplete="off">
|
||||
<button class="btn btn-ghost btn-sm" type="submit" aria-label="Search near this place">Go</button>
|
||||
</form>
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="useMyLocationBtn">Use my location</button>
|
||||
<select id="rangeSelect">
|
||||
{% for km in rangeOptions %}
|
||||
<option value="{{ km }}" {{ km == rangeKm ? 'selected' : '' }}>{{ km }} km</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if hasLocation %}
|
||||
<a href="{{ path('event_list', filters|merge({lat: null, lon: null, range: null})) }}" id="clearLocationLink" style="color:var(--accent); text-decoration:underline; font-family:var(--f-mono); font-size:12px;">clear</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="locate-status" id="locationStatus">
|
||||
{% if hasLocation %}Showing events within {{ rangeKm }} km of {{ lat|number_format(3) }}, {{ lon|number_format(3) }}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -35,11 +107,11 @@
|
||||
<div class="wrap">
|
||||
<div class="card-grid">
|
||||
{% 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 %}
|
||||
<div class="no-results" style="grid-column:1/-1; padding:60px 20px; text-align:center; color:var(--muted); border:1px dashed var(--line-strong); border-radius:var(--r-card);">
|
||||
<p class="eyebrow" style="margin-bottom:8px;">No events</p>
|
||||
<p>Nothing matches this filter yet — try "All" or check back once the importer has run.</p>
|
||||
<p>Nothing matches those filters yet — try clearing one, or check back once the importer has run.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -48,8 +120,155 @@
|
||||
currentPage: page,
|
||||
totalPages: totalPages,
|
||||
routeName: 'event_list',
|
||||
routeParams: { category: activeCategory }
|
||||
routeParams: filters
|
||||
} %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function(){
|
||||
var currentLat = {{ hasLocation ? lat : 'null' }};
|
||||
var currentLon = {{ hasLocation ? lon : 'null' }};
|
||||
var rangeSelect = document.getElementById('rangeSelect');
|
||||
|
||||
// Shared with the home hero map and /map (see map.js) so a search/
|
||||
// geolocation made on any of the three sticks when moving between them.
|
||||
var LAST_LOCATION_KEY = 'vindn:lastLocation';
|
||||
|
||||
function loadLastLocation() {
|
||||
try {
|
||||
var raw = window.localStorage && localStorage.getItem(LAST_LOCATION_KEY);
|
||||
if (!raw) return null;
|
||||
var parsed = JSON.parse(raw);
|
||||
if (typeof parsed.lat === 'number' && typeof parsed.lon === 'number' && isFinite(parsed.lat) && isFinite(parsed.lon)) {
|
||||
return parsed;
|
||||
}
|
||||
} catch (e) { /* localStorage disabled/unavailable, or corrupt value — just skip it */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveLastLocation(lat, lon, name) {
|
||||
try {
|
||||
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 */ }
|
||||
}
|
||||
|
||||
function currentRangeKm() {
|
||||
return rangeSelect ? parseInt(rangeSelect.value, 10) : {{ rangeKm }};
|
||||
}
|
||||
|
||||
// Full reloads happen after every location change here, so there's no
|
||||
// point setting the input's value client-side before navigating — the
|
||||
// restore-on-load block below re-reads it from localStorage instead,
|
||||
// right after this same call has just written it there.
|
||||
function navigateWithLocation(lat, lon, rangeKm, name) {
|
||||
// Preserve whatever's currently shown (e.g. a range-only change)
|
||||
// unless a new place/geolocation explicitly overrides it.
|
||||
if (name === undefined) {
|
||||
var locInput = document.getElementById('locationSearchInput');
|
||||
name = locInput ? locInput.value : null;
|
||||
}
|
||||
saveLastLocation(lat, lon, name);
|
||||
var url = new URL(window.location.href);
|
||||
url.searchParams.set('lat', lat.toFixed(5));
|
||||
url.searchParams.set('lon', lon.toFixed(5));
|
||||
url.searchParams.set('range', rangeKm || url.searchParams.get('range') || 25);
|
||||
url.searchParams.delete('page'); // the result set changed, page N may no longer exist
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
|
||||
function setStatus(text, isError) {
|
||||
var el = document.getElementById('locationStatus');
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.classList.toggle('is-error', !!isError);
|
||||
}
|
||||
|
||||
// Nominatim: explicit submit only, never as-you-type — required by its
|
||||
// usage policy (max ~1 req/s, no autocomplete-style querying).
|
||||
var locationForm = document.getElementById('locationSearchForm');
|
||||
if (locationForm) {
|
||||
locationForm.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var input = document.getElementById('locationSearchInput');
|
||||
var query = (input.value || '').trim();
|
||||
if (!query) return;
|
||||
setStatus('Looking up "' + query + '"…', false);
|
||||
fetch('https://nominatim.openstreetmap.org/search?format=json&limit=1&q=' + encodeURIComponent(query))
|
||||
.then(function (res) { return res.json(); })
|
||||
.then(function (results) {
|
||||
if (!results || !results.length) {
|
||||
setStatus('Could not find "' + query + '"', true);
|
||||
return;
|
||||
}
|
||||
navigateWithLocation(parseFloat(results[0].lat), parseFloat(results[0].lon), currentRangeKm(), results[0].display_name);
|
||||
})
|
||||
.catch(function () {
|
||||
setStatus('Location search failed', true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var useMyLocationBtn = document.getElementById('useMyLocationBtn');
|
||||
if (useMyLocationBtn) {
|
||||
useMyLocationBtn.addEventListener('click', function () {
|
||||
if (!navigator.geolocation) {
|
||||
setStatus('Location not supported by your browser', true);
|
||||
return;
|
||||
}
|
||||
setStatus('Finding you…', false);
|
||||
navigator.geolocation.getCurrentPosition(function (pos) {
|
||||
navigateWithLocation(pos.coords.latitude, pos.coords.longitude, currentRangeKm(), 'Your location');
|
||||
}, function () {
|
||||
setStatus("Couldn't get your location", true);
|
||||
}, { timeout: 8000 });
|
||||
});
|
||||
}
|
||||
|
||||
if (rangeSelect) {
|
||||
rangeSelect.addEventListener('change', function () {
|
||||
// No location chosen yet — just remember the preference for
|
||||
// whichever search/geolocation happens next.
|
||||
if (currentLat === null || currentLon === null) return;
|
||||
navigateWithLocation(currentLat, currentLon, currentRangeKm());
|
||||
});
|
||||
}
|
||||
|
||||
var clearLocationLink = document.getElementById('clearLocationLink');
|
||||
if (clearLocationLink) {
|
||||
// Otherwise the restore-on-load check below would immediately undo
|
||||
// the clear on the very next visit.
|
||||
clearLocationLink.addEventListener('click', function () {
|
||||
try { window.localStorage && localStorage.removeItem(LAST_LOCATION_KEY); } catch (e) {}
|
||||
});
|
||||
}
|
||||
|
||||
var sortSelect = document.getElementById('sortSelect');
|
||||
if (sortSelect) {
|
||||
sortSelect.addEventListener('change', function () {
|
||||
var opt = sortSelect.options[sortSelect.selectedIndex];
|
||||
window.location.href = opt.getAttribute('data-href');
|
||||
});
|
||||
}
|
||||
|
||||
var saved = loadLastLocation();
|
||||
|
||||
// Every full reload here (search, geolocation, range change) already
|
||||
// lands with an explicit ?lat=, so this doesn't overwrite it — it only
|
||||
// fills in the name text a fresh navigation can't carry in the URL.
|
||||
if (saved && saved.name) {
|
||||
var locInput = document.getElementById('locationSearchInput');
|
||||
if (locInput && !locInput.value) locInput.value = saved.name;
|
||||
}
|
||||
|
||||
// A remembered search/geolocation from the home map or /map takes over
|
||||
// here too — unless this URL already has its own explicit ?lat=, which
|
||||
// always wins (e.g. a link shared with a location baked in).
|
||||
if (!/[?&]lat=/.test(window.location.search) && saved) {
|
||||
navigateWithLocation(saved.lat, saved.lon, currentRangeKm(), saved.name);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -72,7 +72,6 @@
|
||||
<h4>Explore</h4>
|
||||
<ul>
|
||||
<li><a href="{{ path('event_list') }}">Browse events</a></li>
|
||||
<li><a href="{{ path('search') }}">Search</a></li>
|
||||
<li><a href="{{ path('map') }}">Map</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block title %}Search — VINDN{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.search-shell{ display:grid; grid-template-columns: 260px 1fr; gap:36px; align-items:start; }
|
||||
.filter-group{ padding-bottom:22px; margin-bottom:22px; border-bottom:1px solid var(--line); }
|
||||
.filter-group:last-child{ border-bottom:none; }
|
||||
.filter-group h4{ font-family:var(--f-mono); font-size:12px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); margin:0 0 14px; }
|
||||
.filter-option{ display:flex; align-items:center; gap:10px; font-size:14px; padding:6px 0; cursor:pointer; }
|
||||
.filter-option input{ accent-color: var(--accent); width:16px; height:16px; }
|
||||
.results-toolbar{ display:flex; justify-content:space-between; align-items:center; margin-bottom:18px; flex-wrap:wrap; gap:12px; }
|
||||
.search-input-bar{ display:flex; gap:10px; margin-bottom:28px; }
|
||||
.search-input-bar input{ flex:1; padding:16px 20px; border:1px solid var(--line-strong); border-radius: var(--r-pill); font-size:15px; }
|
||||
.no-results{ padding:60px 20px; text-align:center; color:var(--muted); border:1px dashed var(--line-strong); border-radius:var(--r-card); }
|
||||
#locationSearchForm{ display:flex; gap:6px; margin-bottom:10px; }
|
||||
#locationSearchForm input{ flex:1; min-width:0; padding:9px 10px; border:1px solid var(--line-strong); border-radius:6px; font-size:13px; }
|
||||
.range-row{ display:flex; align-items:center; justify-content:space-between; gap:8px; margin-top:10px; font-size:13px; }
|
||||
.range-row select{ padding:6px 8px; border:1px solid var(--line-strong); border-radius:6px; font-family:var(--f-mono); font-size:12px; background:#fff; }
|
||||
#locationStatus{ font-family:var(--f-mono); font-size:11px; color:var(--muted); margin-top:8px; }
|
||||
@media (max-width: 860px){ .search-shell{ grid-template-columns:1fr; } }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<section class="section" style="padding-bottom:0;">
|
||||
<div class="wrap">
|
||||
<p class="eyebrow" style="margin-bottom:10px;">Search</p>
|
||||
<h1 style="font-size:clamp(28px,4vw,40px); margin-bottom:24px;">
|
||||
{{ q ? 'Searching: "' ~ q ~ '"' : 'Search events' }}
|
||||
</h1>
|
||||
|
||||
<form class="search-input-bar" method="get" action="{{ path('search') }}">
|
||||
{% if activeCategory %}<input type="hidden" name="category" value="{{ activeCategory }}">{% endif %}
|
||||
{% if hasLocation %}
|
||||
<input type="hidden" name="lat" value="{{ lat }}">
|
||||
<input type="hidden" name="lon" value="{{ lon }}">
|
||||
<input type="hidden" name="range" value="{{ rangeKm }}">
|
||||
{% endif %}
|
||||
<label for="q" style="display:none;">Search events</label>
|
||||
<input id="q" name="q" type="text" value="{{ q }}" placeholder="Search by event, venue or city…">
|
||||
<button class="btn btn-primary" type="submit">Search</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" style="padding-top:0;">
|
||||
<div class="wrap">
|
||||
<div class="search-shell">
|
||||
|
||||
<!-- FILTERS -->
|
||||
<aside aria-label="Filters">
|
||||
<div class="filter-group">
|
||||
<h4>Near a location</h4>
|
||||
<form id="locationSearchForm">
|
||||
<label for="locationSearchInput" style="display:none;">Search a place</label>
|
||||
<input id="locationSearchInput" type="text" placeholder="Search a place…" autocomplete="off">
|
||||
<button class="btn btn-ghost btn-sm" type="submit" aria-label="Search near this place">Go</button>
|
||||
</form>
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="useMyLocationBtn" style="width:100%; justify-content:center;">Use my location</button>
|
||||
{% if hasLocation %}
|
||||
<div class="range-row">
|
||||
<span>Within</span>
|
||||
<select id="rangeSelect">
|
||||
{% for km in rangeOptions %}
|
||||
<option value="{{ km }}" {{ km == rangeKm ? 'selected' : '' }}>{{ km }} km</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<p id="locationStatus">
|
||||
Near {{ lat|number_format(3) }}, {{ lon|number_format(3) }} —
|
||||
<a href="{{ path('search', {q: q ?: null, category: activeCategory, when: when != 'any' ? when : null}) }}" style="color:var(--accent); text-decoration:underline;">clear</a>
|
||||
</p>
|
||||
{% else %}
|
||||
<p id="locationStatus"></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<form method="get" action="{{ path('search') }}" id="filterForm">
|
||||
<input type="hidden" name="q" value="{{ q }}">
|
||||
{% if hasLocation %}
|
||||
<input type="hidden" name="lat" value="{{ lat }}">
|
||||
<input type="hidden" name="lon" value="{{ lon }}">
|
||||
<input type="hidden" name="range" value="{{ rangeKm }}">
|
||||
{% endif %}
|
||||
<div class="filter-group">
|
||||
<h4>Category</h4>
|
||||
<label class="filter-option">
|
||||
<input type="radio" name="category" value="" {{ activeCategory ? '' : 'checked' }} onchange="this.form.submit()"> All
|
||||
</label>
|
||||
{% for category in categories %}
|
||||
<label class="filter-option">
|
||||
<input type="radio" name="category" value="{{ category }}" {{ activeCategory == category ? 'checked' : '' }} onchange="this.form.submit()"> {{ category }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<h4>When</h4>
|
||||
<label class="filter-option"><input type="radio" name="when" value="any" {{ when == 'any' ? 'checked' : '' }} onchange="this.form.submit()"> Any date</label>
|
||||
<label class="filter-option"><input type="radio" name="when" value="today" {{ when == 'today' ? 'checked' : '' }} onchange="this.form.submit()"> Today</label>
|
||||
<label class="filter-option"><input type="radio" name="when" value="week" {{ when == 'week' ? 'checked' : '' }} onchange="this.form.submit()"> Next 7 days</label>
|
||||
</div>
|
||||
<a href="{{ path('search') }}" class="btn btn-ghost btn-sm" style="width:100%; justify-content:center;">Clear filters</a>
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
<!-- RESULTS -->
|
||||
<div>
|
||||
<div class="results-toolbar">
|
||||
<p class="mono" style="font-size:13px; color:var(--muted);">
|
||||
{{ total }} EVENT{{ total == 1 ? '' : 'S' }} MATCH{{ hasLocation ? ' · SORTED BY DISTANCE' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if events|length %}
|
||||
<div class="board" aria-label="Search results">
|
||||
{% for event in events %}
|
||||
<a class="board-row" href="{{ path('event_show', {id: event.id}) }}" aria-label="View {{ event.title }}">
|
||||
<div class="board-date">
|
||||
{{ event.startDateTime ? event.startDateTime|date('M j') : '—' }}
|
||||
<span class="dow">{{ event.startDateTime ? event.startDateTime|date('D') : 'TBD' }}</span>
|
||||
</div>
|
||||
<div class="board-name">
|
||||
{{ event.title }}
|
||||
<span class="where">
|
||||
{{ event.city }}{% if event.categoryNames|first %} · {{ event.categoryNames|first }}{% endif %}
|
||||
{% if hasLocation and distances[event.id] is defined %}
|
||||
{% set dist = distances[event.id] %}
|
||||
· {{ dist < 1 ? (dist * 1000)|round ~ ' m' : (dist < 10 ? dist|round(1) : dist|round) ~ ' km' }} away
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="board-status">{{ event.startDateTime ? event.startDateTime|date('H:i') : '' }}</div>
|
||||
<div class="board-go">→</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="no-results">
|
||||
<p class="eyebrow" style="margin-bottom:8px;">No matches</p>
|
||||
<p>Nothing fits those filters yet. Try clearing a filter or a different search term.</p>
|
||||
</div>
|
||||
{% 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
|
||||
}
|
||||
} %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function(){
|
||||
var currentLat = {{ hasLocation ? lat : 'null' }};
|
||||
var currentLon = {{ hasLocation ? lon : 'null' }};
|
||||
|
||||
function navigateWithLocation(lat, lon, rangeKm) {
|
||||
var url = new URL(window.location.href);
|
||||
url.searchParams.set('lat', lat.toFixed(5));
|
||||
url.searchParams.set('lon', lon.toFixed(5));
|
||||
url.searchParams.set('range', rangeKm || url.searchParams.get('range') || 25);
|
||||
url.searchParams.delete('page'); // the result set changed, page N may no longer exist
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
|
||||
var locationForm = document.getElementById('locationSearchForm');
|
||||
if (locationForm) {
|
||||
locationForm.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var input = document.getElementById('locationSearchInput');
|
||||
var query = (input.value || '').trim();
|
||||
if (!query) return;
|
||||
var statusEl = document.getElementById('locationStatus');
|
||||
if (statusEl) statusEl.textContent = 'Looking up "' + query + '"…';
|
||||
fetch('https://nominatim.openstreetmap.org/search?format=json&limit=1&q=' + encodeURIComponent(query))
|
||||
.then(function (res) { return res.json(); })
|
||||
.then(function (results) {
|
||||
if (!results || !results.length) {
|
||||
if (statusEl) statusEl.textContent = 'Could not find "' + query + '"';
|
||||
return;
|
||||
}
|
||||
navigateWithLocation(parseFloat(results[0].lat), parseFloat(results[0].lon));
|
||||
})
|
||||
.catch(function () {
|
||||
if (statusEl) statusEl.textContent = 'Location search failed';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var useMyLocationBtn = document.getElementById('useMyLocationBtn');
|
||||
if (useMyLocationBtn) {
|
||||
useMyLocationBtn.addEventListener('click', function () {
|
||||
var statusEl = document.getElementById('locationStatus');
|
||||
if (!navigator.geolocation) {
|
||||
if (statusEl) statusEl.textContent = 'Location not supported by your browser';
|
||||
return;
|
||||
}
|
||||
if (statusEl) statusEl.textContent = 'Finding you…';
|
||||
navigator.geolocation.getCurrentPosition(function (pos) {
|
||||
navigateWithLocation(pos.coords.latitude, pos.coords.longitude);
|
||||
}, function () {
|
||||
if (statusEl) statusEl.textContent = "Couldn't get your location";
|
||||
}, { timeout: 8000 });
|
||||
});
|
||||
}
|
||||
|
||||
var rangeSelect = document.getElementById('rangeSelect');
|
||||
if (rangeSelect) {
|
||||
rangeSelect.addEventListener('change', function () {
|
||||
if (currentLat === null || currentLon === null) return;
|
||||
navigateWithLocation(currentLat, currentLon, parseInt(rangeSelect.value, 10));
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in new issue
Block a user