mirror of
https://github.com/ajvpot/meshexplorer.git
synced 2026-06-22 19:04:45 +02:00
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:
@@ -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 © <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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user