mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-03-28 17:42:56 +01:00
Redesign node detail page with hero map header
- Add hero panel with non-interactive map background when GPS coords exist - Fix coordinate detection: check node model fields before falling back to tags - Move node name to standard page header above hero panel - QR code displayed in hero panel (right side, 140px) - Map pans to show node at 1/3 horizontal position (avoiding QR overlap) - Replace Telemetry section with Tags card in grid layout - Consolidate First Seen, Last Seen, Location into single row - Add configurable offset support to map-node.js (offsetX, offsetY) - Add configurable size support to qrcode-init.js
This commit is contained in:
@@ -347,3 +347,19 @@ footer {
|
||||
.leaflet-marker-icon:hover {
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Node Header Hero Map Background
|
||||
========================================================================== */
|
||||
|
||||
#header-map {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Ensure Leaflet elements stay within the map layer */
|
||||
#header-map .leaflet-pane,
|
||||
#header-map .leaflet-control {
|
||||
z-index: auto !important;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
* - name: Node display name (required)
|
||||
* - type: Node adv_type (optional)
|
||||
* - publicKey: Node public key (optional, for linking)
|
||||
* - elementId: Map container element ID (default: 'node-map')
|
||||
* - interactive: Enable map interactions (default: true)
|
||||
* - zoom: Initial zoom level (default: 15)
|
||||
* - showMarker: Show node marker (default: true)
|
||||
* - offsetX: Horizontal position of node (0-1, default: 0.5 = center)
|
||||
* - offsetY: Vertical position of node (0-1, default: 0.5 = center)
|
||||
*/
|
||||
|
||||
(function() {
|
||||
@@ -26,54 +32,90 @@
|
||||
var nodeLon = config.lon;
|
||||
var nodeName = config.name || 'Unnamed Node';
|
||||
var nodeType = config.type || '';
|
||||
var elementId = config.elementId || 'node-map';
|
||||
var interactive = config.interactive !== false; // Default true
|
||||
var zoomLevel = config.zoom || 15;
|
||||
var showMarker = config.showMarker !== false; // Default true
|
||||
var offsetX = typeof config.offsetX === 'number' ? config.offsetX : 0.5; // 0-1, default center
|
||||
var offsetY = typeof config.offsetY === 'number' ? config.offsetY : 0.5; // 0-1, default center
|
||||
|
||||
// Check if map container exists
|
||||
var mapContainer = document.getElementById('node-map');
|
||||
var mapContainer = document.getElementById(elementId);
|
||||
if (!mapContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build map options
|
||||
var mapOptions = {};
|
||||
|
||||
// Disable interactions if non-interactive
|
||||
if (!interactive) {
|
||||
mapOptions.dragging = false;
|
||||
mapOptions.touchZoom = false;
|
||||
mapOptions.scrollWheelZoom = false;
|
||||
mapOptions.doubleClickZoom = false;
|
||||
mapOptions.boxZoom = false;
|
||||
mapOptions.keyboard = false;
|
||||
mapOptions.zoomControl = false;
|
||||
mapOptions.attributionControl = false;
|
||||
}
|
||||
|
||||
// Initialize map centered on the node's location
|
||||
var map = L.map('node-map').setView([nodeLat, nodeLon], 15);
|
||||
var map = L.map(elementId, mapOptions).setView([nodeLat, nodeLon], zoomLevel);
|
||||
|
||||
// Apply offset to position node at specified location instead of center
|
||||
// offsetX/Y of 0.5 = center (no pan), 0.33 = 1/3 from left/top
|
||||
if (offsetX !== 0.5 || offsetY !== 0.5) {
|
||||
var containerWidth = mapContainer.offsetWidth;
|
||||
var containerHeight = mapContainer.offsetHeight;
|
||||
// Pan amount: how far to move the map so node appears at offset position
|
||||
// Positive X = pan right (node moves left), Positive Y = pan down (node moves up)
|
||||
var panX = (0.5 - offsetX) * containerWidth;
|
||||
var panY = (0.5 - offsetY) * containerHeight;
|
||||
map.panBy([panX, panY], { animate: false });
|
||||
}
|
||||
|
||||
// Add tile layer
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}).addTo(map);
|
||||
|
||||
/**
|
||||
* Get emoji marker based on node type
|
||||
*/
|
||||
function getNodeEmoji(type) {
|
||||
var normalizedType = type ? type.toLowerCase() : null;
|
||||
if (normalizedType === 'chat') return '💬';
|
||||
if (normalizedType === 'repeater') return '📡';
|
||||
if (normalizedType === 'room') return '🪧';
|
||||
return '📍';
|
||||
// Only add marker if showMarker is true
|
||||
if (showMarker) {
|
||||
/**
|
||||
* Get emoji marker based on node type
|
||||
*/
|
||||
function getNodeEmoji(type) {
|
||||
var normalizedType = type ? type.toLowerCase() : null;
|
||||
if (normalizedType === 'chat') return '💬';
|
||||
if (normalizedType === 'repeater') return '📡';
|
||||
if (normalizedType === 'room') return '🪧';
|
||||
return '📍';
|
||||
}
|
||||
|
||||
// Create marker icon (just the emoji, no label)
|
||||
var emoji = getNodeEmoji(nodeType);
|
||||
var icon = L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: '<span style="font-size: 32px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">' + emoji + '</span>',
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16]
|
||||
});
|
||||
|
||||
// Add marker
|
||||
var marker = L.marker([nodeLat, nodeLon], { icon: icon }).addTo(map);
|
||||
|
||||
// Only add popup if map is interactive
|
||||
if (interactive) {
|
||||
var typeHtml = nodeType ? '<p><span class="opacity-70">Type:</span> ' + nodeType + '</p>' : '';
|
||||
var popupContent = '<div class="p-2">' +
|
||||
'<h3 class="font-bold text-lg mb-2">' + emoji + ' ' + nodeName + '</h3>' +
|
||||
'<div class="space-y-1 text-sm">' +
|
||||
typeHtml +
|
||||
'<p><span class="opacity-70">Coordinates:</span> ' + nodeLat.toFixed(4) + ', ' + nodeLon.toFixed(4) + '</p>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
marker.bindPopup(popupContent);
|
||||
}
|
||||
}
|
||||
|
||||
// Create marker icon (just the emoji, no label)
|
||||
var emoji = getNodeEmoji(nodeType);
|
||||
var icon = L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: '<span style="font-size: 32px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">' + emoji + '</span>',
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16]
|
||||
});
|
||||
|
||||
// Add marker
|
||||
var marker = L.marker([nodeLat, nodeLon], { icon: icon }).addTo(map);
|
||||
|
||||
// Build popup content
|
||||
var typeHtml = nodeType ? '<p><span class="opacity-70">Type:</span> ' + nodeType + '</p>' : '';
|
||||
var popupContent = '<div class="p-2">' +
|
||||
'<h3 class="font-bold text-lg mb-2">' + emoji + ' ' + nodeName + '</h3>' +
|
||||
'<div class="space-y-1 text-sm">' +
|
||||
typeHtml +
|
||||
'<p><span class="opacity-70">Coordinates:</span> ' + nodeLat.toFixed(4) + ', ' + nodeLon.toFixed(4) + '</p>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
// Add popup (shown on click, not by default)
|
||||
marker.bindPopup(popupContent);
|
||||
})();
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* - publicKey: 64-char hex public key (required)
|
||||
* - advType: Node advertisement type (optional)
|
||||
* - containerId: ID of container element (default: 'qr-code')
|
||||
* - size: QR code size in pixels (default: 128)
|
||||
*/
|
||||
|
||||
(function() {
|
||||
@@ -25,6 +26,7 @@
|
||||
var publicKey = config.publicKey;
|
||||
var advType = config.advType || '';
|
||||
var containerId = config.containerId || 'qr-code';
|
||||
var size = config.size || 128;
|
||||
|
||||
// Map adv_type to numeric type for meshcore:// protocol
|
||||
var typeMap = {
|
||||
@@ -46,8 +48,8 @@
|
||||
try {
|
||||
new QRCode(qrContainer, {
|
||||
text: meshcoreUrl,
|
||||
width: 256,
|
||||
height: 256,
|
||||
width: size,
|
||||
height: size,
|
||||
colorDark: '#000000',
|
||||
colorLight: '#ffffff',
|
||||
correctLevel: QRCode.CorrectLevel.L
|
||||
|
||||
@@ -34,105 +34,93 @@
|
||||
{% endif %}
|
||||
|
||||
{% if node %}
|
||||
{# Get display name from tag or node.name #}
|
||||
{% set ns = namespace(tag_name=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'name' %}
|
||||
{% set ns.tag_name = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<!-- Node Info Card -->
|
||||
{% set display_name = ns.tag_name or node.name or 'Unnamed Node' %}
|
||||
|
||||
{# Get coordinates from node model first, then fall back to tags (bug fix) #}
|
||||
{% set ns_coords = namespace(lat=node.lat, lon=node.lon) %}
|
||||
{% if not ns_coords.lat or not ns_coords.lon %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'lat' and not ns_coords.lat %}
|
||||
{% set ns_coords.lat = tag.value|float %}
|
||||
{% elif tag.key == 'lon' and not ns_coords.lon %}
|
||||
{% set ns_coords.lon = tag.value|float %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% set has_coords = ns_coords.lat is not none and ns_coords.lon is not none %}
|
||||
|
||||
{# Node type emoji #}
|
||||
{% set type_emoji = '📍' %}
|
||||
{% if node.adv_type %}
|
||||
{% if node.adv_type|lower == 'chat' %}
|
||||
{% set type_emoji = '💬' %}
|
||||
{% elif node.adv_type|lower == 'repeater' %}
|
||||
{% set type_emoji = '📡' %}
|
||||
{% elif node.adv_type|lower == 'room' %}
|
||||
{% set type_emoji = '🪧' %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Page Header -->
|
||||
<h1 class="text-3xl font-bold mb-6">
|
||||
<span title="{{ node.adv_type or 'Unknown' }}">{{ type_emoji }}</span>
|
||||
{{ display_name }}
|
||||
</h1>
|
||||
|
||||
<!-- Node Hero Panel -->
|
||||
{% if has_coords %}
|
||||
<div class="relative rounded-box overflow-hidden mb-6 shadow-xl" style="height: 180px;">
|
||||
<!-- Map container (non-interactive background) -->
|
||||
<div id="header-map" class="absolute inset-0 z-0"></div>
|
||||
|
||||
<!-- QR code overlay (right side, fills height) -->
|
||||
<div class="relative z-20 h-full p-3 flex items-center justify-end">
|
||||
<div id="qr-code" class="bg-white p-2 rounded shadow-lg"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- QR Code Card (no map) -->
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body flex-row items-center gap-4">
|
||||
<div id="qr-code" class="bg-white p-1 rounded"></div>
|
||||
<p class="text-sm opacity-70">Scan to add as contact</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Node Details Card -->
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<!-- 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 %}
|
||||
{{ 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>
|
||||
<!-- Public Key -->
|
||||
<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>
|
||||
|
||||
{% set ns_map = namespace(lat=none, lon=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'lat' %}
|
||||
{% set ns_map.lat = tag.value %}
|
||||
{% elif tag.key == 'lon' %}
|
||||
{% set ns_map.lon = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<!-- 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 -->
|
||||
<!-- First/Last Seen and Location -->
|
||||
<div class="flex flex-wrap gap-x-8 gap-y-2 mt-4 text-sm">
|
||||
<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 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>
|
||||
<span class="opacity-70">First seen:</span>
|
||||
{{ node.first_seen[:19].replace('T', ' ') if node.first_seen else '-' }}
|
||||
</div>
|
||||
|
||||
<!-- Location Map -->
|
||||
{% if ns_map.lat and ns_map.lon %}
|
||||
<div>
|
||||
<h3 class="font-semibold opacity-70 mb-2">Location</h3>
|
||||
<div id="node-map" class="mb-2"></div>
|
||||
<div class="text-sm opacity-70">
|
||||
<p>Coordinates: {{ ns_map.lat }}, {{ ns_map.lon }}</p>
|
||||
</div>
|
||||
<span class="opacity-70">Last seen:</span>
|
||||
{{ node.last_seen[:19].replace('T', ' ') if node.last_seen else '-' }}
|
||||
</div>
|
||||
{% if has_coords %}
|
||||
<div>
|
||||
<span class="opacity-70">Location:</span>
|
||||
{{ ns_coords.lat }}, {{ ns_coords.lon }}
|
||||
</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>
|
||||
|
||||
@@ -193,52 +181,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Telemetry -->
|
||||
<!-- Tags -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Recent Telemetry</h2>
|
||||
{% if telemetry %}
|
||||
<h2 class="card-title">Tags</h2>
|
||||
{% if node.tags %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-compact w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Data</th>
|
||||
<th>Received By</th>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tel in telemetry %}
|
||||
{% for tag in node.tags %}
|
||||
<tr>
|
||||
<td class="text-xs whitespace-nowrap">{{ tel.received_at[:19].replace('T', ' ') if tel.received_at else '-' }}</td>
|
||||
<td class="text-xs font-mono">
|
||||
{% if tel.parsed_data %}
|
||||
{{ tel.parsed_data | tojson }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if tel.received_by %}
|
||||
<a href="/nodes/{{ tel.received_by }}" class="link link-hover">
|
||||
{% if tel.receiver_tag_name or tel.receiver_name %}
|
||||
<div class="font-medium text-sm">{{ tel.receiver_tag_name or tel.receiver_name }}</div>
|
||||
<div class="text-xs font-mono opacity-70">{{ tel.received_by[:16] }}...</div>
|
||||
{% else %}
|
||||
<span class="font-mono text-xs">{{ tel.received_by[:16] }}...</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="opacity-50">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<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="opacity-70">No telemetry recorded.</p>
|
||||
<p class="opacity-70">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>
|
||||
</div>
|
||||
@@ -265,28 +239,39 @@
|
||||
window.qrCodeConfig = {
|
||||
name: {{ (ns_qr.tag_name or node.name or 'Node') | tojson }},
|
||||
publicKey: {{ node.public_key | tojson }},
|
||||
advType: {{ (node.adv_type or '') | tojson }}
|
||||
advType: {{ (node.adv_type or '') | tojson }},
|
||||
size: 140
|
||||
};
|
||||
</script>
|
||||
<script src="{{ url_for('static', path='js/qrcode-init.js') }}"></script>
|
||||
|
||||
{% set ns_map = namespace(lat=none, lon=none, name=none) %}
|
||||
{# Get coordinates from node model first, then fall back to tags #}
|
||||
{% set ns_map = namespace(lat=node.lat, lon=node.lon, name=none) %}
|
||||
{% if not ns_map.lat or not ns_map.lon %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'lat' and not ns_map.lat %}
|
||||
{% set ns_map.lat = tag.value|float %}
|
||||
{% elif tag.key == 'lon' and not ns_map.lon %}
|
||||
{% set ns_map.lon = tag.value|float %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'lat' %}
|
||||
{% set ns_map.lat = tag.value %}
|
||||
{% elif tag.key == 'lon' %}
|
||||
{% set ns_map.lon = tag.value %}
|
||||
{% elif tag.key == 'name' %}
|
||||
{% if tag.key == 'name' %}
|
||||
{% set ns_map.name = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if ns_map.lat and ns_map.lon %}
|
||||
<script>
|
||||
window.nodeMapConfig = {
|
||||
elementId: 'header-map',
|
||||
lat: {{ ns_map.lat }},
|
||||
lon: {{ ns_map.lon }},
|
||||
name: {{ (ns_map.name or node.name or 'Unnamed Node') | tojson }},
|
||||
type: {{ (node.adv_type or '') | tojson }}
|
||||
type: {{ (node.adv_type or '') | tojson }},
|
||||
interactive: false,
|
||||
zoom: 14,
|
||||
offsetX: 0.33
|
||||
};
|
||||
</script>
|
||||
<script src="{{ url_for('static', path='js/map-node.js') }}"></script>
|
||||
|
||||
Reference in New Issue
Block a user