Heavily customized version of smokesignal - https://whtwnd.com/kayrozen.com/3lpwe4ymowg2t

feat: implement internationalized map picker with user geolocation

- Add map picker modal with translations (EN/FR)
- Implement user geolocation with "Locate Me" button
- Fix HTMX form integration for proper state transitions
- Remove deprecated geocoding code and eliminate warnings
- Fix map style CORS issues and maintain midnight glass theme

kayrozen 94341587 4f4d2813

+16
i18n/en-us/forms.ftl
··· 153 153 button-back = Back 154 154 button-add-location = Add location 155 155 156 + # Map modal 157 + map-modal-default-title = Location 158 + button-open-in-openstreetmap = Open in OpenStreetMap 159 + 160 + # Map picker modal 161 + map-picker-modal-title = Choose Location on Map 162 + map-picker-instructions = Click on the map to select a location 163 + button-use-location = Use This Location 164 + 165 + # Geolocation for map picker 166 + button-locate-me = Locate Me 167 + locating-user = Locating your position... 168 + location-error = Unable to access your location 169 + location-permission-denied = Location access denied 170 + 171 + 156 172
+16 -1
i18n/fr-ca/forms.ftl
··· 154 154 title-manual-location-entry = Saisie manuelle du lieu 155 155 select-country = Sélectionner un pays 156 156 button-back = Retour 157 - button-add-location = Ajouter un lieu 157 + button-add-location = Ajouter un lieu 158 + 159 + # Map modal 160 + map-modal-default-title = Lieu 161 + button-open-in-openstreetmap = Ouvrir dans OpenStreetMap 162 + 163 + # Map picker modal 164 + map-picker-modal-title = Choisir un lieu sur la carte 165 + map-picker-instructions = Cliquez sur la carte pour sélectionner un lieu 166 + button-use-location = Utiliser ce lieu 167 + 168 + # Geolocation for map picker 169 + button-locate-me = Me localiser 170 + locating-user = Localisation en cours... 171 + location-error = Impossible d'accéder à votre position 172 + location-permission-denied = Accès à la localisation refusé
+439 -3
static/location-map-viewer.js
··· 413 413 } 414 414 415 415 openFullMap(lat, lng, venueName) { 416 - // Open in external map application 417 - const url = `https://maps.google.com/?q=${lat},${lng}`; 418 - window.open(url, '_blank', 'noopener'); 416 + // Get translations from body data attributes 417 + const defaultTitle = document.body.dataset.i18nMapDefaultTitle || 'Location'; 418 + const closeText = document.body.dataset.i18nClose || 'Close'; 419 + const openMapText = document.body.dataset.i18nOpenOpenstreetmap || 'Open in OpenStreetMap'; 420 + 421 + // Create modal for full map view 422 + const modal = document.createElement('div'); 423 + modal.className = 'modal is-active'; 424 + modal.innerHTML = ` 425 + <div class="modal-background" onclick="this.parentElement.remove()"></div> 426 + <div class="modal-content"> 427 + <div class="box"> 428 + <h3 class="title is-4">${venueName || defaultTitle}</h3> 429 + <div id="full-map-${Date.now()}" style="height: 400px; width: 100%;"></div> 430 + <div class="mt-4"> 431 + <button class="button" onclick="this.closest('.modal').remove()">${closeText}</button> 432 + <a href="https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}&zoom=16" 433 + target="_blank" class="button is-info"> 434 + <span class="icon"><i class="fas fa-external-link-alt"></i></span> 435 + <span>${openMapText}</span> 436 + </a> 437 + </div> 438 + </div> 439 + </div> 440 + <button class="modal-close is-large" onclick="this.parentElement.remove()"></button> 441 + `; 442 + 443 + document.body.appendChild(modal); 444 + 445 + // Initialize full map 446 + const mapId = modal.querySelector('[id^="full-map-"]').id; 447 + setTimeout(() => { 448 + if (typeof maplibregl !== 'undefined') { 449 + try { 450 + const fullMap = new maplibregl.Map({ 451 + container: mapId, 452 + style: { 453 + version: 8, 454 + sources: { 455 + 'osm': { 456 + type: 'raster', 457 + tiles: [ 458 + 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png', 459 + 'https://b.tile.openstreetmap.org/{z}/{x}/{y}.png', 460 + 'https://c.tile.openstreetmap.org/{z}/{x}/{y}.png' 461 + ], 462 + tileSize: 256, 463 + attribution: '© OpenStreetMap contributors' 464 + } 465 + }, 466 + layers: [ 467 + { 468 + id: 'osm', 469 + type: 'raster', 470 + source: 'osm' 471 + } 472 + ] 473 + }, 474 + center: [lng, lat], 475 + zoom: 16, 476 + attributionControl: true 477 + }); 478 + 479 + new maplibregl.Marker({ 480 + color: '#7877c6' 481 + }) 482 + .setLngLat([lng, lat]) 483 + .addTo(fullMap); 484 + 485 + // Apply midnight glass theme 486 + fullMap.on('load', () => { 487 + this.applyMidnightGlassTheme(fullMap); 488 + }); 489 + } catch (error) { 490 + console.error('Error creating full map:', error); 491 + } 492 + } 493 + }, 100); 419 494 } 420 495 421 496 hideLoading() { ··· 528 603 } 529 604 }); 530 605 } 606 + } 607 + 608 + // Export for global access 609 + window.MiniMapViewer = MiniMapViewer; 610 + 611 + // Global map picker function for location forms 612 + window.openMapPicker = function() { 613 + // Get translations from body data attributes 614 + const pickerTitle = document.body.dataset.i18nMapPickerTitle || 'Choose Location on Map'; 615 + const pickerInstructions = document.body.dataset.i18nMapPickerInstructions || 'Click on the map to select a location'; 616 + const useLocationText = document.body.dataset.i18nUseLocation || 'Use This Location'; 617 + const closeText = document.body.dataset.i18nClose || 'Close'; 618 + const locateMeText = document.body.dataset.i18nLocateMe || 'Locate Me'; 619 + const locatingUserText = document.body.dataset.i18nLocatingUser || 'Locating your position...'; 620 + const locationErrorText = document.body.dataset.i18nLocationError || 'Unable to access your location'; 621 + const locationPermissionDeniedText = document.body.dataset.i18nLocationPermissionDenied || 'Location access denied'; 622 + 623 + // Create modal for map picker 624 + const modal = document.createElement('div'); 625 + modal.className = 'modal is-active'; 626 + modal.id = 'map-picker-modal'; 627 + 628 + const mapId = 'map-picker-' + Date.now(); 629 + modal.innerHTML = ` 630 + <div class="modal-background" onclick="closeMapPicker()"></div> 631 + <div class="modal-content"> 632 + <div class="box"> 633 + <h3 class="title is-4">${pickerTitle}</h3> 634 + <p class="subtitle is-6">${pickerInstructions}</p> 635 + <div class="mb-3"> 636 + <button class="button is-info is-small" id="locate-me-btn" onclick="locateUser()"> 637 + <span class="icon"> 638 + <i class="fas fa-location-arrow"></i> 639 + </span> 640 + <span>${locateMeText}</span> 641 + </button> 642 + <div id="location-status" class="help" style="display: none;"></div> 643 + </div> 644 + <div id="${mapId}" style="height: 400px; width: 100%; cursor: crosshair;"></div> 645 + <div class="mt-4 is-flex is-justify-content-space-between"> 646 + <button class="button" onclick="closeMapPicker()">${closeText}</button> 647 + <button class="button is-primary" id="use-location-btn" onclick="useSelectedLocation()" disabled> 648 + ${useLocationText} 649 + </button> 650 + </div> 651 + </div> 652 + </div> 653 + <button class="modal-close is-large" onclick="closeMapPicker()"></button> 654 + `; 655 + 656 + document.body.appendChild(modal); 657 + 658 + let selectedLocation = null; 659 + let marker = null; 660 + let map = null; 661 + 662 + // Initialize map 663 + setTimeout(() => { 664 + if (typeof maplibregl !== 'undefined') { 665 + try { 666 + map = new maplibregl.Map({ 667 + container: mapId, 668 + style: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json', 669 + center: [-73.5673, 45.5017], // Default to Montreal 670 + zoom: 10, 671 + attributionControl: true 672 + }); 673 + 674 + // Apply midnight glass theme 675 + map.on('load', () => { 676 + if (window.MiniMapViewer && typeof window.MiniMapViewer.prototype.applyMidnightGlassTheme === 'function') { 677 + window.MiniMapViewer.prototype.applyMidnightGlassTheme.call({ map }, map); 678 + } 679 + }); 680 + 681 + // Add click handler for location selection 682 + map.on('click', async (e) => { 683 + await selectLocation(e.lngLat.lng, e.lngLat.lat); 684 + }); 685 + 686 + // Store map reference for cleanup 687 + window.mapPickerInstance = { 688 + map, 689 + marker, 690 + selectedLocation, 691 + selectLocation, 692 + translations: { 693 + locatingUserText, 694 + locationErrorText, 695 + locationPermissionDeniedText 696 + } 697 + }; 698 + 699 + } catch (error) { 700 + console.error('Error creating map picker:', error); 701 + document.getElementById(mapId).innerHTML = ` 702 + <div class="notification is-warning"> 703 + <p>Map could not be loaded. Please enter the location manually.</p> 704 + </div> 705 + `; 706 + } 707 + } 708 + }, 100); 709 + 710 + // Function to select a location (used by both click and geolocation) 711 + async function selectLocation(lng, lat) { 712 + // Remove existing marker 713 + if (marker) { 714 + marker.remove(); 715 + } 716 + 717 + // Add new marker 718 + marker = new maplibregl.Marker({ 719 + color: '#7877c6' 720 + }) 721 + .setLngLat([lng, lat]) 722 + .addTo(map); 723 + 724 + // Store selected location 725 + selectedLocation = { lat, lng }; 726 + 727 + // Enable use location button 728 + document.getElementById('use-location-btn').disabled = false; 729 + 730 + // Try to reverse geocode for address 731 + try { 732 + const response = await fetch(`http://localhost:8080/reverse?format=json&lat=${lat}&lon=${lng}&zoom=16&addressdetails=1`); 733 + const data = await response.json(); 734 + selectedLocation.address = data.display_name || ''; 735 + selectedLocation.components = data.address || {}; 736 + } catch (error) { 737 + console.warn('Reverse geocoding failed:', error); 738 + selectedLocation.address = `${lat.toFixed(6)}, ${lng.toFixed(6)}`; 739 + selectedLocation.components = {}; 740 + } 741 + 742 + // Update instance 743 + if (window.mapPickerInstance) { 744 + window.mapPickerInstance.selectedLocation = selectedLocation; 745 + window.mapPickerInstance.marker = marker; 746 + } 747 + } 748 + }; 749 + 750 + // Function to locate the user when requested 751 + window.locateUser = function() { 752 + const statusDiv = document.getElementById('location-status'); 753 + const locateBtn = document.getElementById('locate-me-btn'); 754 + 755 + if (!window.mapPickerInstance || !window.mapPickerInstance.map) { 756 + return; 757 + } 758 + 759 + const { map, translations } = window.mapPickerInstance; 760 + 761 + // Show loading state 762 + locateBtn.disabled = true; 763 + locateBtn.innerHTML = ` 764 + <span class="icon"> 765 + <i class="fas fa-spinner fa-spin"></i> 766 + </span> 767 + <span>${translations.locatingUserText}</span> 768 + `; 769 + statusDiv.style.display = 'block'; 770 + statusDiv.textContent = translations.locatingUserText; 771 + statusDiv.className = 'help has-text-info'; 772 + 773 + // Check if geolocation is supported 774 + if (!navigator.geolocation) { 775 + showLocationError(translations.locationErrorText); 776 + return; 777 + } 778 + 779 + // Get user's location 780 + navigator.geolocation.getCurrentPosition( 781 + // Success callback 782 + async (position) => { 783 + const { latitude, longitude } = position.coords; 784 + 785 + // Center map on user's location 786 + map.flyTo({ 787 + center: [longitude, latitude], 788 + zoom: 15, 789 + duration: 2000 790 + }); 791 + 792 + // Select this location 793 + await window.mapPickerInstance.selectLocation(longitude, latitude); 794 + 795 + // Reset button state 796 + resetLocateButton(); 797 + statusDiv.style.display = 'none'; 798 + }, 799 + // Error callback 800 + (error) => { 801 + let errorMessage; 802 + switch (error.code) { 803 + case error.PERMISSION_DENIED: 804 + errorMessage = translations.locationPermissionDeniedText; 805 + break; 806 + case error.POSITION_UNAVAILABLE: 807 + case error.TIMEOUT: 808 + default: 809 + errorMessage = translations.locationErrorText; 810 + break; 811 + } 812 + showLocationError(errorMessage); 813 + }, 814 + // Options 815 + { 816 + enableHighAccuracy: true, 817 + timeout: 10000, 818 + maximumAge: 300000 // 5 minutes 819 + } 820 + ); 821 + 822 + function showLocationError(message) { 823 + statusDiv.style.display = 'block'; 824 + statusDiv.textContent = message; 825 + statusDiv.className = 'help has-text-danger'; 826 + resetLocateButton(); 827 + } 828 + 829 + function resetLocateButton() { 830 + const locateMeText = document.body.dataset.i18nLocateMe || 'Locate Me'; 831 + locateBtn.disabled = false; 832 + locateBtn.innerHTML = ` 833 + <span class="icon"> 834 + <i class="fas fa-location-arrow"></i> 835 + </span> 836 + <span>${locateMeText}</span> 837 + `; 838 + } 839 + }; 840 + 841 + window.closeMapPicker = function() { 842 + const modal = document.getElementById('map-picker-modal'); 843 + if (modal) { 844 + // Clean up map instance 845 + if (window.mapPickerInstance && window.mapPickerInstance.map) { 846 + window.mapPickerInstance.map.remove(); 847 + } 848 + if (window.mapPickerInstance && window.mapPickerInstance.marker) { 849 + window.mapPickerInstance.marker.remove(); 850 + } 851 + window.mapPickerInstance = null; 852 + 853 + modal.remove(); 854 + } 855 + }; 856 + 857 + window.useSelectedLocation = function() { 858 + if (window.mapPickerInstance && window.mapPickerInstance.selectedLocation) { 859 + const location = window.mapPickerInstance.selectedLocation; 860 + 861 + // Create form data for HTMX request 862 + const formData = new FormData(); 863 + formData.append('build_state', 'Selected'); 864 + 865 + // Add location data 866 + formData.append('latitude', location.lat.toString()); 867 + formData.append('longitude', location.lng.toString()); 868 + 869 + // Parse address components from reverse geocoding 870 + if (location.address) { 871 + formData.append('location_name', location.address.split(',')[0] || ''); 872 + } 873 + 874 + if (location.components) { 875 + // Map Nominatim address components to form fields 876 + if (location.components.house_number && location.components.road) { 877 + formData.append('location_street', `${location.components.house_number} ${location.components.road}`); 878 + } else if (location.components.road) { 879 + formData.append('location_street', location.components.road); 880 + } 881 + 882 + if (location.components.city || location.components.town || location.components.village) { 883 + formData.append('location_locality', location.components.city || location.components.town || location.components.village); 884 + } 885 + 886 + if (location.components.state || location.components.province) { 887 + formData.append('location_region', location.components.state || location.components.province); 888 + } 889 + 890 + if (location.components.postcode) { 891 + formData.append('location_postal_code', location.components.postcode); 892 + } 893 + 894 + if (location.components.country) { 895 + formData.append('location_country', location.components.country); 896 + } 897 + } 898 + 899 + // Trigger HTMX request to update the form 900 + if (typeof htmx !== 'undefined') { 901 + htmx.ajax('POST', '/event/location', { 902 + values: Object.fromEntries(formData), 903 + target: '#locationGroup', 904 + swap: 'outerHTML' 905 + }); 906 + } else { 907 + // Fallback: try to fill form fields directly 908 + fillFormFields(location); 909 + } 910 + 911 + // Close modal 912 + closeMapPicker(); 913 + } 914 + }; 915 + 916 + // Fallback function to fill form fields directly 917 + function fillFormFields(location) { 918 + const latField = document.querySelector('input[name="latitude"]'); 919 + const lngField = document.querySelector('input[name="longitude"]'); 920 + const nameField = document.querySelector('input[name="location_name"]'); 921 + const streetField = document.querySelector('input[name="location_street"]'); 922 + const localityField = document.querySelector('input[name="location_locality"]'); 923 + const regionField = document.querySelector('input[name="location_region"]'); 924 + const postalField = document.querySelector('input[name="location_postal_code"]'); 925 + const countryField = document.querySelector('select[name="location_country"]'); 926 + 927 + if (latField) latField.value = location.lat.toString(); 928 + if (lngField) lngField.value = location.lng.toString(); 929 + 930 + if (location.address && nameField) { 931 + nameField.value = location.address.split(',')[0] || ''; 932 + } 933 + 934 + if (location.components) { 935 + if (streetField && location.components.road) { 936 + if (location.components.house_number) { 937 + streetField.value = `${location.components.house_number} ${location.components.road}`; 938 + } else { 939 + streetField.value = location.components.road; 940 + } 941 + } 942 + 943 + if (localityField && (location.components.city || location.components.town || location.components.village)) { 944 + localityField.value = location.components.city || location.components.town || location.components.village; 945 + } 946 + 947 + if (regionField && (location.components.state || location.components.province)) { 948 + regionField.value = location.components.state || location.components.province; 949 + } 950 + 951 + if (postalField && location.components.postcode) { 952 + postalField.value = location.components.postcode; 953 + } 954 + 955 + if (countryField && location.components.country) { 956 + countryField.value = location.components.country; 957 + } 958 + } 959 + 960 + // Trigger events to notify form of changes 961 + [latField, lngField, nameField, streetField, localityField, regionField, postalField, countryField].forEach(field => { 962 + if (field) { 963 + field.dispatchEvent(new Event('input', { bubbles: true })); 964 + field.dispatchEvent(new Event('change', { bubbles: true })); 965 + } 966 + }); 531 967 } 532 968 533 969 // Initialize maps when DOM is ready
-504
static/map-integration.js
··· 1 - /** 2 - * Map Integration Component 3 - * Handles MapLibreGL integration for venue location picking and display 4 - */ 5 - class MapIntegration { 6 - constructor() { 7 - this.map = null; 8 - this.marker = null; 9 - this.selectedLocation = null; 10 - 11 - this.init(); 12 - } 13 - 14 - init() { 15 - // Try to initialize maps when DOM is ready 16 - if (document.readyState === 'loading') { 17 - document.addEventListener('DOMContentLoaded', () => { 18 - this.initializeMiniMaps(); 19 - this.setupHTMXListener(); 20 - }); 21 - } else { 22 - this.initializeMiniMaps(); 23 - this.setupHTMXListener(); 24 - } 25 - } 26 - 27 - setupHTMXListener() { 28 - // Re-initialize after HTMX swaps 29 - if (document.body) { 30 - document.body.addEventListener('htmx:afterSwap', (e) => { 31 - this.initializeMiniMaps(e.target); 32 - }); 33 - } 34 - } 35 - 36 - initializeMiniMaps(container = document) { 37 - // Initialize mini maps for event display 38 - const miniMaps = container.querySelectorAll('.venue-map-preview[data-lat][data-lng]'); 39 - miniMaps.forEach(mapContainer => { 40 - const lat = parseFloat(mapContainer.dataset.lat); 41 - const lng = parseFloat(mapContainer.dataset.lng); 42 - const venueName = mapContainer.dataset.venueName || 'Event Location'; 43 - 44 - if (lat && lng && !mapContainer.querySelector('.maplibregl-map')) { 45 - this.createMiniMap(mapContainer, lat, lng, venueName); 46 - } 47 - }); 48 - 49 - // Initialize mini maps for event listings 50 - const eventMiniMaps = container.querySelectorAll('.event-mini-map[data-lat][data-lng]'); 51 - eventMiniMaps.forEach(mapContainer => { 52 - const lat = parseFloat(mapContainer.dataset.lat); 53 - const lng = parseFloat(mapContainer.dataset.lng); 54 - const venueName = mapContainer.dataset.venueName || 'Event Location'; 55 - 56 - if (lat && lng && !mapContainer.querySelector('.maplibregl-map')) { 57 - this.createMiniMap(mapContainer, lat, lng, venueName); 58 - } 59 - }); 60 - } 61 - 62 - createMiniMap(container, lat, lng, title) { 63 - // Use MapLibreGL for mini maps 64 - if (typeof maplibregl === 'undefined') { 65 - // Fallback if MapLibreGL is not loaded 66 - container.innerHTML = ` 67 - <div class="map-fallback"> 68 - <p class="has-text-centered"> 69 - <span class="icon"><i class="fas fa-map-marker-alt"></i></span><br> 70 - <strong>${title}</strong><br> 71 - <small>${lat.toFixed(4)}, ${lng.toFixed(4)}</small> 72 - </p> 73 - </div> 74 - `; 75 - return; 76 - } 77 - 78 - try { 79 - // Set container dimensions 80 - container.style.height = '200px'; 81 - container.style.width = '100%'; 82 - 83 - // Create MapLibreGL map with midnight glass theme 84 - const map = new maplibregl.Map({ 85 - container: container, 86 - style: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json', 87 - center: [lng, lat], 88 - zoom: 14, 89 - interactive: false, // Disable interaction for mini maps 90 - attributionControl: false 91 - }); 92 - 93 - // Add marker with midnight glass theme 94 - new maplibregl.Marker({ 95 - color: '#7877c6' 96 - }) 97 - .setLngLat([lng, lat]) 98 - .setPopup(new maplibregl.Popup().setText(title)) 99 - .addTo(map); 100 - 101 - // Apply midnight glass theme 102 - map.on('load', () => { 103 - this.applyMidnightGlassTheme(map); 104 - }); 105 - 106 - // Add click handler to open full map 107 - container.style.cursor = 'pointer'; 108 - container.addEventListener('click', () => { 109 - this.showFullMap(lat, lng, title); 110 - }); 111 - 112 - } catch (error) { 113 - console.error('Error creating mini map:', error); 114 - // Fallback to text display 115 - container.innerHTML = ` 116 - <div class="map-fallback"> 117 - <p class="has-text-centered"> 118 - <span class="icon"><i class="fas fa-map-marker-alt"></i></span><br> 119 - <strong>${title}</strong><br> 120 - <small>${lat.toFixed(4)}, ${lng.toFixed(4)}</small> 121 - </p> 122 - </div> 123 - `; 124 - } 125 - } 126 - 127 - showFullMap(lat, lng, title) { 128 - // Create modal for full map view 129 - const modal = document.createElement('div'); 130 - modal.className = 'modal is-active'; 131 - modal.innerHTML = ` 132 - <div class="modal-background" onclick="this.parentElement.remove()"></div> 133 - <div class="modal-content"> 134 - <div class="box"> 135 - <h3 class="title is-4">${title}</h3> 136 - <div id="full-map-${Date.now()}" style="height: 400px; width: 100%;"></div> 137 - <div class="mt-4"> 138 - <button class="button" onclick="this.closest('.modal').remove()">Close</button> 139 - <a href="https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}&zoom=16" 140 - target="_blank" class="button is-info"> 141 - <span class="icon"><i class="fas fa-external-link-alt"></i></span> 142 - <span>Open in OpenStreetMap</span> 143 - </a> 144 - <a href="https://maps.google.com/?q=${lat},${lng}" 145 - target="_blank" class="button is-link"> 146 - <span class="icon"><i class="fas fa-external-link-alt"></i></span> 147 - <span>Open in Google Maps</span> 148 - </a> 149 - </div> 150 - </div> 151 - </div> 152 - <button class="modal-close is-large" onclick="this.parentElement.remove()"></button> 153 - `; 154 - 155 - document.body.appendChild(modal); 156 - 157 - // Initialize full map 158 - const mapId = modal.querySelector('[id^="full-map-"]').id; 159 - setTimeout(() => { 160 - if (typeof maplibregl !== 'undefined') { 161 - const fullMap = new maplibregl.Map({ 162 - container: mapId, 163 - style: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json', 164 - center: [lng, lat], 165 - zoom: 15 166 - }); 167 - 168 - new maplibregl.Marker({ 169 - color: '#7877c6' 170 - }) 171 - .setLngLat([lng, lat]) 172 - .setPopup(new maplibregl.Popup().setText(title)) 173 - .addTo(fullMap); 174 - 175 - // Add navigation controls (zoom buttons) 176 - fullMap.addControl(new maplibregl.NavigationControl(), 'top-right'); 177 - 178 - // Apply midnight glass theme 179 - fullMap.on('load', () => { 180 - window.mapIntegration.applyMidnightGlassTheme(fullMap); 181 - }); 182 - } else { 183 - // Fallback for when MapLibreGL is not available 184 - document.getElementById(mapId).innerHTML = ` 185 - <div class="map-fallback" style="height: 400px; display: flex; align-items: center; justify-content: center; background: #f5f5f5; border: 1px solid #ddd;"> 186 - <div class="has-text-centered"> 187 - <span class="icon is-large"><i class="fas fa-map-marker-alt fa-2x"></i></span><br> 188 - <strong>${title}</strong><br> 189 - <small>${lat.toFixed(4)}, ${lng.toFixed(4)}</small> 190 - </div> 191 - </div> 192 - `; 193 - } 194 - }, 100); 195 - } 196 - 197 - applyMidnightGlassTheme(map) { 198 - // Fond et terre 199 - if(map.getLayer('background')) { 200 - map.setPaintProperty('background', 'background-color', '#1a1b2a'); 201 - } 202 - if(map.getLayer('landcover')) { 203 - map.setPaintProperty('landcover', 'fill-color', '#2e2f49'); 204 - } 205 - if(map.getLayer('landuse_residential')) { 206 - map.setPaintProperty('landuse_residential', 'fill-color', '#3a3b5e'); 207 - map.setPaintProperty('landuse_residential', 'fill-opacity', 0.4); 208 - } 209 - if(map.getLayer('landuse')) { 210 - map.setPaintProperty('landuse', 'fill-color', '#3a3b5e'); 211 - map.setPaintProperty('landuse', 'fill-opacity', 0.3); 212 - } 213 - 214 - // Eau et ombres 215 - if(map.getLayer('water')) { 216 - map.setPaintProperty('water', 'fill-color', [ 217 - 'interpolate', ['linear'], ['zoom'], 218 - 0, '#2b2f66', 219 - 10, '#5b5ea6', 220 - 15, '#8779c3' 221 - ]); 222 - map.setPaintProperty('water', 'fill-opacity', 0.85); 223 - } 224 - if(map.getLayer('water_shadow')) { 225 - map.setPaintProperty('water_shadow', 'fill-color', '#555a9a'); 226 - map.setPaintProperty('water_shadow', 'fill-opacity', 0.3); 227 - } 228 - 229 - // Parcs 230 - ['park_national_park', 'park_nature_reserve'].forEach(id => { 231 - if(map.getLayer(id)) { 232 - map.setPaintProperty(id, 'fill-color', '#50537a'); 233 - map.setPaintProperty(id, 'fill-opacity', 0.3); 234 - } 235 - }); 236 - 237 - // Routes principales et secondaires 238 - const roadsPrimary = [ 239 - 'road_pri_case_noramp', 'road_pri_fill_noramp', 240 - 'road_pri_case_ramp', 'road_pri_fill_ramp' 241 - ]; 242 - roadsPrimary.forEach(id => { 243 - if(map.getLayer(id)) { 244 - map.setPaintProperty(id, 'line-color', '#9389b8'); 245 - if(id.includes('fill')) { 246 - map.setPaintProperty(id, 'line-width', 2); 247 - } 248 - } 249 - }); 250 - 251 - const roadsSecondary = [ 252 - 'road_sec_case_noramp', 'road_sec_fill_noramp' 253 - ]; 254 - roadsSecondary.forEach(id => { 255 - if(map.getLayer(id)) { 256 - map.setPaintProperty(id, 'line-color', '#6d6ea1'); 257 - if(id.includes('fill')) { 258 - map.setPaintProperty(id, 'line-width', 1.5); 259 - } 260 - } 261 - }); 262 - 263 - // Bâtiments 264 - ['building', 'building-top'].forEach(id => { 265 - if(map.getLayer(id)) { 266 - map.setPaintProperty(id, 'fill-color', '#9a92bc'); 267 - map.setPaintProperty(id, 'fill-opacity', 0.35); 268 - } 269 - }); 270 - 271 - // Ponts 272 - ['bridge_pri_case', 'bridge_pri_fill', 'bridge_sec_case', 'bridge_sec_fill'].forEach(id => { 273 - if(map.getLayer(id)) { 274 - map.setPaintProperty(id, 'line-color', '#7a75aa'); 275 - } 276 - }); 277 - } 278 - } 279 - 280 - // Map Picker functionality for location selection 281 - window.openMapPicker = function() { 282 - const modal = document.createElement('div'); 283 - modal.id = 'map-picker-modal'; 284 - modal.className = 'modal is-active'; 285 - modal.innerHTML = ` 286 - <div class="modal-background" onclick="closeMapPicker()"></div> 287 - <div class="modal-card"> 288 - <header class="modal-card-head"> 289 - <p class="modal-card-title">Pick Location on Map</p> 290 - <button class="delete" onclick="closeMapPicker()"></button> 291 - </header> 292 - <section class="modal-card-body"> 293 - <div class="notification is-info is-light"> 294 - <span class="icon"><i class="fas fa-mouse-pointer"></i></span> 295 - <strong>Click anywhere on the map to select a location.</strong> 296 - <p>The address will be automatically filled in the form.</p> 297 - </div> 298 - <div id="location-picker-map" style="height: 400px; width: 100%;"></div> 299 - <div id="selected-location-info" class="is-hidden"> 300 - <div class="notification is-success is-light mt-3"> 301 - <h6 class="title is-6">Selected Location:</h6> 302 - <p><strong>Coordinates:</strong> <span id="selected-coords"></span></p> 303 - <p><strong>Address:</strong> <span id="selected-address">Loading...</span></p> 304 - </div> 305 - </div> 306 - </section> 307 - <footer class="modal-card-foot"> 308 - <button id="use-location-btn" class="button is-primary" onclick="useSelectedLocation()" disabled> 309 - Use This Location 310 - </button> 311 - <button class="button" onclick="closeMapPicker()">Cancel</button> 312 - </footer> 313 - </div> 314 - `; 315 - 316 - document.body.appendChild(modal); 317 - 318 - // Initialize map picker 319 - setTimeout(() => { 320 - initializeMapPicker(); 321 - }, 100); 322 - }; 323 - 324 - window.closeMapPicker = function() { 325 - const modal = document.getElementById('map-picker-modal'); 326 - if (modal) { 327 - modal.remove(); 328 - } 329 - }; 330 - 331 - function initializeMapPicker() { 332 - if (typeof maplibregl === 'undefined') { 333 - console.error('MapLibreGL is not loaded'); 334 - // Show fallback message 335 - document.getElementById('location-picker-map').innerHTML = ` 336 - <div class="map-fallback" style="height: 400px; display: flex; align-items: center; justify-content: center; background: #f5f5f5; border: 1px solid #ddd;"> 337 - <div class="has-text-centered"> 338 - <span class="icon is-large"><i class="fas fa-exclamation-triangle fa-2x"></i></span><br> 339 - <strong>Map not available</strong><br> 340 - <small>Please enter coordinates manually</small> 341 - </div> 342 - </div> 343 - `; 344 - return; 345 - } 346 - 347 - // Default to Montreal coordinates 348 - let defaultLat = 45.5088; 349 - let defaultLng = -73.5878; 350 - 351 - // Try to get user's location 352 - if (navigator.geolocation) { 353 - navigator.geolocation.getCurrentPosition( 354 - position => { 355 - defaultLat = position.coords.latitude; 356 - defaultLng = position.coords.longitude; 357 - initMap(defaultLat, defaultLng); 358 - }, 359 - () => { 360 - // Fallback to default location 361 - initMap(defaultLat, defaultLng); 362 - } 363 - ); 364 - } else { 365 - initMap(defaultLat, defaultLng); 366 - } 367 - 368 - function initMap(lat, lng) { 369 - const map = new maplibregl.Map({ 370 - container: 'location-picker-map', 371 - style: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json', 372 - center: [lng, lat], 373 - zoom: 12 374 - }); 375 - 376 - let selectedMarker = null; 377 - window.mapPickerSelectedLocation = null; 378 - 379 - // Add navigation controls (zoom buttons) 380 - map.addControl(new maplibregl.NavigationControl(), 'top-right'); 381 - 382 - // Apply midnight glass theme 383 - map.on('load', () => { 384 - window.mapIntegration.applyMidnightGlassTheme(map); 385 - }); 386 - 387 - // Handle map clicks 388 - map.on('click', function(e) { 389 - const { lat, lng } = e.lngLat; 390 - 391 - // Remove existing marker 392 - if (selectedMarker) { 393 - selectedMarker.remove(); 394 - } 395 - 396 - // Add new marker with midnight glass theme 397 - selectedMarker = new maplibregl.Marker({ 398 - color: '#7877c6' 399 - }) 400 - .setLngLat([lng, lat]) 401 - .addTo(map); 402 - 403 - // Store selected location 404 - window.mapPickerSelectedLocation = { lat, lng }; 405 - 406 - // Update UI 407 - document.getElementById('selected-coords').textContent = `${lat.toFixed(4)}, ${lng.toFixed(4)}`; 408 - document.getElementById('selected-location-info').classList.remove('is-hidden'); 409 - document.getElementById('use-location-btn').disabled = false; 410 - 411 - // Reverse geocode to get address 412 - reverseGeocode(lat, lng); 413 - }); 414 - 415 - // Add geolocation control 416 - map.addControl(new maplibregl.GeolocateControl({ 417 - positionOptions: { 418 - enableHighAccuracy: true 419 - }, 420 - trackUserLocation: true, 421 - showUserHeading: true 422 - })); 423 - } 424 - } 425 - 426 - function reverseGeocode(lat, lng) { 427 - // Use Nominatim for reverse geocoding 428 - const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1`; 429 - 430 - fetch(url) 431 - .then(response => response.json()) 432 - .then(data => { 433 - const address = data.display_name || `${lat.toFixed(4)}, ${lng.toFixed(4)}`; 434 - document.getElementById('selected-address').textContent = address; 435 - 436 - // Store address components for form population 437 - if (data.address) { 438 - window.mapPickerSelectedLocation.addressComponents = data.address; 439 - window.mapPickerSelectedLocation.displayName = data.display_name; 440 - } 441 - }) 442 - .catch(error => { 443 - console.error('Reverse geocoding error:', error); 444 - document.getElementById('selected-address').textContent = 'Address lookup failed'; 445 - }); 446 - } 447 - 448 - window.useSelectedLocation = function() { 449 - if (!window.mapPickerSelectedLocation) { 450 - return; 451 - } 452 - 453 - const location = window.mapPickerSelectedLocation; 454 - const address = location.addressComponents || {}; 455 - 456 - // Populate form fields 457 - const fields = { 458 - 'latitude': location.lat, 459 - 'longitude': location.lng, 460 - 'location_name': address.building || address.house_number && address.road ? `${address.house_number} ${address.road}` : '', 461 - 'location_street': address.road || '', 462 - 'location_locality': address.city || address.town || address.village || '', 463 - 'location_region': address.state || address.province || '', 464 - 'location_postal_code': address.postcode || '', 465 - 'location_country': address.country_code ? address.country_code.toUpperCase() : '' 466 - }; 467 - 468 - // Update form fields 469 - Object.keys(fields).forEach(fieldName => { 470 - const field = document.querySelector(`input[name="${fieldName}"], select[name="${fieldName}"]`); 471 - if (field && fields[fieldName]) { 472 - field.value = fields[fieldName]; 473 - } else if (fields[fieldName]) { 474 - // Create hidden field 475 - const hiddenField = document.createElement('input'); 476 - hiddenField.type = 'hidden'; 477 - hiddenField.name = fieldName; 478 - hiddenField.value = fields[fieldName]; 479 - document.querySelector('#locationGroup').appendChild(hiddenField); 480 - } 481 - }); 482 - 483 - // Trigger form update via HTMX 484 - htmx.ajax('POST', '/event/location', { 485 - values: { 486 - build_state: 'Selected', 487 - ...fields 488 - }, 489 - target: '#locationGroup', 490 - swap: 'outerHTML' 491 - }); 492 - 493 - // Close modal 494 - closeMapPicker(); 495 - }; 496 - 497 - // Initialize map integration when DOM is ready 498 - if (document.readyState === 'loading') { 499 - document.addEventListener('DOMContentLoaded', () => { 500 - window.mapIntegration = new MapIntegration(); 501 - }); 502 - } else { 503 - window.mapIntegration = new MapIntegration(); 504 - }
+12 -3
templates/base.en-us.html
··· 20 20 <script src="/static/sse.js"></script> 21 21 <script src="/static/site.js"></script> 22 22 <script src="/static/venue-search.js"></script> 23 - <script src="/static/map-integration.js"></script> 23 + <script src="/static/location-map-viewer.js"></script> 24 24 <script src="/static/form-enhancement.js"></script> 25 - <script src="/static/location-map-viewer.js"></script> 26 25 27 26 <!-- MapLibreGL JS --> 28 27 <script src="https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.js"></script> ··· 78 77 {% endblock %} 79 78 <meta name="theme-color" content="#00d1b2"> 80 79 </head> 81 - <body hx-ext="loading-states"> 80 + <body hx-ext="loading-states" 81 + data-i18n-map-default-title="{{ t('map-modal-default-title') }}" 82 + data-i18n-close="{{ t('close') }}" 83 + data-i18n-open-openstreetmap="{{ t('button-open-in-openstreetmap') }}" 84 + data-i18n-map-picker-title="{{ t('map-picker-modal-title') }}" 85 + data-i18n-map-picker-instructions="{{ t('map-picker-instructions') }}" 86 + data-i18n-use-location="{{ t('button-use-location') }}" 87 + data-i18n-locate-me="{{ t('button-locate-me') }}" 88 + data-i18n-locating-user="{{ t('locating-user') }}" 89 + data-i18n-location-error="{{ t('location-error') }}" 90 + data-i18n-location-permission-denied="{{ t('location-permission-denied') }}"> 82 91 {% include 'nav.' + current_locale + '.html' %} 83 92 {% block content %}{% endblock %} 84 93 {% include 'footer.' + current_locale + '.html' %}
+12 -3
templates/base.fr-ca.html
··· 20 20 <script src="/static/sse.js"></script> 21 21 <script src="/static/site.js"></script> 22 22 <script src="/static/venue-search.js"></script> 23 - <script src="/static/map-integration.js"></script> 23 + <script src="/static/location-map-viewer.js"></script> 24 24 <script src="/static/form-enhancement.js"></script> 25 - <script src="/static/location-map-viewer.js"></script> 26 25 27 26 <!-- MapLibreGL JS --> 28 27 <script src="https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.js"></script> ··· 195 194 } 196 195 </style> 197 196 </head> 198 - <body hx-ext="loading-states"> 197 + <body hx-ext="loading-states" 198 + data-i18n-map-default-title="{{ t('map-modal-default-title') }}" 199 + data-i18n-close="{{ t('close') }}" 200 + data-i18n-open-openstreetmap="{{ t('button-open-in-openstreetmap') }}" 201 + data-i18n-map-picker-title="{{ t('map-picker-modal-title') }}" 202 + data-i18n-map-picker-instructions="{{ t('map-picker-instructions') }}" 203 + data-i18n-use-location="{{ t('button-use-location') }}" 204 + data-i18n-locate-me="{{ t('button-locate-me') }}" 205 + data-i18n-locating-user="{{ t('locating-user') }}" 206 + data-i18n-location-error="{{ t('location-error') }}" 207 + data-i18n-location-permission-denied="{{ t('location-permission-denied') }}"> 199 208 {% include 'nav.' + current_locale + '.html' %} 200 209 {% block content %}{% endblock %} 201 210 {% include 'footer.' + current_locale + '.html' %}
+1 -1
templates/create_event.en-us.html
··· 8 8 <script src="https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.js"></script> 9 9 10 10 <!-- Map Integration (for location picking) --> 11 - <script src="/static/map-integration.js"></script> 11 + 12 12 13 13 <!-- Venue Search JS --> 14 14 <script src="/static/venue-search.js"></script>
+1 -1
templates/edit_event.en-us.html
··· 8 8 <script src="https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.js"></script> 9 9 10 10 <!-- Map Integration (for location picking) --> 11 - <script src="/static/map-integration.js"></script> 11 + 12 12 13 13 <!-- Venue Search JS --> 14 14 <script src="/static/venue-search.js"></script>