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 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-01-15 22:46:36 +01:00
parent 61bd4b41ae
commit 72da96e647
5 changed files with 201 additions and 10 deletions

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap</a>'
}).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(`<b>${name}</b>`)
.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(`<b>${c.name}</b>`);
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) {
<button class="btn btn-outline-secondary btn-sm btn-reply" onclick="replyTo('${escapeHtml(msg.sender)}')">
<i class="bi bi-reply"></i> Reply
</button>
${contactsGeoCache[msg.sender] ? `
<button class="btn btn-outline-primary btn-sm ms-1" onclick="showContactOnMap('${escapeHtml(msg.sender)}', ${contactsGeoCache[msg.sender].lat}, ${contactsGeoCache[msg.sender].lon})">
<i class="bi bi-geo-alt"></i> Map
</button>
` : ''}
</div>
` : ''}
`;

View File

@@ -831,7 +831,7 @@ function createContactCard(contact, index) {
const mapBtn = document.createElement('button');
mapBtn.className = 'btn btn-outline-primary btn-action';
mapBtn.innerHTML = '<i class="bi bi-geo-alt"></i> 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 = '<i class="bi bi-geo-alt"></i> 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;

View File

@@ -17,6 +17,11 @@
<!-- Bootstrap Icons (local) -->
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap-icons/bootstrap-icons.css') }}">
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="" />
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
@@ -116,6 +121,14 @@
<small class="d-block text-muted">Direct meshcli commands</small>
</div>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3"
id="mapBtn" title="Show all contacts with GPS on map">
<i class="bi bi-map" style="font-size: 1.5rem;"></i>
<div>
<span>Map</span>
<small class="d-block text-muted">All contacts with GPS</small>
</div>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-toggle="modal" data-bs-target="#settingsModal" data-bs-dismiss="offcanvas">
<i class="bi bi-gear" style="font-size: 1.5rem;"></i>
<span>Settings</span>
@@ -262,6 +275,21 @@
</div>
</div>
<!-- Map Modal (Leaflet) -->
<div class="modal fade" id="mapModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-geo-alt"></i> <span id="mapModalTitle">Map</span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<div id="leafletMap" style="height: 400px; width: 100%;"></div>
</div>
</div>
</div>
</div>
<!-- Toast container for notifications -->
<div class="toast-container position-fixed top-0 start-0 p-3">
<div id="notificationToast" class="toast" role="alert">
@@ -276,6 +304,11 @@
<!-- Bootstrap 5 JS Bundle (local) -->
<script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<!-- Message Content Processing Utilities (must load before app.js and dm.js) -->
<script src="{{ url_for('static', filename='js/message-utils.js') }}"></script>