mirror of
https://github.com/sstent/AICyclingCoach.git
synced 2026-03-17 18:35:50 +00:00
sync
This commit is contained in:
231
frontend/src/components/routes/FileUpload.jsx
Normal file
231
frontend/src/components/routes/FileUpload.jsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import LoadingSpinner from '../LoadingSpinner'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { gpx } from '@tmcw/togeojson'
|
||||
|
||||
const RouteVisualization = dynamic(() => import('./RouteVisualization'), {
|
||||
ssr: false,
|
||||
loading: () => <div className="h-64 bg-gray-100 rounded-md flex items-center justify-center">Loading map...</div>
|
||||
})
|
||||
|
||||
const FileUpload = ({ onUploadSuccess }) => {
|
||||
const { apiKey } = useAuth()
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [previewData, setPreviewData] = useState(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const handleFile = async (file) => {
|
||||
if (file.type !== 'application/gpx+xml') {
|
||||
setError('Please upload a valid GPX file')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Preview parsing
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const parser = new DOMParser()
|
||||
const gpxDoc = parser.parseFromString(e.target.result, 'text/xml')
|
||||
const geoJson = gpx(gpxDoc)
|
||||
|
||||
// Extract basic info from GeoJSON
|
||||
const name = geoJson.features[0]?.properties?.name || 'Unnamed Route'
|
||||
|
||||
// Calculate distance and elevation (simplified)
|
||||
let totalDistance = 0
|
||||
let elevationGain = 0
|
||||
let elevationLoss = 0
|
||||
let maxElevation = 0
|
||||
|
||||
// Simple calculation - in a real app you'd want more accurate distance calculation
|
||||
const coordinates = geoJson.features[0]?.geometry?.coordinates || []
|
||||
for (let i = 1; i < coordinates.length; i++) {
|
||||
const [prevLon, prevLat, prevEle] = coordinates[i-1]
|
||||
const [currLon, currLat, currEle] = coordinates[i]
|
||||
// Simple distance calculation (you might want to use a more accurate method)
|
||||
const distance = Math.sqrt(Math.pow(currLon - prevLon, 2) + Math.pow(currLat - prevLat, 2)) * 111000 // rough meters
|
||||
totalDistance += distance
|
||||
|
||||
if (prevEle && currEle) {
|
||||
const eleDiff = currEle - prevEle
|
||||
if (eleDiff > 0) {
|
||||
elevationGain += eleDiff
|
||||
} else {
|
||||
elevationLoss += Math.abs(eleDiff)
|
||||
}
|
||||
maxElevation = Math.max(maxElevation, currEle)
|
||||
}
|
||||
}
|
||||
|
||||
const avgGrade = totalDistance > 0 ? ((elevationGain / totalDistance) * 100).toFixed(1) : '0.0'
|
||||
|
||||
setPreviewData({
|
||||
name,
|
||||
distance: (totalDistance / 1000).toFixed(1) + 'km',
|
||||
elevationGain: elevationGain.toFixed(0) + 'm',
|
||||
elevationLoss: elevationLoss.toFixed(0) + 'm',
|
||||
maxElevation: maxElevation.toFixed(0) + 'm',
|
||||
avgGrade: avgGrade + '%',
|
||||
category: 'mixed',
|
||||
gpxContent: e.target.result
|
||||
})
|
||||
} catch (parseError) {
|
||||
setError('Error parsing GPX file: ' + parseError.message)
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
} catch (err) {
|
||||
setError('Error parsing GPX file')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!previewData) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
const blob = new Blob([previewData.gpxContent], { type: 'application/gpx+xml' })
|
||||
formData.append('file', blob, previewData.name + '.gpx')
|
||||
|
||||
const response = await fetch('/api/routes/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-API-Key': apiKey
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Upload failed')
|
||||
|
||||
const result = await response.json()
|
||||
onUploadSuccess(result)
|
||||
setPreviewData(null)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Upload failed')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onDragOver = useCallback((e) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}, [])
|
||||
|
||||
const onDragLeave = useCallback((e) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
const onDrop = useCallback((e) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) handleFile(file)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center ${
|
||||
isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
|
||||
}`}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
id="gpx-upload"
|
||||
className="hidden"
|
||||
accept=".gpx,application/gpx+xml"
|
||||
onChange={(e) => e.target.files[0] && handleFile(e.target.files[0])}
|
||||
/>
|
||||
<label htmlFor="gpx-upload" className="cursor-pointer">
|
||||
<p className="text-gray-600">
|
||||
Drag and drop GPX file here or{' '}
|
||||
<span className="text-blue-600 font-medium">browse files</span>
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{previewData && (
|
||||
<div className="mt-6 bg-white p-6 rounded-lg shadow-md">
|
||||
<h3 className="text-lg font-medium mb-4">Route Preview</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Route Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={previewData.name}
|
||||
onChange={(e) => setPreviewData(prev => ({...prev, name: e.target.value}))}
|
||||
className="w-full p-2 border rounded-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
|
||||
<select
|
||||
value={previewData.category}
|
||||
onChange={(e) => setPreviewData(prev => ({...prev, category: e.target.value}))}
|
||||
className="w-full p-2 border rounded-md"
|
||||
>
|
||||
<option value="climbing">Climbing</option>
|
||||
<option value="flat">Flat</option>
|
||||
<option value="mixed">Mixed</option>
|
||||
<option value="intervals">Intervals</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Distance</label>
|
||||
<p className="p-2 bg-gray-50 rounded-md">{previewData.distance}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Elevation Gain</label>
|
||||
<p className="p-2 bg-gray-50 rounded-md">{previewData.elevationGain}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Avg Grade</label>
|
||||
<p className="p-2 bg-gray-50 rounded-md">{previewData.avgGrade}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Max Elevation</label>
|
||||
<p className="p-2 bg-gray-50 rounded-md">{previewData.maxElevation}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Elevation Loss</label>
|
||||
<p className="p-2 bg-gray-50 rounded-md">{previewData.elevationLoss}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={isLoading}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400"
|
||||
>
|
||||
{isLoading ? 'Uploading...' : 'Confirm Upload'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-64">
|
||||
<RouteVisualization gpxData={previewData.gpxContent} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 text-red-600 bg-red-50 p-3 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileUpload
|
||||
80
frontend/src/components/routes/RouteFilter.jsx
Normal file
80
frontend/src/components/routes/RouteFilter.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const RouteFilter = ({ filters, onFilterChange }) => {
|
||||
const handleChange = (field, value) => {
|
||||
onFilterChange({
|
||||
...filters,
|
||||
[field]: value
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6 p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Search
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filters.searchQuery}
|
||||
onChange={(e) => handleChange('searchQuery', e.target.value)}
|
||||
placeholder="Search routes..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Min Distance (km)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={filters.minDistance}
|
||||
onChange={(e) => handleChange('minDistance', Number(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Max Distance (km)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={filters.maxDistance}
|
||||
onChange={(e) => handleChange('maxDistance', Number(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Difficulty
|
||||
</label>
|
||||
<select
|
||||
value={filters.difficulty}
|
||||
onChange={(e) => handleChange('difficulty', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">All Difficulties</option>
|
||||
<option value="easy">Easy</option>
|
||||
<option value="moderate">Moderate</option>
|
||||
<option value="hard">Hard</option>
|
||||
<option value="extreme">Extreme</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RouteFilter.propTypes = {
|
||||
filters: PropTypes.shape({
|
||||
searchQuery: PropTypes.string,
|
||||
minDistance: PropTypes.number,
|
||||
maxDistance: PropTypes.number,
|
||||
difficulty: PropTypes.string
|
||||
}).isRequired,
|
||||
onFilterChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default RouteFilter;
|
||||
107
frontend/src/components/routes/RouteList.jsx
Normal file
107
frontend/src/components/routes/RouteList.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import LoadingSpinner from '../LoadingSpinner'
|
||||
import { format } from 'date-fns'
|
||||
import { FaStar } from 'react-icons/fa'
|
||||
|
||||
const RouteList = ({ routes, onRouteSelect }) => {
|
||||
const { apiKey } = useAuth()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedCategory, setSelectedCategory] = useState('all')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const categories = ['all', 'climbing', 'flat', 'mixed', 'intervals']
|
||||
|
||||
const filteredRoutes = routes.filter(route => {
|
||||
const matchesSearch = route.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
const matchesCategory = selectedCategory === 'all' || route.category === selectedCategory
|
||||
return matchesSearch && matchesCategory
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<div className="mb-6 space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search routes..."
|
||||
className="w-full p-2 border rounded-md"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map(category => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium ${
|
||||
selectedCategory === category
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{category.charAt(0).toUpperCase() + category.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : error ? (
|
||||
<div className="text-red-600">{error}</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredRoutes.map(route => (
|
||||
<div
|
||||
key={route.id}
|
||||
className="p-4 border rounded-md hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => onRouteSelect(route)}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<h3 className="font-medium text-lg">{route.name}</h3>
|
||||
<span className="text-sm text-gray-500">
|
||||
{format(new Date(route.created_at), 'MMM d, yyyy')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4 mt-2 text-gray-600">
|
||||
<div>
|
||||
<span className="font-medium">Distance: </span>
|
||||
{(route.distance / 1000).toFixed(1)}km
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Elevation: </span>
|
||||
{route.elevation_gain}m
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Category: </span>
|
||||
{route.category}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Grade: </span>
|
||||
{route.grade_avg}%
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium">Difficulty: </span>
|
||||
{[...Array(5)].map((_, index) => (
|
||||
<FaStar
|
||||
key={index}
|
||||
className={`w-4 h-4 ${
|
||||
index < Math.round(route.difficulty_rating / 2)
|
||||
? 'text-yellow-400'
|
||||
: 'text-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RouteList
|
||||
61
frontend/src/components/routes/RouteMetadata.jsx
Normal file
61
frontend/src/components/routes/RouteMetadata.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { FaRoute, FaMountain, FaTachometerAlt, FaStar } from 'react-icons/fa'
|
||||
|
||||
const RouteMetadata = ({ route }) => {
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<h3 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<FaRoute className="text-blue-600" /> Route Details
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
|
||||
<FaMountain className="text-gray-600" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Elevation Gain</p>
|
||||
<p className="font-medium">{route.elevation_gain}m</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
|
||||
<FaTachometerAlt className="text-gray-600" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Avg Grade</p>
|
||||
<p className="font-medium">{route.grade_avg}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
|
||||
<FaStar className="text-gray-600" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Difficulty</p>
|
||||
<div className="flex gap-1 text-yellow-400">
|
||||
{[...Array(5)].map((_, index) => (
|
||||
<FaStar
|
||||
key={index}
|
||||
className={`w-4 h-4 ${
|
||||
index < Math.round(route.difficulty_rating / 2)
|
||||
? 'fill-current'
|
||||
: 'text-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 md:col-span-3 grid grid-cols-2 gap-4 mt-4">
|
||||
<div className="p-3 bg-gray-50 rounded-lg">
|
||||
<p className="text-sm text-gray-500">Distance</p>
|
||||
<p className="font-medium">{(route.distance / 1000).toFixed(1)}km</p>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 rounded-lg">
|
||||
<p className="text-sm text-gray-500">Category</p>
|
||||
<p className="font-medium capitalize">{route.category}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RouteMetadata
|
||||
93
frontend/src/components/routes/RouteVisualization.jsx
Normal file
93
frontend/src/components/routes/RouteVisualization.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useRef, useEffect, useState } from 'react'
|
||||
import { MapContainer, TileLayer, Polyline, Marker, Popup } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import { gpx } from '@tmcw/togeojson'
|
||||
|
||||
// Fix leaflet marker icons
|
||||
delete L.Icon.Default.prototype._getIconUrl
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png'
|
||||
})
|
||||
|
||||
const RouteVisualization = ({ gpxData }) => {
|
||||
const mapRef = useRef()
|
||||
const elevationChartRef = useRef(null)
|
||||
const [routePoints, setRoutePoints] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!gpxData) return
|
||||
|
||||
try {
|
||||
const parser = new DOMParser()
|
||||
const gpxDoc = parser.parseFromString(gpxData, 'text/xml')
|
||||
const geoJson = gpx(gpxDoc)
|
||||
|
||||
if (!geoJson.features[0]) return
|
||||
|
||||
const coordinates = geoJson.features[0].geometry.coordinates
|
||||
const points = coordinates.map(coord => [coord[1], coord[0]]) // [lat, lon]
|
||||
const bounds = L.latLngBounds(points)
|
||||
|
||||
setRoutePoints(points)
|
||||
|
||||
if (mapRef.current) {
|
||||
mapRef.current.flyToBounds(bounds, { padding: [50, 50] })
|
||||
}
|
||||
|
||||
// Plot elevation profile
|
||||
if (elevationChartRef.current) {
|
||||
const elevations = coordinates.map(coord => coord[2] || 0)
|
||||
const distances = []
|
||||
let distance = 0
|
||||
|
||||
for (let i = 1; i < coordinates.length; i++) {
|
||||
const prevPoint = L.latLng(coordinates[i-1][1], coordinates[i-1][0])
|
||||
const currPoint = L.latLng(coordinates[i][1], coordinates[i][0])
|
||||
distance += prevPoint.distanceTo(currPoint)
|
||||
distances.push(distance)
|
||||
}
|
||||
|
||||
// TODO: Integrate charting library
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing GPX data:', error)
|
||||
}
|
||||
}, [gpxData])
|
||||
|
||||
return (
|
||||
<div className="h-full w-full relative">
|
||||
<MapContainer
|
||||
center={[51.505, -0.09]}
|
||||
zoom={13}
|
||||
scrollWheelZoom={false}
|
||||
className="h-full rounded-md"
|
||||
ref={mapRef}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<Polyline
|
||||
positions={routePoints}
|
||||
color="#3b82f6"
|
||||
weight={4}
|
||||
/>
|
||||
<Marker position={[51.505, -0.09]}>
|
||||
<Popup>Start/End Point</Popup>
|
||||
</Marker>
|
||||
</MapContainer>
|
||||
|
||||
<div
|
||||
ref={elevationChartRef}
|
||||
className="absolute bottom-4 left-4 right-4 h-32 bg-white/90 backdrop-blur-sm rounded-md p-4 shadow-md"
|
||||
>
|
||||
{/* Elevation chart will be rendered here */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RouteVisualization
|
||||
116
frontend/src/components/routes/SectionList.jsx
Normal file
116
frontend/src/components/routes/SectionList.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const SectionList = ({ sections, onSplit, onUpdate }) => {
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [localSections, setLocalSections] = useState(sections);
|
||||
|
||||
const surfaceTypes = ['road', 'gravel', 'mixed', 'trail'];
|
||||
const gearOptions = {
|
||||
road: ['Standard (39x25)', 'Mid-compact (36x30)', 'Compact (34x28)'],
|
||||
gravel: ['1x System', '2x Gravel', 'Adventure'],
|
||||
trail: ['MTB Wide-range', 'Fat Bike']
|
||||
};
|
||||
|
||||
const handleEdit = (sectionId) => {
|
||||
setEditing(sectionId);
|
||||
setLocalSections(sections);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate(localSections);
|
||||
setEditing(null);
|
||||
};
|
||||
|
||||
const handleChange = (sectionId, field, value) => {
|
||||
setLocalSections(prev => prev.map(section =>
|
||||
section.id === sectionId ? { ...section, [field]: value } : section
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold">Route Sections</h3>
|
||||
<div className="space-x-2">
|
||||
<button
|
||||
onClick={() => onSplit([Math.floor(sections.length/2)])}
|
||||
className="bg-green-600 text-white px-3 py-1 rounded-md hover:bg-green-700"
|
||||
>
|
||||
Split Route
|
||||
</button>
|
||||
{editing && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="bg-blue-600 text-white px-3 py-1 rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Save All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localSections.map((section) => (
|
||||
<div key={section.id} className="bg-white p-4 rounded-lg shadow-md">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h4 className="font-medium">Section {section.id}</h4>
|
||||
<button
|
||||
onClick={() => editing === section.id ? handleSave() : handleEdit(section.id)}
|
||||
className={`text-sm ${editing === section.id ? 'text-blue-600' : 'text-gray-600 hover:text-gray-800'}`}
|
||||
>
|
||||
{editing === section.id ? 'Save' : 'Edit'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="space-y-1">
|
||||
<p>Distance: {section.distance} km</p>
|
||||
<p>Elevation: {section.elevationGain} m</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p>Max Grade: {section.maxGrade}%</p>
|
||||
<p>Surface:
|
||||
{editing === section.id ? (
|
||||
<select
|
||||
value={section.surfaceType}
|
||||
onChange={(e) => handleChange(section.id, 'surfaceType', e.target.value)}
|
||||
className="ml-2 p-1 border rounded"
|
||||
>
|
||||
{surfaceTypes.map(type => (
|
||||
<option key={type} value={type}>{type.charAt(0).toUpperCase() + type.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="ml-2 capitalize">{section.surfaceType}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing === section.id && (
|
||||
<div className="mt-3 pt-2 border-t">
|
||||
<label className="block text-sm font-medium mb-1">Gear Recommendation</label>
|
||||
<select
|
||||
value={section.gearRecommendation}
|
||||
onChange={(e) => handleChange(section.id, 'gearRecommendation', e.target.value)}
|
||||
className="w-full p-1 border rounded"
|
||||
>
|
||||
{gearOptions[section.surfaceType]?.map(gear => (
|
||||
<option key={gear} value={gear}>{gear}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SectionList.propTypes = {
|
||||
sections: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onSplit: PropTypes.func.isRequired,
|
||||
onUpdate: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default SectionList;
|
||||
104
frontend/src/components/routes/SectionManager.jsx
Normal file
104
frontend/src/components/routes/SectionManager.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button, Input, Select } from '../ui'
|
||||
import { FaPlus, FaTrash } from 'react-icons/fa'
|
||||
|
||||
const SectionManager = ({ route, onSectionsUpdate }) => {
|
||||
const [sections, setSections] = useState(route.sections || [])
|
||||
const [newSection, setNewSection] = useState({
|
||||
name: '',
|
||||
start: 0,
|
||||
end: 0,
|
||||
difficulty: 3,
|
||||
recommended_gear: 'road'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
onSectionsUpdate(sections)
|
||||
}, [sections, onSectionsUpdate])
|
||||
|
||||
const addSection = () => {
|
||||
if (newSection.name && newSection.start < newSection.end) {
|
||||
setSections([...sections, {
|
||||
...newSection,
|
||||
id: Date.now().toString(),
|
||||
distance: newSection.end - newSection.start
|
||||
}])
|
||||
setNewSection({
|
||||
name: '',
|
||||
start: 0,
|
||||
end: 0,
|
||||
difficulty: 3,
|
||||
recommended_gear: 'road'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const removeSection = (sectionId) => {
|
||||
setSections(sections.filter(s => s.id !== sectionId))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4 items-end">
|
||||
<Input
|
||||
label="Section Name"
|
||||
value={newSection.name}
|
||||
onChange={(e) => setNewSection({...newSection, name: e.target.value})}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
label="Start (km)"
|
||||
value={newSection.start}
|
||||
onChange={(e) => setNewSection({...newSection, start: +e.target.value})}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
label="End (km)"
|
||||
value={newSection.end}
|
||||
onChange={(e) => setNewSection({...newSection, end: +e.target.value})}
|
||||
/>
|
||||
<Select
|
||||
label="Difficulty"
|
||||
value={newSection.difficulty}
|
||||
options={[1,2,3,4,5].map(n => ({value: n, label: `${n}/5`}))}
|
||||
onChange={(e) => setNewSection({...newSection, difficulty: +e.target.value})}
|
||||
/>
|
||||
<Select
|
||||
label="Gear"
|
||||
value={newSection.recommended_gear}
|
||||
options={[
|
||||
{value: 'road', label: 'Road Bike'},
|
||||
{value: 'gravel', label: 'Gravel Bike'},
|
||||
{value: 'tt', label: 'Time Trial'},
|
||||
{value: 'climbing', label: 'Climbing Bike'}
|
||||
]}
|
||||
onChange={(e) => setNewSection({...newSection, recommended_gear: e.target.value})}
|
||||
/>
|
||||
<Button onClick={addSection} className="h-[42px]">
|
||||
<FaPlus className="mr-2" /> Add Section
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{sections.map(section => (
|
||||
<div key={section.id} className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1 grid grid-cols-4 gap-4">
|
||||
<p className="font-medium">{section.name}</p>
|
||||
<p>{section.start}km - {section.end}km</p>
|
||||
<p>Difficulty: {section.difficulty}/5</p>
|
||||
<p className="capitalize">{section.recommended_gear.replace('_', ' ')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeSection(section.id)}
|
||||
className="text-red-600 hover:text-red-700 p-2"
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SectionManager
|
||||
26
frontend/src/components/routes/__tests__/FileUpload.test.jsx
Normal file
26
frontend/src/components/routes/__tests__/FileUpload.test.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import FileUpload from '../FileUpload'
|
||||
|
||||
describe('FileUpload', () => {
|
||||
const mockFile = new File(['<gpx><trk><name>Test Route</name></trk></gpx>'], 'test.gpx', {
|
||||
type: 'application/gpx+xml'
|
||||
})
|
||||
|
||||
it('handles file upload and preview', async () => {
|
||||
const mockSuccess = jest.fn()
|
||||
render(<FileUpload onUploadSuccess={mockSuccess} />)
|
||||
|
||||
// Simulate file drop
|
||||
const dropZone = screen.getByText('Drag and drop GPX file here')
|
||||
fireEvent.dragOver(dropZone)
|
||||
fireEvent.drop(dropZone, {
|
||||
dataTransfer: { files: [mockFile] }
|
||||
})
|
||||
|
||||
// Check preview
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Route')).toBeInTheDocument()
|
||||
expect(screen.getByText('Confirm Upload')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
41
frontend/src/components/routes/__tests__/RouteList.test.jsx
Normal file
41
frontend/src/components/routes/__tests__/RouteList.test.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import RouteList from '../RouteList'
|
||||
|
||||
const mockRoutes = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Mountain Loop',
|
||||
distance: 45000,
|
||||
elevation_gain: 800,
|
||||
category: 'climbing'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Lakeside Ride',
|
||||
distance: 25000,
|
||||
elevation_gain: 200,
|
||||
category: 'flat'
|
||||
}
|
||||
]
|
||||
|
||||
describe('RouteList', () => {
|
||||
it('displays routes and handles filtering', () => {
|
||||
render(<RouteList routes={mockRoutes} />)
|
||||
|
||||
// Check initial render
|
||||
expect(screen.getByText('Mountain Loop')).toBeInTheDocument()
|
||||
expect(screen.getByText('Lakeside Ride')).toBeInTheDocument()
|
||||
|
||||
// Test search
|
||||
fireEvent.change(screen.getByPlaceholderText('Search routes...'), {
|
||||
target: { value: 'mountain' }
|
||||
})
|
||||
expect(screen.getByText('Mountain Loop')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Lakeside Ride')).not.toBeInTheDocument()
|
||||
|
||||
// Test category filter
|
||||
fireEvent.click(screen.getByText('Flat'))
|
||||
expect(screen.queryByText('Mountain Loop')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Lakeside Ride')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import RouteMetadata from '../RouteMetadata'
|
||||
import { AuthProvider } from '../../../context/AuthContext'
|
||||
|
||||
const mockRoute = {
|
||||
id: 1,
|
||||
name: 'Test Route',
|
||||
description: 'Initial description',
|
||||
category: 'mixed'
|
||||
}
|
||||
|
||||
const Wrapper = ({ children }) => (
|
||||
<AuthProvider>
|
||||
{children}
|
||||
</AuthProvider>
|
||||
)
|
||||
|
||||
describe('RouteMetadata', () => {
|
||||
it('handles editing and updating route details', async () => {
|
||||
const mockUpdate = jest.fn()
|
||||
render(<RouteMetadata route={mockRoute} onUpdate={mockUpdate} />, { wrapper: Wrapper })
|
||||
|
||||
// Test initial view mode
|
||||
expect(screen.getByText('Test Route')).toBeInTheDocument()
|
||||
expect(screen.getByText('Initial description')).toBeInTheDocument()
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(screen.getByText('Edit'))
|
||||
|
||||
// Verify form fields
|
||||
const nameInput = screen.getByDisplayValue('Test Route')
|
||||
const descInput = screen.getByDisplayValue('Initial description')
|
||||
const categorySelect = screen.getByDisplayValue('Mixed')
|
||||
|
||||
// Make changes
|
||||
fireEvent.change(nameInput, { target: { value: 'Updated Route' } })
|
||||
fireEvent.change(descInput, { target: { value: 'New description' } })
|
||||
fireEvent.change(categorySelect, { target: { value: 'climbing' } })
|
||||
|
||||
// Save changes
|
||||
fireEvent.click(screen.getByText('Save'))
|
||||
|
||||
// Verify update was called
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: 'Updated Route',
|
||||
description: 'New description',
|
||||
category: 'climbing'
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import RouteVisualization from '../RouteVisualization'
|
||||
|
||||
const mockGPX = `
|
||||
<gpx>
|
||||
<trk>
|
||||
<trkseg>
|
||||
<trkpt lat="37.7749" lon="-122.4194"><ele>50</ele></trkpt>
|
||||
<trkpt lat="37.7859" lon="-122.4294"><ele>60</ele></trkpt>
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>
|
||||
`
|
||||
|
||||
describe('RouteVisualization', () => {
|
||||
it('renders map with GPX track', () => {
|
||||
render(<RouteVisualization gpxData={mockGPX} />)
|
||||
|
||||
// Check map container is rendered
|
||||
expect(screen.getByRole('presentation')).toBeInTheDocument()
|
||||
|
||||
// Check if polyline is created with coordinates
|
||||
const path = document.querySelector('.leaflet-overlay-pane path')
|
||||
expect(path).toHaveAttribute('d', expect.stringContaining('M37.7749 -122.4194L37.7859 -122.4294'))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user