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:
Louis King
2026-02-08 23:16:13 +00:00
parent 7de6520ae7
commit b6f3b2d864
4 changed files with 204 additions and 159 deletions

View File

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

View File

@@ -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: '&copy; <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);
})();

View File

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

View File

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