From 72da96e647fed410ec73024bc2e3be225feece8b Mon Sep 17 00:00:00 2001 From: MarekWo Date: Thu, 15 Jan 2026 22:46:36 +0100 Subject: [PATCH] feat: Add Leaflet map for contact locations - Replace Google Maps with Leaflet + OpenStreetMap (free, no API key) - Add Map button in main menu to show all contacts with GPS - Add Map button on message bubbles (next to Reply) for senders with GPS - Contact Management Map buttons now open modal instead of new tab - Lazy map initialization with proper Bootstrap modal handling Co-Authored-By: Claude Opus 4.5 --- README.md | 2 + app/static/css/style.css | 18 +++++ app/static/js/app.js | 146 ++++++++++++++++++++++++++++++++++++++ app/static/js/contacts.js | 12 +--- app/templates/base.html | 33 +++++++++ 5 files changed, 201 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 9fa164a..3a9148d 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ A lightweight web interface for meshcore-cli, providing browser-based access to - **Direct Messages (DM)** - Private messaging with delivery status tracking - **Smart notifications** - Unread message counters per channel with cross-device sync - **Contact management** - Manual approval mode, filtering, cleanup tools +- **Contact map** - View contacts with GPS coordinates on OpenStreetMap (Leaflet) - **Message archives** - Automatic daily archiving with browse-by-date selector - **Interactive Console** - Direct meshcli command execution via WebSocket - **PWA support** - Browser notifications and installable app (experimental) @@ -200,6 +201,7 @@ docker compose up -d --build - [x] PWA Notifications (Experimental) - Browser notifications and app badge counters - [x] Full Offline Support - Local Bootstrap libraries and Service Worker caching - [x] Interactive Console - Direct meshcli access via WebSocket with command history +- [x] Contact Map - View contacts with GPS coordinates on OpenStreetMap (Leaflet) ### Next Steps diff --git a/app/static/css/style.css b/app/static/css/style.css index e30b028..a6bb58f 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -698,3 +698,21 @@ main { opacity: 0.6; cursor: not-allowed; } + +/* ============================================================================= + Leaflet Map Modal + ============================================================================= */ + +#mapModal .modal-body { + min-height: 400px; +} + +#leafletMap { + z-index: 1; +} + +/* Fix Leaflet controls z-index in Bootstrap modal */ +.leaflet-top, +.leaflet-bottom { + z-index: 1000; +} diff --git a/app/static/js/app.js b/app/static/js/app.js index d2ac69c..55bd495 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -16,6 +16,11 @@ let unreadCounts = {}; // Track unread message counts per channel let dmLastSeenTimestamps = {}; // Track last seen DM timestamp per conversation let dmUnreadCounts = {}; // Track unread DM counts per conversation +// Map state (Leaflet) +let leafletMap = null; +let markersGroup = null; +let contactsGeoCache = {}; // { 'contactName': { lat, lon }, ... } + /** * Global navigation function - closes offcanvas and cleans up before navigation * This prevents Bootstrap backdrop/body classes from persisting after page change @@ -45,6 +50,128 @@ window.navigateTo = function(url) { }, 100); }; +// ============================================================================= +// Leaflet Map Functions +// ============================================================================= + +/** + * Initialize Leaflet map (called once on first modal open) + */ +function initLeafletMap() { + if (leafletMap) return; + + leafletMap = L.map('leafletMap').setView([52.0, 19.0], 6); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap' + }).addTo(leafletMap); + + markersGroup = L.layerGroup().addTo(leafletMap); +} + +/** + * Show single contact on map + */ +function showContactOnMap(name, lat, lon) { + const modalEl = document.getElementById('mapModal'); + const modal = new bootstrap.Modal(modalEl); + document.getElementById('mapModalTitle').textContent = name; + + const onShown = function() { + initLeafletMap(); + markersGroup.clearLayers(); + + L.marker([lat, lon]) + .addTo(markersGroup) + .bindPopup(`${name}`) + .openPopup(); + + leafletMap.setView([lat, lon], 13); + leafletMap.invalidateSize(); + + modalEl.removeEventListener('shown.bs.modal', onShown); + }; + + modalEl.addEventListener('shown.bs.modal', onShown); + modal.show(); +} + +// Make showContactOnMap available globally (for contacts.js) +window.showContactOnMap = showContactOnMap; + +/** + * Show all contacts with GPS on map + */ +async function showAllContactsOnMap() { + const modalEl = document.getElementById('mapModal'); + const modal = new bootstrap.Modal(modalEl); + document.getElementById('mapModalTitle').textContent = 'All Contacts'; + + const onShown = async function() { + initLeafletMap(); + markersGroup.clearLayers(); + + try { + const response = await fetch('/api/contacts/detailed'); + const data = await response.json(); + + if (data.success && data.contacts) { + const contactsWithGps = data.contacts.filter(c => + c.adv_lat && c.adv_lon && (c.adv_lat !== 0 || c.adv_lon !== 0) + ); + + if (contactsWithGps.length === 0) { + leafletMap.setView([52.0, 19.0], 6); + } else { + const bounds = []; + contactsWithGps.forEach(c => { + L.marker([c.adv_lat, c.adv_lon]) + .addTo(markersGroup) + .bindPopup(`${c.name}`); + bounds.push([c.adv_lat, c.adv_lon]); + }); + + if (bounds.length === 1) { + leafletMap.setView(bounds[0], 13); + } else { + leafletMap.fitBounds(bounds, { padding: [20, 20] }); + } + } + } + } catch (err) { + console.error('Error loading contacts for map:', err); + } + + leafletMap.invalidateSize(); + modalEl.removeEventListener('shown.bs.modal', onShown); + }; + + modalEl.addEventListener('shown.bs.modal', onShown); + modal.show(); +} + +/** + * Load contacts geo cache for message map buttons + */ +async function loadContactsGeoCache() { + try { + const response = await fetch('/api/contacts/detailed'); + const data = await response.json(); + + if (data.success && data.contacts) { + contactsGeoCache = {}; + data.contacts.forEach(c => { + if (c.adv_lat && c.adv_lon && (c.adv_lat !== 0 || c.adv_lon !== 0)) { + contactsGeoCache[c.name] = { lat: c.adv_lat, lon: c.adv_lon }; + } + }); + console.log(`Loaded geo cache for ${Object.keys(contactsGeoCache).length} contacts`); + } + } catch (err) { + console.error('Error loading contacts geo cache:', err); + } +} + // Initialize on page load document.addEventListener('DOMContentLoaded', async function() { console.log('mc-webui initialized'); @@ -85,6 +212,20 @@ document.addEventListener('DOMContentLoaded', async function() { updatePendingContactsBadge(); loadStatus(); + // Load contacts geo cache for map buttons on messages + loadContactsGeoCache(); + + // Map button in menu + const mapBtn = document.getElementById('mapBtn'); + if (mapBtn) { + mapBtn.addEventListener('click', () => { + // Close offcanvas first + const offcanvas = bootstrap.Offcanvas.getInstance(document.getElementById('mainMenu')); + if (offcanvas) offcanvas.hide(); + showAllContactsOnMap(); + }); + } + // Update notification toggle UI updateNotificationToggleUI(); @@ -446,6 +587,11 @@ function createMessageElement(msg) { + ${contactsGeoCache[msg.sender] ? ` + + ` : ''} ` : ''} `; diff --git a/app/static/js/contacts.js b/app/static/js/contacts.js index 221797f..0d010b4 100644 --- a/app/static/js/contacts.js +++ b/app/static/js/contacts.js @@ -831,7 +831,7 @@ function createContactCard(contact, index) { const mapBtn = document.createElement('button'); mapBtn.className = 'btn btn-outline-primary btn-action'; mapBtn.innerHTML = ' Map'; - mapBtn.onclick = () => openGoogleMaps(contact.adv_lat, contact.adv_lon); + mapBtn.onclick = () => window.showContactOnMap(contact.name, contact.adv_lat, contact.adv_lon); actionsDiv.appendChild(mapBtn); } @@ -1509,7 +1509,7 @@ function createExistingContactCard(contact, index) { const mapBtn = document.createElement('button'); mapBtn.className = 'btn btn-sm btn-outline-primary'; mapBtn.innerHTML = ' Map'; - mapBtn.onclick = () => openGoogleMaps(contact.adv_lat, contact.adv_lon); + mapBtn.onclick = () => window.showContactOnMap(contact.name, contact.adv_lat, contact.adv_lon); actionsDiv.appendChild(mapBtn); } @@ -1552,14 +1552,6 @@ function copyContactKey(publicKeyPrefix, buttonEl) { }); } -function openGoogleMaps(lat, lon) { - // Create Google Maps URL with coordinates - const mapsUrl = `https://www.google.com/maps?q=${lat},${lon}`; - - // Open in new tab - window.open(mapsUrl, '_blank'); -} - function showDeleteModal(contact) { contactToDelete = contact; diff --git a/app/templates/base.html b/app/templates/base.html index d56d8f0..ac0e909 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -17,6 +17,11 @@ + + + @@ -116,6 +121,14 @@ Direct meshcli commands + + + + + + +