node page: map-based location history + collapsible recent adverts (#43)

Replace the Location History coordinate table with an embedded Leaflet
map (new LocationHistoryMap component): one circle marker per position
with timestamp/coord popups, the most-recent point highlighted, and a
subtle polyline connecting points chronologically, auto-fit to bounds.

Cap Recent Adverts at the 5 most recent with a "Show more"/"Show less"
toggle and a count in the subtitle.

Co-authored-by: Alex Vanderpot <alex@Alexs-MacBook-Pro-2.local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Vanderpot
2026-06-15 22:38:16 -04:00
committed by GitHub
parent eaaf729b15
commit 27c94e1aee
2 changed files with 111 additions and 30 deletions
@@ -1,5 +1,7 @@
"use client";
import { useState } from "react";
import dynamic from "next/dynamic";
import { useParams } from "next/navigation";
import Link from "next/link";
import moment from "moment";
@@ -13,6 +15,9 @@ import { useNodeData, type NodeData, type NodeInfo, type Advert, type LocationHi
import { ArrowRightEndOnRectangleIcon, ArrowRightStartOnRectangleIcon } from "@heroicons/react/24/outline";
import { RegionProvider } from "@/contexts/RegionContext";
// Leaflet needs `window`, so load the location-history map client-side only.
const LocationHistoryMap = dynamic(() => import("@/components/LocationHistoryMap"), { ssr: false });
// Interfaces are now imported from useNodeData hook
// Function to determine node type based on capabilities
@@ -27,6 +32,7 @@ export default function MeshcoreNodePage() {
const params = useParams();
const publicKey = params.publicKey as string;
const { config } = useConfig();
const [showAllAdverts, setShowAllAdverts] = useState(false);
// Use TanStack Query for node data
const {
@@ -353,7 +359,9 @@ export default function MeshcoreNodePage() {
<div className="bg-white dark:bg-neutral-900 shadow rounded-lg">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-medium text-gray-900 dark:text-gray-100">Recent Adverts</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">Latest {recentAdverts.length} adverts</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
Showing {Math.min(showAllAdverts ? recentAdverts.length : 5, recentAdverts.length)} of {recentAdverts.length} adverts
</p>
</div>
<div className="p-6 space-y-4">
{recentAdverts.length === 0 ? (
@@ -361,9 +369,19 @@ export default function MeshcoreNodePage() {
No adverts found
</div>
) : (
recentAdverts.map((advert) => (
<AdvertDetails key={advert.group_id} advert={advert} initiatingNodeKey={node.public_key} />
))
<>
{(showAllAdverts ? recentAdverts : recentAdverts.slice(0, 5)).map((advert) => (
<AdvertDetails key={advert.group_id} advert={advert} initiatingNodeKey={node.public_key} />
))}
{recentAdverts.length > 5 && (
<button
onClick={() => setShowAllAdverts((prev) => !prev)}
className="w-full text-center text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 py-2"
>
{showAllAdverts ? "Show less" : `Show more (${recentAdverts.length - 5} more)`}
</button>
)}
</>
)}
</div>
</div>
@@ -374,32 +392,13 @@ export default function MeshcoreNodePage() {
<h2 className="text-lg font-medium text-gray-900 dark:text-gray-100">Location History</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">Recent location updates (last 30 days)</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-neutral-800">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Timestamp</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Latitude</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Longitude</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-neutral-900 divide-y divide-gray-200 dark:divide-gray-700">
{locationHistory.map((location, index) => (
<tr key={index}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{moment.utc(location.mesh_timestamp).format('MM-DD HH:mm:ss')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{location.latitude.toFixed(6)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{location.longitude.toFixed(6)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{locationHistory.length === 0 ? (
<div className="text-center text-gray-500 dark:text-gray-400 py-8">
No location data
</div>
) : (
<LocationHistoryMap locations={locationHistory} />
)}
</div>
</div>
@@ -0,0 +1,82 @@
"use client";
import { useEffect } from "react";
import { MapContainer, TileLayer, CircleMarker, Polyline, Popup, useMap } from "react-leaflet";
import moment from "moment";
import 'leaflet/dist/leaflet.css';
import L from "leaflet";
import { type LocationHistory } from "@/hooks/useNodeData";
interface LocationHistoryMapProps {
locations: LocationHistory[];
}
// Fit the map to all location points on mount / when the points change.
function FitBounds({ coords }: { coords: [number, number][] }) {
const map = useMap();
useEffect(() => {
if (coords.length === 0) return;
if (coords.length === 1) {
map.setView(coords[0], 14);
return;
}
map.fitBounds(L.latLngBounds(coords), { padding: [24, 24] });
}, [map, coords]);
return null;
}
export default function LocationHistoryMap({ locations }: LocationHistoryMapProps) {
// locations come newest-first; coords reversed to chronological for the track line.
const coords = locations.map((l) => [l.latitude, l.longitude] as [number, number]);
const trackCoords = [...coords].reverse();
return (
<MapContainer
center={coords[0] ?? [0, 0]}
zoom={13}
scrollWheelZoom={false}
style={{ height: '400px', width: '100%' }}
className="rounded-b-lg z-0"
>
<TileLayer
attribution='Tiles &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
maxZoom={19}
/>
{trackCoords.length > 1 && (
<Polyline
positions={trackCoords}
pathOptions={{ color: '#3b82f6', weight: 2, opacity: 0.4 }}
/>
)}
{locations.map((location, index) => {
const isLatest = index === 0;
return (
<CircleMarker
key={`${location.latitude},${location.longitude},${location.mesh_timestamp}`}
center={[location.latitude, location.longitude]}
radius={isLatest ? 8 : 5}
pathOptions={{
color: isLatest ? '#2563eb' : '#6b7280',
fillColor: isLatest ? '#3b82f6' : '#9ca3af',
fillOpacity: isLatest ? 0.9 : 0.5,
weight: isLatest ? 2 : 1,
}}
>
<Popup>
<div className="text-xs space-y-1">
<div className="font-medium">
{moment.utc(location.mesh_timestamp).format('YYYY-MM-DD HH:mm:ss')} UTC
</div>
<div className="font-mono">
{location.latitude.toFixed(6)}, {location.longitude.toFixed(6)}
</div>
{isLatest && <div className="text-blue-600">Most recent</div>}
</div>
</Popup>
</CircleMarker>
);
})}
<FitBounds coords={coords} />
</MapContainer>
);
}