Refactor HTML output: extract inline CSS, JS, and SVGs

Extract inline styles, JavaScript, and SVG icons from templates into
reusable external resources for improved maintainability and caching.

New static files:
- static/css/app.css: Custom CSS (scrollbar, prose, animations, Leaflet)
- static/js/charts.js: Chart.js helpers with shared colors/options
- static/js/map-main.js: Full map page functionality
- static/js/map-node.js: Node detail page map
- static/js/qrcode-init.js: QR code generation

New icon macros in macros/icons.html:
- icon_info, icon_alert, icon_chart, icon_refresh, icon_menu
- icon_github, icon_globe, icon_error, icon_channel
- icon_success, icon_lock, icon_user, icon_email, icon_tag, icon_users

Updated templates to use external resources and icon macros:
- base.html, home.html, dashboard.html, map.html, node_detail.html
- nodes.html, messages.html, advertisements.html, members.html
- errors/404.html, admin/*.html

Net reduction: ~700 lines of inline code removed from templates.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Louis King
2026-02-08 21:23:06 +00:00
parent 470c374f11
commit 6b1b277c6c
20 changed files with 1253 additions and 886 deletions
+349
View File
@@ -0,0 +1,349 @@
/**
* MeshCore Hub - Custom Application Styles
*
* This file contains all custom CSS that extends the Tailwind/DaisyUI framework.
* Organized in sections:
* - Scrollbar styling
* - Table styling
* - Text utilities
* - Prose (markdown content) styling
* - View Transitions API
* - Card animations
* - Leaflet map theming
*/
/* ==========================================================================
Scrollbar Styling
========================================================================== */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: oklch(var(--b2));
}
::-webkit-scrollbar-thumb {
background: oklch(var(--bc) / 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(var(--bc) / 0.5);
}
/* ==========================================================================
Table Styling
========================================================================== */
.table-compact td,
.table-compact th {
padding: 0.5rem 0.75rem;
}
/* ==========================================================================
Text Utilities
========================================================================== */
.truncate-cell {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ==========================================================================
Prose Styling (Custom Markdown Pages)
========================================================================== */
.prose h1 {
font-size: 2.25rem;
font-weight: 700;
margin-top: 1.5rem;
margin-bottom: 1rem;
}
.prose h2 {
font-size: 1.875rem;
font-weight: 600;
margin-top: 1.25rem;
margin-bottom: 0.75rem;
}
.prose h3 {
font-size: 1.5rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.prose h4 {
font-size: 1.25rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.prose p {
margin-bottom: 1rem;
line-height: 1.75;
}
.prose ul,
.prose ol {
margin-bottom: 1rem;
padding-left: 1.5rem;
}
.prose ul {
list-style-type: disc;
}
.prose ol {
list-style-type: decimal;
}
.prose li {
margin-bottom: 0.25rem;
}
.prose a {
color: oklch(var(--p));
text-decoration: underline;
}
.prose a:hover {
color: oklch(var(--pf));
}
.prose code {
background: oklch(var(--b2));
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.875em;
}
.prose pre {
background: oklch(var(--b2));
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin-bottom: 1rem;
}
.prose pre code {
background: none;
padding: 0;
}
.prose blockquote {
border-left: 4px solid oklch(var(--bc) / 0.3);
padding-left: 1rem;
margin: 1rem 0;
font-style: italic;
}
.prose table {
width: 100%;
margin-bottom: 1rem;
border-collapse: collapse;
}
.prose th,
.prose td {
border: 1px solid oklch(var(--bc) / 0.2);
padding: 0.5rem;
text-align: left;
}
.prose th {
background: oklch(var(--b2));
font-weight: 600;
}
.prose hr {
border: none;
border-top: 1px solid oklch(var(--bc) / 0.2);
margin: 2rem 0;
}
.prose img {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
margin: 1rem 0;
}
/* ==========================================================================
View Transitions API
========================================================================== */
/* Named transition elements */
.navbar {
view-transition-name: navbar;
position: relative;
z-index: 50;
}
main {
view-transition-name: main-content;
position: relative;
z-index: 10;
}
footer {
view-transition-name: footer;
position: relative;
z-index: 10;
}
/* Subtle slide + fade for main content */
::view-transition-old(main-content) {
animation: vt-fade-out 200ms ease-out forwards;
}
::view-transition-new(main-content) {
animation: vt-slide-up 250ms ease-out forwards;
}
/* Keep navbar and footer stable */
::view-transition-old(navbar),
::view-transition-new(navbar),
::view-transition-old(footer),
::view-transition-new(footer) {
animation: none;
}
/* Subtle crossfade for background */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 150ms;
}
@keyframes vt-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes vt-slide-up {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ==========================================================================
Card Entrance Animations
========================================================================== */
/* Only for stat cards with .animate-stagger class */
.animate-stagger > .card,
.animate-stagger > .stat {
animation: card-fade-in 300ms ease-out backwards;
}
.animate-stagger > :nth-child(1) {
animation-delay: 0ms;
}
.animate-stagger > :nth-child(2) {
animation-delay: 50ms;
}
.animate-stagger > :nth-child(3) {
animation-delay: 100ms;
}
.animate-stagger > :nth-child(4) {
animation-delay: 150ms;
}
.animate-stagger > :nth-child(5) {
animation-delay: 200ms;
}
.animate-stagger > :nth-child(6) {
animation-delay: 250ms;
}
@keyframes card-fade-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ==========================================================================
Reduced Motion Preferences
========================================================================== */
@media (prefers-reduced-motion: reduce) {
.card,
::view-transition-old(main-content),
::view-transition-new(main-content),
::view-transition-old(root),
::view-transition-new(root) {
animation: none !important;
}
}
/* ==========================================================================
Leaflet Map Theming (Dark Mode)
========================================================================== */
/* Popup styling */
.leaflet-popup-content-wrapper {
background: oklch(var(--b1));
color: oklch(var(--bc));
}
.leaflet-popup-tip {
background: oklch(var(--b1));
}
/* Map container defaults */
#map,
#node-map {
border-radius: var(--rounded-box);
}
#map {
height: calc(100vh - 350px);
min-height: 400px;
}
#node-map {
height: 300px;
}
/* Map label visibility */
.map-label {
opacity: 0;
transition: opacity 0.15s ease-in-out;
}
.map-marker:hover .map-label {
opacity: 1;
}
.show-labels .map-label {
opacity: 1;
}
/* Bring hovered marker to front */
.leaflet-marker-icon:hover {
z-index: 10000 !important;
}
+216
View File
@@ -0,0 +1,216 @@
/**
* MeshCore Hub - Chart.js Helpers
*
* Provides common chart configuration and initialization helpers
* for activity charts used on home and dashboard pages.
*/
/**
* OKLCH color values for consistent theming
*/
const ChartColors = {
// Primary (purple/blue)
primary: 'oklch(0.65 0.24 265)',
primaryFill: 'oklch(0.65 0.24 265 / 0.1)',
// Secondary (pink/magenta)
secondary: 'oklch(0.7 0.17 330)',
secondaryFill: 'oklch(0.7 0.17 330 / 0.1)',
// Accent (teal/cyan)
accent: 'oklch(0.75 0.18 180)',
accentFill: 'oklch(0.75 0.18 180 / 0.1)',
// Neutral grays
grid: 'oklch(0.4 0 0 / 0.2)',
text: 'oklch(0.7 0 0)',
tooltipBg: 'oklch(0.25 0 0)',
tooltipText: 'oklch(0.9 0 0)',
tooltipBorder: 'oklch(0.4 0 0)'
};
/**
* Create common chart options with optional legend
* @param {boolean} showLegend - Whether to show the legend
* @returns {Object} Chart.js options object
*/
function createChartOptions(showLegend) {
return {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: showLegend,
position: 'bottom',
labels: {
color: ChartColors.text,
boxWidth: 12,
padding: 8
}
},
tooltip: {
mode: 'index',
intersect: false,
backgroundColor: ChartColors.tooltipBg,
titleColor: ChartColors.tooltipText,
bodyColor: ChartColors.tooltipText,
borderColor: ChartColors.tooltipBorder,
borderWidth: 1
}
},
scales: {
x: {
grid: { color: ChartColors.grid },
ticks: {
color: ChartColors.text,
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 10
}
},
y: {
beginAtZero: true,
grid: { color: ChartColors.grid },
ticks: {
color: ChartColors.text,
precision: 0
}
}
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
}
};
}
/**
* Format date labels for chart display (e.g., "8 Feb")
* @param {Array} data - Array of objects with 'date' property
* @returns {Array} Formatted date strings
*/
function formatDateLabels(data) {
return data.map(function(d) {
var date = new Date(d.date);
return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
});
}
/**
* Create a single-dataset line chart
* @param {string} canvasId - ID of the canvas element
* @param {Object} data - Data object with 'data' array containing {date, count} objects
* @param {string} label - Dataset label
* @param {string} borderColor - Line color
* @param {string} backgroundColor - Fill color
* @param {boolean} fill - Whether to fill under the line
*/
function createLineChart(canvasId, data, label, borderColor, backgroundColor, fill) {
var ctx = document.getElementById(canvasId);
if (!ctx || !data || !data.data || data.data.length === 0) {
return null;
}
return new Chart(ctx, {
type: 'line',
data: {
labels: formatDateLabels(data.data),
datasets: [{
label: label,
data: data.data.map(function(d) { return d.count; }),
borderColor: borderColor,
backgroundColor: backgroundColor,
fill: fill,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
}]
},
options: createChartOptions(false)
});
}
/**
* Create a multi-dataset activity chart (for home page)
* @param {string} canvasId - ID of the canvas element
* @param {Object} advertData - Advertisement data with 'data' array
* @param {Object} messageData - Message data with 'data' array
*/
function createActivityChart(canvasId, advertData, messageData) {
var ctx = document.getElementById(canvasId);
if (!ctx || !advertData || !advertData.data || advertData.data.length === 0) {
return null;
}
var labels = formatDateLabels(advertData.data);
var advertCounts = advertData.data.map(function(d) { return d.count; });
var messageCounts = messageData && messageData.data
? messageData.data.map(function(d) { return d.count; })
: [];
return new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Advertisements',
data: advertCounts,
borderColor: ChartColors.secondary,
backgroundColor: ChartColors.secondaryFill,
fill: false,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
}, {
label: 'Messages',
data: messageCounts,
borderColor: ChartColors.accent,
backgroundColor: ChartColors.accentFill,
fill: false,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
}]
},
options: createChartOptions(true)
});
}
/**
* Initialize dashboard charts (nodes, advertisements, messages)
* @param {Object} nodeData - Node count data
* @param {Object} advertData - Advertisement data
* @param {Object} messageData - Message data
*/
function initDashboardCharts(nodeData, advertData, messageData) {
// Node count chart (primary color)
createLineChart(
'nodeChart',
nodeData,
'Total Nodes',
ChartColors.primary,
ChartColors.primaryFill,
true
);
// Advertisements chart (secondary color)
createLineChart(
'advertChart',
advertData,
'Advertisements',
ChartColors.secondary,
ChartColors.secondaryFill,
true
);
// Messages chart (accent color)
createLineChart(
'messageChart',
messageData,
'Messages',
ChartColors.accent,
ChartColors.accentFill,
true
);
}
+372
View File
@@ -0,0 +1,372 @@
/**
* MeshCore Hub - Main Map Page
*
* Full map functionality with filters, markers, and clustering.
* Requires Leaflet.js to be loaded before this script.
*
* Configuration:
* - Set window.mapConfig.logoUrl before loading this script
* - Set window.mapConfig.dataUrl for the data endpoint (default: '/map/data')
*/
(function() {
'use strict';
// Configuration (can be set before script loads)
var config = window.mapConfig || {};
var logoUrl = config.logoUrl || '/static/img/logo.svg';
var dataUrl = config.dataUrl || '/map/data';
// Initialize map with world view (will be centered on nodes once loaded)
var map = L.map('map').setView([0, 0], 2);
// 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);
// Store all nodes and markers
var allNodes = [];
var allMembers = [];
var markers = [];
var mapCenter = { lat: 0, lon: 0 };
var infraCenter = null;
// Maximum radius (km) from anchor point for bounds calculation
var MAX_BOUNDS_RADIUS_KM = 20;
// Padding for fitBounds - more padding on mobile for tighter zoom
var isMobilePortrait = window.innerWidth < 480;
var isMobile = window.innerWidth < 768;
var BOUNDS_PADDING = isMobilePortrait ? [50, 50] : (isMobile ? [75, 75] : [100, 100]);
/**
* Calculate distance between two points in km (Haversine formula)
*/
function getDistanceKm(lat1, lon1, lat2, lon2) {
var R = 6371; // Earth's radius in km
var dLat = (lat2 - lat1) * Math.PI / 180;
var dLon = (lon2 - lon1) * Math.PI / 180;
var a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
/**
* Filter nodes within radius of anchor point for bounds calculation
*/
function getNodesWithinRadius(nodes, anchorLat, anchorLon, radiusKm) {
return nodes.filter(function(n) {
return getDistanceKm(anchorLat, anchorLon, n.lat, n.lon) <= radiusKm;
});
}
/**
* Get anchor point for bounds calculation (infra center or nodes center)
*/
function getAnchorPoint(nodes) {
if (infraCenter) {
return infraCenter;
}
// Fall back to center of provided nodes
if (nodes.length === 0) return { lat: 0, lon: 0 };
return {
lat: nodes.reduce(function(sum, n) { return sum + n.lat; }, 0) / nodes.length,
lon: nodes.reduce(function(sum, n) { return sum + n.lon; }, 0) / nodes.length
};
}
/**
* Normalize adv_type to lowercase for consistent comparison
*/
function normalizeType(type) {
return type ? type.toLowerCase() : null;
}
/**
* Get display name for node type
*/
function getTypeDisplay(node) {
var type = normalizeType(node.adv_type);
if (type === 'chat') return 'Chat';
if (type === 'repeater') return 'Repeater';
if (type === 'room') return 'Room';
return type ? type.charAt(0).toUpperCase() + type.slice(1) : 'Unknown';
}
/**
* Create marker icon for a node
*/
function createNodeIcon(node) {
var displayName = node.name || '';
var relativeTime = typeof formatRelativeTime === 'function' ? formatRelativeTime(node.last_seen) : '';
var timeDisplay = relativeTime ? ' (' + relativeTime + ')' : '';
// Use logo for infrastructure nodes, blue circle for others
var 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 {
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({
className: 'custom-div-icon',
html: '<div class="map-marker" style="display: flex; flex-direction: column; align-items: center; gap: 2px;">' +
iconHtml +
'<span class="map-label" style="font-size: 10px; font-weight: bold; color: #fff; background: rgba(0,0,0,0.5); padding: 1px 4px; border-radius: 3px; white-space: nowrap; text-align: center;">' + displayName + timeDisplay + '</span>' +
'</div>',
iconSize: [120, 50],
iconAnchor: [60, 12]
});
}
/**
* Create popup content for a node
*/
function createPopupContent(node) {
var ownerHtml = '';
if (node.owner) {
var ownerDisplay = node.owner.callsign
? node.owner.name + ' (' + node.owner.callsign + ')'
: node.owner.name;
ownerHtml = '<p><span class="opacity-70">Owner:</span> ' + ownerDisplay + '</p>';
}
var roleHtml = '';
if (node.role) {
roleHtml = '<p><span class="opacity-70">Role:</span> <span class="badge badge-xs badge-ghost">' + node.role + '</span></p>';
}
var typeDisplay = getTypeDisplay(node);
// Use logo for infrastructure nodes, blue circle for others
var iconHtml = node.is_infra
? '<img src="' + logoUrl + '" alt="Infra" style="width: 20px; height: 20px; display: inline-block; vertical-align: middle;">'
: '<span style="display: inline-block; width: 12px; height: 12px; background: #3b82f6; border: 2px solid #1e40af; border-radius: 50%; vertical-align: middle;"></span>';
var lastSeenHtml = node.last_seen
? '<p><span class="opacity-70">Last seen:</span> ' + node.last_seen.substring(0, 19).replace('T', ' ') + '</p>'
: '';
return '<div class="p-2">' +
'<h3 class="font-bold text-lg mb-2">' + iconHtml + ' ' + node.name + '</h3>' +
'<div class="space-y-1 text-sm">' +
'<p><span class="opacity-70">Type:</span> ' + typeDisplay + '</p>' +
roleHtml +
ownerHtml +
'<p><span class="opacity-70">Key:</span> <code class="text-xs">' + node.public_key.substring(0, 16) + '...</code></p>' +
'<p><span class="opacity-70">Location:</span> ' + node.lat.toFixed(4) + ', ' + node.lon.toFixed(4) + '</p>' +
lastSeenHtml +
'</div>' +
'<a href="/nodes/' + node.public_key + '" class="btn btn-outline btn-xs mt-3">View Details</a>' +
'</div>';
}
/**
* Clear all markers from map
*/
function clearMarkers() {
markers.forEach(function(marker) {
map.removeLayer(marker);
});
markers = [];
}
/**
* Core filter logic - returns filtered nodes and updates markers
*/
function applyFiltersCore() {
var categoryFilter = document.getElementById('filter-category').value;
var typeFilter = document.getElementById('filter-type').value;
var memberFilter = document.getElementById('filter-member').value;
// Filter nodes
var filteredNodes = allNodes.filter(function(node) {
// Category filter (infrastructure only)
if (categoryFilter === 'infra' && !node.is_infra) return false;
// Type filter (case-insensitive)
var nodeType = normalizeType(node.adv_type);
if (typeFilter && nodeType !== typeFilter) return false;
// Member filter - match node's member_id tag to selected member_id
if (memberFilter) {
if (node.member_id !== memberFilter) return false;
}
return true;
});
// Clear existing markers
clearMarkers();
// Add filtered markers
filteredNodes.forEach(function(node) {
var marker = L.marker([node.lat, node.lon], { icon: createNodeIcon(node) }).addTo(map);
marker.bindPopup(createPopupContent(node));
markers.push(marker);
});
// Update counts
var countEl = document.getElementById('node-count');
var filteredEl = document.getElementById('filtered-count');
if (filteredNodes.length === allNodes.length) {
countEl.textContent = allNodes.length + ' nodes on map';
filteredEl.classList.add('hidden');
} else {
countEl.textContent = allNodes.length + ' total';
filteredEl.textContent = filteredNodes.length + ' shown';
filteredEl.classList.remove('hidden');
}
return filteredNodes;
}
/**
* Apply filters and recenter map on filtered nodes
*/
function applyFilters() {
var filteredNodes = applyFiltersCore();
var categoryFilter = document.getElementById('filter-category').value;
// Fit bounds if we have filtered nodes
if (filteredNodes.length > 0) {
var nodesToFit = filteredNodes;
// Apply radius filter when showing all nodes (not infra-only)
if (categoryFilter !== 'infra') {
var anchor = getAnchorPoint(filteredNodes);
var nearbyNodes = getNodesWithinRadius(filteredNodes, anchor.lat, anchor.lon, MAX_BOUNDS_RADIUS_KM);
if (nearbyNodes.length > 0) {
nodesToFit = nearbyNodes;
}
}
var bounds = L.latLngBounds(nodesToFit.map(function(n) { return [n.lat, n.lon]; }));
map.fitBounds(bounds, { padding: BOUNDS_PADDING });
} else if (mapCenter.lat !== 0 || mapCenter.lon !== 0) {
map.setView([mapCenter.lat, mapCenter.lon], 10);
}
}
/**
* Apply filters without recentering (for initial load after manual center)
*/
function applyFiltersNoRecenter() {
applyFiltersCore();
}
/**
* Populate member filter dropdown
*/
function populateMemberFilter() {
var select = document.getElementById('filter-member');
// Sort members by name
var sortedMembers = allMembers.slice().sort(function(a, b) {
return a.name.localeCompare(b.name);
});
// Add options for all members
sortedMembers.forEach(function(member) {
if (member.member_id) {
var option = document.createElement('option');
option.value = member.member_id;
option.textContent = member.callsign
? member.name + ' (' + member.callsign + ')'
: member.name;
select.appendChild(option);
}
});
}
/**
* Clear all filters
*/
function clearFilters() {
document.getElementById('filter-category').value = '';
document.getElementById('filter-type').value = '';
document.getElementById('filter-member').value = '';
document.getElementById('show-labels').checked = false;
updateLabelVisibility();
applyFilters();
}
/**
* Toggle label visibility
*/
function updateLabelVisibility() {
var showLabels = document.getElementById('show-labels').checked;
var mapEl = document.getElementById('map');
if (showLabels) {
mapEl.classList.add('show-labels');
} else {
mapEl.classList.remove('show-labels');
}
}
// Event listeners for filters
document.getElementById('filter-category').addEventListener('change', applyFilters);
document.getElementById('filter-type').addEventListener('change', applyFilters);
document.getElementById('filter-member').addEventListener('change', applyFilters);
document.getElementById('show-labels').addEventListener('change', updateLabelVisibility);
document.getElementById('clear-filters').addEventListener('click', clearFilters);
// Fetch and display nodes
fetch(dataUrl)
.then(function(response) { return response.json(); })
.then(function(data) {
allNodes = data.nodes;
allMembers = data.members || [];
mapCenter = data.center;
infraCenter = data.infra_center;
// Log debug info
var debug = data.debug || {};
console.log('Map data loaded:', debug);
console.log('Sample node data:', allNodes.length > 0 ? allNodes[0] : 'No nodes');
if (debug.error) {
document.getElementById('node-count').textContent = 'Error: ' + debug.error;
return;
}
if (debug.total_nodes === 0) {
document.getElementById('node-count').textContent = 'No nodes in database';
return;
}
if (debug.nodes_with_coords === 0) {
document.getElementById('node-count').textContent = debug.total_nodes + ' nodes (none have coordinates)';
return;
}
// Populate member filter
populateMemberFilter();
// Initial display - center map on infrastructure nodes if available, else nodes within radius
var infraNodes = allNodes.filter(function(n) { return n.is_infra; });
if (infraNodes.length > 0) {
var bounds = L.latLngBounds(infraNodes.map(function(n) { return [n.lat, n.lon]; }));
map.fitBounds(bounds, { padding: BOUNDS_PADDING });
} else if (allNodes.length > 0) {
// Use radius filter to exclude outliers
var anchor = getAnchorPoint(allNodes);
var nearbyNodes = getNodesWithinRadius(allNodes, anchor.lat, anchor.lon, MAX_BOUNDS_RADIUS_KM);
var nodesToFit = nearbyNodes.length > 0 ? nearbyNodes : allNodes;
var bounds = L.latLngBounds(nodesToFit.map(function(n) { return [n.lat, n.lon]; }));
map.fitBounds(bounds, { padding: BOUNDS_PADDING });
}
// Apply filters (won't re-center since we just did above)
applyFiltersNoRecenter();
})
.catch(function(error) {
console.error('Error loading map data:', error);
document.getElementById('node-count').textContent = 'Error loading data';
});
})();
@@ -0,0 +1,79 @@
/**
* MeshCore Hub - Node Detail Map
*
* Simple map for displaying a single node's location.
* Requires Leaflet.js to be loaded before this script.
*
* Configuration via window.nodeMapConfig:
* - lat: Node latitude (required)
* - lon: Node longitude (required)
* - name: Node display name (required)
* - type: Node adv_type (optional)
* - publicKey: Node public key (optional, for linking)
*/
(function() {
'use strict';
// Get configuration
var config = window.nodeMapConfig;
if (!config || typeof config.lat === 'undefined' || typeof config.lon === 'undefined') {
console.warn('Node map config missing or invalid');
return;
}
var nodeLat = config.lat;
var nodeLon = config.lon;
var nodeName = config.name || 'Unnamed Node';
var nodeType = config.type || '';
// Check if map container exists
var mapContainer = document.getElementById('node-map');
if (!mapContainer) {
return;
}
// Initialize map centered on the node's location
var map = L.map('node-map').setView([nodeLat, nodeLon], 15);
// 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 '📍';
}
// 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);
})();
@@ -0,0 +1,60 @@
/**
* MeshCore Hub - QR Code Generation
*
* Generates QR codes for adding MeshCore contacts.
* Requires qrcodejs library to be loaded before this script.
*
* Configuration via window.qrCodeConfig:
* - name: Contact name (required)
* - publicKey: 64-char hex public key (required)
* - advType: Node advertisement type (optional)
* - containerId: ID of container element (default: 'qr-code')
*/
(function() {
'use strict';
// Get configuration
var config = window.qrCodeConfig;
if (!config || !config.publicKey) {
console.warn('QR code config missing or invalid');
return;
}
var nodeName = config.name || 'Node';
var publicKey = config.publicKey;
var advType = config.advType || '';
var containerId = config.containerId || 'qr-code';
// Map adv_type to numeric type for meshcore:// protocol
var typeMap = {
'chat': 1,
'repeater': 2,
'room': 3,
'sensor': 4
};
var typeNum = typeMap[advType.toLowerCase()] || 1;
// Build meshcore:// URL
var meshcoreUrl = 'meshcore://contact/add?name=' + encodeURIComponent(nodeName) +
'&public_key=' + publicKey +
'&type=' + typeNum;
// Generate QR code
var qrContainer = document.getElementById(containerId);
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>';
}
}
})();
@@ -1,13 +1,12 @@
{% extends "base.html" %}
{% from "macros/icons.html" import icon_lock %}
{% block title %}{{ network_name }} - Access Denied{% endblock %}
{% block content %}
<div class="flex flex-col items-center justify-center min-h-[50vh]">
<div class="text-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 mx-auto text-error opacity-50 mb-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
{{ icon_lock("h-24 w-24 mx-auto text-error opacity-50 mb-6") }}
<h1 class="text-3xl font-bold mb-2">Access Denied</h1>
<p class="text-lg opacity-70 mb-6">You don't have permission to access the admin area.</p>
<p class="text-sm opacity-50 mb-8">Please contact the network administrator if you believe this is an error.</p>
@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "macros/icons.html" import icon_user, icon_email, icon_users, icon_tag %}
{% block title %}{{ network_name }} - Admin{% endblock %}
@@ -20,19 +21,13 @@
<div class="flex flex-wrap items-center gap-4 text-sm opacity-70 mb-6">
{% if auth_username or auth_user %}
<span class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
{{ icon_user("h-4 w-4") }}
{{ auth_username or auth_user }}
</span>
{% endif %}
{% if auth_email %}
<span class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
{{ icon_email("h-4 w-4") }}
{{ auth_email }}
</span>
{% endif %}
@@ -43,11 +38,7 @@
<a href="/a/members" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
<div class="card-body">
<h2 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
{{ icon_users("h-6 w-6") }}
Members
</h2>
<p>Manage network members and operators.</p>
@@ -56,11 +47,7 @@
<a href="/a/node-tags" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
<div class="card-body">
<h2 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
</svg>
{{ icon_tag("h-6 w-6") }}
Node Tags
</h2>
<p>Manage custom tags and metadata for network nodes.</p>
@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "macros/icons.html" import icon_success, icon_error, icon_alert %}
{% block title %}{{ network_name }} - Members Admin{% endblock %}
@@ -20,18 +21,14 @@
<!-- Flash Messages -->
{% if message %}
<div class="alert alert-success mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ icon_success("stroke-current shrink-0 h-6 w-6") }}
<span>{{ message }}</span>
</div>
{% endif %}
{% if error %}
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ icon_error("stroke-current shrink-0 h-6 w-6") }}
<span>{{ error }}</span>
</div>
{% endif %}
@@ -232,9 +229,7 @@
<p class="py-4">Are you sure you want to delete member <strong id="delete_member_name"></strong>?</p>
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
<span>This action cannot be undone.</span>
</div>
@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "macros/icons.html" import icon_success, icon_error, icon_alert, icon_info, icon_tag %}
{% block title %}{{ network_name }} - Node Tags Admin{% endblock %}
@@ -20,18 +21,14 @@
<!-- Flash Messages -->
{% if message %}
<div class="alert alert-success mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ icon_success("stroke-current shrink-0 h-6 w-6") }}
<span>{{ message }}</span>
</div>
{% endif %}
{% if error %}
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ icon_error("stroke-current shrink-0 h-6 w-6") }}
<span>{{ error }}</span>
</div>
{% endif %}
@@ -253,9 +250,7 @@
</div>
<div class="alert alert-warning mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
<span>This will move the tag from the current node to the destination node.</span>
</div>
@@ -281,9 +276,7 @@
<p class="py-4">Are you sure you want to delete the tag "<span id="deleteKeyDisplay" class="font-mono font-semibold"></span>"?</p>
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
<span>This action cannot be undone.</span>
</div>
@@ -324,9 +317,7 @@
</div>
<div class="alert alert-info mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ icon_info("stroke-current shrink-0 h-6 w-6") }}
<span>Tags that already exist on the destination node will be skipped. Original tags remain on this node.</span>
</div>
@@ -351,9 +342,7 @@
<p class="mb-4">Are you sure you want to delete all {{ tags|length }} tag(s) from <strong>{{ selected_node.name or 'Unnamed' }}</strong>?</p>
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
<span>This action cannot be undone. All tags will be permanently deleted.</span>
</div>
@@ -370,17 +359,13 @@
{% elif selected_public_key and not selected_node %}
<div class="alert alert-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
<span>Node not found: {{ selected_public_key }}</span>
</div>
{% else %}
<div class="card bg-base-100 shadow-xl">
<div class="card-body text-center py-12">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto mb-4 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
</svg>
{{ icon_tag("h-16 w-16 mx-auto mb-4 opacity-30") }}
<h2 class="text-xl font-semibold mb-2">Select a Node</h2>
<p class="opacity-70">Choose a node from the dropdown above to view and manage its tags.</p>
</div>
@@ -1,5 +1,6 @@
{% extends "base.html" %}
{% from "_macros.html" import pagination %}
{% from "macros/icons.html" import icon_alert %}
{% block title %}{{ network_name }} - Advertisements{% endblock %}
@@ -11,9 +12,7 @@
{% if api_error %}
<div class="alert alert-warning mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
<span>Could not fetch data from API: {{ api_error }}</span>
</div>
{% endif %}
+4 -119
View File
@@ -1,4 +1,4 @@
{% from "macros/icons.html" import icon_home, icon_dashboard, icon_nodes, icon_advertisements, icon_messages, icon_map, icon_members, icon_page %}
{% from "macros/icons.html" import icon_home, icon_dashboard, icon_nodes, icon_advertisements, icon_messages, icon_map, icon_members, icon_page, icon_menu %}
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
@@ -37,121 +37,8 @@
<!-- Leaflet CSS for maps -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: oklch(var(--b2));
}
::-webkit-scrollbar-thumb {
background: oklch(var(--bc) / 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(var(--bc) / 0.5);
}
/* Table styling */
.table-compact td, .table-compact th {
padding: 0.5rem 0.75rem;
}
/* Truncate text in table cells */
.truncate-cell {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Prose styling for custom markdown pages */
.prose h1 { font-size: 2.25rem; font-weight: 700; margin-top: 1.5rem; margin-bottom: 1rem; }
.prose h2 { font-size: 1.875rem; font-weight: 600; margin-top: 1.25rem; margin-bottom: 0.75rem; }
.prose h3 { font-size: 1.5rem; font-weight: 600; margin-top: 1rem; margin-bottom: 0.5rem; }
.prose h4 { font-size: 1.25rem; font-weight: 600; margin-top: 1rem; margin-bottom: 0.5rem; }
.prose p { margin-bottom: 1rem; line-height: 1.75; }
.prose ul, .prose ol { margin-bottom: 1rem; padding-left: 1.5rem; }
.prose ul { list-style-type: disc; }
.prose ol { list-style-type: decimal; }
.prose li { margin-bottom: 0.25rem; }
.prose a { color: oklch(var(--p)); text-decoration: underline; }
.prose a:hover { color: oklch(var(--pf)); }
.prose code { background: oklch(var(--b2)); padding: 0.125rem 0.25rem; border-radius: 0.25rem; font-size: 0.875em; }
.prose pre { background: oklch(var(--b2)); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin-bottom: 1rem; }
.prose pre code { background: none; padding: 0; }
.prose blockquote { border-left: 4px solid oklch(var(--bc) / 0.3); padding-left: 1rem; margin: 1rem 0; font-style: italic; }
.prose table { width: 100%; margin-bottom: 1rem; border-collapse: collapse; }
.prose th, .prose td { border: 1px solid oklch(var(--bc) / 0.2); padding: 0.5rem; text-align: left; }
.prose th { background: oklch(var(--b2)); font-weight: 600; }
.prose hr { border: none; border-top: 1px solid oklch(var(--bc) / 0.2); margin: 2rem 0; }
.prose img { max-width: 100%; height: auto; border-radius: 0.5rem; margin: 1rem 0; }
/* View Transitions API - Cross-document page transitions */
.navbar { view-transition-name: navbar; position: relative; z-index: 50; }
main { view-transition-name: main-content; position: relative; z-index: 10; }
footer { view-transition-name: footer; position: relative; z-index: 10; }
/* Subtle slide + fade for main content */
::view-transition-old(main-content) {
animation: vt-fade-out 200ms ease-out forwards;
}
::view-transition-new(main-content) {
animation: vt-slide-up 250ms ease-out forwards;
}
/* Keep navbar and footer stable */
::view-transition-old(navbar),
::view-transition-new(navbar),
::view-transition-old(footer),
::view-transition-new(footer) {
animation: none;
}
/* Subtle crossfade for background */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 150ms;
}
@keyframes vt-fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes vt-slide-up {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Card entrance animations - only for stat cards with .animate-stagger class */
.animate-stagger > .card,
.animate-stagger > .stat {
animation: card-fade-in 300ms ease-out backwards;
}
.animate-stagger > :nth-child(1) { animation-delay: 0ms; }
.animate-stagger > :nth-child(2) { animation-delay: 50ms; }
.animate-stagger > :nth-child(3) { animation-delay: 100ms; }
.animate-stagger > :nth-child(4) { animation-delay: 150ms; }
.animate-stagger > :nth-child(5) { animation-delay: 200ms; }
.animate-stagger > :nth-child(6) { animation-delay: 250ms; }
@keyframes card-fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* Respect reduced motion preferences */
@media (prefers-reduced-motion: reduce) {
.card,
::view-transition-old(main-content),
::view-transition-new(main-content),
::view-transition-old(root),
::view-transition-new(root) {
animation: none !important;
}
}
</style>
<!-- Custom application styles -->
<link rel="stylesheet" href="{{ url_for('static', path='css/app.css') }}">
{% block extra_head %}{% endblock %}
</head>
@@ -161,9 +48,7 @@
<div class="navbar-start">
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" />
</svg>
{{ icon_menu("h-5 w-5") }}
</div>
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
<li><a href="/" class="{% if request.url.path == '/' %}active{% endif %}">{{ icon_home("h-4 w-4") }} Home</a></li>
+16 -137
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "macros/icons.html" import icon_nodes, icon_advertisements, icon_messages, icon_alert, icon_refresh, icon_channel %}
{% block title %}{{ network_name }} - Network Overview{% endblock %}
@@ -6,18 +7,14 @@
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Network Overview</h1>
<button onclick="location.reload()" class="btn btn-ghost btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ icon_refresh("h-5 w-5") }}
Refresh
</button>
</div>
{% if api_error %}
<div class="alert alert-warning mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
<span>Could not fetch data from API: {{ api_error }}</span>
</div>
{% endif %}
@@ -27,9 +24,7 @@
<!-- Total Nodes -->
<div class="stat bg-base-100 rounded-box shadow">
<div class="stat-figure text-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
{{ icon_nodes("h-8 w-8") }}
</div>
<div class="stat-title">Total Nodes</div>
<div class="stat-value text-primary">{{ stats.total_nodes }}</div>
@@ -39,9 +34,7 @@
<!-- Advertisements (7 days) -->
<div class="stat bg-base-100 rounded-box shadow">
<div class="stat-figure text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
</svg>
{{ icon_advertisements("h-8 w-8") }}
</div>
<div class="stat-title">Advertisements</div>
<div class="stat-value text-secondary">{{ stats.advertisements_7d }}</div>
@@ -51,9 +44,7 @@
<!-- Messages (7 days) -->
<div class="stat bg-base-100 rounded-box shadow">
<div class="stat-figure text-accent">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
{{ icon_messages("h-8 w-8") }}
</div>
<div class="stat-title">Messages</div>
<div class="stat-value text-accent">{{ stats.messages_7d }}</div>
@@ -67,9 +58,7 @@
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-base">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
{{ icon_nodes("h-5 w-5") }}
Total Nodes
</h2>
<p class="text-xs opacity-70">Over time (last 7 days)</p>
@@ -83,9 +72,7 @@
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-base">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
</svg>
{{ icon_advertisements("h-5 w-5") }}
Advertisements
</h2>
<p class="text-xs opacity-70">Per day (last 7 days)</p>
@@ -99,9 +86,7 @@
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-base">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
{{ icon_messages("h-5 w-5") }}
Messages
</h2>
<p class="text-xs opacity-70">Per day (last 7 days)</p>
@@ -118,9 +103,7 @@
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
</svg>
{{ icon_advertisements("h-6 w-6") }}
Recent Advertisements
</h2>
{% if stats.recent_advertisements %}
@@ -174,9 +157,7 @@
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
</svg>
{{ icon_channel("h-6 w-6") }}
Recent Channel Messages
</h2>
<div class="space-y-4">
@@ -206,115 +187,13 @@
{% block extra_scripts %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="{{ url_for('static', path='js/charts.js') }}"></script>
<script>
(function() {
const advertData = {{ advert_activity_json | safe }};
const messageData = {{ message_activity_json | safe }};
const nodeData = {{ node_count_json | safe }};
// Common chart options
const commonOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
mode: 'index',
intersect: false,
backgroundColor: 'oklch(0.25 0 0)',
titleColor: 'oklch(0.9 0 0)',
bodyColor: 'oklch(0.9 0 0)',
borderColor: 'oklch(0.4 0 0)',
borderWidth: 1
}
},
scales: {
x: {
grid: { color: 'oklch(0.4 0 0 / 0.2)' },
ticks: { color: 'oklch(0.7 0 0)', maxRotation: 45, minRotation: 45 }
},
y: {
beginAtZero: true,
grid: { color: 'oklch(0.4 0 0 / 0.2)' },
ticks: { color: 'oklch(0.7 0 0)', precision: 0 }
}
},
interaction: { mode: 'nearest', axis: 'x', intersect: false }
};
// Helper to format dates
function formatLabels(data) {
return data.map(d => {
const date = new Date(d.date);
return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
});
}
// Advertisements chart (secondary color - pink/magenta)
const advertCtx = document.getElementById('advertChart');
if (advertCtx && advertData.data && advertData.data.length > 0) {
new Chart(advertCtx, {
type: 'line',
data: {
labels: formatLabels(advertData.data),
datasets: [{
label: 'Advertisements',
data: advertData.data.map(d => d.count),
borderColor: 'oklch(0.7 0.17 330)',
backgroundColor: 'oklch(0.7 0.17 330 / 0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
}]
},
options: commonOptions
});
}
// Messages chart (accent color - teal/cyan)
const messageCtx = document.getElementById('messageChart');
if (messageCtx && messageData.data && messageData.data.length > 0) {
new Chart(messageCtx, {
type: 'line',
data: {
labels: formatLabels(messageData.data),
datasets: [{
label: 'Messages',
data: messageData.data.map(d => d.count),
borderColor: 'oklch(0.75 0.18 180)',
backgroundColor: 'oklch(0.75 0.18 180 / 0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
}]
},
options: commonOptions
});
}
// Node count chart (primary color - purple/blue)
const nodeCtx = document.getElementById('nodeChart');
if (nodeCtx && nodeData.data && nodeData.data.length > 0) {
new Chart(nodeCtx, {
type: 'line',
data: {
labels: formatLabels(nodeData.data),
datasets: [{
label: 'Total Nodes',
data: nodeData.data.map(d => d.count),
borderColor: 'oklch(0.65 0.24 265)',
backgroundColor: 'oklch(0.65 0.24 265 / 0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
}]
},
options: commonOptions
});
}
var nodeData = {{ node_count_json | safe }};
var advertData = {{ advert_activity_json | safe }};
var messageData = {{ message_activity_json | safe }};
initDashboardCharts(nodeData, advertData, messageData);
})();
</script>
{% endblock %}
@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "macros/icons.html" import icon_home, icon_nodes %}
{% block title %}Page Not Found - {{ network_name }}{% endblock %}
@@ -17,15 +18,11 @@
</p>
<div class="flex gap-4 justify-center">
<a href="/" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
{{ icon_home("h-5 w-5 mr-2") }}
Go Home
</a>
<a href="/nodes" class="btn btn-outline">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
{{ icon_nodes("h-5 w-5 mr-2") }}
Browse Nodes
</a>
</div>
+9 -104
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% from "macros/icons.html" import icon_dashboard, icon_map, icon_nodes, icon_advertisements, icon_messages, icon_page %}
{% from "macros/icons.html" import icon_dashboard, icon_map, icon_nodes, icon_advertisements, icon_messages, icon_page, icon_info, icon_chart, icon_globe, icon_github %}
{% block title %}{{ network_name }} - Home{% endblock %}
@@ -96,9 +96,7 @@
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ icon_info("h-6 w-6") }}
Network Info
</h2>
<div class="space-y-2">
@@ -160,15 +158,11 @@
<p class="text-xs opacity-50 mt-4 text-center">Connecting people and things, without using the internet</p>
<div class="flex gap-2 mt-4">
<a href="https://meshcore.co.uk/" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
{{ icon_globe("h-4 w-4 mr-1") }}
Website
</a>
<a href="https://github.com/meshcore-dev/MeshCore" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
{{ icon_github("h-4 w-4 mr-1") }}
GitHub
</a>
</div>
@@ -179,9 +173,7 @@
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
</svg>
{{ icon_chart("h-6 w-6") }}
Network Activity
</h2>
<p class="text-sm opacity-70 mb-2">Activity per day (last 7 days)</p>
@@ -195,99 +187,12 @@
{% block extra_scripts %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="{{ url_for('static', path='js/charts.js') }}"></script>
<script>
(function() {
const advertData = {{ advert_activity_json | safe }};
const messageData = {{ message_activity_json | safe }};
const ctx = document.getElementById('activityChart');
if (ctx && advertData.data && advertData.data.length > 0) {
// Format dates for display (show only day/month)
const labels = advertData.data.map(d => {
const date = new Date(d.date);
return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
});
const advertCounts = advertData.data.map(d => d.count);
const messageCounts = messageData.data ? messageData.data.map(d => d.count) : [];
new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Advertisements',
data: advertCounts,
borderColor: 'oklch(0.7 0.17 330)',
backgroundColor: 'oklch(0.7 0.17 330 / 0.1)',
fill: false,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
}, {
label: 'Messages',
data: messageCounts,
borderColor: 'oklch(0.7 0.15 200)',
backgroundColor: 'oklch(0.7 0.15 200 / 0.1)',
fill: false,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
color: 'oklch(0.7 0 0)',
boxWidth: 12,
padding: 8
}
},
tooltip: {
mode: 'index',
intersect: false,
backgroundColor: 'oklch(0.25 0 0)',
titleColor: 'oklch(0.9 0 0)',
bodyColor: 'oklch(0.9 0 0)',
borderColor: 'oklch(0.4 0 0)',
borderWidth: 1
}
},
scales: {
x: {
grid: {
color: 'oklch(0.4 0 0 / 0.2)'
},
ticks: {
color: 'oklch(0.7 0 0)',
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 10
}
},
y: {
beginAtZero: true,
grid: {
color: 'oklch(0.4 0 0 / 0.2)'
},
ticks: {
color: 'oklch(0.7 0 0)',
precision: 0
}
}
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
}
}
});
}
var advertData = {{ advert_activity_json | safe }};
var messageData = {{ message_activity_json | safe }};
createActivityChart('activityChart', advertData, messageData);
})();
</script>
{% endblock %}
@@ -45,3 +45,99 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{% endmacro %}
{% macro icon_info(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{% endmacro %}
{% macro icon_alert(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{% endmacro %}
{% macro icon_chart(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
</svg>
{% endmacro %}
{% macro icon_refresh(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{% endmacro %}
{% macro icon_menu(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" />
</svg>
{% endmacro %}
{% macro icon_github(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
{% endmacro %}
{% macro icon_external_link(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
{% endmacro %}
{% macro icon_globe(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
{% endmacro %}
{% macro icon_error(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{% endmacro %}
{% macro icon_channel(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
</svg>
{% endmacro %}
{% macro icon_success(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{% endmacro %}
{% macro icon_lock(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
{% endmacro %}
{% macro icon_user(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
{% endmacro %}
{% macro icon_email(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
{% endmacro %}
{% macro icon_tag(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
</svg>
{% endmacro %}
{% macro icon_users(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
{% endmacro %}
+5 -354
View File
@@ -2,37 +2,6 @@
{% block title %}{{ network_name }} - Node Map{% endblock %}
{% block extra_head %}
<style>
#map {
height: calc(100vh - 350px);
min-height: 400px;
border-radius: var(--rounded-box);
}
.leaflet-popup-content-wrapper {
background: oklch(var(--b1));
color: oklch(var(--bc));
}
.leaflet-popup-tip {
background: oklch(var(--b1));
}
/* Map label visibility */
.map-label {
opacity: 0;
transition: opacity 0.15s ease-in-out;
}
.map-marker:hover .map-label {
opacity: 1;
}
.show-labels .map-label {
opacity: 1;
}
/* Bring hovered marker to front */
.leaflet-marker-icon:hover {
z-index: 10000 !important;
}
</style>
{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
@@ -113,328 +82,10 @@
{% block extra_scripts %}
<script>
// Logo URL for infrastructure nodes
const logoUrl = "{{ logo_url }}";
// Initialize map with world view (will be centered on nodes once loaded)
const map = L.map('map').setView([0, 0], 2);
// 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);
// Store all nodes and markers
let allNodes = [];
let allMembers = [];
let markers = [];
let mapCenter = { lat: 0, lon: 0 };
let infraCenter = null;
// 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 isMobilePortrait = window.innerWidth < 480;
const isMobile = window.innerWidth < 768;
const BOUNDS_PADDING = isMobilePortrait ? [50, 50] : (isMobile ? [75, 75] : [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
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
// Filter nodes within radius of anchor point for bounds calculation
function getNodesWithinRadius(nodes, anchorLat, anchorLon, radiusKm) {
return nodes.filter(n =>
getDistanceKm(anchorLat, anchorLon, n.lat, n.lon) <= radiusKm
);
}
// Get anchor point for bounds calculation (infra center or nodes center)
function getAnchorPoint(nodes) {
if (infraCenter) {
return infraCenter;
}
// Fall back to center of provided nodes
if (nodes.length === 0) return { lat: 0, lon: 0 };
return {
lat: nodes.reduce((sum, n) => sum + n.lat, 0) / nodes.length,
lon: nodes.reduce((sum, n) => sum + n.lon, 0) / nodes.length
};
}
// Normalize adv_type to lowercase for consistent comparison
function normalizeType(type) {
return type ? type.toLowerCase() : null;
}
// formatRelativeTime is provided by /static/js/utils.js
// Get display name for node type
function getTypeDisplay(node) {
const type = normalizeType(node.adv_type);
if (type === 'chat') return 'Chat';
if (type === 'repeater') return 'Repeater';
if (type === 'room') return 'Room';
return type ? type.charAt(0).toUpperCase() + type.slice(1) : 'Unknown';
}
// Create marker icon for a node
function createNodeIcon(node) {
const displayName = node.name || '';
const relativeTime = formatRelativeTime(node.last_seen);
const timeDisplay = relativeTime ? ` (${relativeTime})` : '';
// 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 {
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({
className: 'custom-div-icon',
html: `<div class="map-marker" style="display: flex; flex-direction: column; align-items: center; gap: 2px;">
${iconHtml}
<span class="map-label" style="font-size: 10px; font-weight: bold; color: #fff; background: rgba(0,0,0,0.5); padding: 1px 4px; border-radius: 3px; white-space: nowrap; text-align: center;">${displayName}${timeDisplay}</span>
</div>`,
iconSize: [120, 50],
iconAnchor: [60, 12]
});
}
// Create popup content for a node
function createPopupContent(node) {
let ownerHtml = '';
if (node.owner) {
const ownerDisplay = node.owner.callsign
? `${node.owner.name} (${node.owner.callsign})`
: node.owner.name;
ownerHtml = `<p><span class="opacity-70">Owner:</span> ${ownerDisplay}</p>`;
}
let roleHtml = '';
if (node.role) {
roleHtml = `<p><span class="opacity-70">Role:</span> <span class="badge badge-xs badge-ghost">${node.role}</span></p>`;
}
const typeDisplay = getTypeDisplay(node);
// 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;">`
: `<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">
<h3 class="font-bold text-lg mb-2">${iconHtml} ${node.name}</h3>
<div class="space-y-1 text-sm">
<p><span class="opacity-70">Type:</span> ${typeDisplay}</p>
${roleHtml}
${ownerHtml}
<p><span class="opacity-70">Key:</span> <code class="text-xs">${node.public_key.substring(0, 16)}...</code></p>
<p><span class="opacity-70">Location:</span> ${node.lat.toFixed(4)}, ${node.lon.toFixed(4)}</p>
${node.last_seen ? `<p><span class="opacity-70">Last seen:</span> ${node.last_seen.substring(0, 19).replace('T', ' ')}</p>` : ''}
</div>
<a href="/nodes/${node.public_key}" class="btn btn-outline btn-xs mt-3">View Details</a>
</div>
`;
}
// Clear all markers from map
function clearMarkers() {
markers.forEach(marker => map.removeLayer(marker));
markers = [];
}
// Core filter logic - returns filtered nodes and updates markers
function applyFiltersCore() {
const categoryFilter = document.getElementById('filter-category').value;
const typeFilter = document.getElementById('filter-type').value;
const memberFilter = document.getElementById('filter-member').value;
// Filter nodes
const filteredNodes = allNodes.filter(node => {
// Category filter (infrastructure only)
if (categoryFilter === 'infra' && !node.is_infra) return false;
// Type filter (case-insensitive)
const nodeType = normalizeType(node.adv_type);
if (typeFilter && nodeType !== typeFilter) return false;
// Member filter - match node's member_id tag to selected member_id
if (memberFilter) {
if (node.member_id !== memberFilter) return false;
}
return true;
});
// Clear existing markers
clearMarkers();
// Add filtered markers
filteredNodes.forEach(node => {
const marker = L.marker([node.lat, node.lon], { icon: createNodeIcon(node) }).addTo(map);
marker.bindPopup(createPopupContent(node));
markers.push(marker);
});
// Update counts
const countEl = document.getElementById('node-count');
const filteredEl = document.getElementById('filtered-count');
if (filteredNodes.length === allNodes.length) {
countEl.textContent = `${allNodes.length} nodes on map`;
filteredEl.classList.add('hidden');
} else {
countEl.textContent = `${allNodes.length} total`;
filteredEl.textContent = `${filteredNodes.length} shown`;
filteredEl.classList.remove('hidden');
}
return filteredNodes;
}
// Apply filters and recenter map on filtered nodes
function applyFilters() {
const filteredNodes = applyFiltersCore();
const categoryFilter = document.getElementById('filter-category').value;
// Fit bounds if we have filtered nodes
if (filteredNodes.length > 0) {
let nodesToFit = filteredNodes;
// Apply radius filter when showing all nodes (not infra-only)
if (categoryFilter !== 'infra') {
const anchor = getAnchorPoint(filteredNodes);
const nearbyNodes = getNodesWithinRadius(filteredNodes, anchor.lat, anchor.lon, MAX_BOUNDS_RADIUS_KM);
if (nearbyNodes.length > 0) {
nodesToFit = nearbyNodes;
}
}
const bounds = L.latLngBounds(nodesToFit.map(n => [n.lat, n.lon]));
map.fitBounds(bounds, { padding: BOUNDS_PADDING });
} else if (mapCenter.lat !== 0 || mapCenter.lon !== 0) {
map.setView([mapCenter.lat, mapCenter.lon], 10);
}
}
// Apply filters without recentering (for initial load after manual center)
function applyFiltersNoRecenter() {
applyFiltersCore();
}
// Populate member filter dropdown
function populateMemberFilter() {
const select = document.getElementById('filter-member');
// Sort members by name
const sortedMembers = [...allMembers].sort((a, b) => a.name.localeCompare(b.name));
// Add options for all members
sortedMembers.forEach(member => {
if (member.member_id) {
const option = document.createElement('option');
option.value = member.member_id;
option.textContent = member.callsign
? `${member.name} (${member.callsign})`
: member.name;
select.appendChild(option);
}
});
}
// Clear all filters
function clearFilters() {
document.getElementById('filter-category').value = '';
document.getElementById('filter-type').value = '';
document.getElementById('filter-member').value = '';
document.getElementById('show-labels').checked = false;
updateLabelVisibility();
applyFilters();
}
// Toggle label visibility
function updateLabelVisibility() {
const showLabels = document.getElementById('show-labels').checked;
const mapEl = document.getElementById('map');
if (showLabels) {
mapEl.classList.add('show-labels');
} else {
mapEl.classList.remove('show-labels');
}
}
// Event listeners for filters
document.getElementById('filter-category').addEventListener('change', applyFilters);
document.getElementById('filter-type').addEventListener('change', applyFilters);
document.getElementById('filter-member').addEventListener('change', applyFilters);
document.getElementById('show-labels').addEventListener('change', updateLabelVisibility);
document.getElementById('clear-filters').addEventListener('click', clearFilters);
// Fetch and display nodes
fetch('/map/data')
.then(response => response.json())
.then(data => {
allNodes = data.nodes;
allMembers = data.members || [];
mapCenter = data.center;
infraCenter = data.infra_center;
// Log debug info
const debug = data.debug || {};
console.log('Map data loaded:', debug);
console.log('Sample node data:', allNodes.length > 0 ? allNodes[0] : 'No nodes');
if (debug.error) {
document.getElementById('node-count').textContent = `Error: ${debug.error}`;
return;
}
if (debug.total_nodes === 0) {
document.getElementById('node-count').textContent = 'No nodes in database';
return;
}
if (debug.nodes_with_coords === 0) {
document.getElementById('node-count').textContent = `${debug.total_nodes} nodes (none have coordinates)`;
return;
}
// Populate member filter
populateMemberFilter();
// Initial display - center map on infrastructure nodes if available, else nodes within radius
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: 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: BOUNDS_PADDING });
}
// Apply filters (won't re-center since we just did above)
applyFiltersNoRecenter();
})
.catch(error => {
console.error('Error loading map data:', error);
document.getElementById('node-count').textContent = 'Error loading data';
});
window.mapConfig = {
logoUrl: "{{ logo_url }}",
dataUrl: "/map/data"
};
</script>
<script src="{{ url_for('static', path='js/map-main.js') }}"></script>
{% endblock %}
+2 -3
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "macros/icons.html" import icon_info %}
{% block title %}{{ network_name }} - Members{% endblock %}
@@ -71,9 +72,7 @@
</div>
{% else %}
<div class="alert alert-info">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ icon_info("stroke-current shrink-0 h-6 w-6") }}
<div>
<h3 class="font-bold">No members configured</h3>
<p class="text-sm">To display network members, create a members.yaml file in your seed directory.</p>
+2 -3
View File
@@ -1,5 +1,6 @@
{% extends "base.html" %}
{% from "_macros.html" import pagination %}
{% from "macros/icons.html" import icon_alert %}
{% block title %}{{ network_name }} - Messages{% endblock %}
@@ -11,9 +12,7 @@
{% if api_error %}
<div class="alert alert-warning mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
<span>Could not fetch data from API: {{ api_error }}</span>
</div>
{% endif %}
+16 -100
View File
@@ -1,22 +1,10 @@
{% extends "base.html" %}
{% from "macros/icons.html" import icon_alert, icon_error %}
{% 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;
border-radius: var(--rounded-box);
}
.leaflet-popup-content-wrapper {
background: oklch(var(--b1));
color: oklch(var(--bc));
}
.leaflet-popup-tip {
background: oklch(var(--b1));
}
</style>
{% endblock %}
{% block content %}
@@ -40,9 +28,7 @@
{% if api_error %}
<div class="alert alert-warning mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
<span>Could not fetch data from API: {{ api_error }}</span>
</div>
{% endif %}
@@ -260,9 +246,7 @@
{% else %}
<div class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ icon_error("stroke-current shrink-0 h-6 w-6") }}
<span>Node not found: {{ public_key }}</span>
</div>
<a href="/nodes" class="btn btn-primary mt-4">Back to Nodes</a>
@@ -278,43 +262,13 @@
{% 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>';
}
}
})();
window.qrCodeConfig = {
name: {{ (ns_qr.tag_name or node.name or 'Node') | tojson }},
publicKey: {{ node.public_key | tojson }},
advType: {{ (node.adv_type or '') | tojson }}
};
</script>
<script src="{{ url_for('static', path='js/qrcode-init.js') }}"></script>
{% set ns_map = namespace(lat=none, lon=none, name=none) %}
{% for tag in node.tags or [] %}
@@ -328,52 +282,14 @@
{% endfor %}
{% if ns_map.lat and ns_map.lon %}
<script>
// Initialize map centered on the node's location
const nodeLat = {{ ns_map.lat }};
const nodeLon = {{ ns_map.lon }};
const nodeName = {{ (ns_map.name or node.name or 'Unnamed Node') | tojson }};
const nodeType = {{ (node.adv_type or '') | tojson }};
const publicKey = {{ node.public_key | tojson }};
const map = L.map('node-map').setView([nodeLat, nodeLon], 15);
// 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) {
const 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)
const emoji = getNodeEmoji(nodeType);
const 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
const marker = L.marker([nodeLat, nodeLon], { icon: icon }).addTo(map);
// Add popup (shown on click, not by default)
marker.bindPopup(`
<div class="p-2">
<h3 class="font-bold text-lg mb-2">${emoji} ${nodeName}</h3>
<div class="space-y-1 text-sm">
${nodeType ? `<p><span class="opacity-70">Type:</span> ${nodeType}</p>` : ''}
<p><span class="opacity-70">Coordinates:</span> ${nodeLat.toFixed(4)}, ${nodeLon.toFixed(4)}</p>
</div>
</div>
`);
window.nodeMapConfig = {
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 }}
};
</script>
<script src="{{ url_for('static', path='js/map-node.js') }}"></script>
{% endif %}
{% endif %}
{% endblock %}
+2 -3
View File
@@ -1,5 +1,6 @@
{% extends "base.html" %}
{% from "_macros.html" import pagination %}
{% from "macros/icons.html" import icon_alert %}
{% block title %}{{ network_name }} - Nodes{% endblock %}
@@ -11,9 +12,7 @@
{% if api_error %}
<div class="alert alert-warning mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
<span>Could not fetch data from API: {{ api_error }}</span>
</div>
{% endif %}