diff --git a/frontend/public/js/location.js b/frontend/public/js/location.js new file mode 100644 index 0000000..53442fd --- /dev/null +++ b/frontend/public/js/location.js @@ -0,0 +1,80 @@ +// Single source of truth for "where is the user" across the home hero map, +// /map, and Browse Events' location filter — storage, place search, and +// GPS lookup used to be copy-pasted across all three, which is exactly how +// one of them (a Nominatim result not being saved) silently drifted out of +// sync with the other two. Each page still owns what happens once a +// location resolves (recenter a map vs. navigate to a new URL) — that part +// genuinely differs per page — but the operations themselves live here once. +(function (window) { + var LAST_LOCATION_KEY = 'vindn:lastLocation'; + + function load() { + 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 save(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 clear() { + try { + if (window.localStorage) localStorage.removeItem(LAST_LOCATION_KEY); + } catch (e) { /* localStorage disabled/unavailable */ } + } + + // Nominatim: explicit submit only, never as-you-type — required by its + // usage policy (max ~1 req/s, no autocomplete-style querying). Every + // "search a place" form on the site should call this instead of its own + // fetch, so they all behave identically (and a fix here fixes all three). + function searchPlace(query, callbacks) { + callbacks = callbacks || {}; + if (callbacks.onStart) callbacks.onStart(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 (callbacks.onNotFound) callbacks.onNotFound(query); + return; + } + if (callbacks.onFound) callbacks.onFound(parseFloat(results[0].lat), parseFloat(results[0].lon), results[0].display_name); + }) + .catch(function () { + if (callbacks.onError) callbacks.onError(); + }); + } + + // "Use my location" — shared so every page reports the same fixed label + // for a GPS-derived point (no reverse geocoding happens) and the same + // error/unsupported messaging. + function useMyLocation(callbacks) { + callbacks = callbacks || {}; + if (!navigator.geolocation) { + if (callbacks.onUnsupported) callbacks.onUnsupported(); + return; + } + if (callbacks.onStart) callbacks.onStart(); + navigator.geolocation.getCurrentPosition(function (pos) { + if (callbacks.onFound) callbacks.onFound(pos.coords.latitude, pos.coords.longitude, 'Your location'); + }, function () { + if (callbacks.onError) callbacks.onError(); + }, { timeout: 8000 }); + } + + window.VindnLocation = { + load: load, + save: save, + clear: clear, + searchPlace: searchPlace, + useMyLocation: useMyLocation + }; +})(window); diff --git a/frontend/public/js/map.js b/frontend/public/js/map.js index 8238632..1dbd3db 100644 --- a/frontend/public/js/map.js +++ b/frontend/public/js/map.js @@ -12,28 +12,9 @@ // so this only matters if that ever gets forgotten. var DEFAULT_CENTER = [50.9375, 6.9603]; - // Remembers the last place a user searched/geolocated to, shared between - // the home hero map and /map, so switching between them (or coming back - // later) doesn't reset to Köln and make them search again. - 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 */ } - } + // Storage/search/GPS lookups live in window.VindnLocation (location.js, + // loaded globally in base.html.twig) — shared with Browse Events' own + // location filter too, so all three stay in sync by construction. // Mirrors whatever setUserLocation/setSource just resolved (a searched // place name, or nothing for GPS/a map click) into the location search @@ -490,9 +471,13 @@ }); } + function statusForLimit() { + return limit ? ('Showing the ' + limit + ' nearest events') : 'Showing events near you'; + } + function setUserLocation(lat, lon, statusText, placeName) { state.userLoc = [lat, lon]; - saveLastLocation(lat, lon, placeName); + window.VindnLocation.save(lat, lon, placeName); setLocationInputValue(placeName); if (limit) rebuildMarkers(nearest([lat, lon], allEvents.slice(), limit)); @@ -512,26 +497,22 @@ function requestLocation(isAutoCheck) { var statusEl = document.getElementById('locateStatus'); - if (!navigator.geolocation) { - setLocateStatus(statusEl, 'Location not supported by your browser', true); - return; - } - setLocateStatus(statusEl, 'Finding you…', false); - navigator.geolocation.getCurrentPosition(function (pos) { - // 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', 'Your location'); - }, function () { - setLocateStatus(statusEl, "Couldn't get your location", true); - }, { timeout: 8000 }); + window.VindnLocation.useMyLocation({ + onUnsupported: function () { setLocateStatus(statusEl, 'Location not supported by your browser', true); }, + onStart: function () { setLocateStatus(statusEl, 'Finding you…', false); }, + onFound: function (lat, lon, name) { + // A manual search/click may have already happened while this + // (silent, permission-check-triggered) lookup was still resolving. + if (isAutoCheck && manualLocationSet) return; + setUserLocation(lat, lon, statusForLimit(), name); + }, + onError: function () { setLocateStatus(statusEl, "Couldn't get your location", true); } + }); } var locateBtn = document.getElementById('locateBtn'); if (locateBtn) locateBtn.addEventListener('click', function () { manualLocationSet = true; requestLocation(false); }); - // 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) { @@ -541,29 +522,22 @@ if (!query) return; manualLocationSet = true; var statusEl = document.getElementById('locateStatus'); - setLocateStatus(statusEl, '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) { - 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', results[0].display_name); - }) - .catch(function () { - setLocateStatus(statusEl, 'Location search failed', true); - }); + window.VindnLocation.searchPlace(query, { + onStart: function () { setLocateStatus(statusEl, 'Looking up "' + query + '"…', false); }, + onNotFound: function () { setLocateStatus(statusEl, 'Could not find "' + query + '"', true); }, + onFound: function (lat, lon, name) { setUserLocation(lat, lon, statusForLimit(), name); }, + onError: function () { setLocateStatus(statusEl, 'Location search failed', true); } + }); }); } // A remembered search/geolocation takes priority over asking the // browser again — restoring it counts as the user's own manual choice, // so a slower-to-resolve auto-geolocation check below can't clobber it. - var lastLocation = loadLastLocation(); + var lastLocation = window.VindnLocation.load(); if (lastLocation) { manualLocationSet = true; - setUserLocation(lastLocation.lat, lastLocation.lon, limit ? ('Showing the ' + limit + ' nearest events') : 'Showing events near you', lastLocation.name); + setUserLocation(lastLocation.lat, lastLocation.lon, statusForLimit(), 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 @@ -730,7 +704,7 @@ // conservative: reload whenever full coverage isn't guaranteed, // rather than risk silently showing an incomplete result. function setSource(lat, lon, placeName) { - saveLastLocation(lat, lon, placeName); + window.VindnLocation.save(lat, lon, placeName); setLocationInputValue(placeName); var distFromOriginal = distanceKm(originalCenter[0], originalCenter[1], lat, lon); if (distFromOriginal + state.rangeKm > maxRangeKm) { @@ -781,8 +755,6 @@ }); } - // 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) { @@ -792,37 +764,28 @@ if (!query) return; manualLocationSet = true; var statusEl = document.getElementById('locateStatus'); - setLocateStatus(statusEl, '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) { - setLocateStatus(statusEl, 'Could not find "' + query + '"', true); - return; - } - setSource(parseFloat(results[0].lat), parseFloat(results[0].lon), results[0].display_name); - }) - .catch(function () { - setLocateStatus(statusEl, 'Location search failed', true); - }); + window.VindnLocation.searchPlace(query, { + onStart: function () { setLocateStatus(statusEl, 'Looking up "' + query + '"…', false); }, + onNotFound: function () { setLocateStatus(statusEl, 'Could not find "' + query + '"', true); }, + onFound: function (lat, lon, name) { setSource(lat, lon, name); }, + onError: function () { setLocateStatus(statusEl, 'Location search failed', true); } + }); }); } function requestLocation(isAutoCheck) { var statusEl = document.getElementById('locateStatus'); - if (!navigator.geolocation) { - setLocateStatus(statusEl, 'Location not supported by your browser', true); - return; - } - setLocateStatus(statusEl, 'Finding you…', false); - navigator.geolocation.getCurrentPosition(function (pos) { - // 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, 'Your location'); - }, function () { - setLocateStatus(statusEl, "Couldn't get your location", true); - }, { timeout: 8000 }); + window.VindnLocation.useMyLocation({ + onUnsupported: function () { setLocateStatus(statusEl, 'Location not supported by your browser', true); }, + onStart: function () { setLocateStatus(statusEl, 'Finding you…', false); }, + onFound: function (lat, lon, name) { + // A manual search/click/drag may have already happened while this + // (silent, permission-check-triggered) lookup was still resolving. + if (isAutoCheck && manualLocationSet) return; + setSource(lat, lon, name); + }, + onError: function () { setLocateStatus(statusEl, "Couldn't get your location", true); } + }); } var locateBtn = document.getElementById('locateBtn'); @@ -832,21 +795,26 @@ // 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(); + var savedForName = window.VindnLocation.load(); 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 - // choice, so a slower-to-resolve auto-geolocation check below can't - // 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. + // An explicit ?lat=/&lon= in the URL (e.g. a shared link, or this same + // page reloading itself after setSource's own redirect) always wins — + // and, crucially, also skips the auto-geolocation check below entirely. + // That check used to run on *every* fresh page load regardless of the + // URL, which meant searching for a place far enough to trigger the + // redirect immediately got overwritten by GPS again the moment the + // reloaded page re-ran this same check with a clean (unset) + // manualLocationSet. Otherwise, a remembered search/geolocation takes + // priority over asking the browser again — restoring it counts as the + // user's own manual choice, so a slower-to-resolve auto-geolocation + // check can't clobber it. var urlHasExplicitLocation = /[?&]lat=/.test(window.location.search); var lastLocation = urlHasExplicitLocation ? null : savedForName; if (lastLocation) { manualLocationSet = true; setSource(lastLocation.lat, lastLocation.lon, lastLocation.name); - } else if (navigator.permissions && navigator.permissions.query) { + } else if (!urlHasExplicitLocation && navigator.permissions && navigator.permissions.query) { navigator.permissions.query({ name: 'geolocation' }).then(function (status) { if (status.state === 'granted') requestLocation(true); }).catch(function () {}); diff --git a/frontend/templates/base.html.twig b/frontend/templates/base.html.twig index 39230c2..2bd8b9a 100644 --- a/frontend/templates/base.html.twig +++ b/frontend/templates/base.html.twig @@ -59,6 +59,7 @@ + {% block scripts %}{% endblock %} diff --git a/frontend/templates/events/list.html.twig b/frontend/templates/events/list.html.twig index 4f2eddc..fb999b5 100644 --- a/frontend/templates/events/list.html.twig +++ b/frontend/templates/events/list.html.twig @@ -133,27 +133,10 @@ 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 */ } - } + // Storage/search/GPS lookups live in window.VindnLocation (location.js, + // loaded globally in base.html.twig) — shared with the home hero map and + // /map, so a search/geolocation made on any of the three sticks when + // moving between them. function currentRangeKm() { return rangeSelect ? parseInt(rangeSelect.value, 10) : {{ rangeKm }}; @@ -170,7 +153,7 @@ var locInput = document.getElementById('locationSearchInput'); name = locInput ? locInput.value : null; } - saveLastLocation(lat, lon, name); + window.VindnLocation.save(lat, lon, name); var url = new URL(window.location.href); url.searchParams.set('lat', lat.toFixed(5)); url.searchParams.set('lon', lon.toFixed(5)); @@ -186,8 +169,6 @@ 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) { @@ -195,35 +176,24 @@ 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); - }); + window.VindnLocation.searchPlace(query, { + onStart: function () { setStatus('Looking up "' + query + '"…', false); }, + onNotFound: function () { setStatus('Could not find "' + query + '"', true); }, + onFound: function (lat, lon, name) { navigateWithLocation(lat, lon, currentRangeKm(), name); }, + onError: 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 }); + window.VindnLocation.useMyLocation({ + onUnsupported: function () { setStatus('Location not supported by your browser', true); }, + onStart: function () { setStatus('Finding you…', false); }, + onFound: function (lat, lon, name) { navigateWithLocation(lat, lon, currentRangeKm(), name); }, + onError: function () { setStatus("Couldn't get your location", true); } + }); }); } @@ -240,9 +210,7 @@ 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) {} - }); + clearLocationLink.addEventListener('click', function () { window.VindnLocation.clear(); }); } var sortSelect = document.getElementById('sortSelect'); @@ -253,7 +221,7 @@ }); } - var saved = loadLastLocation(); + var saved = window.VindnLocation.load(); // Every full reload here (search, geolocation, range change) already // lands with an explicit ?lat=, so this doesn't overwrite it — it only