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 = '';
+ } else {
+ iconHtml = '
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 + ? 'Last seen: ' + node.last_seen.substring(0, 19).replace('T', ' ') + '
' + : ''; + + return 'Type: ' + typeDisplay + '
' + + roleHtml + + ownerHtml + + 'Key: ' + node.public_key.substring(0, 16) + '...
Location: ' + node.lat.toFixed(4) + ', ' + node.lon.toFixed(4) + '
' + + lastSeenHtml + + 'Type: ' + nodeType + '
' : ''; + var popupContent = 'Coordinates: ' + nodeLat.toFixed(4) + ', ' + nodeLon.toFixed(4) + '
' + + '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 %}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 @@