update 2
This commit is contained in:
@@ -12,8 +12,8 @@
|
||||
"react-scripts": "5.0.1",
|
||||
"react-router-dom": "^6.8.1",
|
||||
"axios": "^1.6.2",
|
||||
"@google/maps": "^1.1.3",
|
||||
"@googlemaps/js-api-loader": "^1.16.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"@headlessui/react": "^1.7.17",
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"react-hook-form": "^7.48.2",
|
||||
@@ -24,8 +24,6 @@
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"date-fns": "^2.30.0",
|
||||
"recharts": "^2.8.0",
|
||||
"react-map-gl": "^7.1.7",
|
||||
"mapbox-gl": "^2.15.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"clsx": "^2.0.0",
|
||||
|
||||
437
frontend/src/components/Maps/PropertyMap.js
Normal file
437
frontend/src/components/Maps/PropertyMap.js
Normal file
@@ -0,0 +1,437 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { MapContainer, TileLayer, Polygon, Marker, useMapEvents } from 'react-leaflet';
|
||||
import { Icon } from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
// Fix for default markers in react-leaflet
|
||||
delete Icon.Default.prototype._getIconUrl;
|
||||
Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: require('leaflet/dist/images/marker-icon-2x.png'),
|
||||
iconUrl: require('leaflet/dist/images/marker-icon.png'),
|
||||
shadowUrl: require('leaflet/dist/images/marker-shadow.png'),
|
||||
});
|
||||
|
||||
// Custom component to handle map clicks for drawing polygons
|
||||
const DrawingHandler = ({ isDrawing, onPointAdd, onDrawingComplete }) => {
|
||||
useMapEvents({
|
||||
click: (e) => {
|
||||
if (isDrawing) {
|
||||
onPointAdd([e.latlng.lat, e.latlng.lng]);
|
||||
}
|
||||
},
|
||||
keydown: (e) => {
|
||||
if (e.originalEvent.key === 'Escape' && isDrawing) {
|
||||
onDrawingComplete();
|
||||
}
|
||||
}
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
// Calculate polygon area in square feet
|
||||
const calculatePolygonArea = (coordinates) => {
|
||||
if (!coordinates || coordinates.length < 3) return 0;
|
||||
|
||||
// Shoelace formula for polygon area
|
||||
let area = 0;
|
||||
const n = coordinates.length;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n;
|
||||
area += coordinates[i][0] * coordinates[j][1];
|
||||
area -= coordinates[j][0] * coordinates[i][1];
|
||||
}
|
||||
|
||||
area = Math.abs(area) / 2;
|
||||
|
||||
// Convert from decimal degrees to square feet (approximate)
|
||||
const avgLat = coordinates.reduce((sum, coord) => sum + coord[0], 0) / n;
|
||||
const meterToFeet = 3.28084;
|
||||
const degToMeter = 111320 * Math.cos(avgLat * Math.PI / 180);
|
||||
|
||||
return area * Math.pow(degToMeter * meterToFeet, 2);
|
||||
};
|
||||
|
||||
const PropertyMap = ({
|
||||
center = [39.8283, -98.5795], // Center of USA as default
|
||||
zoom = 15,
|
||||
property,
|
||||
sections = [],
|
||||
onSectionCreate,
|
||||
onSectionUpdate,
|
||||
onSectionDelete,
|
||||
onPropertyUpdate,
|
||||
editable = false,
|
||||
className = "h-96 w-full"
|
||||
}) => {
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [currentPolygon, setCurrentPolygon] = useState([]);
|
||||
const [selectedSection, setSelectedSection] = useState(null);
|
||||
const [showSectionForm, setShowSectionForm] = useState(false);
|
||||
const [sectionName, setSectionName] = useState('');
|
||||
const [grassType, setGrassType] = useState('');
|
||||
const [soilType, setSoilType] = useState('');
|
||||
const mapRef = useRef(null);
|
||||
|
||||
// Handle adding points to current polygon
|
||||
const handlePointAdd = useCallback((point) => {
|
||||
setCurrentPolygon(prev => [...prev, point]);
|
||||
}, []);
|
||||
|
||||
// Handle completing polygon drawing
|
||||
const handleDrawingComplete = useCallback(() => {
|
||||
if (currentPolygon.length >= 3) {
|
||||
const area = calculatePolygonArea(currentPolygon);
|
||||
setShowSectionForm(true);
|
||||
} else {
|
||||
setCurrentPolygon([]);
|
||||
}
|
||||
setIsDrawing(false);
|
||||
}, [currentPolygon]);
|
||||
|
||||
// Start drawing mode
|
||||
const startDrawing = () => {
|
||||
setIsDrawing(true);
|
||||
setCurrentPolygon([]);
|
||||
setSelectedSection(null);
|
||||
};
|
||||
|
||||
// Cancel drawing
|
||||
const cancelDrawing = () => {
|
||||
setIsDrawing(false);
|
||||
setCurrentPolygon([]);
|
||||
setShowSectionForm(false);
|
||||
setSectionName('');
|
||||
setGrassType('');
|
||||
setSoilType('');
|
||||
};
|
||||
|
||||
// Save new section
|
||||
const saveSection = async () => {
|
||||
if (!sectionName.trim() || currentPolygon.length < 3) return;
|
||||
|
||||
const area = calculatePolygonArea(currentPolygon);
|
||||
const sectionData = {
|
||||
name: sectionName,
|
||||
area,
|
||||
polygonData: {
|
||||
type: 'Polygon',
|
||||
coordinates: [currentPolygon.map(([lat, lng]) => [lng, lat])] // GeoJSON format
|
||||
},
|
||||
grassType: grassType || null,
|
||||
soilType: soilType || null
|
||||
};
|
||||
|
||||
try {
|
||||
if (onSectionCreate) {
|
||||
await onSectionCreate(sectionData);
|
||||
}
|
||||
|
||||
// Reset form
|
||||
cancelDrawing();
|
||||
} catch (error) {
|
||||
console.error('Error creating section:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle section click
|
||||
const handleSectionClick = (section) => {
|
||||
setSelectedSection(selectedSection?.id === section.id ? null : section);
|
||||
};
|
||||
|
||||
// Delete selected section
|
||||
const deleteSelectedSection = async () => {
|
||||
if (!selectedSection || !onSectionDelete) return;
|
||||
|
||||
try {
|
||||
await onSectionDelete(selectedSection.id);
|
||||
setSelectedSection(null);
|
||||
} catch (error) {
|
||||
console.error('Error deleting section:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Get section color based on grass type
|
||||
const getSectionColor = (section) => {
|
||||
const colors = {
|
||||
'bermuda': '#10b981',
|
||||
'fescue': '#059669',
|
||||
'kentucky bluegrass': '#047857',
|
||||
'zoysia': '#065f46',
|
||||
'st augustine': '#064e3b',
|
||||
'centipede': '#6ee7b7',
|
||||
'default': '#3b82f6'
|
||||
};
|
||||
|
||||
return colors[section.grassType?.toLowerCase()] || colors.default;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
{/* Map Controls */}
|
||||
{editable && (
|
||||
<div className="absolute top-4 right-4 z-10 flex flex-col space-y-2">
|
||||
<div className="bg-white rounded-lg shadow-lg p-2">
|
||||
{!isDrawing ? (
|
||||
<button
|
||||
onClick={startDrawing}
|
||||
className="btn-primary text-sm px-3 py-2"
|
||||
title="Draw new lawn section"
|
||||
>
|
||||
Draw Section
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col space-y-2">
|
||||
<button
|
||||
onClick={handleDrawingComplete}
|
||||
className="btn-success text-sm px-3 py-2"
|
||||
disabled={currentPolygon.length < 3}
|
||||
title="Finish drawing (or press Escape)"
|
||||
>
|
||||
Finish ({currentPolygon.length} points)
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelDrawing}
|
||||
className="btn-secondary text-sm px-3 py-2"
|
||||
title="Cancel drawing"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedSection && (
|
||||
<div className="bg-white rounded-lg shadow-lg p-3">
|
||||
<h4 className="font-medium text-gray-900 mb-2">{selectedSection.name}</h4>
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
{Math.round(selectedSection.area).toLocaleString()} sq ft
|
||||
</p>
|
||||
{selectedSection.grassType && (
|
||||
<p className="text-xs text-gray-500 mb-1">
|
||||
Grass: {selectedSection.grassType}
|
||||
</p>
|
||||
)}
|
||||
{selectedSection.soilType && (
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
Soil: {selectedSection.soilType}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={deleteSelectedSection}
|
||||
className="btn-danger text-xs px-2 py-1 w-full"
|
||||
>
|
||||
Delete Section
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section Creation Form Modal */}
|
||||
{showSectionForm && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black bg-opacity-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
New Lawn Section
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="label">Section Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={sectionName}
|
||||
onChange={(e) => setSectionName(e.target.value)}
|
||||
className="input"
|
||||
placeholder="e.g., Front Yard, Back Lawn"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Grass Type</label>
|
||||
<select
|
||||
value={grassType}
|
||||
onChange={(e) => setGrassType(e.target.value)}
|
||||
className="input"
|
||||
>
|
||||
<option value="">Select grass type</option>
|
||||
<option value="bermuda">Bermuda</option>
|
||||
<option value="fescue">Fescue</option>
|
||||
<option value="kentucky bluegrass">Kentucky Bluegrass</option>
|
||||
<option value="zoysia">Zoysia</option>
|
||||
<option value="st augustine">St. Augustine</option>
|
||||
<option value="centipede">Centipede</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Soil Type</label>
|
||||
<select
|
||||
value={soilType}
|
||||
onChange={(e) => setSoilType(e.target.value)}
|
||||
className="input"
|
||||
>
|
||||
<option value="">Select soil type</option>
|
||||
<option value="clay">Clay</option>
|
||||
<option value="sand">Sand</option>
|
||||
<option value="loam">Loam</option>
|
||||
<option value="silt">Silt</option>
|
||||
<option value="rocky">Rocky</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-3 rounded-lg">
|
||||
<p className="text-sm text-gray-600">
|
||||
Area: {Math.round(calculatePolygonArea(currentPolygon)).toLocaleString()} sq ft
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{currentPolygon.length} points drawn
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<button
|
||||
onClick={cancelDrawing}
|
||||
className="btn-outline"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={saveSection}
|
||||
disabled={!sectionName.trim()}
|
||||
className="btn-primary"
|
||||
>
|
||||
Save Section
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Map */}
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={zoom}
|
||||
className="h-full w-full rounded-lg"
|
||||
ref={mapRef}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
{/* Satellite imagery option */}
|
||||
<TileLayer
|
||||
attribution='Tiles © Esri — Source: Esri, Maxar, GeoEye, Earthstar Geographics, CNES/Airbus DS, USDA, USGS, AeroGRID, IGN, and the GIS User Community'
|
||||
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
|
||||
/>
|
||||
|
||||
{/* Drawing handler */}
|
||||
<DrawingHandler
|
||||
isDrawing={isDrawing}
|
||||
onPointAdd={handlePointAdd}
|
||||
onDrawingComplete={handleDrawingComplete}
|
||||
/>
|
||||
|
||||
{/* Property marker */}
|
||||
{property && property.latitude && property.longitude && (
|
||||
<Marker
|
||||
position={[property.latitude, property.longitude]}
|
||||
title={property.name}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Existing sections */}
|
||||
{sections.map((section) => {
|
||||
if (!section.polygonData?.coordinates?.[0]) return null;
|
||||
|
||||
const coordinates = section.polygonData.coordinates[0].map(([lng, lat]) => [lat, lng]);
|
||||
const isSelected = selectedSection?.id === section.id;
|
||||
|
||||
return (
|
||||
<Polygon
|
||||
key={section.id}
|
||||
positions={coordinates}
|
||||
pathOptions={{
|
||||
fillColor: getSectionColor(section),
|
||||
fillOpacity: isSelected ? 0.6 : 0.4,
|
||||
color: getSectionColor(section),
|
||||
weight: isSelected ? 3 : 2,
|
||||
opacity: isSelected ? 1 : 0.8,
|
||||
}}
|
||||
eventHandlers={{
|
||||
click: () => handleSectionClick(section)
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Current polygon being drawn */}
|
||||
{currentPolygon.length > 0 && (
|
||||
<>
|
||||
{/* Show markers for each point */}
|
||||
{currentPolygon.map((point, index) => (
|
||||
<Marker
|
||||
key={index}
|
||||
position={point}
|
||||
icon={new Icon({
|
||||
iconUrl: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iNCIgZmlsbD0iIzNiODJmNiIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIyIi8+Cjwvc3ZnPgo=',
|
||||
iconSize: [12, 12],
|
||||
iconAnchor: [6, 6]
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Show polygon if we have enough points */}
|
||||
{currentPolygon.length >= 3 && (
|
||||
<Polygon
|
||||
positions={currentPolygon}
|
||||
pathOptions={{
|
||||
fillColor: '#3b82f6',
|
||||
fillOpacity: 0.3,
|
||||
color: '#3b82f6',
|
||||
weight: 2,
|
||||
dashArray: '5, 5'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</MapContainer>
|
||||
|
||||
{/* Drawing instructions */}
|
||||
{isDrawing && (
|
||||
<div className="absolute bottom-4 left-4 bg-white rounded-lg shadow-lg p-3 max-w-xs">
|
||||
<p className="text-sm text-gray-700">
|
||||
<strong>Drawing Mode:</strong>
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
Click to add points. Press <kbd className="px-1 py-0.5 bg-gray-100 rounded text-xs">Escape</kbd> or
|
||||
click "Finish" when done.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Need at least 3 points to create a section.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section stats */}
|
||||
{sections.length > 0 && !isDrawing && (
|
||||
<div className="absolute bottom-4 left-4 bg-white rounded-lg shadow-lg p-3">
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
{sections.length} Section{sections.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600">
|
||||
Total: {sections.reduce((sum, section) => sum + (section.area || 0), 0).toLocaleString()} sq ft
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PropertyMap;
|
||||
@@ -2,6 +2,9 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Import Leaflet CSS for maps */
|
||||
@import 'leaflet/dist/leaflet.css';
|
||||
|
||||
/* Base styles */
|
||||
@layer base {
|
||||
html {
|
||||
|
||||
@@ -32,9 +32,9 @@ const Login = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
// Redirect to Google OAuth endpoint
|
||||
window.location.href = `${process.env.REACT_APP_API_URL || 'http://localhost:5000'}/api/auth/google`;
|
||||
const handleAuthentikLogin = () => {
|
||||
// Redirect to Authentik OAuth endpoint
|
||||
window.location.href = `${process.env.REACT_APP_API_URL || 'http://localhost:5000'}/api/auth/authentik`;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -170,28 +170,14 @@ const Login = () => {
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoogleLogin}
|
||||
onClick={handleAuthentikLogin}
|
||||
className="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-lg shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors duration-200"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2L2 7v10c0 5.55 3.84 9.74 9 11 5.16-1.26 9-5.45 9-11V7l-10-5z"/>
|
||||
<path d="M10 17l-4-4 1.41-1.41L10 14.17l6.59-6.59L18 9l-8 8z" fill="white"/>
|
||||
</svg>
|
||||
<span className="ml-2">Sign in with Google</span>
|
||||
<span className="ml-2">Sign in with Authentik</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user