From cbcd09003ee18d96d5aee796922a9ac540af1bf5 Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Wed, 31 Dec 2025 01:00:41 +0100 Subject: [PATCH] hhhh --- config/community.php | 1 + partials/landing/main/home.php | 97 ++++++++------- partials/landing/search/search.php | 59 ++++++++- public/assets/js/app.js | 194 ++++++++++++----------------- src/App/Search.php | 61 +++++++-- 5 files changed, 232 insertions(+), 180 deletions(-) diff --git a/config/community.php b/config/community.php index de26205..981c7c1 100644 --- a/config/community.php +++ b/config/community.php @@ -1,3 +1,4 @@ + pdo(); if ($pdo) { - $stmt = $pdo->prepare('SELECT id, title, teaser_public, description, city, region, zip, starts_at, allow_kids, visibility, location_label, lat, lng FROM events WHERE starts_at >= NOW() AND status != "cancelled" ORDER BY starts_at ASC LIMIT 50'); + $stmt = $pdo->prepare('SELECT id, title, teaser_public, description, city, region, zip, starts_at, allow_kids, visibility, location_label, lat, lng, created_at FROM events WHERE starts_at >= NOW() AND status != "cancelled" ORDER BY created_at DESC, starts_at ASC LIMIT 10'); $stmt->execute(); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; foreach ($rows as $r) { @@ -58,63 +58,64 @@ try {
Echt lokale Gruppen
-
-
Schnellsuche
-

Events in deiner Region

-
- - -
-
- - -
-
- - -
- - -

Geodaten werden nur zur Anzeige verwendet.

-
+

Termine entdecken

-

Die nächsten anstehenden Events

-

Gäste sehen nur Basisinfos. Als Mitglied siehst du vollständige Details, kannst zusagen und neue Treffen anlegen.

-
-
- - - - - +

Neueste Termine

+

Die zehn neuesten veröffentlichten Events – kompakt zum Durchscrollen. Gäste sehen Basisinfos, Mitglieder erhalten alle Details.

-
-
-
-
- Nur für eingeloggte Mitglieder -

Volle Beschreibung, Kontakt, Kinder-Infos und Anmeldeoptionen.

-
- +
+ +
+
+ +
+
+ +
+
+

Schnellsuche

+

Passende Events finden

+
+
+ + +
+
+ +
+ + +
+ + +
+
+ + +
+
+ +
+
+

Optional Standort ermitteln oder Ort eingeben; Umkreis bestimmt die Treffer in der Suche.

diff --git a/partials/landing/search/search.php b/partials/landing/search/search.php index 1d62d37..8058483 100644 --- a/partials/landing/search/search.php +++ b/partials/landing/search/search.php @@ -4,29 +4,73 @@ declare(strict_types=1); $app = app(); $pdo = $app->pdo(); $q = trim((string)($_GET['q'] ?? '')); +$loc = trim((string)($_GET['loc'] ?? '')); +$radius = isset($_GET['radius']) ? (float)$_GET['radius'] : 5.0; +$radius = ($radius > 0) ? $radius : 5.0; +$lat = isset($_GET['lat']) && $_GET['lat'] !== '' ? (float)$_GET['lat'] : null; +$lng = isset($_GET['lng']) && $_GET['lng'] !== '' ? (float)$_GET['lng'] : null; $results = []; -if ($q !== '' && $pdo) { +function geocode_loc(string $loc): array +{ + $url = 'https://nominatim.openstreetmap.org/search?' . http_build_query([ + 'format' => 'jsonv2', + 'limit' => 1, + 'q' => $loc, + ]); + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => "User-Agent: papa-kind-treff/1.0\r\nAccept-Language: de\r\n", + 'timeout' => 6, + ], + ]); + $resp = @file_get_contents($url, false, $ctx); + if ($resp === false) return [null, null]; + $json = json_decode($resp, true); + if (!is_array($json) || empty($json[0]['lat']) || empty($json[0]['lon'])) return [null, null]; + return [round((float)$json[0]['lat'], 6), round((float)$json[0]['lon'], 6)]; +} + +if ($pdo && ($q !== '' || $loc !== '' || ($lat !== null && $lng !== null))) { + if ($lat === null && $lng === null && $loc !== '') { + [$lat, $lng] = geocode_loc($loc); + } + $geo = ($lat !== null && $lng !== null) ? ['lat' => $lat, 'lng' => $lng, 'radius' => $radius] : null; $search = new \App\Search($pdo); - $results = $search->searchEvents($q, 100); + $results = $search->searchEvents($q, 100, $geo); } ?>

Suche

Events finden

-
+
+
+ + + + +
+
+ + +
- -

Bitte gib einen Suchbegriff ein.

+ +

Bitte gib einen Suchbegriff oder einen Ort ein.

-

Ergebnis(se) für „

+

Ergebnis(se)

Keine passenden Events gefunden.

@@ -39,6 +83,9 @@ if ($q !== '' && $pdo) { 📍 + + km +

diff --git a/public/assets/js/app.js b/public/assets/js/app.js index 9a69b16..3c82f3c 100644 --- a/public/assets/js/app.js +++ b/public/assets/js/app.js @@ -52,110 +52,54 @@ document.addEventListener('DOMContentLoaded', () => { // Events from backend (injected on page); fallback to empty const events = Array.isArray(window.__events) ? window.__events : []; - const state = { topic: 'all', age: '', region: '', query: '', geo: null }; const el = { - list: document.getElementById('eventList'), - chips: document.querySelectorAll('[data-filter]'), - locInput: document.getElementById('locInput'), - topicSelect: document.getElementById('topicSelect'), - ageSelect: document.getElementById('ageSelect'), - btnSearch: document.getElementById('btnSearch'), - btnGeo: document.getElementById('btnGeo'), + sliderTrack: document.getElementById('eventSlider'), + sliderViewport: document.querySelector('.slider__viewport'), + sliderPrev: document.querySelector('[data-slider-prev]'), + sliderNext: document.querySelector('[data-slider-next]'), modal: document.getElementById('eventModal'), modalBody: document.getElementById('eventModalBody'), modalTitle: document.getElementById('eventModalTitle'), + quickForm: document.getElementById('quickSearchForm'), + quickLoc: document.getElementById('qsLoc'), + quickQuery: document.getElementById('qsQuery'), + quickLat: document.getElementById('qsLat'), + quickLng: document.getElementById('qsLng'), + quickGeo: document.getElementById('quickGeo'), }; - const searchState = { active: false }; const fmtDate = (iso) => { const d = new Date(iso); return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }); }; - const haversine = (lat1, lon1, lat2, lon2) => { - const toRad = (d) => d * Math.PI / 180; - const R = 6371e3; - const dLat = toRad(lat2 - lat1); - const dLon = toRad(lon2 - lon1); - const a = Math.sin(dLat/2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon/2) ** 2; - const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); - return R * c; - }; - - const tag = (label) => `${label}`; + const smallTag = (label) => `${label}`; const renderCard = (item) => { const guest = !isLoggedIn; - const access = item.visibility === 'members' && guest ? '
Nur für Mitglieder
' : ''; - const desc = `

${item.teaser}

`; - const contact = !guest ? `
Kontakt: ${item.contact}
` : ''; const kids = item.allowKids ? 'Mit Kindern' : 'Ohne Kinder'; - const tags = [ - tag(item.topic === 'kaffee' ? 'Kaffee & Austausch' : item.topic.charAt(0).toUpperCase() + item.topic.slice(1)), - tag(kids), - item.ageGroup ? tag(`Alter: ${item.ageGroup}`) : '', - ].filter(Boolean).join(''); - return ` -
-
- ${access} -
- ${fmtDate(item.startsAt)} - 📍 ${item.region || item.city} - ${item.visibility === 'public' ? 'Öffentlich' : 'Mitglieder'} -
-

${item.title}

-

${guest ? item.teaser : item.description.slice(0, 140) + '...'}

- ${guest ? '' : desc} -
${tags}
- ${contact} -
- - ${guest ? '' : ''} -
+
+
${fmtDate(item.startsAt)}
+

${item.title}

+

${item.teaser}

+
📍 ${item.city || item.region || ''}
+
${smallTag(kids)} ${item.visibility === 'public' ? smallTag('Öffentlich') : smallTag('Mitglieder')}
+
+ + ${guest ? '' : ''}
`; }; - const matchesFilter = (ev) => { - const topicOk = state.topic === 'all' || state.topic === ev.topic || state.topic === ''; - const ageOk = !state.age || ev.ageGroup === state.age || (state.age === 'baby' && ev.ageGroup === 'kids'); // simple fallback - const regionField = (ev.region || '').toLowerCase(); - const cityField = (ev.city || '').toLowerCase(); - const zipField = (ev.zip || ''); - const queryHaystack = (ev.title + ev.teaser + ev.description + (ev.city || '') + (ev.region || '')).toLowerCase(); - const regionOk = !state.region || regionField.includes(state.region) || cityField.includes(state.region) || zipField.startsWith(state.region); - const queryOk = !state.query || queryHaystack.includes(state.query); - return topicOk && ageOk && regionOk && queryOk; - }; - - const renderEvents = () => { - if (!el.list) return; - let filtered = events.filter(matchesFilter); - filtered.sort((a,b) => new Date(a.startsAt) - new Date(b.startsAt)); - - if (state.geo && !searchState.active) { - const withCoords = filtered.filter(ev => ev.lat !== null && ev.lng !== null); - withCoords.forEach(ev => { ev._distance = haversine(state.geo.lat, state.geo.lng, ev.lat, ev.lng); }); - withCoords.sort((a,b) => (a._distance || Infinity) - (b._distance || Infinity)); - const near = withCoords.filter(ev => ev._distance <= 5000).slice(0,5); - const fallback = withCoords.slice(0,5); - filtered = (near.length ? near : fallback).map(ev => { - const copy = { ...ev }; - if (ev._distance) { - copy.teaser = `${ev.teaser} · ca. ${(ev._distance/1000).toFixed(1)} km entfernt`; - } - return copy; - }); - } else if (!searchState.active) { - filtered = filtered.slice(0,5); + const renderSlider = () => { + if (!el.sliderTrack) return; + if (!events.length) { + el.sliderTrack.innerHTML = '

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();