mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-04 07:52:59 +02:00
Improve map UI and add QR code to node detail page
Map improvements: - Change non-infra nodes from emojis to subtle blue circles - Add "Show Chat Nodes" checkbox (hidden by default) - Fix z-index for hovered marker labels - Increase zoom on mobile devices - Simplify legend to show Infrastructure and Node icons Node detail page: - Add QR code for meshcore:// contact protocol - Move activity (first/last seen) to title row - QR code positioned under public key with white background - Protocol: meshcore://contact/add?name=<name>&public_key=<key>&type=<n> - Type mapping: chat=1, repeater=2, room=3, sensor=4 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -76,6 +76,12 @@
|
||||
<!-- Populated dynamically -->
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer gap-2 py-1">
|
||||
<span class="label-text">Show Chat Nodes</span>
|
||||
<input type="checkbox" id="show-chat" class="checkbox checkbox-sm">
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer gap-2 py-1">
|
||||
<span class="label-text">Show Labels</span>
|
||||
@@ -101,20 +107,8 @@
|
||||
<span>Infrastructure</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-lg">💬</span>
|
||||
<span>Chat</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-lg">📡</span>
|
||||
<span>Repeater</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-lg">🪧</span>
|
||||
<span>Room</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-lg">📍</span>
|
||||
<span>Other</span>
|
||||
<div style="width: 10px; height: 10px; background: #3b82f6; border: 2px solid #1e40af; border-radius: 50%;"></div>
|
||||
<span>Node</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -146,6 +140,10 @@
|
||||
// Maximum radius (km) from anchor point for bounds calculation
|
||||
const MAX_BOUNDS_RADIUS_KM = 20;
|
||||
|
||||
// Padding for fitBounds - more padding on mobile for tighter zoom
|
||||
const isMobile = window.innerWidth < 768;
|
||||
const BOUNDS_PADDING = isMobile ? [150, 150] : [100, 100];
|
||||
|
||||
// Calculate distance between two points in km (Haversine formula)
|
||||
function getDistanceKm(lat1, lon1, lat2, lon2) {
|
||||
const R = 6371; // Earth's radius in km
|
||||
@@ -185,15 +183,6 @@
|
||||
|
||||
// formatRelativeTime is provided by /static/js/utils.js
|
||||
|
||||
// Get emoji marker based on node type
|
||||
function getNodeEmoji(node) {
|
||||
const type = normalizeType(node.adv_type);
|
||||
if (type === 'chat') return '💬';
|
||||
if (type === 'repeater') return '📡';
|
||||
if (type === 'room') return '🪧';
|
||||
return '📍';
|
||||
}
|
||||
|
||||
// Get display name for node type
|
||||
function getTypeDisplay(node) {
|
||||
const type = normalizeType(node.adv_type);
|
||||
@@ -209,13 +198,12 @@
|
||||
const relativeTime = formatRelativeTime(node.last_seen);
|
||||
const timeDisplay = relativeTime ? ` (${relativeTime})` : '';
|
||||
|
||||
// Use logo for infrastructure nodes, emoji for others
|
||||
// Use logo for infrastructure nodes, blue circle for others
|
||||
let iconHtml;
|
||||
if (node.is_infra) {
|
||||
iconHtml = `<img src="${logoUrl}" alt="Infra" style="width: 24px; height: 24px; filter: drop-shadow(0 0 2px #1a237e) drop-shadow(0 0 4px #1a237e) drop-shadow(0 1px 2px rgba(0,0,0,0.7));">`;
|
||||
} else {
|
||||
const emoji = getNodeEmoji(node);
|
||||
iconHtml = `<span style="font-size: 24px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">${emoji}</span>`;
|
||||
iconHtml = `<div style="width: 12px; height: 12px; background: #3b82f6; border: 2px solid #1e40af; border-radius: 50%; box-shadow: 0 0 4px rgba(59,130,246,0.6), 0 1px 2px rgba(0,0,0,0.5);"></div>`;
|
||||
}
|
||||
|
||||
return L.divIcon({
|
||||
@@ -246,10 +234,10 @@
|
||||
|
||||
const typeDisplay = getTypeDisplay(node);
|
||||
|
||||
// Use logo for infrastructure nodes, emoji for others
|
||||
// Use logo for infrastructure nodes, blue circle for others
|
||||
const iconHtml = node.is_infra
|
||||
? `<img src="${logoUrl}" alt="Infra" style="width: 20px; height: 20px; display: inline-block; vertical-align: middle;">`
|
||||
: getNodeEmoji(node);
|
||||
: `<span style="display: inline-block; width: 12px; height: 12px; background: #3b82f6; border: 2px solid #1e40af; border-radius: 50%; vertical-align: middle;"></span>`;
|
||||
|
||||
return `
|
||||
<div class="p-2">
|
||||
@@ -278,14 +266,19 @@
|
||||
const categoryFilter = document.getElementById('filter-category').value;
|
||||
const typeFilter = document.getElementById('filter-type').value;
|
||||
const memberFilter = document.getElementById('filter-member').value;
|
||||
const showChat = document.getElementById('show-chat').checked;
|
||||
|
||||
// Filter nodes
|
||||
const filteredNodes = allNodes.filter(node => {
|
||||
// Hide chat/companion nodes unless checkbox is checked
|
||||
const nodeType = normalizeType(node.adv_type);
|
||||
if (!showChat && nodeType === 'chat') return false;
|
||||
|
||||
// Category filter (infrastructure only)
|
||||
if (categoryFilter === 'infra' && !node.is_infra) return false;
|
||||
|
||||
// Type filter (case-insensitive)
|
||||
if (typeFilter && normalizeType(node.adv_type) !== typeFilter) return false;
|
||||
if (typeFilter && nodeType !== typeFilter) return false;
|
||||
|
||||
// Member filter - match node's member_id tag to selected member_id
|
||||
if (memberFilter) {
|
||||
@@ -340,7 +333,7 @@
|
||||
}
|
||||
|
||||
const bounds = L.latLngBounds(nodesToFit.map(n => [n.lat, n.lon]));
|
||||
map.fitBounds(bounds, { padding: [100, 100] });
|
||||
map.fitBounds(bounds, { padding: BOUNDS_PADDING });
|
||||
} else if (mapCenter.lat !== 0 || mapCenter.lon !== 0) {
|
||||
map.setView([mapCenter.lat, mapCenter.lon], 10);
|
||||
}
|
||||
@@ -376,6 +369,7 @@
|
||||
document.getElementById('filter-category').value = '';
|
||||
document.getElementById('filter-type').value = '';
|
||||
document.getElementById('filter-member').value = '';
|
||||
document.getElementById('show-chat').checked = false;
|
||||
document.getElementById('show-labels').checked = false;
|
||||
updateLabelVisibility();
|
||||
applyFilters();
|
||||
@@ -396,6 +390,7 @@
|
||||
document.getElementById('filter-category').addEventListener('change', applyFilters);
|
||||
document.getElementById('filter-type').addEventListener('change', applyFilters);
|
||||
document.getElementById('filter-member').addEventListener('change', applyFilters);
|
||||
document.getElementById('show-chat').addEventListener('change', applyFilters);
|
||||
document.getElementById('show-labels').addEventListener('change', updateLabelVisibility);
|
||||
document.getElementById('clear-filters').addEventListener('click', clearFilters);
|
||||
|
||||
@@ -435,14 +430,14 @@
|
||||
const infraNodes = allNodes.filter(n => n.is_infra);
|
||||
if (infraNodes.length > 0) {
|
||||
const bounds = L.latLngBounds(infraNodes.map(n => [n.lat, n.lon]));
|
||||
map.fitBounds(bounds, { padding: [100, 100] });
|
||||
map.fitBounds(bounds, { padding: BOUNDS_PADDING });
|
||||
} else if (allNodes.length > 0) {
|
||||
// Use radius filter to exclude outliers
|
||||
const anchor = getAnchorPoint(allNodes);
|
||||
const nearbyNodes = getNodesWithinRadius(allNodes, anchor.lat, anchor.lon, MAX_BOUNDS_RADIUS_KM);
|
||||
const nodesToFit = nearbyNodes.length > 0 ? nearbyNodes : allNodes;
|
||||
const bounds = L.latLngBounds(nodesToFit.map(n => [n.lat, n.lon]));
|
||||
map.fitBounds(bounds, { padding: [100, 100] });
|
||||
map.fitBounds(bounds, { padding: BOUNDS_PADDING });
|
||||
}
|
||||
|
||||
// Apply filters (won't re-center since we just did above)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block title %}{{ network_name }} - Node Details{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||
<style>
|
||||
#node-map {
|
||||
height: 300px;
|
||||
@@ -56,36 +57,28 @@
|
||||
<!-- Node Info Card -->
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title text-2xl">
|
||||
{% if node.adv_type %}
|
||||
{% if node.adv_type|lower == 'chat' %}
|
||||
<span title="Chat">💬</span>
|
||||
{% elif node.adv_type|lower == 'repeater' %}
|
||||
<span title="Repeater">📡</span>
|
||||
{% elif node.adv_type|lower == 'room' %}
|
||||
<span title="Room">🪧</span>
|
||||
{% else %}
|
||||
<span title="{{ node.adv_type }}">📍</span>
|
||||
<!-- Title Row with Activity -->
|
||||
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||
<h1 class="card-title text-2xl">
|
||||
{% if node.adv_type %}
|
||||
{% if node.adv_type|lower == 'chat' %}
|
||||
<span title="Chat">💬</span>
|
||||
{% elif node.adv_type|lower == 'repeater' %}
|
||||
<span title="Repeater">📡</span>
|
||||
{% elif node.adv_type|lower == 'room' %}
|
||||
<span title="Room">🪧</span>
|
||||
{% else %}
|
||||
<span title="{{ node.adv_type }}">📍</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{{ ns.tag_name or node.name or 'Unnamed Node' }}
|
||||
</h1>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<div>
|
||||
<h3 class="font-semibold opacity-70 mb-2">Public Key</h3>
|
||||
<code class="text-sm bg-base-200 p-2 rounded block break-all">{{ node.public_key }}</code>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold opacity-70 mb-2">Activity</h3>
|
||||
<div class="space-y-1 text-sm">
|
||||
<p><span class="opacity-70">First seen:</span> {{ node.first_seen[:19].replace('T', ' ') if node.first_seen else '-' }}</p>
|
||||
<p><span class="opacity-70">Last seen:</span> {{ node.last_seen[:19].replace('T', ' ') if node.last_seen else '-' }}</p>
|
||||
</div>
|
||||
{{ ns.tag_name or node.name or 'Unnamed Node' }}
|
||||
</h1>
|
||||
<div class="text-sm text-right">
|
||||
<p><span class="opacity-70">First seen:</span> {{ node.first_seen[:19].replace('T', ' ') if node.first_seen else '-' }}</p>
|
||||
<p><span class="opacity-70">Last seen:</span> {{ node.last_seen[:19].replace('T', ' ') if node.last_seen else '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags and Map Grid -->
|
||||
{% set ns_map = namespace(lat=none, lon=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'lat' %}
|
||||
@@ -95,42 +88,17 @@
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<div class="grid grid-cols-1 {% if ns_map.lat and ns_map.lon %}lg:grid-cols-2{% endif %} gap-6 mt-6">
|
||||
<!-- Tags -->
|
||||
{% if node.tags or (admin_enabled and is_authenticated) %}
|
||||
<!-- Public Key + QR Code and Map Row -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
|
||||
<!-- Public Key and QR Code -->
|
||||
<div>
|
||||
<h3 class="font-semibold opacity-70 mb-2">Tags</h3>
|
||||
{% if node.tags %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tag in node.tags %}
|
||||
<tr>
|
||||
<td class="font-mono">{{ tag.key }}</td>
|
||||
<td>{{ tag.value }}</td>
|
||||
<td class="opacity-70">{{ tag.value_type or 'string' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<h3 class="font-semibold opacity-70 mb-2">Public Key</h3>
|
||||
<code class="text-sm bg-base-200 p-2 rounded block break-all">{{ node.public_key }}</code>
|
||||
<div class="mt-4">
|
||||
<div id="qr-code" class="inline-block bg-white p-3 rounded"></div>
|
||||
<p class="text-xs opacity-50 mt-2">Scan to add as contact</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm opacity-70 mb-2">No tags defined.</p>
|
||||
{% endif %}
|
||||
{% if admin_enabled and is_authenticated %}
|
||||
<div class="mt-3">
|
||||
<a href="/a/node-tags?public_key={{ node.public_key }}" class="btn btn-sm btn-outline">{% if node.tags %}Edit Tags{% else %}Add Tags{% endif %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Location Map -->
|
||||
{% if ns_map.lat and ns_map.lon %}
|
||||
@@ -143,6 +111,42 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Tags Section -->
|
||||
{% if node.tags or (admin_enabled and is_authenticated) %}
|
||||
<div class="mt-6">
|
||||
<h3 class="font-semibold opacity-70 mb-2">Tags</h3>
|
||||
{% if node.tags %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tag in node.tags %}
|
||||
<tr>
|
||||
<td class="font-mono">{{ tag.key }}</td>
|
||||
<td>{{ tag.value }}</td>
|
||||
<td class="opacity-70">{{ tag.value_type or 'string' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm opacity-70 mb-2">No tags defined.</p>
|
||||
{% endif %}
|
||||
{% if admin_enabled and is_authenticated %}
|
||||
<div class="mt-3">
|
||||
<a href="/a/node-tags?public_key={{ node.public_key }}" class="btn btn-sm btn-outline">{% if node.tags %}Edit Tags{% else %}Add Tags{% endif %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -267,6 +271,51 @@
|
||||
|
||||
{% block extra_scripts %}
|
||||
{% if node %}
|
||||
{% set ns_qr = namespace(tag_name=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'name' %}
|
||||
{% set ns_qr.tag_name = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<script>
|
||||
// Generate QR code for adding contact
|
||||
(function() {
|
||||
const nodeName = {{ (ns_qr.tag_name or node.name or 'Node') | tojson }};
|
||||
const publicKey = {{ node.public_key | tojson }};
|
||||
const advType = {{ (node.adv_type or '') | tojson }};
|
||||
|
||||
// Map adv_type to numeric type for meshcore:// protocol
|
||||
const typeMap = {
|
||||
'chat': 1,
|
||||
'repeater': 2,
|
||||
'room': 3,
|
||||
'sensor': 4
|
||||
};
|
||||
const typeNum = typeMap[advType.toLowerCase()] || 1;
|
||||
|
||||
// Build meshcore:// URL
|
||||
const meshcoreUrl = `meshcore://contact/add?name=${encodeURIComponent(nodeName)}&public_key=${publicKey}&type=${typeNum}`;
|
||||
|
||||
// Generate QR code
|
||||
const qrContainer = document.getElementById('qr-code');
|
||||
if (qrContainer && typeof QRCode !== 'undefined') {
|
||||
try {
|
||||
new QRCode(qrContainer, {
|
||||
text: meshcoreUrl,
|
||||
width: 256,
|
||||
height: 256,
|
||||
colorDark: '#000000',
|
||||
colorLight: '#ffffff',
|
||||
correctLevel: QRCode.CorrectLevel.L
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('QR code generation failed:', error);
|
||||
qrContainer.innerHTML = '<p class="text-sm opacity-50">QR code unavailable</p>';
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% set ns_map = namespace(lat=none, lon=none, name=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'lat' %}
|
||||
|
||||
Reference in New Issue
Block a user