feat(weeks4-5): operations endpoints + UI components with Recharts & Leaflet
This commit is contained in:
131
src/components/admin/CampaignMap.tsx
Normal file
131
src/components/admin/CampaignMap.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup, CircleMarker } from 'react-leaflet';
|
||||
import * as turf from '@turf/turf';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
/**
|
||||
* Campaign Map Component
|
||||
* Visualize geospatial content coverage
|
||||
*/
|
||||
|
||||
interface Location {
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
city?: string;
|
||||
state?: string;
|
||||
content_generated?: boolean;
|
||||
}
|
||||
|
||||
interface CampaignMapProps {
|
||||
geoData?: {
|
||||
type: string;
|
||||
features: any[];
|
||||
};
|
||||
defaultCenter?: [number, number];
|
||||
defaultZoom?: number;
|
||||
}
|
||||
|
||||
export default function CampaignMap({
|
||||
geoData,
|
||||
defaultCenter = [39.8283, -98.5795], // Center of USA
|
||||
defaultZoom = 4
|
||||
}: CampaignMapProps) {
|
||||
const [locations, setLocations] = useState<Location[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLocations();
|
||||
}, []);
|
||||
|
||||
async function fetchLocations() {
|
||||
try {
|
||||
const token = localStorage.getItem('godToken') || '';
|
||||
const res = await fetch('/api/god/sql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-God-Token': token
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: `
|
||||
SELECT
|
||||
id::text,
|
||||
ST_Y(location::geometry) as lat,
|
||||
ST_X(location::geometry) as lng,
|
||||
city,
|
||||
state,
|
||||
content_generated
|
||||
FROM geo_locations
|
||||
WHERE location IS NOT NULL
|
||||
LIMIT 1000
|
||||
`
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setLocations(data.rows || []);
|
||||
setIsLoading(false);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch locations:', error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-full h-[500px] bg-slate-900 rounded-xl flex items-center justify-center text-slate-500">
|
||||
Loading map data...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-[500px] rounded-xl overflow-hidden border border-slate-800">
|
||||
<MapContainer
|
||||
center={defaultCenter}
|
||||
zoom={defaultZoom}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
className="z-0"
|
||||
>
|
||||
{/* Tile Layer (Map Background) */}
|
||||
<TileLayer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
attribution='© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
|
||||
/>
|
||||
|
||||
{/* Location Markers */}
|
||||
{locations.map((location) => (
|
||||
<CircleMarker
|
||||
key={location.id}
|
||||
center={[location.lat, location.lng]}
|
||||
radius={location.content_generated ? 8 : 5}
|
||||
fillColor={location.content_generated ? '#10b981' : '#3b82f6'}
|
||||
color={location.content_generated ? '#059669' : '#2563eb'}
|
||||
weight={1}
|
||||
opacity={0.8}
|
||||
fillOpacity={0.6}
|
||||
>
|
||||
<Popup>
|
||||
<div className="text-sm">
|
||||
<strong>{location.city || 'Unknown'}, {location.state || '??'}</strong>
|
||||
<br />
|
||||
Status: {location.content_generated ? '✅ Generated' : '⏳ Pending'}
|
||||
<br />
|
||||
<span className="text-xs text-gray-500">
|
||||
{location.lat.toFixed(4)}, {location.lng.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
))}
|
||||
</MapContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user