diff --git a/src/meshcore_hub/web/static/css/app.css b/src/meshcore_hub/web/static/css/app.css new file mode 100644 index 0000000..af89427 --- /dev/null +++ b/src/meshcore_hub/web/static/css/app.css @@ -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; +} diff --git a/src/meshcore_hub/web/static/js/charts.js b/src/meshcore_hub/web/static/js/charts.js new file mode 100644 index 0000000..be88b94 --- /dev/null +++ b/src/meshcore_hub/web/static/js/charts.js @@ -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 + ); +} diff --git a/src/meshcore_hub/web/static/js/map-main.js b/src/meshcore_hub/web/static/js/map-main.js new file mode 100644 index 0000000..5e7a2ab --- /dev/null +++ b/src/meshcore_hub/web/static/js/map-main.js @@ -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: '© OpenStreetMap 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 = 'Infra'; + } else { + iconHtml = '
'; + } + + return L.divIcon({ + className: 'custom-div-icon', + html: '
' + + iconHtml + + '' + displayName + timeDisplay + '' + + '
', + 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 = '

Owner: ' + ownerDisplay + '

'; + } + + var roleHtml = ''; + if (node.role) { + roleHtml = '

Role: ' + node.role + '

'; + } + + var typeDisplay = getTypeDisplay(node); + + // Use logo for infrastructure nodes, blue circle for others + var iconHtml = node.is_infra + ? 'Infra' + : ''; + + var lastSeenHtml = node.last_seen + ? '

Last seen: ' + node.last_seen.substring(0, 19).replace('T', ' ') + '

' + : ''; + + return '
' + + '

' + iconHtml + ' ' + node.name + '

' + + '
' + + '

Type: ' + typeDisplay + '

' + + roleHtml + + ownerHtml + + '

Key: ' + node.public_key.substring(0, 16) + '...

' + + '

Location: ' + node.lat.toFixed(4) + ', ' + node.lon.toFixed(4) + '

' + + lastSeenHtml + + '
' + + 'View Details' + + '
'; + } + + /** + * 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'; + }); +})(); diff --git a/src/meshcore_hub/web/static/js/map-node.js b/src/meshcore_hub/web/static/js/map-node.js new file mode 100644 index 0000000..3a55cf3 --- /dev/null +++ b/src/meshcore_hub/web/static/js/map-node.js @@ -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: '© OpenStreetMap 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: '' + emoji + '', + iconSize: [32, 32], + iconAnchor: [16, 16] + }); + + // Add marker + var marker = L.marker([nodeLat, nodeLon], { icon: icon }).addTo(map); + + // Build popup content + var typeHtml = nodeType ? '

Type: ' + nodeType + '

' : ''; + var popupContent = '
' + + '

' + emoji + ' ' + nodeName + '

' + + '
' + + typeHtml + + '

Coordinates: ' + nodeLat.toFixed(4) + ', ' + nodeLon.toFixed(4) + '

' + + '
' + + '
'; + + // Add popup (shown on click, not by default) + marker.bindPopup(popupContent); +})(); diff --git a/src/meshcore_hub/web/static/js/qrcode-init.js b/src/meshcore_hub/web/static/js/qrcode-init.js new file mode 100644 index 0000000..2c47cb4 --- /dev/null +++ b/src/meshcore_hub/web/static/js/qrcode-init.js @@ -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 = '

QR code unavailable

'; + } + } +})(); diff --git a/src/meshcore_hub/web/templates/admin/access_denied.html b/src/meshcore_hub/web/templates/admin/access_denied.html index 0f42a4a..1547f4c 100644 --- a/src/meshcore_hub/web/templates/admin/access_denied.html +++ b/src/meshcore_hub/web/templates/admin/access_denied.html @@ -1,13 +1,12 @@ {% extends "base.html" %} +{% from "macros/icons.html" import icon_lock %} {% block title %}{{ network_name }} - Access Denied{% endblock %} {% block content %}
- - - + {{ icon_lock("h-24 w-24 mx-auto text-error opacity-50 mb-6") }}

Access Denied

You don't have permission to access the admin area.

Please contact the network administrator if you believe this is an error.

diff --git a/src/meshcore_hub/web/templates/admin/index.html b/src/meshcore_hub/web/templates/admin/index.html index 9087600..ba13560 100644 --- a/src/meshcore_hub/web/templates/admin/index.html +++ b/src/meshcore_hub/web/templates/admin/index.html @@ -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 @@
{% if auth_username or auth_user %} - - - + {{ icon_user("h-4 w-4") }} {{ auth_username or auth_user }} {% endif %} {% if auth_email %} - - - + {{ icon_email("h-4 w-4") }} {{ auth_email }} {% endif %} @@ -43,11 +38,7 @@

- - - + {{ icon_users("h-6 w-6") }} Members

Manage network members and operators.

@@ -56,11 +47,7 @@

- - - + {{ icon_tag("h-6 w-6") }} Node Tags

Manage custom tags and metadata for network nodes.

diff --git a/src/meshcore_hub/web/templates/admin/members.html b/src/meshcore_hub/web/templates/admin/members.html index 293e459..75f4aec 100644 --- a/src/meshcore_hub/web/templates/admin/members.html +++ b/src/meshcore_hub/web/templates/admin/members.html @@ -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 @@ {% if message %}
- - - + {{ icon_success("stroke-current shrink-0 h-6 w-6") }} {{ message }}
{% endif %} {% if error %}
- - - + {{ icon_error("stroke-current shrink-0 h-6 w-6") }} {{ error }}
{% endif %} @@ -232,9 +229,7 @@

Are you sure you want to delete member ?

- - - + {{ icon_alert("stroke-current shrink-0 h-6 w-6") }} This action cannot be undone.
diff --git a/src/meshcore_hub/web/templates/admin/node_tags.html b/src/meshcore_hub/web/templates/admin/node_tags.html index 36639bc..fe60822 100644 --- a/src/meshcore_hub/web/templates/admin/node_tags.html +++ b/src/meshcore_hub/web/templates/admin/node_tags.html @@ -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 @@ {% if message %}
- - - + {{ icon_success("stroke-current shrink-0 h-6 w-6") }} {{ message }}
{% endif %} {% if error %}
- - - + {{ icon_error("stroke-current shrink-0 h-6 w-6") }} {{ error }}
{% endif %} @@ -253,9 +250,7 @@
- - - + {{ icon_alert("stroke-current shrink-0 h-6 w-6") }} This will move the tag from the current node to the destination node.
@@ -281,9 +276,7 @@

Are you sure you want to delete the tag ""?

- - - + {{ icon_alert("stroke-current shrink-0 h-6 w-6") }} This action cannot be undone.
@@ -324,9 +317,7 @@
- - - + {{ icon_info("stroke-current shrink-0 h-6 w-6") }} Tags that already exist on the destination node will be skipped. Original tags remain on this node.
@@ -351,9 +342,7 @@

Are you sure you want to delete all {{ tags|length }} tag(s) from {{ selected_node.name or 'Unnamed' }}?

- - - + {{ icon_alert("stroke-current shrink-0 h-6 w-6") }} This action cannot be undone. All tags will be permanently deleted.
@@ -370,17 +359,13 @@ {% elif selected_public_key and not selected_node %}
- - - + {{ icon_alert("stroke-current shrink-0 h-6 w-6") }} Node not found: {{ selected_public_key }}
{% else %}
- - - + {{ icon_tag("h-16 w-16 mx-auto mb-4 opacity-30") }}

Select a Node

Choose a node from the dropdown above to view and manage its tags.

diff --git a/src/meshcore_hub/web/templates/advertisements.html b/src/meshcore_hub/web/templates/advertisements.html index 08dc9c7..da63c55 100644 --- a/src/meshcore_hub/web/templates/advertisements.html +++ b/src/meshcore_hub/web/templates/advertisements.html @@ -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 %}
- - - + {{ icon_alert("stroke-current shrink-0 h-6 w-6") }} Could not fetch data from API: {{ api_error }}
{% endif %} diff --git a/src/meshcore_hub/web/templates/base.html b/src/meshcore_hub/web/templates/base.html index 2e73302..6c692e3 100644 --- a/src/meshcore_hub/web/templates/base.html +++ b/src/meshcore_hub/web/templates/base.html @@ -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 %} @@ -37,121 +37,8 @@ - + + {% block extra_head %}{% endblock %} @@ -161,9 +48,7 @@