mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-03-28 17:42:45 +01:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: '© <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>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user