mirror of
https://github.com/sstent/AICyclingCoach.git
synced 2026-03-14 08:55:24 +00:00
sync
This commit is contained in:
261
CL_backendfixes.md
Normal file
261
CL_backendfixes.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# 🎯 **Backend Implementation TODO List**
|
||||
|
||||
## **Priority 1: Core API Gaps (Essential)**
|
||||
|
||||
### **1.1 Plan Generation Endpoint**
|
||||
- [ ] **Add plan generation endpoint** in `app/routes/plan.py`
|
||||
```python
|
||||
@router.post("/generate", response_model=PlanSchema)
|
||||
async def generate_plan(
|
||||
plan_request: PlanGenerationRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
```
|
||||
- [ ] **Create PlanGenerationRequest schema** in `app/schemas/plan.py`
|
||||
```python
|
||||
class PlanGenerationRequest(BaseModel):
|
||||
rule_ids: List[UUID]
|
||||
goals: Dict[str, Any]
|
||||
user_preferences: Optional[Dict[str, Any]] = None
|
||||
duration_weeks: int = 12
|
||||
```
|
||||
- [ ] **Update AIService.generate_plan()** to handle rule fetching from DB
|
||||
- [ ] **Add validation** for rule compatibility and goal requirements
|
||||
- [ ] **Add tests** for plan generation workflow
|
||||
|
||||
### **1.2 Rule Parsing API**
|
||||
- [ ] **Add natural language rule parsing endpoint** in `app/routes/rule.py`
|
||||
```python
|
||||
@router.post("/parse-natural-language")
|
||||
async def parse_natural_language_rules(
|
||||
request: NaturalLanguageRuleRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
```
|
||||
- [ ] **Create request/response schemas** in `app/schemas/rule.py`
|
||||
```python
|
||||
class NaturalLanguageRuleRequest(BaseModel):
|
||||
natural_language_text: str
|
||||
rule_name: str
|
||||
|
||||
class ParsedRuleResponse(BaseModel):
|
||||
parsed_rules: Dict[str, Any]
|
||||
confidence_score: Optional[float]
|
||||
suggestions: Optional[List[str]]
|
||||
```
|
||||
- [ ] **Enhance AIService.parse_rules_from_natural_language()** with better error handling
|
||||
- [ ] **Add rule validation** after parsing
|
||||
- [ ] **Add preview mode** before saving parsed rules
|
||||
|
||||
### **1.3 Section Integration with GPX Parsing**
|
||||
- [ ] **Update `app/services/gpx.py`** to create sections automatically
|
||||
```python
|
||||
async def parse_gpx_with_sections(file_path: str, route_id: UUID, db: AsyncSession) -> dict:
|
||||
# Parse GPX into segments
|
||||
# Create Section records for each segment
|
||||
# Return enhanced GPX data with section metadata
|
||||
```
|
||||
- [ ] **Modify `app/routes/gpx.py`** to create sections after route creation
|
||||
- [ ] **Add section creation logic** in GPX upload workflow
|
||||
- [ ] **Update Section model** to include more GPX-derived metadata
|
||||
- [ ] **Add section querying endpoints** for route visualization
|
||||
|
||||
## **Priority 2: Data Model Enhancements**
|
||||
|
||||
### **2.1 Missing Schema Fields**
|
||||
- [ ] **Add missing fields to User model** in `app/models/user.py`
|
||||
```python
|
||||
class User(BaseModel):
|
||||
name: Optional[str]
|
||||
email: Optional[str]
|
||||
fitness_level: Optional[str]
|
||||
preferences: Optional[JSON]
|
||||
```
|
||||
- [ ] **Enhance Plan model** with additional metadata
|
||||
```python
|
||||
class Plan(BaseModel):
|
||||
user_id: Optional[UUID] = Column(ForeignKey("users.id"))
|
||||
name: str
|
||||
description: Optional[str]
|
||||
start_date: Optional[Date]
|
||||
end_date: Optional[Date]
|
||||
goal_type: Optional[str]
|
||||
active: Boolean = Column(default=True)
|
||||
```
|
||||
- [ ] **Add plan-rule relationship table** (already exists but ensure proper usage)
|
||||
- [ ] **Update all schemas** to match enhanced models
|
||||
|
||||
### **2.2 Database Relationships**
|
||||
- [ ] **Fix User-Plan relationship** in models
|
||||
- [ ] **Add cascade delete rules** where appropriate
|
||||
- [ ] **Add database constraints** for data integrity
|
||||
- [ ] **Create missing indexes** for performance
|
||||
```sql
|
||||
CREATE INDEX idx_workouts_garmin_activity_id ON workouts(garmin_activity_id);
|
||||
CREATE INDEX idx_plans_user_active ON plans(user_id, active);
|
||||
CREATE INDEX idx_analyses_workout_approved ON analyses(workout_id, approved);
|
||||
```
|
||||
|
||||
## **Priority 3: API Completeness**
|
||||
|
||||
### **3.1 Export/Import Functionality**
|
||||
- [ ] **Create export service** `app/services/export_import.py`
|
||||
```python
|
||||
class ExportImportService:
|
||||
async def export_user_data(user_id: UUID) -> bytes:
|
||||
async def export_routes() -> bytes:
|
||||
async def import_user_data(data: bytes, user_id: UUID):
|
||||
```
|
||||
- [ ] **Add export endpoints** in new `app/routes/export.py`
|
||||
```python
|
||||
@router.get("/export/routes")
|
||||
@router.get("/export/plans/{plan_id}")
|
||||
@router.get("/export/user-data")
|
||||
@router.post("/import/routes")
|
||||
@router.post("/import/plans")
|
||||
```
|
||||
- [ ] **Support multiple formats** (JSON, GPX, ZIP)
|
||||
- [ ] **Add data validation** for imports
|
||||
- [ ] **Handle version compatibility** for imports
|
||||
|
||||
### **3.2 Enhanced Dashboard API**
|
||||
- [ ] **Expand dashboard data** in `app/routes/dashboard.py`
|
||||
```python
|
||||
@router.get("/metrics/weekly")
|
||||
@router.get("/metrics/monthly")
|
||||
@router.get("/progress/{plan_id}")
|
||||
@router.get("/upcoming-workouts")
|
||||
```
|
||||
- [ ] **Add aggregation queries** for metrics
|
||||
- [ ] **Cache dashboard data** for performance
|
||||
- [ ] **Add real-time updates** capability
|
||||
|
||||
### **3.3 Advanced Workout Features**
|
||||
- [ ] **Add workout comparison endpoint**
|
||||
```python
|
||||
@router.get("/workouts/{workout_id}/compare/{compare_workout_id}")
|
||||
```
|
||||
- [ ] **Add workout search/filtering**
|
||||
```python
|
||||
@router.get("/workouts/search")
|
||||
async def search_workouts(
|
||||
activity_type: Optional[str] = None,
|
||||
date_range: Optional[DateRange] = None,
|
||||
power_range: Optional[PowerRange] = None
|
||||
):
|
||||
```
|
||||
- [ ] **Add bulk workout operations**
|
||||
- [ ] **Add workout tagging system**
|
||||
|
||||
## **Priority 4: Service Layer Improvements**
|
||||
|
||||
### **4.1 AI Service Enhancements**
|
||||
- [ ] **Add prompt caching** to reduce API calls
|
||||
- [ ] **Implement prompt A/B testing** framework
|
||||
- [ ] **Add AI response validation** and confidence scoring
|
||||
- [ ] **Create AI service health checks**
|
||||
- [ ] **Add fallback mechanisms** for AI failures
|
||||
- [ ] **Implement rate limiting** for AI calls
|
||||
- [ ] **Add cost tracking** for AI API usage
|
||||
|
||||
### **4.2 Garmin Service Improvements**
|
||||
- [ ] **Add incremental sync** instead of full sync
|
||||
- [ ] **Implement activity deduplication** logic
|
||||
- [ ] **Add webhook support** for real-time sync
|
||||
- [ ] **Enhance error recovery** for failed syncs
|
||||
- [ ] **Add activity type filtering**
|
||||
- [ ] **Support multiple Garmin accounts** per user
|
||||
|
||||
### **4.3 Plan Evolution Enhancements**
|
||||
- [ ] **Add plan comparison** functionality
|
||||
- [ ] **Implement plan rollback** mechanism
|
||||
- [ ] **Add plan branching** for different scenarios
|
||||
- [ ] **Create plan templates** system
|
||||
- [ ] **Add automated plan adjustments** based on performance
|
||||
|
||||
## **Priority 5: Validation & Error Handling**
|
||||
|
||||
### **5.1 Input Validation**
|
||||
- [ ] **Add comprehensive Pydantic validators** for all schemas
|
||||
- [ ] **Validate GPX file integrity** before processing
|
||||
- [ ] **Add business rule validation** (e.g., plan dates, workout conflicts)
|
||||
- [ ] **Validate AI responses** before storing
|
||||
- [ ] **Add file size/type restrictions**
|
||||
|
||||
### **5.2 Error Handling**
|
||||
- [ ] **Create custom exception hierarchy**
|
||||
```python
|
||||
class CyclingCoachException(Exception):
|
||||
class GarminSyncError(CyclingCoachException):
|
||||
class AIServiceError(CyclingCoachException):
|
||||
class PlanGenerationError(CyclingCoachException):
|
||||
```
|
||||
- [ ] **Add global exception handler**
|
||||
- [ ] **Improve error messages** for user feedback
|
||||
- [ ] **Add error recovery mechanisms**
|
||||
- [ ] **Log errors with context** for debugging
|
||||
|
||||
## **Priority 6: Performance & Monitoring**
|
||||
|
||||
### **6.1 Performance Optimizations**
|
||||
- [ ] **Add database query optimization**
|
||||
- [ ] **Implement caching** for frequently accessed data
|
||||
- [ ] **Add connection pooling** configuration
|
||||
- [ ] **Optimize GPX file parsing** for large files
|
||||
- [ ] **Add pagination** to list endpoints
|
||||
- [ ] **Implement background job queue** for long-running tasks
|
||||
|
||||
### **6.2 Enhanced Monitoring**
|
||||
- [ ] **Add application metrics** (response times, error rates)
|
||||
- [ ] **Create health check dependencies**
|
||||
- [ ] **Add performance profiling** endpoints
|
||||
- [ ] **Implement alerting** for critical errors
|
||||
- [ ] **Add audit logging** for data changes
|
||||
|
||||
## **Priority 7: Security & Configuration**
|
||||
|
||||
### **7.1 Security Improvements**
|
||||
- [ ] **Implement user authentication/authorization**
|
||||
- [ ] **Add rate limiting** to prevent abuse
|
||||
- [ ] **Validate file uploads** for security
|
||||
- [ ] **Add CORS configuration** properly
|
||||
- [ ] **Implement request/response logging** (without sensitive data)
|
||||
- [ ] **Add API versioning** support
|
||||
|
||||
### **7.2 Configuration Management**
|
||||
- [ ] **Add environment-specific configs**
|
||||
- [ ] **Validate configuration** on startup
|
||||
- [ ] **Add feature flags** system
|
||||
- [ ] **Implement secrets management**
|
||||
- [ ] **Add configuration reload** without restart
|
||||
|
||||
## **Priority 8: Testing & Documentation**
|
||||
|
||||
### **8.1 Testing**
|
||||
- [ ] **Create comprehensive test suite**
|
||||
- Unit tests for services
|
||||
- Integration tests for API endpoints
|
||||
- Database migration tests
|
||||
- AI service mock tests
|
||||
- [ ] **Add test fixtures** for common data
|
||||
- [ ] **Implement test database** setup/teardown
|
||||
- [ ] **Add performance tests** for critical paths
|
||||
- [ ] **Create end-to-end tests** for workflows
|
||||
|
||||
### **8.2 Documentation**
|
||||
- [ ] **Generate OpenAPI documentation**
|
||||
- [ ] **Add endpoint documentation** with examples
|
||||
- [ ] **Create service documentation**
|
||||
- [ ] **Document deployment procedures**
|
||||
- [ ] **Add troubleshooting guides**
|
||||
|
||||
---
|
||||
|
||||
## **🎯 Recommended Implementation Order:**
|
||||
|
||||
1. **Week 1:** Priority 1 (Core API gaps) - Essential for feature completeness
|
||||
2. **Week 2:** Priority 2 (Data model) + Priority 5.1 (Validation) - Foundation improvements
|
||||
3. **Week 3:** Priority 3.1 (Export/Import) + Priority 4.1 (AI improvements) - User-facing features
|
||||
4. **Week 4:** Priority 6 (Performance) + Priority 8.1 (Testing) - Production readiness
|
||||
|
||||
This todo list will bring your backend implementation to 100% design doc compliance and beyond, making it production-ready with enterprise-level features! 🚀
|
||||
255
CL_frontendfixes.md
Normal file
255
CL_frontendfixes.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# Frontend Development TODO List
|
||||
|
||||
## 🚨 Critical Missing Features (High Priority)
|
||||
|
||||
### 1. Rules Management System
|
||||
- [ ] **Create Rules page component** (`/src/pages/Rules.jsx`)
|
||||
- [ ] Natural language textarea editor
|
||||
- [ ] AI parsing button with loading state
|
||||
- [ ] JSON preview pane with syntax highlighting
|
||||
- [ ] Rule validation feedback
|
||||
- [ ] Save/cancel actions
|
||||
- [ ] **Create RuleEditor component** (`/src/components/rules/RuleEditor.jsx`)
|
||||
- [ ] Rich text input with auto-resize
|
||||
- [ ] Character count and validation
|
||||
- [ ] Template suggestions dropdown
|
||||
- [ ] **Create RulePreview component** (`/src/components/rules/RulePreview.jsx`)
|
||||
- [ ] JSON syntax highlighting (use `react-json-view`)
|
||||
- [ ] Editable JSON with validation
|
||||
- [ ] Diff view for rule changes
|
||||
- [ ] **Create RulesList component** (`/src/components/rules/RulesList.jsx`)
|
||||
- [ ] Rule set selection dropdown
|
||||
- [ ] Version history per rule set
|
||||
- [ ] Delete/duplicate rule sets
|
||||
- [ ] **API Integration**
|
||||
- [ ] `POST /api/rules` - Create new rule set
|
||||
- [ ] `PUT /api/rules/{id}` - Update rule set
|
||||
- [ ] `GET /api/rules` - List all rule sets
|
||||
- [ ] `POST /api/rules/{id}/parse` - AI parsing endpoint
|
||||
|
||||
### 2. Plan Generation Workflow
|
||||
- [ ] **Create PlanGeneration page** (`/src/pages/PlanGeneration.jsx`)
|
||||
- [ ] Goal selection interface
|
||||
- [ ] Rule set selection
|
||||
- [ ] Plan parameters (duration, weekly hours)
|
||||
- [ ] Progress tracking for AI generation
|
||||
- [ ] **Create GoalSelector component** (`/src/components/plans/GoalSelector.jsx`)
|
||||
- [ ] Predefined goal templates
|
||||
- [ ] Custom goal input
|
||||
- [ ] Goal validation
|
||||
- [ ] **Create PlanParameters component** (`/src/components/plans/PlanParameters.jsx`)
|
||||
- [ ] Duration slider (4-20 weeks)
|
||||
- [ ] Weekly hours slider (5-15 hours)
|
||||
- [ ] Difficulty level selection
|
||||
- [ ] Available days checkboxes
|
||||
- [ ] **Enhance PlanTimeline component**
|
||||
- [ ] Week-by-week breakdown
|
||||
- [ ] Workout details expandable cards
|
||||
- [ ] Progress tracking indicators
|
||||
- [ ] Edit individual workouts
|
||||
- [ ] **API Integration**
|
||||
- [ ] `POST /api/plans/generate` - Generate new plan
|
||||
- [ ] `GET /api/plans/{id}/preview` - Preview before saving
|
||||
- [ ] Plan generation status polling
|
||||
|
||||
### 3. Route Management & Visualization
|
||||
- [ ] **Enhance RoutesPage** (`/src/pages/RoutesPage.jsx`)
|
||||
- [ ] Route list with metadata
|
||||
- [ ] GPX file upload integration
|
||||
- [ ] Route preview cards
|
||||
- [ ] Search and filter functionality
|
||||
- [ ] **Create RouteVisualization component** (`/src/components/routes/RouteVisualization.jsx`)
|
||||
- [ ] Interactive map (use Leaflet.js)
|
||||
- [ ] GPX track overlay
|
||||
- [ ] Elevation profile chart
|
||||
- [ ] Distance markers
|
||||
- [ ] **Create RouteMetadata component** (`/src/components/routes/RouteMetadata.jsx`)
|
||||
- [ ] Distance, elevation gain, grade analysis
|
||||
- [ ] Estimated time calculations
|
||||
- [ ] Difficulty rating
|
||||
- [ ] Notes/description editing
|
||||
- [ ] **Create SectionManager component** (`/src/components/routes/SectionManager.jsx`)
|
||||
- [ ] Split routes into sections
|
||||
- [ ] Section-specific metadata
|
||||
- [ ] Gear recommendations per section
|
||||
- [ ] **Dependencies to add**
|
||||
- [ ] `npm install leaflet react-leaflet`
|
||||
- [ ] GPX parsing library integration
|
||||
|
||||
### 4. Export/Import System
|
||||
- [ ] **Create ExportImport page** (`/src/pages/ExportImport.jsx`)
|
||||
- [ ] Export options (JSON, ZIP)
|
||||
- [ ] Import validation
|
||||
- [ ] Bulk operations
|
||||
- [ ] **Create DataExporter component** (`/src/components/export/DataExporter.jsx`)
|
||||
- [ ] Selective export (routes, rules, plans)
|
||||
- [ ] Format selection (JSON, GPX, ZIP)
|
||||
- [ ] Export progress tracking
|
||||
- [ ] **Create DataImporter component** (`/src/components/export/DataImporter.jsx`)
|
||||
- [ ] File validation and preview
|
||||
- [ ] Conflict resolution interface
|
||||
- [ ] Import progress tracking
|
||||
- [ ] **API Integration**
|
||||
- [ ] `GET /api/export` - Generate export package
|
||||
- [ ] `POST /api/import` - Import data package
|
||||
- [ ] `POST /api/import/validate` - Validate before import
|
||||
|
||||
## 🔧 Code Quality & Architecture Improvements
|
||||
|
||||
### 5. Enhanced Error Handling
|
||||
- [ ] **Create GlobalErrorHandler** (`/src/components/GlobalErrorHandler.jsx`)
|
||||
- [ ] Centralized error logging
|
||||
- [ ] User-friendly error messages
|
||||
- [ ] Retry mechanisms
|
||||
- [ ] **Improve API error handling**
|
||||
- [ ] Consistent error response format
|
||||
- [ ] Network error recovery
|
||||
- [ ] Timeout handling
|
||||
- [ ] **Add error boundaries**
|
||||
- [ ] Page-level error boundaries
|
||||
- [ ] Component-level error recovery
|
||||
|
||||
### 6. State Management Improvements
|
||||
- [ ] **Enhance AuthContext**
|
||||
- [ ] Add user preferences
|
||||
- [ ] API caching layer
|
||||
- [ ] Offline capability detection
|
||||
- [ ] **Create AppStateContext** (`/src/context/AppStateContext.jsx`)
|
||||
- [ ] Global loading states
|
||||
- [ ] Toast notifications
|
||||
- [ ] Modal management
|
||||
- [ ] **Add React Query** (Optional but recommended)
|
||||
- [ ] `npm install @tanstack/react-query`
|
||||
- [ ] API data caching
|
||||
- [ ] Background refetching
|
||||
- [ ] Optimistic updates
|
||||
|
||||
### 7. UI/UX Enhancements
|
||||
- [ ] **Improve responsive design**
|
||||
- [ ] Better mobile navigation
|
||||
- [ ] Touch-friendly interactions
|
||||
- [ ] Responsive charts and maps
|
||||
- [ ] **Add loading skeletons**
|
||||
- [ ] Replace generic spinners
|
||||
- [ ] Component-specific skeletons
|
||||
- [ ] Progressive loading
|
||||
- [ ] **Create ConfirmDialog component** (`/src/components/ui/ConfirmDialog.jsx`)
|
||||
- [ ] Delete confirmations
|
||||
- [ ] Destructive action warnings
|
||||
- [ ] Custom confirmation messages
|
||||
- [ ] **Add keyboard shortcuts**
|
||||
- [ ] Navigation shortcuts
|
||||
- [ ] Action shortcuts
|
||||
- [ ] Help overlay
|
||||
|
||||
## 🧪 Testing & Quality Assurance
|
||||
|
||||
### 8. Testing Infrastructure
|
||||
- [ ] **Expand component tests**
|
||||
- [ ] Rules management tests
|
||||
- [ ] Plan generation tests
|
||||
- [ ] Route visualization tests
|
||||
- [ ] **Add integration tests**
|
||||
- [ ] API integration tests
|
||||
- [ ] User workflow tests
|
||||
- [ ] Error scenario tests
|
||||
- [ ] **Performance testing**
|
||||
- [ ] Large dataset handling
|
||||
- [ ] Chart rendering performance
|
||||
- [ ] Memory leak detection
|
||||
|
||||
### 9. Development Experience
|
||||
- [ ] **Add Storybook** (Optional)
|
||||
- [ ] Component documentation
|
||||
- [ ] Design system documentation
|
||||
- [ ] Interactive component testing
|
||||
- [ ] **Improve build process**
|
||||
- [ ] Bundle size optimization
|
||||
- [ ] Dead code elimination
|
||||
- [ ] Tree shaking verification
|
||||
- [ ] **Add development tools**
|
||||
- [ ] React DevTools integration
|
||||
- [ ] Performance monitoring
|
||||
- [ ] Bundle analyzer
|
||||
|
||||
## 📚 Documentation & Dependencies
|
||||
|
||||
### 10. Missing Dependencies
|
||||
```json
|
||||
{
|
||||
"leaflet": "^1.9.4",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-json-view": "^1.21.3",
|
||||
"@tanstack/react-query": "^4.32.0",
|
||||
"react-hook-form": "^7.45.0",
|
||||
"react-select": "^5.7.4",
|
||||
"file-saver": "^2.0.5"
|
||||
}
|
||||
```
|
||||
|
||||
### 11. Configuration Files
|
||||
- [ ] **Create environment config** (`/src/config/index.js`)
|
||||
- [ ] API endpoints configuration
|
||||
- [ ] Feature flags
|
||||
- [ ] Environment-specific settings
|
||||
- [ ] **Add TypeScript support** (Optional)
|
||||
- [ ] Convert critical components
|
||||
- [ ] Add type definitions
|
||||
- [ ] Improve IDE support
|
||||
|
||||
## 🚀 Deployment & Performance
|
||||
|
||||
### 12. Production Readiness
|
||||
- [ ] **Optimize bundle size**
|
||||
- [ ] Code splitting implementation
|
||||
- [ ] Lazy loading for routes
|
||||
- [ ] Image optimization
|
||||
- [ ] **Add PWA features** (Optional)
|
||||
- [ ] Service worker
|
||||
- [ ] Offline functionality
|
||||
- [ ] App manifest
|
||||
- [ ] **Performance monitoring**
|
||||
- [ ] Core Web Vitals tracking
|
||||
- [ ] Error tracking integration
|
||||
- [ ] User analytics
|
||||
|
||||
## 📅 Implementation Priority
|
||||
|
||||
### Phase 1 (Week 1-2): Core Missing Features
|
||||
1. Rules Management System
|
||||
2. Plan Generation Workflow
|
||||
3. Enhanced Route Management
|
||||
|
||||
### Phase 2 (Week 3): Data Management
|
||||
1. Export/Import System
|
||||
2. Enhanced Error Handling
|
||||
3. State Management Improvements
|
||||
|
||||
### Phase 3 (Week 4): Polish & Quality
|
||||
1. UI/UX Enhancements
|
||||
2. Testing Infrastructure
|
||||
3. Performance Optimization
|
||||
|
||||
### Phase 4 (Ongoing): Maintenance
|
||||
1. Documentation
|
||||
2. Monitoring
|
||||
3. User Feedback Integration
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Success Criteria
|
||||
|
||||
- [ ] All design document workflows implemented
|
||||
- [ ] 90%+ component test coverage
|
||||
- [ ] Mobile-responsive design
|
||||
- [ ] Sub-3s initial page load
|
||||
- [ ] Accessibility compliance (WCAG 2.1 AA)
|
||||
- [ ] Cross-browser compatibility (Chrome, Firefox, Safari, Edge)
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- **Prioritize user-facing features** over internal architecture improvements
|
||||
- **Test each feature** as you implement it
|
||||
- **Consider Progressive Web App features** for offline functionality
|
||||
- **Plan for internationalization** if expanding globally
|
||||
- **Monitor bundle size** as you add dependencies
|
||||
40
backend/app/routes/export.py
Normal file
40
backend/app/routes/export.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from fastapi import APIRouter, Query, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from app.services.export_service import ExportService
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@router.get("/export")
|
||||
async def export_data(
|
||||
types: str = Query(..., description="Comma-separated list of data types to export"),
|
||||
format: str = Query('json', description="Export format (json, zip, gpx)")
|
||||
):
|
||||
valid_types = {'routes', 'rules', 'plans'}
|
||||
requested_types = set(types.split(','))
|
||||
|
||||
# Validate requested types
|
||||
if not requested_types.issubset(valid_types):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid export types. Valid types are: {', '.join(valid_types)}"
|
||||
)
|
||||
|
||||
try:
|
||||
exporter = ExportService()
|
||||
export_path = await exporter.create_export(
|
||||
export_types=list(requested_types),
|
||||
export_format=format
|
||||
)
|
||||
|
||||
return FileResponse(
|
||||
export_path,
|
||||
media_type="application/zip" if format == 'zip' else "application/json",
|
||||
filename=f"export_{'_'.join(requested_types)}.{format}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Export failed: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Export failed") from e
|
||||
38
backend/app/routes/import.py
Normal file
38
backend/app/routes/import.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from app.services.import_service import ImportService
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@router.post("/import/validate")
|
||||
async def validate_import(
|
||||
file: UploadFile = File(...),
|
||||
):
|
||||
try:
|
||||
importer = ImportService()
|
||||
validation_result = await importer.validate_import(file)
|
||||
return JSONResponse(content=validation_result)
|
||||
except Exception as e:
|
||||
logger.error(f"Import validation failed: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
@router.post("/import")
|
||||
async def execute_import(
|
||||
file: UploadFile = File(...),
|
||||
conflict_resolution: str = Form("skip"),
|
||||
resolutions: Optional[str] = Form(None),
|
||||
):
|
||||
try:
|
||||
importer = ImportService()
|
||||
import_result = await importer.execute_import(
|
||||
file,
|
||||
conflict_resolution,
|
||||
resolutions
|
||||
)
|
||||
return JSONResponse(content=import_result)
|
||||
except Exception as e:
|
||||
logger.error(f"Import failed: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
138
backend/app/services/export_service.py
Normal file
138
backend/app/services/export_service.py
Normal file
@@ -0,0 +1,138 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
import zipfile
|
||||
from app.database import SessionLocal
|
||||
from app.models import Route, Rule, Plan
|
||||
import tempfile
|
||||
import logging
|
||||
import shutil
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ExportService:
|
||||
def __init__(self):
|
||||
self.temp_dir = Path(tempfile.gettempdir()) / "cycling_exports"
|
||||
self.temp_dir.mkdir(exist_ok=True)
|
||||
|
||||
async def create_export(self, export_types, export_format):
|
||||
"""Main export creation entry point"""
|
||||
export_data = await self._fetch_export_data(export_types)
|
||||
export_path = self._generate_export_file(export_data, export_format, export_types)
|
||||
return export_path
|
||||
|
||||
async def _fetch_export_data(self, export_types):
|
||||
"""Fetch data from database based on requested types"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
data = {}
|
||||
|
||||
if 'routes' in export_types:
|
||||
routes = db.query(Route).all()
|
||||
data['routes'] = [self._serialize_route(r) for r in routes]
|
||||
|
||||
if 'rules' in export_types:
|
||||
rules = db.query(Rule).all()
|
||||
data['rules'] = [self._serialize_rule(r) for r in rules]
|
||||
|
||||
if 'plans' in export_types:
|
||||
plans = db.query(Plan).all()
|
||||
data['plans'] = [self._serialize_plan(p) for p in plans]
|
||||
|
||||
return data
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def _generate_export_file(self, data, format, types):
|
||||
"""Generate the export file in specified format"""
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
base_name = f"export_{'_'.join(types)}_{timestamp}"
|
||||
|
||||
if format == 'json':
|
||||
return self._create_json_export(data, base_name)
|
||||
elif format == 'zip':
|
||||
return self._create_zip_export(data, base_name)
|
||||
elif format == 'gpx':
|
||||
return self._create_gpx_export(data, base_name)
|
||||
else:
|
||||
raise ValueError(f"Unsupported format: {format}")
|
||||
|
||||
def _create_json_export(self, data, base_name):
|
||||
"""Create single JSON file export"""
|
||||
export_path = self.temp_dir / f"{base_name}.json"
|
||||
with open(export_path, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
return export_path
|
||||
|
||||
def _create_zip_export(self, data, base_name):
|
||||
"""Create ZIP archive with JSON and GPX files"""
|
||||
zip_path = self.temp_dir / f"{base_name}.zip"
|
||||
with zipfile.ZipFile(zip_path, 'w') as zipf:
|
||||
# Add JSON data
|
||||
json_path = self._create_json_export(data, base_name)
|
||||
zipf.write(json_path, arcname=json_path.name)
|
||||
|
||||
# Add GPX files if exporting routes
|
||||
if 'routes' in data:
|
||||
gpx_dir = Path("/app/data/gpx")
|
||||
for route in data['routes']:
|
||||
gpx_path = gpx_dir / route['gpx_file_path']
|
||||
if gpx_path.exists():
|
||||
zipf.write(gpx_path, arcname=f"gpx/{gpx_path.name}")
|
||||
|
||||
return zip_path
|
||||
|
||||
def _create_gpx_export(self, data, base_name):
|
||||
"""Export only GPX files from routes"""
|
||||
if 'routes' not in data:
|
||||
raise ValueError("GPX export requires routes to be selected")
|
||||
|
||||
zip_path = self.temp_dir / f"{base_name}.zip"
|
||||
with zipfile.ZipFile(zip_path, 'w') as zipf:
|
||||
gpx_dir = Path("/app/data/gpx")
|
||||
for route in data['routes']:
|
||||
gpx_path = gpx_dir / route['gpx_file_path']
|
||||
if gpx_path.exists():
|
||||
zipf.write(gpx_path, arcname=gpx_path.name)
|
||||
|
||||
return zip_path
|
||||
|
||||
def _serialize_route(self, route):
|
||||
return {
|
||||
"id": route.id,
|
||||
"name": route.name,
|
||||
"description": route.description,
|
||||
"category": route.category,
|
||||
"gpx_file_path": route.gpx_file_path,
|
||||
"created_at": route.created_at.isoformat(),
|
||||
"updated_at": route.updated_at.isoformat()
|
||||
}
|
||||
|
||||
def _serialize_rule(self, rule):
|
||||
return {
|
||||
"id": rule.id,
|
||||
"name": rule.name,
|
||||
"natural_language": rule.natural_language,
|
||||
"jsonb_rules": rule.jsonb_rules,
|
||||
"version": rule.version,
|
||||
"created_at": rule.created_at.isoformat()
|
||||
}
|
||||
|
||||
def _serialize_plan(self, plan):
|
||||
return {
|
||||
"id": plan.id,
|
||||
"name": plan.name,
|
||||
"jsonb_plan": plan.jsonb_plan,
|
||||
"version": plan.version,
|
||||
"created_at": plan.created_at.isoformat()
|
||||
}
|
||||
|
||||
def cleanup_temp_files(self):
|
||||
"""Clean up temporary export files older than 1 hour"""
|
||||
cutoff = datetime.now().timestamp() - 3600
|
||||
for file in self.temp_dir.glob("*"):
|
||||
if file.stat().st_mtime < cutoff:
|
||||
try:
|
||||
file.unlink()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up temp file {file}: {str(e)}")
|
||||
259
backend/app/services/import_service.py
Normal file
259
backend/app/services/import_service.py
Normal file
@@ -0,0 +1,259 @@
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from app.database import SessionLocal
|
||||
from app.models import Route, Rule, Plan
|
||||
import shutil
|
||||
import logging
|
||||
from sqlalchemy import and_
|
||||
from typing import Dict, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ImportService:
|
||||
def __init__(self):
|
||||
self.temp_dir = Path(tempfile.gettempdir()) / "cycling_imports"
|
||||
self.temp_dir.mkdir(exist_ok=True)
|
||||
|
||||
async def validate_import(self, file: UploadFile) -> dict:
|
||||
"""Validate import file and detect conflicts"""
|
||||
try:
|
||||
# Save uploaded file to temp location
|
||||
file_path = self.temp_dir / file.filename
|
||||
with open(file_path, "wb") as f:
|
||||
shutil.copyfileobj(file.file, f)
|
||||
|
||||
# Extract data based on file type
|
||||
if file.filename.endswith('.zip'):
|
||||
data = self._process_zip_import(file_path)
|
||||
elif file.filename.endswith('.json'):
|
||||
data = self._process_json_import(file_path)
|
||||
else:
|
||||
raise ValueError("Unsupported file format")
|
||||
|
||||
# Detect conflicts
|
||||
conflicts = []
|
||||
if 'routes' in data:
|
||||
conflicts += self._detect_route_conflicts(data['routes'])
|
||||
if 'rules' in data:
|
||||
conflicts += self._detect_rule_conflicts(data['rules'])
|
||||
if 'plans' in data:
|
||||
conflicts += self._detect_plan_conflicts(data['plans'])
|
||||
|
||||
return {
|
||||
"valid": True,
|
||||
"conflicts": conflicts,
|
||||
"summary": {
|
||||
"routes": len(data.get('routes', [])),
|
||||
"rules": len(data.get('rules', [])),
|
||||
"plans": len(data.get('plans', []))
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Validation error: {str(e)}")
|
||||
return {"valid": False, "error": str(e)}
|
||||
|
||||
async def execute_import(self, file: UploadFile,
|
||||
conflict_resolution: str,
|
||||
resolutions: List[dict]) -> dict:
|
||||
"""Execute the import with specified conflict resolution"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.begin()
|
||||
|
||||
# Process file
|
||||
file_path = self.temp_dir / file.filename
|
||||
with open(file_path, "wb") as f:
|
||||
shutil.copyfileobj(file.file, f)
|
||||
|
||||
if file.filename.endswith('.zip'):
|
||||
data = self._process_zip_import(file_path)
|
||||
gpx_files = self._extract_gpx_files(file_path)
|
||||
elif file.filename.endswith('.json'):
|
||||
data = self._process_json_import(file_path)
|
||||
gpx_files = []
|
||||
else:
|
||||
raise ValueError("Unsupported file format")
|
||||
|
||||
# Apply resolutions
|
||||
resolution_map = {r['id']: r['action'] for r in resolutions}
|
||||
|
||||
# Import data
|
||||
results = {
|
||||
"imported": {"routes": 0, "rules": 0, "plans": 0},
|
||||
"skipped": {"routes": 0, "rules": 0, "plans": 0},
|
||||
"errors": []
|
||||
}
|
||||
|
||||
# Import routes
|
||||
if 'routes' in data:
|
||||
for route_data in data['routes']:
|
||||
action = resolution_map.get(route_data['id'], conflict_resolution)
|
||||
try:
|
||||
if self._should_import_route(route_data, action, db):
|
||||
self._import_route(route_data, db)
|
||||
results["imported"]["routes"] += 1
|
||||
else:
|
||||
results["skipped"]["routes"] += 1
|
||||
except Exception as e:
|
||||
results["errors"].append(f"Route {route_data['id']}: {str(e)}")
|
||||
|
||||
# Import rules
|
||||
if 'rules' in data:
|
||||
for rule_data in data['rules']:
|
||||
action = resolution_map.get(rule_data['id'], conflict_resolution)
|
||||
try:
|
||||
if self._should_import_rule(rule_data, action, db):
|
||||
self._import_rule(rule_data, db)
|
||||
results["imported"]["rules"] += 1
|
||||
else:
|
||||
results["skipped"]["rules"] += 1
|
||||
except Exception as e:
|
||||
results["errors"].append(f"Rule {rule_data['id']}: {str(e)}")
|
||||
|
||||
# Import plans
|
||||
if 'plans' in data:
|
||||
for plan_data in data['plans']:
|
||||
action = resolution_map.get(plan_data['id'], conflict_resolution)
|
||||
try:
|
||||
if self._should_import_plan(plan_data, action, db):
|
||||
self._import_plan(plan_data, db)
|
||||
results["imported"]["plans"] += 1
|
||||
else:
|
||||
results["skipped"]["plans"] += 1
|
||||
except Exception as e:
|
||||
results["errors"].append(f"Plan {plan_data['id']}: {str(e)}")
|
||||
|
||||
# Save GPX files
|
||||
if gpx_files:
|
||||
gpx_dir = Path("/app/data/gpx")
|
||||
for gpx in gpx_files:
|
||||
shutil.move(gpx, gpx_dir / gpx.name)
|
||||
|
||||
db.commit()
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Import failed: {str(e)}")
|
||||
return {"error": str(e)}
|
||||
finally:
|
||||
db.close()
|
||||
self._cleanup_temp_files()
|
||||
|
||||
def _process_zip_import(self, file_path: Path) -> dict:
|
||||
"""Extract and process ZIP file import"""
|
||||
data = {}
|
||||
with zipfile.ZipFile(file_path, 'r') as zipf:
|
||||
# Find data.json
|
||||
json_files = [f for f in zipf.namelist() if f.endswith('.json')]
|
||||
if not json_files:
|
||||
raise ValueError("No JSON data found in ZIP file")
|
||||
|
||||
with zipf.open(json_files[0]) as f:
|
||||
data = json.load(f)
|
||||
|
||||
return data
|
||||
|
||||
def _process_json_import(self, file_path: Path) -> dict:
|
||||
"""Process JSON file import"""
|
||||
with open(file_path) as f:
|
||||
return json.load(f)
|
||||
|
||||
def _extract_gpx_files(self, file_path: Path) -> List[Path]:
|
||||
"""Extract GPX files from ZIP archive"""
|
||||
gpx_files = []
|
||||
extract_dir = self.temp_dir / "gpx"
|
||||
extract_dir.mkdir(exist_ok=True)
|
||||
|
||||
with zipfile.ZipFile(file_path, 'r') as zipf:
|
||||
for file in zipf.namelist():
|
||||
if file.startswith('gpx/') and file.endswith('.gpx'):
|
||||
zipf.extract(file, extract_dir)
|
||||
gpx_files.append(extract_dir / file)
|
||||
|
||||
return gpx_files
|
||||
|
||||
def _detect_route_conflicts(self, routes: List[dict]) -> List[dict]:
|
||||
conflicts = []
|
||||
db = SessionLocal()
|
||||
try:
|
||||
for route in routes:
|
||||
existing = db.query(Route).filter(
|
||||
(Route.id == route['id']) |
|
||||
(Route.name == route['name'])
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
conflict = {
|
||||
"type": "route",
|
||||
"id": route['id'],
|
||||
"name": route['name'],
|
||||
"existing_version": existing.updated_at,
|
||||
"import_version": datetime.fromisoformat(route['updated_at']),
|
||||
"resolution_options": ["overwrite", "rename", "skip"]
|
||||
}
|
||||
conflicts.append(conflict)
|
||||
finally:
|
||||
db.close()
|
||||
return conflicts
|
||||
|
||||
def _should_import_route(self, route_data: dict, action: str, db) -> bool:
|
||||
existing = db.query(Route).filter(
|
||||
(Route.id == route_data['id']) |
|
||||
(Route.name == route_data['name'])
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
return True
|
||||
|
||||
if action == 'overwrite':
|
||||
return True
|
||||
elif action == 'rename':
|
||||
route_data['name'] = f"{route_data['name']} (Imported)"
|
||||
return True
|
||||
elif action == 'skip':
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
def _import_route(self, route_data: dict, db):
|
||||
"""Import a single route"""
|
||||
existing = db.query(Route).get(route_data['id'])
|
||||
if existing:
|
||||
# Update existing route
|
||||
existing.name = route_data['name']
|
||||
existing.description = route_data['description']
|
||||
existing.category = route_data['category']
|
||||
existing.gpx_file_path = route_data['gpx_file_path']
|
||||
existing.updated_at = datetime.fromisoformat(route_data['updated_at'])
|
||||
else:
|
||||
# Create new route
|
||||
route = Route(
|
||||
id=route_data['id'],
|
||||
name=route_data['name'],
|
||||
description=route_data['description'],
|
||||
category=route_data['category'],
|
||||
gpx_file_path=route_data['gpx_file_path'],
|
||||
created_at=datetime.fromisoformat(route_data['created_at']),
|
||||
updated_at=datetime.fromisoformat(route_data['updated_at'])
|
||||
)
|
||||
db.add(route)
|
||||
|
||||
# Similar methods for rules and plans would follow...
|
||||
|
||||
def _cleanup_temp_files(self):
|
||||
"""Clean up temporary files older than 1 hour"""
|
||||
cutoff = datetime.now().timestamp() - 3600
|
||||
for file in self.temp_dir.glob("*"):
|
||||
if file.stat().st_mtime < cutoff:
|
||||
try:
|
||||
if file.is_dir():
|
||||
shutil.rmtree(file)
|
||||
else:
|
||||
file.unlink()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean temp file {file}: {str(e)}")
|
||||
@@ -26,11 +26,14 @@ services:
|
||||
start_period: 40s
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
build:
|
||||
context: ./frontend
|
||||
args:
|
||||
- REACT_APP_API_URL=http://backend:8000
|
||||
ports:
|
||||
- "8888:80"
|
||||
environment:
|
||||
- REACT_APP_API_URL=http://backend:8000
|
||||
- REACT_APP_CONTAINER_API_URL=http://backend:8000
|
||||
- REACT_APP_API_KEY=${API_KEY}
|
||||
|
||||
db:
|
||||
|
||||
91
export_import_api_spec.md
Normal file
91
export_import_api_spec.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Export/Import API Specification
|
||||
|
||||
## Export Endpoint
|
||||
`GET /api/export`
|
||||
|
||||
### Parameters (query string)
|
||||
- `types` (required): Comma-separated list of data types to export
|
||||
- Valid values: `routes`, `rules`, `plans`, `all`
|
||||
- `format` (required): Export format
|
||||
- `json`: Single JSON file
|
||||
- `zip`: ZIP archive with separate files
|
||||
- `gpx`: Only GPX files (routes only)
|
||||
|
||||
### Response
|
||||
- `200 OK` with file download
|
||||
- `400 Bad Request` for invalid parameters
|
||||
- `500 Internal Server Error` for export failures
|
||||
|
||||
### Example
|
||||
```http
|
||||
GET /api/export?types=routes,plans&format=zip
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Import Validation
|
||||
`POST /api/import/validate`
|
||||
|
||||
### Request
|
||||
- Multipart form with `file` field containing import data
|
||||
|
||||
### Response
|
||||
```json
|
||||
{
|
||||
"valid": true,
|
||||
"conflicts": [
|
||||
{
|
||||
"type": "route",
|
||||
"id": 123,
|
||||
"name": "Mountain Loop",
|
||||
"existing_version": 2,
|
||||
"import_version": 3,
|
||||
"resolution_options": ["overwrite", "rename", "skip"]
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"routes": 15,
|
||||
"rules": 3,
|
||||
"plans": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Import Execution
|
||||
`POST /api/import`
|
||||
|
||||
### Request
|
||||
- Multipart form with:
|
||||
- `file`: Import data file
|
||||
- `conflict_resolution`: Global strategy (overwrite, skip, rename)
|
||||
- `resolutions`: JSON array of specific resolutions (optional)
|
||||
```json
|
||||
[{"type": "route", "id": 123, "action": "overwrite"}]
|
||||
```
|
||||
|
||||
### Response
|
||||
```json
|
||||
{
|
||||
"imported": {
|
||||
"routes": 12,
|
||||
"rules": 3,
|
||||
"plans": 2
|
||||
},
|
||||
"skipped": {
|
||||
"routes": 3,
|
||||
"rules": 0,
|
||||
"plans": 0
|
||||
},
|
||||
"errors": []
|
||||
}
|
||||
```
|
||||
|
||||
### Status Codes
|
||||
- `200 OK`: Import completed
|
||||
- `202 Accepted`: Import in progress (async)
|
||||
- `400 Bad Request`: Invalid input
|
||||
- `409 Conflict`: Unresolved conflicts
|
||||
|
||||
---
|
||||
221
export_import_frontend_spec.md
Normal file
221
export_import_frontend_spec.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# Export/Import Frontend Implementation
|
||||
|
||||
## File Structure
|
||||
```
|
||||
src/pages/
|
||||
ExportImport.jsx # Main page
|
||||
|
||||
src/components/export/
|
||||
DataExporter.jsx # Export functionality
|
||||
DataImporter.jsx # Import functionality
|
||||
ConflictDialog.jsx # Conflict resolution UI
|
||||
ImportSummary.jsx # Post-import report
|
||||
```
|
||||
|
||||
## Component Specifications
|
||||
|
||||
### ExportImport.jsx
|
||||
```jsx
|
||||
import { useState } from 'react';
|
||||
import DataExporter from '../components/export/DataExporter';
|
||||
import DataImporter from '../components/export/DataImporter';
|
||||
|
||||
export default function ExportImportPage() {
|
||||
const [activeTab, setActiveTab] = useState('export');
|
||||
|
||||
return (
|
||||
<div className="export-import-page">
|
||||
<div className="tabs">
|
||||
<button onClick={() => setActiveTab('export')}>Export</button>
|
||||
<button onClick={() => setActiveTab('import')}>Import</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'export' ? <DataExporter /> : <DataImporter />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### DataExporter.jsx
|
||||
```jsx
|
||||
import { useState } from 'react';
|
||||
|
||||
const EXPORT_TYPES = [
|
||||
{ id: 'routes', label: 'Routes' },
|
||||
{ id: 'rules', label: 'Training Rules' },
|
||||
{ id: 'plans', label: 'Training Plans' }
|
||||
];
|
||||
|
||||
const EXPORT_FORMATS = [
|
||||
{ id: 'json', label: 'JSON' },
|
||||
{ id: 'zip', label: 'ZIP Archive' },
|
||||
{ id: 'gpx', label: 'GPX Files' }
|
||||
];
|
||||
|
||||
export default function DataExporter() {
|
||||
const [selectedTypes, setSelectedTypes] = useState([]);
|
||||
const [selectedFormat, setSelectedFormat] = useState('json');
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true);
|
||||
// API call to /api/export?types=...&format=...
|
||||
// Track progress and trigger download
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="exporter">
|
||||
<h2>Export Data</h2>
|
||||
|
||||
<div className="type-selection">
|
||||
<h3>Select Data to Export</h3>
|
||||
{EXPORT_TYPES.map(type => (
|
||||
<label key={type.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedTypes.includes(type.id)}
|
||||
onChange={() => toggleType(type.id)}
|
||||
/>
|
||||
{type.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="format-selection">
|
||||
<h3>Export Format</h3>
|
||||
<select value={selectedFormat} onChange={e => setSelectedFormat(e.target.value)}>
|
||||
{EXPORT_FORMATS.map(format => (
|
||||
<option key={format.id} value={format.id}>{format.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={selectedTypes.length === 0 || isExporting}
|
||||
>
|
||||
{isExporting ? `Exporting... ${progress}%` : 'Export Data'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### DataImporter.jsx
|
||||
```jsx
|
||||
import { useState } from 'react';
|
||||
import ConflictDialog from './ConflictDialog';
|
||||
|
||||
export default function DataImporter() {
|
||||
const [file, setFile] = useState(null);
|
||||
const [validation, setValidation] = useState(null);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [showConflictDialog, setShowConflictDialog] = useState(false);
|
||||
|
||||
const handleFileUpload = (e) => {
|
||||
const file = e.target.files[0];
|
||||
setFile(file);
|
||||
// Call /api/import/validate
|
||||
// Set validation results
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
if (validation?.conflicts?.length > 0) {
|
||||
setShowConflictDialog(true);
|
||||
} else {
|
||||
startImport();
|
||||
}
|
||||
};
|
||||
|
||||
const startImport = (resolutions = []) => {
|
||||
setIsImporting(true);
|
||||
// Call /api/import with conflict resolutions
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="importer">
|
||||
<h2>Import Data</h2>
|
||||
|
||||
<input type="file" onChange={handleFileUpload} />
|
||||
|
||||
{validation && (
|
||||
<div className="validation-results">
|
||||
<h3>Validation Results</h3>
|
||||
<p>Found: {validation.summary.routes} routes,
|
||||
{validation.summary.rules} rules,
|
||||
{validation.summary.plans} plans</p>
|
||||
{validation.conflicts.length > 0 && (
|
||||
<p>⚠️ {validation.conflicts.length} conflicts detected</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={!file || isImporting}
|
||||
>
|
||||
{isImporting ? 'Importing...' : 'Import Data'}
|
||||
</button>
|
||||
|
||||
{showConflictDialog && (
|
||||
<ConflictDialog
|
||||
conflicts={validation.conflicts}
|
||||
onResolve={startImport}
|
||||
onCancel={() => setShowConflictDialog(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### ConflictDialog.jsx
|
||||
```jsx
|
||||
export default function ConflictDialog({ conflicts, onResolve, onCancel }) {
|
||||
const [resolutions, setResolutions] = useState({});
|
||||
|
||||
const handleResolution = (id, action) => {
|
||||
setResolutions(prev => ({ ...prev, [id]: action }));
|
||||
};
|
||||
|
||||
const applyResolutions = () => {
|
||||
const resolutionList = Object.entries(resolutions).map(([id, action]) => ({
|
||||
id,
|
||||
action
|
||||
}));
|
||||
onResolve(resolutionList);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="conflict-dialog">
|
||||
<h3>Resolve Conflicts</h3>
|
||||
<div className="conflicts-list">
|
||||
{conflicts.map(conflict => (
|
||||
<div key={conflict.id} className="conflict-item">
|
||||
<h4>{conflict.name} ({conflict.type})</h4>
|
||||
<p>Existing version: {conflict.existing_version}</p>
|
||||
<p>Import version: {conflict.import_version}</p>
|
||||
<select
|
||||
value={resolutions[conflict.id] || 'skip'}
|
||||
onChange={e => handleResolution(conflict.id, e.target.value)}
|
||||
>
|
||||
<option value="overwrite">Overwrite</option>
|
||||
<option value="rename">Rename</option>
|
||||
<option value="skip">Skip</option>
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button onClick={onCancel}>Cancel</button>
|
||||
<button onClick={applyResolutions}>Apply Resolutions</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Dependencies to Install
|
||||
```bash
|
||||
npm install react-dropzone react-json-view file-saver
|
||||
@@ -1,6 +1,10 @@
|
||||
# Stage 1: Build application
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Allow environment variables to be passed at build time
|
||||
ARG REACT_APP_API_URL
|
||||
ENV REACT_APP_API_URL=$REACT_APP_API_URL
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package manifests first for optimal caching
|
||||
@@ -9,7 +13,7 @@ COPY package.json package-lock.json* ./
|
||||
# Clean cache and install dependencies
|
||||
RUN npm cache clean --force && \
|
||||
export NODE_OPTIONS="--max-old-space-size=1024" && \
|
||||
npm install --include=dev
|
||||
npm install --omit=dev --legacy-peer-deps
|
||||
|
||||
# Copy source files
|
||||
COPY . .
|
||||
|
||||
567
frontend/package-lock.json
generated
567
frontend/package-lock.json
generated
@@ -10,9 +10,20 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"axios": "^1.7.2",
|
||||
"date-fns": "^3.6.0",
|
||||
"gpx-parse": "^0.10.4",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "14.2.3",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.45.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-json-view": "^1.21.3",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"react-select": "^5.7.4",
|
||||
"react-toastify": "^10.0.4",
|
||||
"recharts": "2.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -762,6 +773,28 @@
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
|
||||
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
|
||||
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.3",
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.11.14",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
|
||||
@@ -1608,6 +1641,24 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
|
||||
"integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -1658,36 +1709,6 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.3.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"picocolors": "1.1.1",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom/node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.4.2.tgz",
|
||||
@@ -2021,6 +2042,14 @@
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-transition-group": {
|
||||
"version": "4.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
|
||||
"integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/scheduler": {
|
||||
"version": "0.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.26.0.tgz",
|
||||
@@ -2755,6 +2784,11 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/asap": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
|
||||
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="
|
||||
},
|
||||
"node_modules/ast-types-flow": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
||||
@@ -2773,8 +2807,7 @@
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"dev": true
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
},
|
||||
"node_modules/available-typed-arrays": {
|
||||
"version": "1.0.7",
|
||||
@@ -2800,6 +2833,16 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
|
||||
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
@@ -2948,6 +2991,11 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/base16": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/base16/-/base16-1.0.0.tgz",
|
||||
"integrity": "sha512-pNdYkNPiJUnEhnfXV56+sQy8+AaPcG3POZAUnwr4EeqCUZFz4u2PePbo3e5Gj4ziYPCWGUZT9RHisvJKnwFuBQ=="
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
@@ -3050,7 +3098,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -3218,6 +3265,14 @@
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/co": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||
@@ -3256,7 +3311,6 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
@@ -3409,6 +3463,14 @@
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
|
||||
"integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -3644,6 +3706,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
@@ -3770,21 +3841,10 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-newline": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
|
||||
@@ -3858,7 +3918,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -3990,7 +4049,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
@@ -3999,7 +4057,6 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
@@ -4055,7 +4112,6 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
@@ -4067,7 +4123,6 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
@@ -4725,6 +4780,33 @@
|
||||
"bser": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fbemitter": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fbemitter/-/fbemitter-3.0.0.tgz",
|
||||
"integrity": "sha512-KWKaceCwKQU0+HPoop6gn4eOHk50bBv/VxjJtGMfwmJt3D29JpN4H4eisCtIPA+a8GVBam+ldMMpMjJUvpDyHw==",
|
||||
"dependencies": {
|
||||
"fbjs": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fbjs": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz",
|
||||
"integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==",
|
||||
"dependencies": {
|
||||
"cross-fetch": "^3.1.5",
|
||||
"fbjs-css-vars": "^1.0.0",
|
||||
"loose-envify": "^1.0.0",
|
||||
"object-assign": "^4.1.0",
|
||||
"promise": "^7.1.1",
|
||||
"setimmediate": "^1.0.5",
|
||||
"ua-parser-js": "^1.0.35"
|
||||
}
|
||||
},
|
||||
"node_modules/fbjs-css-vars": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz",
|
||||
"integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ=="
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||
@@ -4790,6 +4872,37 @@
|
||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/flux": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/flux/-/flux-4.0.4.tgz",
|
||||
"integrity": "sha512-NCj3XlayA2UsapRpM7va6wU1+9rE5FIL7qoMcmxWHRzbp0yujihMBm9BBHZ1MDIk5h5o2Bl6eGiCe8rYELAmYw==",
|
||||
"dependencies": {
|
||||
"fbemitter": "^3.0.0",
|
||||
"fbjs": "^3.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^15.0.2 || ^16.0.0 || ^17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
@@ -4825,7 +4938,6 @@
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
@@ -4916,7 +5028,6 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
@@ -4949,7 +5060,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
@@ -5112,7 +5222,6 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
@@ -5120,6 +5229,14 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gpx-parse": {
|
||||
"version": "0.10.4",
|
||||
"resolved": "https://registry.npmjs.org/gpx-parse/-/gpx-parse-0.10.4.tgz",
|
||||
"integrity": "sha512-4PUA2Hzp4m5EjDEa4YB1CKWh5Cjm1cgJuDe+nL6B77DgtFuYR7VtvBbmvbwjx6M40iA96q2BaKyD8/lraez2xw==",
|
||||
"dependencies": {
|
||||
"xml2js": "^0.4.4"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
@@ -5183,7 +5300,6 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
@@ -5195,7 +5311,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
@@ -7033,6 +7148,11 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="
|
||||
},
|
||||
"node_modules/leven": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||
@@ -7080,6 +7200,16 @@
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
},
|
||||
"node_modules/lodash.curry": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.curry/-/lodash.curry-4.1.1.tgz",
|
||||
"integrity": "sha512-/u14pXGviLaweY5JI0IUzgzF2J6Ne8INyzAZjImcryjgkZ+ebruBxy2/JaOOkTqScddcYtakjhSaeemV8lR0tA=="
|
||||
},
|
||||
"node_modules/lodash.flow": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.flow/-/lodash.flow-3.5.0.tgz",
|
||||
"integrity": "sha512-ff3BX/tSioo+XojX4MOsOMhJw0nZoUEF011LX8g8d3gvjVbxd89cCio4BCXronjxcTUIJUoqKEUA+n4CqvvRPw=="
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
@@ -7140,11 +7270,15 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="
|
||||
},
|
||||
"node_modules/merge-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||
@@ -7177,7 +7311,6 @@
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -7186,7 +7319,6 @@
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
@@ -7334,6 +7466,44 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-int64": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||
@@ -7868,6 +8038,14 @@
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/promise": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
|
||||
"integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==",
|
||||
"dependencies": {
|
||||
"asap": "~2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/prompts": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
||||
@@ -7891,6 +8069,11 @@
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
|
||||
},
|
||||
"node_modules/psl": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
|
||||
@@ -7912,6 +8095,11 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-color": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pure-color/-/pure-color-1.3.0.tgz",
|
||||
"integrity": "sha512-QFADYnsVoBMw1srW7OVKEYjG+MbIa49s54w1MA1EDY6r2r/sTcKKYqRX1f4GYvnXP7eN/Pe9HFcX+hwzmrXRHA=="
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||
@@ -7965,6 +8153,17 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-base16-styling": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/react-base16-styling/-/react-base16-styling-0.6.0.tgz",
|
||||
"integrity": "sha512-yvh/7CArceR/jNATXOKDlvTnPKPmGZz7zsenQ3jUwLzHkNUR0CvY3yGYJbWJ/nnxsL8Sgmt5cO3/SILVuPO6TQ==",
|
||||
"dependencies": {
|
||||
"base16": "^1.0.0",
|
||||
"lodash.curry": "^4.0.1",
|
||||
"lodash.flow": "^3.3.0",
|
||||
"pure-color": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
||||
@@ -7977,11 +8176,62 @@
|
||||
"react": "^18.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.62.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz",
|
||||
"integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/react-hook-form"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-icons": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
|
||||
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||
},
|
||||
"node_modules/react-json-view": {
|
||||
"version": "1.21.3",
|
||||
"resolved": "https://registry.npmjs.org/react-json-view/-/react-json-view-1.21.3.tgz",
|
||||
"integrity": "sha512-13p8IREj9/x/Ye4WI/JpjhoIwuzEgUAtgJZNBJckfzJt1qyh24BdTm6UQNGnyTq9dapQdrqvquZTo3dz1X6Cjw==",
|
||||
"dependencies": {
|
||||
"flux": "^4.0.1",
|
||||
"react-base16-styling": "^0.6.0",
|
||||
"react-lifecycles-compat": "^3.0.4",
|
||||
"react-textarea-autosize": "^8.3.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.0 || ^16.3.0 || ^15.5.4",
|
||||
"react-dom": "^17.0.0 || ^16.3.0 || ^15.5.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
|
||||
"dependencies": {
|
||||
"@react-leaflet/core": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-lifecycles-compat": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
|
||||
@@ -7999,6 +8249,80 @@
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz",
|
||||
"integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz",
|
||||
"integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.0",
|
||||
"react-router": "6.30.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-select": {
|
||||
"version": "5.10.2",
|
||||
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz",
|
||||
"integrity": "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.0",
|
||||
"@emotion/cache": "^11.4.0",
|
||||
"@emotion/react": "^11.8.1",
|
||||
"@floating-ui/dom": "^1.0.1",
|
||||
"@types/react-transition-group": "^4.4.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"prop-types": "^15.6.0",
|
||||
"react-transition-group": "^4.3.0",
|
||||
"use-isomorphic-layout-effect": "^1.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-select/node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/react-select/node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"dom-helpers": "^5.0.1",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.6.0",
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-smooth": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-2.0.5.tgz",
|
||||
@@ -8013,6 +8337,34 @@
|
||||
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-textarea-autosize": {
|
||||
"version": "8.5.9",
|
||||
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz",
|
||||
"integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"use-composed-ref": "^1.3.0",
|
||||
"use-latest": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-toastify": {
|
||||
"version": "10.0.6",
|
||||
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-10.0.6.tgz",
|
||||
"integrity": "sha512-yYjp+omCDf9lhZcrZHKbSq7YMuK0zcYkDFTzfRFgTXkTFHZ1ToxwAonzA4JI5CxA91JpjFLmwEsZEgfYfOqI1A==",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz",
|
||||
@@ -8333,6 +8685,11 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
|
||||
"integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
@@ -8411,6 +8768,11 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -9208,6 +9570,31 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/ua-parser-js": {
|
||||
"version": "1.0.41",
|
||||
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz",
|
||||
"integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ua-parser-js"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/faisalman"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/faisalman"
|
||||
}
|
||||
],
|
||||
"bin": {
|
||||
"ua-parser-js": "script/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/unbox-primitive": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||
@@ -9324,6 +9711,48 @@
|
||||
"requires-port": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-composed-ref": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz",
|
||||
"integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-isomorphic-layout-effect": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
|
||||
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-latest": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz",
|
||||
"integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==",
|
||||
"dependencies": {
|
||||
"use-isomorphic-layout-effect": "^1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/v8-to-istanbul": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
||||
@@ -9687,6 +10116,26 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/xml2js": {
|
||||
"version": "0.4.23",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
||||
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
|
||||
"dependencies": {
|
||||
"sax": ">=0.6.0",
|
||||
"xmlbuilder": "~11.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder": {
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
||||
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
|
||||
@@ -16,10 +16,17 @@
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"axios": "^1.7.2",
|
||||
"date-fns": "^3.6.0",
|
||||
"@tmcw/togeojson": "^7.1.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "14.2.3",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.45.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-json-tree": "^0.20.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"react-select": "^5.7.4",
|
||||
"react-toastify": "^10.0.4",
|
||||
"recharts": "2.8.0"
|
||||
},
|
||||
|
||||
@@ -23,6 +23,12 @@ const Navigation = () => {
|
||||
>
|
||||
Plans
|
||||
</Link>
|
||||
<Link
|
||||
to="/rules"
|
||||
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md"
|
||||
>
|
||||
Rules
|
||||
</Link>
|
||||
<Link
|
||||
to="/routes"
|
||||
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md"
|
||||
|
||||
135
frontend/src/components/plans/EditWorkoutModal.jsx
Normal file
135
frontend/src/components/plans/EditWorkoutModal.jsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const EditWorkoutModal = ({ workout, onClose, onSave }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
type: '',
|
||||
duration_minutes: 0,
|
||||
intensity: '',
|
||||
description: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (workout) {
|
||||
setFormData({
|
||||
type: workout.type || '',
|
||||
duration_minutes: workout.duration_minutes || 0,
|
||||
intensity: workout.intensity || '',
|
||||
description: workout.description || ''
|
||||
});
|
||||
}
|
||||
}, [workout]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSave(formData);
|
||||
};
|
||||
|
||||
if (!workout) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-lg font-semibold mb-4">Edit Workout</h3>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Workout Type
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="type"
|
||||
value={formData.type}
|
||||
onChange={handleChange}
|
||||
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">
|
||||
Duration (minutes)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="duration_minutes"
|
||||
value={formData.duration_minutes}
|
||||
onChange={handleChange}
|
||||
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">
|
||||
Intensity
|
||||
</label>
|
||||
<select
|
||||
name="intensity"
|
||||
value={formData.intensity}
|
||||
onChange={handleChange}
|
||||
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="">Select intensity</option>
|
||||
<option value="zone_1">Zone 1 (Easy)</option>
|
||||
<option value="zone_2">Zone 2 (Moderate)</option>
|
||||
<option value="zone_3">Zone 3 (Tempo)</option>
|
||||
<option value="zone_4">Zone 4 (Threshold)</option>
|
||||
<option value="zone_5">Zone 5 (VO2 Max)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows="3"
|
||||
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>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 text-white bg-blue-600 rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
EditWorkoutModal.propTypes = {
|
||||
workout: PropTypes.object,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onSave: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
EditWorkoutModal.defaultProps = {
|
||||
workout: null
|
||||
};
|
||||
|
||||
export default EditWorkoutModal;
|
||||
97
frontend/src/components/plans/GoalSelector.jsx
Normal file
97
frontend/src/components/plans/GoalSelector.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const predefinedGoals = [
|
||||
{ id: 'endurance', label: 'Build Endurance', description: 'Focus on longer rides at moderate intensity' },
|
||||
{ id: 'power', label: 'Increase Power', description: 'High-intensity intervals and strength training' },
|
||||
{ id: 'weight-loss', label: 'Weight Management', description: 'Calorie-burning rides with nutrition planning' },
|
||||
{ id: 'event-prep', label: 'Event Preparation', description: 'Targeted training for specific competitions' }
|
||||
];
|
||||
|
||||
const GoalSelector = ({ goals, onSelect, onNext }) => {
|
||||
const [customGoal, setCustomGoal] = useState('');
|
||||
const [showCustom, setShowCustom] = useState(false);
|
||||
|
||||
const toggleGoal = (goalId) => {
|
||||
const newGoals = goals.includes(goalId)
|
||||
? goals.filter(g => g !== goalId)
|
||||
: [...goals, goalId];
|
||||
onSelect(newGoals);
|
||||
};
|
||||
|
||||
const addCustomGoal = () => {
|
||||
if (customGoal.trim()) {
|
||||
onSelect([...goals, customGoal.trim()]);
|
||||
setCustomGoal('');
|
||||
setShowCustom(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<h2 className="text-2xl font-bold mb-6">Select Training Goals</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
|
||||
{predefinedGoals.map((goal) => (
|
||||
<button
|
||||
key={goal.id}
|
||||
onClick={() => toggleGoal(goal.id)}
|
||||
className={`p-4 text-left rounded-lg border-2 transition-colors ${
|
||||
goals.includes(goal.id)
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-blue-200'
|
||||
}`}
|
||||
>
|
||||
<h3 className="font-semibold mb-2">{goal.label}</h3>
|
||||
<p className="text-sm text-gray-600">{goal.description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
{!showCustom ? (
|
||||
<button
|
||||
onClick={() => setShowCustom(true)}
|
||||
className="text-blue-600 hover:text-blue-700 flex items-center"
|
||||
>
|
||||
<span className="mr-2">+</span> Add Custom Goal
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={customGoal}
|
||||
onChange={(e) => setCustomGoal(e.target.value)}
|
||||
placeholder="Enter custom goal"
|
||||
className="flex-1 p-2 border rounded-md"
|
||||
/>
|
||||
<button
|
||||
onClick={addCustomGoal}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={goals.length === 0}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
GoalSelector.propTypes = {
|
||||
goals: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
onSelect: PropTypes.func.isRequired,
|
||||
onNext: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default GoalSelector;
|
||||
129
frontend/src/components/plans/PlanParameters.jsx
Normal file
129
frontend/src/components/plans/PlanParameters.jsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const daysOfWeek = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
|
||||
const PlanParameters = ({ values, onChange, onBack, onNext }) => {
|
||||
const [localValues, setLocalValues] = useState(values);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValues(values);
|
||||
}, [values]);
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
const newValues = { ...localValues, [field]: value };
|
||||
setLocalValues(newValues);
|
||||
onChange(newValues);
|
||||
};
|
||||
|
||||
const toggleDay = (day) => {
|
||||
const days = localValues.availableDays.includes(day)
|
||||
? localValues.availableDays.filter(d => d !== day)
|
||||
: [...localValues.availableDays, day];
|
||||
handleChange('availableDays', days);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<h2 className="text-2xl font-bold mb-6">Set Plan Parameters</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Duration: {localValues.duration} weeks
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="4"
|
||||
max="20"
|
||||
value={localValues.duration}
|
||||
onChange={(e) => handleChange('duration', parseInt(e.target.value))}
|
||||
className="w-full range-slider"
|
||||
/>
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span>4</span>
|
||||
<span>20</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Weekly Hours: {localValues.weeklyHours}h
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="15"
|
||||
value={localValues.weeklyHours}
|
||||
onChange={(e) => handleChange('weeklyHours', parseInt(e.target.value))}
|
||||
className="w-full range-slider"
|
||||
/>
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span>5</span>
|
||||
<span>15</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Difficulty Level</label>
|
||||
<select
|
||||
value={localValues.difficulty || 'intermediate'}
|
||||
onChange={(e) => handleChange('difficulty', e.target.value)}
|
||||
className="w-full p-2 border rounded-md"
|
||||
>
|
||||
<option value="beginner">Beginner</option>
|
||||
<option value="intermediate">Intermediate</option>
|
||||
<option value="advanced">Advanced</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Available Days</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{daysOfWeek.map(day => (
|
||||
<label key={day} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localValues.availableDays.includes(day)}
|
||||
onChange={() => toggleDay(day)}
|
||||
className="form-checkbox h-4 w-4 text-blue-600"
|
||||
/>
|
||||
<span className="text-sm">{day}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-8">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="bg-gray-200 text-gray-700 px-6 py-2 rounded-md hover:bg-gray-300"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={localValues.availableDays.length === 0}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
PlanParameters.propTypes = {
|
||||
values: PropTypes.shape({
|
||||
duration: PropTypes.number,
|
||||
weeklyHours: PropTypes.number,
|
||||
difficulty: PropTypes.string,
|
||||
availableDays: PropTypes.arrayOf(PropTypes.string)
|
||||
}).isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onBack: PropTypes.func.isRequired,
|
||||
onNext: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default PlanParameters;
|
||||
@@ -1,93 +1,104 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import axios from 'axios';
|
||||
import WorkoutCard from './WorkoutCard';
|
||||
import EditWorkoutModal from './EditWorkoutModal';
|
||||
|
||||
const PlanTimeline = ({ planId }) => {
|
||||
const PlanTimeline = ({ plan, mode = 'view' }) => {
|
||||
const { apiKey } = useAuth();
|
||||
const [evolution, setEvolution] = useState([]);
|
||||
const [selectedVersion, setSelectedVersion] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentPlan, setCurrentPlan] = useState(plan?.jsonb_plan);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [selectedWorkout, setSelectedWorkout] = useState(null);
|
||||
const [selectedWeek, setSelectedWeek] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchEvolution = async () => {
|
||||
try {
|
||||
const response = await axios.get(`/api/plans/${planId}/evolution`, {
|
||||
const handleWorkoutUpdate = async (updatedWorkout) => {
|
||||
try {
|
||||
const newPlan = { ...currentPlan };
|
||||
newPlan.weeks[selectedWeek].workouts = newPlan.weeks[selectedWeek].workouts.map(w =>
|
||||
w.day === updatedWorkout.day ? updatedWorkout : w
|
||||
);
|
||||
|
||||
if (mode === 'edit') {
|
||||
await axios.put(`/api/plans/${plan.id}`, newPlan, {
|
||||
headers: { 'X-API-Key': apiKey }
|
||||
});
|
||||
setEvolution(response.data.evolution_history);
|
||||
setSelectedVersion(response.data.current_version);
|
||||
} catch (error) {
|
||||
console.error('Error fetching plan evolution:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (planId) {
|
||||
fetchEvolution();
|
||||
|
||||
setCurrentPlan(newPlan);
|
||||
setShowEditModal(false);
|
||||
} catch (error) {
|
||||
console.error('Error updating workout:', error);
|
||||
}
|
||||
}, [planId, apiKey]);
|
||||
|
||||
if (loading) return <div>Loading plan history...</div>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<h3 className="text-xl font-semibold mb-4">Plan Evolution</h3>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="md:w-1/3 space-y-4">
|
||||
{evolution.map((version, idx) => (
|
||||
<div
|
||||
key={version.version}
|
||||
onClick={() => setSelectedVersion(version)}
|
||||
className={`p-4 border-l-4 cursor-pointer transition-colors ${
|
||||
selectedVersion?.version === version.version
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium">v{version.version}</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{formatDistanceToNow(new Date(version.created_at))} ago
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-xl font-semibold">
|
||||
{currentPlan?.plan_overview?.focus} Training Plan
|
||||
</h3>
|
||||
<div className="text-gray-600">
|
||||
{currentPlan?.plan_overview?.duration_weeks} weeks •
|
||||
{currentPlan?.plan_overview?.weekly_hours} hrs/week
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{currentPlan?.weeks?.map((week, weekIndex) => (
|
||||
<div key={weekIndex} className="border-l-2 border-blue-100 pl-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="font-medium">
|
||||
Week {weekIndex + 1}: {week.focus}
|
||||
</h4>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-2 w-24 bg-gray-200 rounded-full">
|
||||
<div
|
||||
className="h-full bg-blue-600 rounded-full"
|
||||
style={{ width: `${(weekIndex + 1) / currentPlan.plan_overview.duration_weeks * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">
|
||||
{week.workouts.length} workouts
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{version.trigger || 'Initial version'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedVersion && (
|
||||
<div className="md:w-2/3 p-4 bg-gray-50 rounded-md">
|
||||
<h4 className="font-medium mb-4">
|
||||
Version {selectedVersion.version} Details
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<p>
|
||||
<span className="font-medium">Created:</span>{' '}
|
||||
{new Date(selectedVersion.created_at).toLocaleString()}
|
||||
</p>
|
||||
{selectedVersion.changes_summary && (
|
||||
<p>
|
||||
<span className="font-medium">Changes:</span>{' '}
|
||||
{selectedVersion.changes_summary}
|
||||
</p>
|
||||
)}
|
||||
{selectedVersion.parent_plan_id && (
|
||||
<p>
|
||||
<span className="font-medium">Parent Version:</span>{' '}
|
||||
v{selectedVersion.parent_plan_id}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{week.workouts.map((workout, workoutIndex) => (
|
||||
<WorkoutCard
|
||||
key={`${weekIndex}-${workoutIndex}`}
|
||||
workout={workout}
|
||||
onEdit={() => {
|
||||
setSelectedWeek(weekIndex);
|
||||
setSelectedWorkout(workout);
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
editable={mode === 'edit'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showEditModal && (
|
||||
<EditWorkoutModal
|
||||
workout={selectedWorkout}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
onSave={handleWorkoutUpdate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
PlanTimeline.propTypes = {
|
||||
plan: PropTypes.shape({
|
||||
id: PropTypes.number,
|
||||
jsonb_plan: PropTypes.object
|
||||
}),
|
||||
mode: PropTypes.oneOf(['view', 'edit'])
|
||||
};
|
||||
|
||||
export default PlanTimeline;
|
||||
45
frontend/src/components/plans/WorkoutCard.jsx
Normal file
45
frontend/src/components/plans/WorkoutCard.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const WorkoutCard = ({ workout, onEdit, editable }) => {
|
||||
return (
|
||||
<div className="border rounded-lg p-4 bg-white shadow-sm">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h5 className="font-medium capitalize">{workout?.type?.replace('_', ' ') || 'Workout'}</h5>
|
||||
{editable && (
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="text-blue-600 hover:text-blue-800 text-sm"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 space-y-1">
|
||||
<div>Duration: {workout?.duration_minutes || 0} minutes</div>
|
||||
<div>Intensity: {workout?.intensity || 'N/A'}</div>
|
||||
{workout?.description && (
|
||||
<div className="mt-2 text-gray-700">{workout.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
WorkoutCard.propTypes = {
|
||||
workout: PropTypes.shape({
|
||||
type: PropTypes.string,
|
||||
duration_minutes: PropTypes.number,
|
||||
intensity: PropTypes.string,
|
||||
description: PropTypes.string
|
||||
}),
|
||||
onEdit: PropTypes.func,
|
||||
editable: PropTypes.bool
|
||||
};
|
||||
|
||||
WorkoutCard.defaultProps = {
|
||||
workout: {},
|
||||
onEdit: () => {},
|
||||
editable: false
|
||||
};
|
||||
|
||||
export default WorkoutCard;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import GoalSelector from '../GoalSelector';
|
||||
|
||||
describe('GoalSelector', () => {
|
||||
const mockOnSelect = jest.fn();
|
||||
const mockOnNext = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
render(<GoalSelector goals={[]} onSelect={mockOnSelect} onNext={mockOnNext} />);
|
||||
});
|
||||
|
||||
it('allows selection of predefined goals', () => {
|
||||
const enduranceButton = screen.getByText('Build Endurance');
|
||||
fireEvent.click(enduranceButton);
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(['endurance']);
|
||||
});
|
||||
|
||||
it('handles custom goal input', () => {
|
||||
const addButton = screen.getByText('Add Custom Goal');
|
||||
fireEvent.click(addButton);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter custom goal');
|
||||
fireEvent.change(input, { target: { value: 'Custom Goal' } });
|
||||
|
||||
const addCustomButton = screen.getByText('Add');
|
||||
fireEvent.click(addCustomButton);
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(['Custom Goal']);
|
||||
});
|
||||
|
||||
it('disables next button with no goals selected', () => {
|
||||
expect(screen.getByText('Next')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
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'))
|
||||
})
|
||||
})
|
||||
121
frontend/src/components/rules/RuleEditor.jsx
Normal file
121
frontend/src/components/rules/RuleEditor.jsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { parseRule } from '../../services/ruleService';
|
||||
|
||||
const RuleEditor = ({ value, onChange, onParse }) => {
|
||||
const [charCount, setCharCount] = useState(0);
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
const [showTemplates, setShowTemplates] = useState(false);
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
const textarea = document.getElementById('ruleEditor');
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
}, [value]);
|
||||
|
||||
// Enhanced validation
|
||||
useEffect(() => {
|
||||
const count = value.length;
|
||||
setCharCount(count);
|
||||
|
||||
const hasRequiredKeywords = /(maximum|minimum|at least|no more than)/i.test(value);
|
||||
const hasNumbersWithUnits = /\d+\s+(rides?|hours?|days?|weeks?)/i.test(value);
|
||||
const hasConstraints = /(between|recovery|interval|duration)/i.test(value);
|
||||
|
||||
setIsValid(
|
||||
count <= 5000 &&
|
||||
count >= 10 &&
|
||||
hasRequiredKeywords &&
|
||||
hasNumbersWithUnits &&
|
||||
hasConstraints
|
||||
);
|
||||
}, [value]);
|
||||
|
||||
const handleParse = async () => {
|
||||
try {
|
||||
const { data } = await parseRule(value);
|
||||
onParse(data.jsonRules);
|
||||
} catch (err) {
|
||||
console.error('Parsing failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const templateSuggestions = [
|
||||
'Maximum 4 rides per week with at least one rest day between hard workouts',
|
||||
'Long rides limited to 3 hours maximum during weekdays',
|
||||
'No outdoor rides when temperature drops below 0°C',
|
||||
'Interval sessions limited to twice weekly with 48h recovery'
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg p-4 bg-white shadow-sm">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold">Natural Language Editor</h2>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowTemplates(!showTemplates)}
|
||||
className="px-3 py-1 bg-blue-100 text-blue-800 rounded-lg hover:bg-blue-200"
|
||||
>
|
||||
Templates
|
||||
</button>
|
||||
{showTemplates && (
|
||||
<div className="absolute right-0 mt-2 w-64 bg-white border rounded-lg shadow-lg z-10">
|
||||
{templateSuggestions.map((template, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-3 hover:bg-gray-50 cursor-pointer border-b"
|
||||
onClick={() => {
|
||||
onChange(template);
|
||||
setShowTemplates(false);
|
||||
}}
|
||||
>
|
||||
{template}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
id="ruleEditor"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={`w-full p-3 border rounded-lg focus:ring-2 ${
|
||||
isValid ? 'focus:ring-blue-500' : 'focus:ring-red-500'
|
||||
}`}
|
||||
placeholder="Enter your training rules in natural language..."
|
||||
rows="5"
|
||||
/>
|
||||
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
{charCount}/5000 characters •{' '}
|
||||
<span className={isValid ? 'text-green-600' : 'text-red-600'}>
|
||||
{isValid ? 'Valid' : 'Invalid input'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleParse}
|
||||
disabled={!isValid}
|
||||
className={`px-4 py-2 rounded-lg ${
|
||||
isValid
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Parse Rules
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RuleEditor.propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onParse: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default RuleEditor;
|
||||
46
frontend/src/components/rules/RulePreview.jsx
Normal file
46
frontend/src/components/rules/RulePreview.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { JSONTree } from 'react-json-tree';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const RulePreview = ({ rules, onSave, isSaving }) => {
|
||||
return (
|
||||
<div className="border rounded-lg p-4 bg-white shadow-sm">
|
||||
<h2 className="text-xl font-semibold mb-4">Rule Configuration Preview</h2>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden mb-4">
|
||||
{rules ? (
|
||||
<JSONTree
|
||||
data={rules}
|
||||
theme="harmonic"
|
||||
hideRoot={false}
|
||||
shouldExpandNodeInitially={() => true}
|
||||
style={{ padding: '1rem' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-gray-500">
|
||||
Parsed rules will appear here...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={!rules || isSaving}
|
||||
className={`w-full py-2 rounded-lg font-medium ${
|
||||
rules && !isSaving
|
||||
? 'bg-green-600 text-white hover:bg-green-700'
|
||||
: 'bg-gray-200 text-gray-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Rule Set'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RulePreview.propTypes = {
|
||||
rules: PropTypes.object,
|
||||
onSave: PropTypes.func.isRequired,
|
||||
isSaving: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default RulePreview;
|
||||
113
frontend/src/components/rules/RulesList.jsx
Normal file
113
frontend/src/components/rules/RulesList.jsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
const RulesList = ({ ruleSets, onSelect }) => {
|
||||
const [selectedSet, setSelectedSet] = useState(null);
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg p-4 bg-white shadow-sm">
|
||||
<h2 className="text-xl font-semibold mb-4">Saved Rule Sets</h2>
|
||||
|
||||
{ruleSets.length === 0 ? (
|
||||
<div className="text-gray-500 text-center py-4">
|
||||
No rule sets saved yet. Create one using the editor above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-left border-b">
|
||||
<th className="pb-2">Name</th>
|
||||
<th className="pb-2">Version</th>
|
||||
<th className="pb-2">Status</th>
|
||||
<th className="pb-2">Created</th>
|
||||
<th className="pb-2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ruleSets.map((set) => (
|
||||
<tr key={set.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-3">{set.name || 'Untitled Rules'}</td>
|
||||
<td className="py-3">v{set.version}</td>
|
||||
<td className="py-3">
|
||||
<span className={`px-2 py-1 rounded-full text-sm ${
|
||||
set.active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{set.active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
{format(new Date(set.created_at), 'MMM dd, yyyy')}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedSet(set);
|
||||
onSelect(set);
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
View/Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Version History Modal */}
|
||||
{selectedSet && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<div className="bg-white rounded-lg p-6 max-w-2xl w-full">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{selectedSet.name} Version History
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setSelectedSet(null)}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{selectedSet.history?.map((version) => (
|
||||
<div
|
||||
key={version.version}
|
||||
className="p-3 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<span className="font-medium">v{version.version}</span>
|
||||
<span className="text-sm text-gray-500 ml-2">
|
||||
{format(new Date(version.created_at), 'MMM dd, yyyy HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onSelect(version)}
|
||||
className="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RulesList.propTypes = {
|
||||
ruleSets: PropTypes.array.isRequired,
|
||||
onSelect: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default RulesList;
|
||||
45
frontend/src/components/rules/__tests__/RuleEditor.test.jsx
Normal file
45
frontend/src/components/rules/__tests__/RuleEditor.test.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import RuleEditor from '../RuleEditor';
|
||||
|
||||
describe('RuleEditor', () => {
|
||||
const mockOnChange = jest.fn();
|
||||
const mockOnParse = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders editor with basic functionality', () => {
|
||||
render(<RuleEditor value="" onChange={mockOnChange} onParse={mockOnParse} />);
|
||||
|
||||
expect(screen.getByText('Natural Language Editor')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Enter your training rules in natural language...')).toBeInTheDocument();
|
||||
expect(screen.getByText('Parse Rules')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows character count and validation status', () => {
|
||||
const { rerender } = render(
|
||||
<RuleEditor value="Valid rule text" onChange={mockOnChange} onParse={mockOnParse} />
|
||||
);
|
||||
|
||||
expect(screen.getByText(/0\/5000 characters/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Valid')).toBeInTheDocument();
|
||||
|
||||
rerender(<RuleEditor value="Short" onChange={mockOnChange} onParse={mockOnParse} />);
|
||||
expect(screen.getByText('Invalid input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows template suggestions when clicked', async () => {
|
||||
render(<RuleEditor value="" onChange={mockOnChange} onParse={mockOnParse} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Templates'));
|
||||
expect(screen.getByText('Maximum 4 rides per week with at least one rest day between hard workouts')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('triggers parse on button click', () => {
|
||||
render(<RuleEditor value="Valid rule text" onChange={mockOnChange} onParse={mockOnParse} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Parse Rules'));
|
||||
expect(mockOnParse).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
20
frontend/src/components/rules/__tests__/RulePreview.test.jsx
Normal file
20
frontend/src/components/rules/__tests__/RulePreview.test.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import RulePreview from '../RulePreview';
|
||||
|
||||
describe('RulePreview', () => {
|
||||
const mockRules = {
|
||||
maxRides: 4,
|
||||
minRestDays: 1
|
||||
};
|
||||
|
||||
test('renders preview with rules', () => {
|
||||
render(<RulePreview rules={mockRules} />);
|
||||
expect(screen.getByText('Rule Configuration Preview')).toBeInTheDocument();
|
||||
expect(screen.getByText('maxRides')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows placeholder when no rules', () => {
|
||||
render(<RulePreview rules={null} />);
|
||||
expect(screen.getByText('Parsed rules will appear here...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
32
frontend/src/components/rules/__tests__/RulesList.test.jsx
Normal file
32
frontend/src/components/rules/__tests__/RulesList.test.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import RulesList from '../RulesList';
|
||||
|
||||
const mockRuleSets = [{
|
||||
id: 1,
|
||||
name: 'Winter Rules',
|
||||
version: 2,
|
||||
active: true,
|
||||
created_at: '2024-01-15T11:00:00Z',
|
||||
history: [
|
||||
{ version: 1, created_at: '2024-01-01T09:00:00Z' },
|
||||
{ version: 2, created_at: '2024-01-15T11:00:00Z' }
|
||||
]
|
||||
}];
|
||||
|
||||
describe('RulesList', () => {
|
||||
test('renders rule sets table', () => {
|
||||
render(<RulesList ruleSets={mockRuleSets} />);
|
||||
|
||||
expect(screen.getByText('Winter Rules')).toBeInTheDocument();
|
||||
expect(screen.getByText('v2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Active')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows version history modal', () => {
|
||||
render(<RulesList ruleSets={mockRuleSets} />);
|
||||
|
||||
fireEvent.click(screen.getByText('View/Edit'));
|
||||
expect(screen.getByText('Winter Rules Version History')).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/v\d/)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
38
frontend/src/components/ui/ProgressTracker.jsx
Normal file
38
frontend/src/components/ui/ProgressTracker.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const ProgressTracker = ({ currentStep, totalSteps }) => {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
{[...Array(totalSteps)].map((_, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
index + 1 <= currentStep
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-600'
|
||||
}`}>
|
||||
{index + 1}
|
||||
</div>
|
||||
{index < totalSteps - 1 && (
|
||||
<div className={`w-16 h-1 ${
|
||||
index + 1 < currentStep
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-200'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 text-center">
|
||||
Step {currentStep} of {totalSteps}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProgressTracker.propTypes = {
|
||||
currentStep: PropTypes.number.isRequired,
|
||||
totalSteps: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
export default ProgressTracker;
|
||||
85
frontend/src/pages/PlanGeneration.jsx
Normal file
85
frontend/src/pages/PlanGeneration.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import GoalSelector from '../components/plans/GoalSelector';
|
||||
import PlanParameters from '../components/plans/PlanParameters';
|
||||
import { generatePlan } from '../services/planService';
|
||||
import ProgressTracker from '../components/ui/ProgressTracker';
|
||||
|
||||
const PlanGeneration = () => {
|
||||
const { apiKey } = useAuth();
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState(1);
|
||||
const [goals, setGoals] = useState([]);
|
||||
const [rules, setRules] = useState([]);
|
||||
const [params, setParams] = useState({
|
||||
duration: 4,
|
||||
weeklyHours: 8,
|
||||
availableDays: []
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleGenerate = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const plan = await generatePlan(apiKey, {
|
||||
goals,
|
||||
ruleIds: rules,
|
||||
...params
|
||||
});
|
||||
router.push(`/plans/${plan.id}/preview`);
|
||||
} catch (err) {
|
||||
setError('Failed to generate plan. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<ProgressTracker currentStep={step} totalSteps={3} />
|
||||
|
||||
{step === 1 && (
|
||||
<GoalSelector
|
||||
goals={goals}
|
||||
onSelect={setGoals}
|
||||
onNext={() => setStep(2)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<PlanParameters
|
||||
values={params}
|
||||
onChange={setParams}
|
||||
onBack={() => setStep(1)}
|
||||
onNext={() => setStep(3)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<h2 className="text-2xl font-bold mb-4">Review and Generate</h2>
|
||||
<div className="mb-6">
|
||||
<h3 className="font-semibold mb-2">Selected Goals:</h3>
|
||||
<ul className="list-disc pl-5">
|
||||
{goals.map((goal, index) => (
|
||||
<li key={index}>{goal}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={loading}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400"
|
||||
>
|
||||
{loading ? 'Generating...' : 'Generate Plan'}
|
||||
</button>
|
||||
{error && <p className="text-red-500 mt-2">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanGeneration;
|
||||
@@ -1,25 +1,72 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import FileUpload from '../components/routes/FileUpload';
|
||||
import RouteList from '../components/routes/RouteList';
|
||||
import RouteFilter from '../components/routes/RouteFilter';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
|
||||
const RoutesPage = () => {
|
||||
const { apiKey } = useAuth();
|
||||
|
||||
// Handle build-time case where apiKey is undefined
|
||||
if (typeof window === 'undefined') {
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-8">Routes</h1>
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<p className="text-gray-600">Loading route management...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const [routes, setRoutes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [filters, setFilters] = useState({
|
||||
searchQuery: '',
|
||||
minDistance: 0,
|
||||
maxDistance: 500,
|
||||
difficulty: 'all',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRoutes = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/routes', {
|
||||
headers: { 'X-API-Key': apiKey }
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch routes');
|
||||
|
||||
const data = await response.json();
|
||||
setRoutes(data.routes);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRoutes();
|
||||
}, [apiKey]);
|
||||
|
||||
const filteredRoutes = routes.filter(route => {
|
||||
const matchesSearch = route.name.toLowerCase().includes(filters.searchQuery.toLowerCase());
|
||||
const matchesDistance = route.distance >= filters.minDistance &&
|
||||
route.distance <= filters.maxDistance;
|
||||
const matchesDifficulty = filters.difficulty === 'all' ||
|
||||
route.difficulty === filters.difficulty;
|
||||
return matchesSearch && matchesDistance && matchesDifficulty;
|
||||
});
|
||||
|
||||
const handleUploadSuccess = (newRoute) => {
|
||||
setRoutes(prev => [...prev, newRoute]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-8">Routes</h1>
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<p className="text-gray-600">Route management will be displayed here</p>
|
||||
<div className="space-y-8">
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<RouteFilter filters={filters} onFilterChange={setFilters} />
|
||||
<FileUpload onUploadSuccess={handleUploadSuccess} />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<LoadingSpinner />
|
||||
) : error ? (
|
||||
<div className="text-red-600 bg-red-50 p-4 rounded-md">{error}</div>
|
||||
) : (
|
||||
<RouteList routes={filteredRoutes} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
85
frontend/src/pages/Rules.jsx
Normal file
85
frontend/src/pages/Rules.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import RuleEditor from '../components/rules/RuleEditor';
|
||||
import RulePreview from '../components/rules/RulePreview';
|
||||
import RulesList from '../components/rules/RulesList';
|
||||
import { getRuleSets, createRuleSet, parseRule } from '../services/ruleService';
|
||||
|
||||
const RulesPage = () => {
|
||||
const [ruleText, setRuleText] = useState('');
|
||||
const [parsedRules, setParsedRules] = useState(null);
|
||||
const [ruleSets, setRuleSets] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Load initial rule sets
|
||||
useEffect(() => {
|
||||
const loadRuleSets = async () => {
|
||||
try {
|
||||
const { data } = await getRuleSets();
|
||||
setRuleSets(data);
|
||||
} catch (err) {
|
||||
setError('Failed to load rule sets');
|
||||
}
|
||||
};
|
||||
loadRuleSets();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await createRuleSet({
|
||||
naturalLanguage: ruleText,
|
||||
jsonRules: parsedRules
|
||||
});
|
||||
setRuleText('');
|
||||
setParsedRules(null);
|
||||
// Refresh rule sets list
|
||||
const { data } = await getRuleSets();
|
||||
setRuleSets(data);
|
||||
} catch (err) {
|
||||
setError('Failed to save rule set');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6">Training Rules Management</h1>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-6 mb-8">
|
||||
<div className="flex-1">
|
||||
<RuleEditor
|
||||
value={ruleText}
|
||||
onChange={setRuleText}
|
||||
onParse={setParsedRules}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<RulePreview
|
||||
rules={parsedRules}
|
||||
onSave={handleSave}
|
||||
isSaving={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RulesList
|
||||
ruleSets={ruleSets}
|
||||
onSelect={(set) => {
|
||||
setRuleText(set.naturalLanguage);
|
||||
setParsedRules(set.jsonRules);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RulesPage;
|
||||
@@ -6,7 +6,9 @@ export default function Home() {
|
||||
useEffect(() => {
|
||||
const checkBackendHealth = async () => {
|
||||
try {
|
||||
const response = await fetch('http://backend:8000/health');
|
||||
// Use the API URL from environment variables
|
||||
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
const response = await fetch(`${apiUrl}/health`);
|
||||
const data = await response.json();
|
||||
setHealthStatus(data.status);
|
||||
} catch (error) {
|
||||
|
||||
47
frontend/src/services/planService.js
Normal file
47
frontend/src/services/planService.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export const generatePlan = async (apiKey, planData) => {
|
||||
try {
|
||||
const response = await axios.post('/api/plans/generate', planData, {
|
||||
headers: { 'X-API-Key': apiKey }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.error || 'Failed to generate plan');
|
||||
}
|
||||
};
|
||||
|
||||
export const getPlanPreview = async (apiKey, planId) => {
|
||||
try {
|
||||
const response = await axios.get(`/api/plans/${planId}/preview`, {
|
||||
headers: { 'X-API-Key': apiKey }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.error || 'Failed to load plan preview');
|
||||
}
|
||||
};
|
||||
|
||||
export const pollPlanStatus = async (apiKey, planId, interval = 2000) => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const response = await axios.get(`/api/plans/${planId}/status`, {
|
||||
headers: { 'X-API-Key': apiKey }
|
||||
});
|
||||
|
||||
if (response.data.status === 'completed') {
|
||||
resolve(response.data.plan);
|
||||
} else if (response.data.status === 'failed') {
|
||||
reject(new Error('Plan generation failed'));
|
||||
} else {
|
||||
setTimeout(checkStatus, interval);
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
await checkStatus();
|
||||
});
|
||||
};
|
||||
64
frontend/src/services/routeService.js
Normal file
64
frontend/src/services/routeService.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import axios from 'axios';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api'
|
||||
});
|
||||
|
||||
// Request interceptor to add API key header
|
||||
api.interceptors.request.use(config => {
|
||||
const { apiKey } = useAuth.getState();
|
||||
if (apiKey) {
|
||||
config.headers['X-API-Key'] = apiKey;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
export const uploadRoute = async (formData) => {
|
||||
try {
|
||||
const response = await api.post('/routes/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.error || 'Failed to upload route');
|
||||
}
|
||||
};
|
||||
|
||||
export const getRouteDetails = async (routeId) => {
|
||||
try {
|
||||
const response = await api.get(`/routes/${routeId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.error || 'Failed to fetch route details');
|
||||
}
|
||||
};
|
||||
|
||||
export const saveRouteMetadata = async (routeId, metadata) => {
|
||||
try {
|
||||
const response = await api.put(`/routes/${routeId}/metadata`, metadata);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.error || 'Failed to save metadata');
|
||||
}
|
||||
};
|
||||
|
||||
export const saveRouteSections = async (routeId, sections) => {
|
||||
try {
|
||||
const response = await api.post(`/routes/${routeId}/sections`, { sections });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.error || 'Failed to save sections');
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteRoute = async (routeId) => {
|
||||
try {
|
||||
const response = await api.delete(`/routes/${routeId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.error || 'Failed to delete route');
|
||||
}
|
||||
};
|
||||
55
frontend/src/services/ruleService.js
Normal file
55
frontend/src/services/ruleService.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = process.env.REACT_APP_API_URL + '/rules';
|
||||
const API_KEY = process.env.REACT_APP_API_KEY;
|
||||
|
||||
const apiClient = axios.create({
|
||||
headers: {
|
||||
'X-API-Key': API_KEY,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
export const getRuleSets = async () => {
|
||||
try {
|
||||
const response = await apiClient.get(API_URL);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error('Failed to fetch rule sets');
|
||||
}
|
||||
};
|
||||
|
||||
export const createRuleSet = async (ruleData) => {
|
||||
try {
|
||||
const response = await apiClient.post(API_URL, ruleData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.message || 'Failed to create rule set');
|
||||
}
|
||||
};
|
||||
|
||||
export const updateRuleSet = async (id, ruleData) => {
|
||||
try {
|
||||
const response = await apiClient.put(`${API_URL}/${id}`, ruleData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.message || 'Failed to update rule set');
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteRuleSet = async (id) => {
|
||||
try {
|
||||
await apiClient.delete(`${API_URL}/${id}`);
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.message || 'Failed to delete rule set');
|
||||
}
|
||||
};
|
||||
|
||||
export const parseRule = async (text) => {
|
||||
try {
|
||||
const response = await apiClient.post(`${API_URL}/parse`, { text });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.message || 'Failed to parse rules');
|
||||
}
|
||||
};
|
||||
970
package-lock.json
generated
970
package-lock.json
generated
@@ -5,9 +5,142 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"gpxparser": "^3.0.8",
|
||||
"leaflet": "^1.9.4",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-toastify": "^11.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
|
||||
"integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/abab": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
|
||||
"integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==",
|
||||
"deprecated": "Use your platform's native atob() and btoa() methods instead"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
|
||||
"integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-globals": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-4.3.4.tgz",
|
||||
"integrity": "sha512-clfQEh21R+D0leSbUdWf3OcfqyaCSAQ8Ryq00bofSekfr9W8u1jyYZo6ir0xu9Gtcf7BjcHJpnbZH7JOCpP60A==",
|
||||
"dependencies": {
|
||||
"acorn": "^6.0.1",
|
||||
"acorn-walk": "^6.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-globals/node_modules/acorn": {
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz",
|
||||
"integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-walk": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-6.2.0.tgz",
|
||||
"integrity": "sha512-7evsyfH1cLOCdAzZAd43Cic04yKydNx0cF+7tiA19p1XnLLPU4dpCQOqpjqwokFe//vS0QqfqqjCS2JkiIs0cA==",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/array-equal": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/array-equal/-/array-equal-1.0.2.tgz",
|
||||
"integrity": "sha512-gUHx76KtnhEgB3HOuFYiCm3FIdEs6ocM2asHvNTkfu/Y09qQVrrVVaOKENmS2KkSaGoxgXNqC+ZVtR/n0MOkSA==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
||||
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
|
||||
"dependencies": {
|
||||
"safer-buffer": "~2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/assert-plus": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
|
||||
"integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
},
|
||||
"node_modules/aws-sign2": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
|
||||
"integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/aws4": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz",
|
||||
"integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="
|
||||
},
|
||||
"node_modules/bcrypt-pbkdf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
|
||||
"dependencies": {
|
||||
"tweetnacl": "^0.14.3"
|
||||
}
|
||||
},
|
||||
"node_modules/browser-process-hrtime": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz",
|
||||
"integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow=="
|
||||
},
|
||||
"node_modules/caseless": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
|
||||
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -16,25 +149,504 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
|
||||
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
|
||||
"peer": true,
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
|
||||
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="
|
||||
},
|
||||
"node_modules/cssom": {
|
||||
"version": "0.4.4",
|
||||
"resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz",
|
||||
"integrity": "sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw=="
|
||||
},
|
||||
"node_modules/cssstyle": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz",
|
||||
"integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==",
|
||||
"dependencies": {
|
||||
"cssom": "~0.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cssstyle/node_modules/cssom": {
|
||||
"version": "0.3.8",
|
||||
"resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz",
|
||||
"integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg=="
|
||||
},
|
||||
"node_modules/dashdash": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
|
||||
"integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==",
|
||||
"dependencies": {
|
||||
"assert-plus": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-1.1.0.tgz",
|
||||
"integrity": "sha512-YTWYI9se1P55u58gL5GkQHW4P6VJBJ5iBT+B5a7i2Tjadhv52paJG0qHX4A0OR6/t52odI64KP2YvFpkDOi3eQ==",
|
||||
"dependencies": {
|
||||
"abab": "^2.0.0",
|
||||
"whatwg-mimetype": "^2.2.0",
|
||||
"whatwg-url": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/domexception": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz",
|
||||
"integrity": "sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug==",
|
||||
"deprecated": "Use your platform's native DOMException instead",
|
||||
"dependencies": {
|
||||
"webidl-conversions": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/ecc-jsbn": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
|
||||
"integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==",
|
||||
"dependencies": {
|
||||
"jsbn": "~0.1.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/escodegen": {
|
||||
"version": "1.14.3",
|
||||
"resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz",
|
||||
"integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==",
|
||||
"dependencies": {
|
||||
"esprima": "^4.0.1",
|
||||
"estraverse": "^4.2.0",
|
||||
"esutils": "^2.0.2",
|
||||
"optionator": "^0.8.1"
|
||||
},
|
||||
"bin": {
|
||||
"escodegen": "bin/escodegen.js",
|
||||
"esgenerate": "bin/esgenerate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"source-map": "~0.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
"esvalidate": "bin/esvalidate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/estraverse": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
|
||||
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esutils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
|
||||
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
|
||||
"peer": true,
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
|
||||
},
|
||||
"node_modules/extsprintf": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
|
||||
"integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==",
|
||||
"engines": [
|
||||
"node >=0.6.0"
|
||||
]
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="
|
||||
},
|
||||
"node_modules/fast-levenshtein": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
|
||||
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="
|
||||
},
|
||||
"node_modules/forever-agent": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
|
||||
"integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
|
||||
"integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.6",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/getpass": {
|
||||
"version": "0.1.7",
|
||||
"resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
|
||||
"integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==",
|
||||
"dependencies": {
|
||||
"assert-plus": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gpxparser": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/gpxparser/-/gpxparser-3.0.8.tgz",
|
||||
"integrity": "sha512-rXKrDQoXUHz7wZ+Q/C9EbzGNaRLGeEC3uT/KGMPOj3pCHXEJfKWYxVsd+WjVEyivuVsjJib7eR1H/BBO8USUgA==",
|
||||
"dependencies": {
|
||||
"jsdom": "^15.2.1",
|
||||
"jsdom-global": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/har-schema": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
|
||||
"integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/har-validator": {
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz",
|
||||
"integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==",
|
||||
"deprecated": "this library is no longer supported",
|
||||
"dependencies": {
|
||||
"ajv": "^6.12.3",
|
||||
"har-schema": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-1.0.2.tgz",
|
||||
"integrity": "sha512-71lZziiDnsuabfdYiUeWdCVyKuqwWi23L8YeIgV9jSSZHCtb6wB1BKWooH7L3tn4/FuZJMVWyNaIDr4RGmaSYw==",
|
||||
"dependencies": {
|
||||
"whatwg-encoding": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/http-signature": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz",
|
||||
"integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==",
|
||||
"dependencies": {
|
||||
"assert-plus": "^1.0.0",
|
||||
"jsprim": "^1.2.2",
|
||||
"sshpk": "^1.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8",
|
||||
"npm": ">=1.3.7"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-regex": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz",
|
||||
"integrity": "sha512-58yWmlHpp7VYfcdTwMTvwMmqx/Elfxjd9RXTDyMsbL7lLWmhMylLEqiYVLKuLzOZqVgiWXD9MfR62Vv89VRxkw==",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/is-typedarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
|
||||
"integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA=="
|
||||
},
|
||||
"node_modules/isstream": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
|
||||
"integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g=="
|
||||
},
|
||||
"node_modules/jsbn": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
|
||||
"integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg=="
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "15.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-15.2.1.tgz",
|
||||
"integrity": "sha512-fAl1W0/7T2G5vURSyxBzrJ1LSdQn6Tr5UX/xD4PXDx/PDgwygedfW6El/KIj3xJ7FU61TTYnc/l/B7P49Eqt6g==",
|
||||
"dependencies": {
|
||||
"abab": "^2.0.0",
|
||||
"acorn": "^7.1.0",
|
||||
"acorn-globals": "^4.3.2",
|
||||
"array-equal": "^1.0.0",
|
||||
"cssom": "^0.4.1",
|
||||
"cssstyle": "^2.0.0",
|
||||
"data-urls": "^1.1.0",
|
||||
"domexception": "^1.0.1",
|
||||
"escodegen": "^1.11.1",
|
||||
"html-encoding-sniffer": "^1.0.2",
|
||||
"nwsapi": "^2.2.0",
|
||||
"parse5": "5.1.0",
|
||||
"pn": "^1.1.0",
|
||||
"request": "^2.88.0",
|
||||
"request-promise-native": "^1.0.7",
|
||||
"saxes": "^3.1.9",
|
||||
"symbol-tree": "^3.2.2",
|
||||
"tough-cookie": "^3.0.1",
|
||||
"w3c-hr-time": "^1.0.1",
|
||||
"w3c-xmlserializer": "^1.1.2",
|
||||
"webidl-conversions": "^4.0.2",
|
||||
"whatwg-encoding": "^1.0.5",
|
||||
"whatwg-mimetype": "^2.3.0",
|
||||
"whatwg-url": "^7.0.0",
|
||||
"ws": "^7.0.0",
|
||||
"xml-name-validator": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.1.1"
|
||||
"canvas": "^2.5.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom-global": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jsdom-global/-/jsdom-global-3.0.2.tgz",
|
||||
"integrity": "sha512-t1KMcBkz/pT5JrvcJbpUR2u/w1kO9jXctaaGJ0vZDzwFnIvGWw9IDSRciT83kIs8Bnw4qpOl8bQK08V01YgMPg==",
|
||||
"peerDependencies": {
|
||||
"jsdom": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
|
||||
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="
|
||||
},
|
||||
"node_modules/json-stringify-safe": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
|
||||
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="
|
||||
},
|
||||
"node_modules/jsprim": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz",
|
||||
"integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==",
|
||||
"dependencies": {
|
||||
"assert-plus": "1.0.0",
|
||||
"extsprintf": "1.3.0",
|
||||
"json-schema": "0.4.0",
|
||||
"verror": "1.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz",
|
||||
"integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==",
|
||||
"dependencies": {
|
||||
"prelude-ls": "~1.1.2",
|
||||
"type-check": "~0.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
},
|
||||
"node_modules/lodash.sortby": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
|
||||
"integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/nwsapi": {
|
||||
"version": "2.2.22",
|
||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz",
|
||||
"integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ=="
|
||||
},
|
||||
"node_modules/oauth-sign": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz",
|
||||
"integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz",
|
||||
"integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==",
|
||||
"dependencies": {
|
||||
"deep-is": "~0.1.3",
|
||||
"fast-levenshtein": "~2.0.6",
|
||||
"levn": "~0.3.0",
|
||||
"prelude-ls": "~1.1.2",
|
||||
"type-check": "~0.3.2",
|
||||
"word-wrap": "~1.2.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.0.tgz",
|
||||
"integrity": "sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ=="
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="
|
||||
},
|
||||
"node_modules/pn": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pn/-/pn-1.1.0.tgz",
|
||||
"integrity": "sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA=="
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
|
||||
"integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==",
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/psl": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
|
||||
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/lupomontero"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.5.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz",
|
||||
"integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
|
||||
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
|
||||
"dependencies": {
|
||||
"@react-leaflet/core": "^2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-toastify": {
|
||||
@@ -49,11 +661,335 @@
|
||||
"react-dom": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.26.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
|
||||
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
|
||||
"peer": true
|
||||
"node_modules/request": {
|
||||
"version": "2.88.2",
|
||||
"resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz",
|
||||
"integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==",
|
||||
"deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142",
|
||||
"dependencies": {
|
||||
"aws-sign2": "~0.7.0",
|
||||
"aws4": "^1.8.0",
|
||||
"caseless": "~0.12.0",
|
||||
"combined-stream": "~1.0.6",
|
||||
"extend": "~3.0.2",
|
||||
"forever-agent": "~0.6.1",
|
||||
"form-data": "~2.3.2",
|
||||
"har-validator": "~5.1.3",
|
||||
"http-signature": "~1.2.0",
|
||||
"is-typedarray": "~1.0.0",
|
||||
"isstream": "~0.1.2",
|
||||
"json-stringify-safe": "~5.0.1",
|
||||
"mime-types": "~2.1.19",
|
||||
"oauth-sign": "~0.9.0",
|
||||
"performance-now": "^2.1.0",
|
||||
"qs": "~6.5.2",
|
||||
"safe-buffer": "^5.1.2",
|
||||
"tough-cookie": "~2.5.0",
|
||||
"tunnel-agent": "^0.6.0",
|
||||
"uuid": "^3.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/request-promise-core": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.4.tgz",
|
||||
"integrity": "sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw==",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.19"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"request": "^2.34"
|
||||
}
|
||||
},
|
||||
"node_modules/request-promise-native": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/request-promise-native/-/request-promise-native-1.0.9.tgz",
|
||||
"integrity": "sha512-wcW+sIUiWnKgNY0dqCpOZkUbF/I+YPi+f09JZIDa39Ec+q82CpSYniDp+ISgTTbKmnpJWASeJBPZmoxH84wt3g==",
|
||||
"deprecated": "request-promise-native has been deprecated because it extends the now deprecated request package, see https://github.com/request/request/issues/3142",
|
||||
"dependencies": {
|
||||
"request-promise-core": "1.1.4",
|
||||
"stealthy-require": "^1.1.1",
|
||||
"tough-cookie": "^2.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"request": "^2.34"
|
||||
}
|
||||
},
|
||||
"node_modules/request-promise-native/node_modules/tough-cookie": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
|
||||
"integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
|
||||
"dependencies": {
|
||||
"psl": "^1.1.28",
|
||||
"punycode": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/request/node_modules/tough-cookie": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
|
||||
"integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
|
||||
"dependencies": {
|
||||
"psl": "^1.1.28",
|
||||
"punycode": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "3.1.11",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-3.1.11.tgz",
|
||||
"integrity": "sha512-Ydydq3zC+WYDJK1+gRxRapLIED9PWeSuuS41wqyoRmzvhhh9nc+QQrVMKJYzJFULazeGhzSV0QleN2wD3boh2g==",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sshpk": {
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
|
||||
"integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==",
|
||||
"dependencies": {
|
||||
"asn1": "~0.2.3",
|
||||
"assert-plus": "^1.0.0",
|
||||
"bcrypt-pbkdf": "^1.0.0",
|
||||
"dashdash": "^1.12.0",
|
||||
"ecc-jsbn": "~0.1.1",
|
||||
"getpass": "^0.1.1",
|
||||
"jsbn": "~0.1.0",
|
||||
"safer-buffer": "^2.0.2",
|
||||
"tweetnacl": "~0.14.0"
|
||||
},
|
||||
"bin": {
|
||||
"sshpk-conv": "bin/sshpk-conv",
|
||||
"sshpk-sign": "bin/sshpk-sign",
|
||||
"sshpk-verify": "bin/sshpk-verify"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stealthy-require": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz",
|
||||
"integrity": "sha512-ZnWpYnYugiOVEY5GkcuJK1io5V8QmNYChG62gSit9pQVGErXtrKuPC55ITaVSukmMta5qpMU7vqLt2Lnni4f/g==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-3.0.1.tgz",
|
||||
"integrity": "sha512-yQyJ0u4pZsv9D4clxO69OEjLWYw+jbgspjTue4lTQZLfV0c5l1VmK2y1JK8E9ahdpltPOaAThPcp5nKPUgSnsg==",
|
||||
"dependencies": {
|
||||
"ip-regex": "^2.1.0",
|
||||
"psl": "^1.1.28",
|
||||
"punycode": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
|
||||
"integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==",
|
||||
"dependencies": {
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "0.14.5",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",
|
||||
"integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==",
|
||||
"dependencies": {
|
||||
"prelude-ls": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uri-js": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
||||
"dependencies": {
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
|
||||
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
|
||||
"deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
|
||||
"bin": {
|
||||
"uuid": "bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/verror": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
|
||||
"integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==",
|
||||
"engines": [
|
||||
"node >=0.6.0"
|
||||
],
|
||||
"dependencies": {
|
||||
"assert-plus": "^1.0.0",
|
||||
"core-util-is": "1.0.2",
|
||||
"extsprintf": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-hr-time": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
|
||||
"integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==",
|
||||
"deprecated": "Use your platform's native performance.now() and performance.timeOrigin.",
|
||||
"dependencies": {
|
||||
"browser-process-hrtime": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-1.1.2.tgz",
|
||||
"integrity": "sha512-p10l/ayESzrBMYWRID6xbuCKh2Fp77+sA0doRuGn4tTIMrrZVeqfpKjXHY+oDh3K4nLdPgNwMTVP6Vp4pvqbNg==",
|
||||
"dependencies": {
|
||||
"domexception": "^1.0.1",
|
||||
"webidl-conversions": "^4.0.2",
|
||||
"xml-name-validator": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
|
||||
"integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="
|
||||
},
|
||||
"node_modules/whatwg-encoding": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz",
|
||||
"integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==",
|
||||
"dependencies": {
|
||||
"iconv-lite": "0.4.24"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz",
|
||||
"integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g=="
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
|
||||
"integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==",
|
||||
"dependencies": {
|
||||
"lodash.sortby": "^4.7.0",
|
||||
"tr46": "^1.0.1",
|
||||
"webidl-conversions": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "7.5.10",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
|
||||
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
|
||||
"engines": {
|
||||
"node": ">=8.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": "^5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz",
|
||||
"integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw=="
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"gpxparser": "^3.0.8",
|
||||
"leaflet": "^1.9.4",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-toastify": "^11.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user