${item.title}
-${guest ? item.teaser : item.description.slice(0, 140) + '...'}
- ${guest ? '' : desc} - - ${contact} -${item.title}
+${item.teaser}
+Keine Events vorhanden.
'; + return; } - - el.list.innerHTML = filtered.map(renderCard).join('') || 'Keine Events gefunden.
'; - - // wire detail buttons - el.list.querySelectorAll('[data-event-detail]').forEach(btn => { + el.sliderTrack.innerHTML = events.slice(0, 10).map(renderCard).join(''); + el.sliderTrack.querySelectorAll('[data-event-detail]').forEach(btn => { btn.addEventListener('click', () => { const id = parseInt(btn.getAttribute('data-event-detail'), 10); const ev = events.find(e => e.id === id); @@ -174,41 +118,61 @@ document.addEventListener('DOMContentLoaded', () => { }); }; - if (el.chips.length > 0) { - el.chips[0].classList.add('active'); - } + const scrollSlider = (dir) => { + if (!el.sliderViewport) return; + const amount = dir === 'next' ? 320 : -320; + el.sliderViewport.scrollBy({ left: amount, behavior: 'smooth' }); + }; - el.chips.forEach(chip => { - chip.addEventListener('click', () => { - el.chips.forEach(c => c.classList.remove('active')); - chip.classList.add('active'); - state.topic = chip.dataset.filter; - renderEvents(); - }); + el.sliderPrev?.addEventListener('click', () => scrollSlider('prev')); + el.sliderNext?.addEventListener('click', () => scrollSlider('next')); + + // Quick search with geocoding + const geocode = async (query) => { + const url = 'https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=' + encodeURIComponent(query); + const res = await fetch(url, { headers: { 'Accept-Language': 'de', 'User-Agent': 'papa-kind-treff/1.0' } }); + const data = await res.json(); + if (Array.isArray(data) && data[0]) { + return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) }; + } + return null; + }; + + el.quickGeo?.addEventListener('click', () => { + if (!navigator.geolocation) { + alert('Geolocation wird nicht unterstützt.'); + return; + } + navigator.geolocation.getCurrentPosition( + (pos) => { + if (el.quickLat && el.quickLng) { + el.quickLat.value = pos.coords.latitude.toFixed(6); + el.quickLng.value = pos.coords.longitude.toFixed(6); + if (el.quickLoc) el.quickLoc.value = 'Mein Standort'; + } + }, + () => alert('Standort konnte nicht ermittelt werden.') + ); }); - if (el.btnSearch) { - el.btnSearch.addEventListener('click', () => { - const q = (el.locInput?.value || '').trim(); - const url = '/search' + (q ? ('?q=' + encodeURIComponent(q)) : ''); - window.location.href = url; - }); - } - - if (el.btnGeo) { - el.btnGeo.addEventListener('click', () => { - if (!navigator.geolocation) { - alert('Geolocation wird nicht unterstützt.'); - return; + if (el.quickForm) { + el.quickForm.addEventListener('submit', async (e) => { + if (!el.quickLoc || !el.quickLat || !el.quickLng) return; + const hasCoords = el.quickLat.value && el.quickLng.value; + const locVal = (el.quickLoc.value || '').trim(); + if (hasCoords || locVal === '') return; + e.preventDefault(); + try { + const coords = await geocode(locVal); + if (coords) { + el.quickLat.value = coords.lat.toFixed(6); + el.quickLng.value = coords.lng.toFixed(6); + } + } catch (err) { + // ignore + } finally { + el.quickForm.submit(); } - navigator.geolocation.getCurrentPosition( - (pos) => { - state.geo = { lat: pos.coords.latitude, lng: pos.coords.longitude }; - searchState.active = false; - renderEvents(); - }, - () => alert('Standort konnte nicht ermittelt werden.') - ); }); } @@ -218,5 +182,5 @@ document.addEventListener('DOMContentLoaded', () => { }); el.modal?.addEventListener('click', (e) => { if (e.target === el.modal) el.modal.classList.remove('open'); }); - renderEvents(); + renderSlider(); }); diff --git a/src/App/Search.php b/src/App/Search.php index 19ae039..01dcd05 100644 --- a/src/App/Search.php +++ b/src/App/Search.php @@ -7,12 +7,13 @@ final class Search { public function __construct(private ?\PDO $pdo) {} - public function searchEvents(string $query, int $limit = 100): array + public function searchEvents(string $query, int $limit = 100, ?array $geo = null): array { if (!$this->pdo) return []; $q = trim($query); - if ($q === '') return []; + $hasGeo = isset($geo['lat'], $geo['lng']) && is_numeric($geo['lat']) && is_numeric($geo['lng']); + if ($q === '' && !$hasGeo) return []; $tokens = array_filter(preg_split('/\s+/', $q) ?: [], fn($t) => $t !== ''); if (!$tokens) { @@ -39,18 +40,56 @@ final class Search } $i++; } - $where = $conditions ? ('AND ' . implode(' AND ', $conditions)) : ''; + $whereParts = [ + "starts_at >= NOW()", + "status != 'cancelled'", + ]; + if ($conditions) { + $whereParts[] = implode(' AND ', $conditions); + } + + $sql = "SELECT id, title, teaser_public, description, city, region, zip, starts_at, visibility, allow_kids, location_label, lat, lng"; + $distanceFiltering = false; + + if ($hasGeo) { + $lat = (float)$geo['lat']; + $lng = (float)$geo['lng']; + $radius = isset($geo['radius']) && is_numeric($geo['radius']) ? max(0.1, (float)$geo['radius']) : 5.0; + $sql .= ", + (6371 * ACOS(LEAST(1, + COS(RADIANS(:glat)) * COS(RADIANS(lat)) * COS(RADIANS(lng) - RADIANS(:glng)) + + SIN(RADIANS(:glat)) * SIN(RADIANS(lat)) + ))) AS distance_km"; + $distanceFiltering = true; + + $latRange = $radius / 111.0; + $lngRange = $radius / (111.0 * max(0.1, cos($lat * M_PI / 180))); + $whereParts[] = "(lat IS NOT NULL AND lng IS NOT NULL)"; + $whereParts[] = "(lat BETWEEN :latMin AND :latMax)"; + $whereParts[] = "(lng BETWEEN :lngMin AND :lngMax)"; + $params[':glat'] = $lat; + $params[':glng'] = $lng; + $params[':latMin'] = $lat - $latRange; + $params[':latMax'] = $lat + $latRange; + $params[':lngMin'] = $lng - $lngRange; + $params[':lngMax'] = $lng + $lngRange; + $params[':radius'] = $radius; + } + + $where = $whereParts ? ('WHERE ' . implode(' AND ', $whereParts)) : ''; + $sql .= " FROM events $where"; + if ($distanceFiltering) { + $sql .= " HAVING distance_km <= :radius"; + $sql .= " ORDER BY distance_km ASC, starts_at ASC"; + } else { + $sql .= " ORDER BY starts_at ASC"; + } + $sql .= " LIMIT :lim"; - $sql = "SELECT id, title, teaser_public, description, city, region, starts_at, visibility, allow_kids, location_label - FROM events - WHERE starts_at >= NOW() - AND status != 'cancelled' - $where - ORDER BY starts_at ASC - LIMIT :lim"; $stmt = $this->pdo->prepare($sql); foreach ($params as $k => $v) { - $stmt->bindValue($k, $v, \PDO::PARAM_STR); + $type = is_int($v) ? \PDO::PARAM_INT : \PDO::PARAM_STR; + $stmt->bindValue($k, $v, $type); } $stmt->bindValue(':lim', $limit, \PDO::PARAM_INT); $stmt->execute();