From 8cf3fe98cbd376f50f9b14177ae3f825aa0139de Mon Sep 17 00:00:00 2001 From: Sven laptop Date: Wed, 6 May 2026 10:55:31 +0200 Subject: [PATCH] create admin_ui Co-authored-by: Copilot --- .dockerignore | 4 + .gitignore | 17 + DOCKER_README.md | 106 +- GETTING_STARTED.md | 160 ++ admin_ui/Dockerfile | 14 + admin_ui/README.md | 186 ++ admin_ui/docker-compose.yml | 9 + admin_ui/docker-entrypoint.sh | 12 + admin_ui/index.html | 13 + admin_ui/package-lock.json | 1992 +++++++++++++++++ admin_ui/package.json | 19 + admin_ui/public/env-config.js | 4 + admin_ui/src/App.jsx | 50 + admin_ui/src/api.js | 11 + admin_ui/src/components/BusLinesManager.jsx | 90 + admin_ui/src/components/FleetManager.jsx | 88 + admin_ui/src/components/LinkStop.jsx | 104 + admin_ui/src/components/Login.jsx | 61 + admin_ui/src/components/StopsManager.jsx | 84 + admin_ui/src/main.jsx | 10 + admin_ui/src/styles.css | 174 ++ admin_ui/vite.config.mjs | 9 + auth_api/index.js | 22 + bus_terminal/lib/models/navigation.dart | 126 +- .../lib/screens/daily_schedule_screen.dart | 399 ++-- bus_terminal/lib/screens/login_screen.dart | 295 ++- .../lib/screens/navigation_screen.dart | 998 ++++++--- bus_terminal/lib/services/auth_service.dart | 1 - .../lib/services/navigation_service.dart | 225 +- .../lib/services/schedule_service.dart | 39 + bus_terminal/lib/theme/app_theme.dart | 192 +- .../lib/widgets/custom_input_field.dart | 24 +- bus_terminal/lib/widgets/route_card.dart | 20 +- docker-compose.yml | 16 + scripts/build-and-push.sh | 51 + scripts/deploy.sh | 70 + scripts/init.sh | 78 + transit_api/README.md | 39 + transit_api/db.js | 2 + transit_api/index.js | 413 +++- 40 files changed, 5534 insertions(+), 693 deletions(-) create mode 100644 .gitignore create mode 100644 GETTING_STARTED.md create mode 100644 admin_ui/Dockerfile create mode 100644 admin_ui/README.md create mode 100644 admin_ui/docker-compose.yml create mode 100644 admin_ui/docker-entrypoint.sh create mode 100644 admin_ui/index.html create mode 100644 admin_ui/package-lock.json create mode 100644 admin_ui/package.json create mode 100644 admin_ui/public/env-config.js create mode 100644 admin_ui/src/App.jsx create mode 100644 admin_ui/src/api.js create mode 100644 admin_ui/src/components/BusLinesManager.jsx create mode 100644 admin_ui/src/components/FleetManager.jsx create mode 100644 admin_ui/src/components/LinkStop.jsx create mode 100644 admin_ui/src/components/Login.jsx create mode 100644 admin_ui/src/components/StopsManager.jsx create mode 100644 admin_ui/src/main.jsx create mode 100644 admin_ui/src/styles.css create mode 100644 admin_ui/vite.config.mjs create mode 100755 scripts/build-and-push.sh create mode 100755 scripts/deploy.sh create mode 100755 scripts/init.sh diff --git a/.dockerignore b/.dockerignore index 90b586d..baa65a3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,3 +10,7 @@ build coverage .vscode .idea +ADMIN_DEPLOYMENT_SUMMARY.md +CI_CD_PORTAINER_GUIDE.md +DEPLOYMENT.md +GETTING_STARTED.md \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4bdf535 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +.env +/*.env +transit_api/.env + +# Local build / node_modules +admin_ui/node_modules/ +admin_ui/dist/ + +# Sensitive docs to keep private +ADMIN_DEPLOYMENT_SUMMARY.md +CI_CD_PORTAINER_GUIDE.md +DEPLOYMENT.md + +# Runtime files +*.log +*.sqlite3 +\ \ No newline at end of file diff --git a/DOCKER_README.md b/DOCKER_README.md index abdf08f..c413ede 100644 --- a/DOCKER_README.md +++ b/DOCKER_README.md @@ -1,15 +1,19 @@ # Docker Setup voor Transit Project APIs -Dit project bevat Docker-configuratie voor alle 3 APIs en PostgreSQL database. +Dit project bevat Docker-configuratie voor alle APIs, Admin Control Center en PostgreSQL database. ## Bestanden - **auth_api/Dockerfile** - Image voor Authentication API (port 5000) - **employee_api/Dockerfile** - Image voor Employee API (port 4000) - **transit_api/Dockerfile** - Image voor Transit API (port 5001) +- **admin_ui/Dockerfile** - Image voor Admin Control Center (port 5174) - **docker-compose.yml** - Orchestratie van alle services - **.dockerignore** - Bestanden die niet in Docker images opgenomen worden - **.env.example** - Template voor environment variabelen +- **DEPLOYMENT.md** - Gedetailleerde deployment guide +- **scripts/deploy.sh** - Deployment helper script +- **scripts/build-and-push.sh** - Build en push script voor registry ## Vereisten @@ -28,11 +32,14 @@ cp .env.example .env ### 2. Services starten ```bash -# Start alle services (database + alle APIs) +# Start alle services (APIs + Admin UI) docker-compose up -d # Of build en start tegelijk (als je code hebt aangepast) docker-compose up -d --build + +# Quick start met script +./scripts/deploy.sh start ``` ### 3. Logs bekijken @@ -43,23 +50,37 @@ docker-compose logs -f # Logs van specifieke service docker-compose logs -f auth_api -docker-compose logs -f employee_api docker-compose logs -f transit_api -docker-compose logs -f db +docker-compose logs -f admin_ui + +# Via script +./scripts/deploy.sh logs admin_ui ``` ## Beschikbare Services -| Service | Port | Database | Beschrijving | -|---------|------|----------|--------------| -| auth_api | 5000 | PostgreSQL | Authentication endpoints | -| employee_api | 4000 | PostgreSQL | Employee management endpoints | -| transit_api | 5001 | PostgreSQL | Transit schedule endpoints | -| db | 5432 | - | PostgreSQL database server | +| Service | Port | Beschrijving | +|---------|------|--------------| +| admin_ui | 5174 | Admin Control Center (React + Nginx) | +| auth_api | 5000 | Authentication endpoints | +| employee_api | 4000 | Employee management endpoints | +| transit_api | 5001 | Transit management endpoints | ## Commando's -### Services beheren +### Services beheren (Quick way) + +```bash +# Via helper script +./scripts/deploy.sh start # Start alle services +./scripts/deploy.sh stop # Stop alle services +./scripts/deploy.sh restart # Herstart alle services +./scripts/deploy.sh status # Show status +./scripts/deploy.sh logs # Bekijk logs admin_ui +./scripts/deploy.sh logs transit_api # Bekijk logs specifieke service +``` + +### Services beheren (Docker compose) ```bash # Start services @@ -80,6 +101,7 @@ docker-compose down -v ```bash # Herstart specifieke service docker-compose restart auth_api +docker-compose restart admin_ui # Build specifieke image docker-compose build auth_api @@ -88,17 +110,17 @@ docker-compose build auth_api docker-compose logs -f transit_api ``` -### Database commando's +### Admin UI ```bash -# Verbinding met database -docker-compose exec db psql -U transit_user -d transit_db +# Bekijk Admin UI +http://localhost:5174 -# Database backup -docker-compose exec db pg_dump -U transit_user transit_db > backup.sql +# Logs +docker-compose logs -f admin_ui -# Database restore -docker-compose exec -T db psql -U transit_user transit_db < backup.sql +# Shell in container +docker-compose exec admin_ui sh ``` ## Troubleshooting @@ -129,6 +151,54 @@ docker-compose logs auth_api # Controleer database connection string ``` +### Admin UI niet bereikbaar +```bash +# Check Admin UI container +docker-compose logs admin_ui + +# Zorg ervoor dat poort 5174 niet in gebruik is +lsof -i :5174 +``` + +## Admin Control Center + +De Admin UI is een modern React + Vite applicatie die alle admin functies biedt. + +### Features + +- **Authenticatie**: JWT-based login met dev token helper +- **Stops Management**: Create en beheer transit stops met geo-coΓΆrdinaten +- **Bus Lines**: Define bus routes met start/end stops en reistijd +- **Fleet Management**: Monitor fleet vehicles +- **Stop Linking**: Assign stops aan bus lines met volgorde en ETA + +### Toegang + +``` +URL: http://localhost:5174 +``` + +### Deployment Configuratie + +Environment variabelen in docker-compose.yml: +- `VITE_AUTH_API`: URL naar auth_api (standaard: http://auth_api:5000) +- `VITE_TRANSIT_API`: URL naar transit_api (standaard: http://transit_api:5001) + +Voor externe toegang, update de URLs naar je deployment domain. + +### Build en Deployment + +```bash +# Build Admin UI image +docker-compose build admin_ui + +# Push naar registry +./scripts/build-and-push.sh ghcr.io/your-org/xtransit latest + +# Deploy met updated image +docker-compose up -d +``` + ## Development ### Wijzigingen doorgevoerd in code diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md new file mode 100644 index 0000000..f760091 --- /dev/null +++ b/GETTING_STARTED.md @@ -0,0 +1,160 @@ +# Quick Start Guide - XTransit Admin Control Center + +## πŸš€ 5-Minute Setup + +### Step 1: Initial Setup +```bash +# From the project root +./scripts/init.sh +``` + +This will: +- Check Docker and Docker Compose installation +- Create `.env` file from template +- Build all Docker images +- Prompt you to configure database credentials + +### Step 2: Configure Database Connection + +Edit `.env` with your database details: +```env +POSTGRES_HOST=your-db-host.com +POSTGRES_USER_AUTH=transitAUTH +POSTGRES_PASSWORD_AUTH=your_password +POSTGRES_USER_TRANSIT=transitAPI +POSTGRES_PASSWORD_TRANSIT=your_password +JWT_SECRET=your-secure-secret +ADMIN_USER_CODES=42 +``` + +### Step 3: Start Services +```bash +./scripts/deploy.sh start +``` + +### Step 4: Access Admin UI +``` +http://localhost:5174 +``` + +Click "Get Dev Token" to login (development only). + +## πŸ“± Admin UI Features + +### Stops Management +- Create new transit stops +- Set geolocation (latitude, longitude) +- View all stops +- Link stops to bus lines + +### Bus Lines +- Create new bus routes +- Define start and end stops +- Set estimated travel time +- Assign stops to lines with order + +### Fleet +- View fleet vehicles (read-only) +- See vehicle details + +## 🐳 Docker Services + +| Port | Service | URL | +|------|---------|-----| +| 5174 | Admin UI | http://localhost:5174 | +| 5000 | Auth API | http://localhost:5000 | +| 5001 | Transit API | http://localhost:5001 | +| 4000 | Employee API | http://localhost:4000 | + +## πŸ”§ Common Commands + +```bash +# View service status +./scripts/deploy.sh status + +# Restart all services +./scripts/deploy.sh restart + +# View logs +./scripts/deploy.sh logs admin_ui +./scripts/deploy.sh logs transit_api + +# Stop services +./scripts/deploy.sh stop + +# Clean up containers and volumes +./scripts/deploy.sh clean +``` + +## πŸ” Authentication + +### Development (Local Testing) +- Click "Get Dev Token" button in login screen +- Uses hardcoded user ID 42 for testing + +### Production +- Implement real authentication system +- Update auth_api with your authentication provider +- Remove dev token button from code +- Configure proper admin user codes in ADMIN_USER_CODES + +## πŸ“ Configuration Files + +- **docker-compose.yml** - All service definitions +- **.env** - Database and API credentials +- **DEPLOYMENT.md** - Comprehensive deployment guide +- **DOCKER_README.md** - Docker-specific documentation + +## ⚠️ Troubleshooting + +### "Address already in use" +```bash +# Find and stop the process using the port +lsof -i :5174 # Find process on port 5174 +kill -9 # Kill the process +``` + +### "Cannot connect to database" +```bash +# Check database credentials in .env +# Verify database is accessible from your machine +# Ensure database user exists and has proper permissions +``` + +### "Admin UI not loading" +```bash +# Check container is running +docker-compose ps admin_ui + +# View logs +docker-compose logs -f admin_ui + +# Restart service +docker-compose restart admin_ui +``` + +### "Permission denied" errors +The transit API user needs proper database permissions: +```sql +-- Grant permissions to transitAPI user +GRANT CONNECT ON DATABASE "XTransit" TO transitAPI; +GRANT USAGE ON SCHEMA public TO transitAPI; +GRANT SELECT, INSERT, UPDATE ON all tables IN SCHEMA public TO transitAPI; +``` + +## πŸ“š For More Information + +- **DEPLOYMENT.md** - Full deployment guide for production +- **DOCKER_README.md** - Docker and container information +- **admin_ui/README.md** - Admin UI specific details + +## 🎯 Next Steps + +1. βœ… Run initial setup script +2. βœ… Configure database credentials +3. βœ… Start services +4. βœ… Access Admin UI at http://localhost:5174 +5. βœ… Create test stops and bus lines +6. βœ… Test the admin interface + +For production deployment, see **DEPLOYMENT.md** for security and configuration best practices. diff --git a/admin_ui/Dockerfile b/admin_ui/Dockerfile new file mode 100644 index 0000000..3341630 --- /dev/null +++ b/admin_ui/Dockerfile @@ -0,0 +1,14 @@ +FROM node:18-alpine AS build +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install --production=false --no-audit --no-fund +COPY . . +RUN npm run build + +FROM nginx:stable-alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh +EXPOSE 80 +ENTRYPOINT ["/docker-entrypoint.sh"] +CMD ["nginx", "-g", "daemon off;"] diff --git a/admin_ui/README.md b/admin_ui/README.md new file mode 100644 index 0000000..2f46d97 --- /dev/null +++ b/admin_ui/README.md @@ -0,0 +1,186 @@ +# Transit Admin Control Center + +Modern React + Vite web application for comprehensive transit system administration. + +## Features + +βœ… **Stop Management** - Create and manage transit stops with geolocation +βœ… **Bus Lines** - Define routes with start/end stops and estimated durations +βœ… **Fleet Management** - Monitor and view fleet vehicles +βœ… **Stop Linking** - Assign stops to bus lines with order and ETA +βœ… **JWT Authentication** - Secure authentication with role-based access +βœ… **Production Ready** - Docker containerized with runtime env configuration + +## Local Development + +### Prerequisites +- Node.js 18+ +- npm or yarn + +### Setup + +```bash +cd admin_ui +npm install +``` + +### Environment Configuration + +Create a `.env` file in the `admin_ui` directory: + +```env +VITE_AUTH_API=http://localhost:5000 +VITE_TRANSIT_API=http://localhost:5001 +``` + +### Run Development Server + +```bash +npm run dev +``` + +The app will be available at `http://localhost:5173` + +### Build for Production + +```bash +npm run build +``` + +This creates a production bundle in the `dist/` directory. + +## Docker Deployment + +### Build Docker Image + +```bash +docker build -t xtransit/admin_ui:latest . +``` + +### Run with Docker + +```bash +docker run -p 5174:80 \ + -e VITE_AUTH_API=http://localhost:5000 \ + -e VITE_TRANSIT_API=http://localhost:5001 \ + xtransit/admin_ui:latest +``` + +### Using Docker Compose + +The admin UI is integrated into the main `docker-compose.yml` at the project root. + +From the project root: + +```bash +# Start all services including admin UI +docker-compose up -d + +# Access the admin UI +http://localhost:5174 +``` + +## Configuration + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `VITE_AUTH_API` | Auth API URL (internal: http://auth_api:5000) | http://localhost:5000 | +| `VITE_TRANSIT_API` | Transit API URL (internal: http://transit_api:5001) | http://localhost:5001 | + +### Runtime Configuration + +When running in Docker, environment variables are injected at runtime via `docker-entrypoint.sh` which creates `/usr/share/nginx/html/env-config.js`. This allows configuration without rebuilding the image. + +## Architecture + +### Technology Stack +- **Frontend**: React 18.2 + Vite 5.0 +- **HTTP Client**: Axios 1.4 +- **Server**: Nginx (Alpine Linux) +- **Build**: Multi-stage Docker build for optimized image size + +### Component Structure + +- `App.jsx` - Main app router, session management +- `Login.jsx` - Authentication with dev token helper +- `StopsManager.jsx` - Create/list transit stops +- `BusLinesManager.jsx` - Create/list bus routes +- `FleetManager.jsx` - View fleet vehicles +- `LinkStop.jsx` - Link stops to bus lines + +### API Integration + +The app communicates with: +- **Auth API** (/login, /me, /dev/token) +- **Transit API** (/admin/stops, /admin/bus-lines, /admin/fleet/buses) + +All requests include JWT Bearer token for authentication. + +## Authentication + +### Login Flow + +1. User clicks "Get Dev Token" button (development only) +2. Client calls POST `/dev/token` +3. Receives JWT token with userId and userCode +4. JWT is stored in localStorage +5. All API requests include `Authorization: Bearer ` + +### Admin Authorization + +Users must have a `userCode` in their JWT that matches `ADMIN_USER_CODES` environment variable in the transit API to access admin endpoints. + +### Production Setup + +1. Remove dev token button from Login.jsx +2. Implement real authentication system (OAuth/SAML) +3. Configure proper JWT generation in auth_api +4. Set strong `JWT_SECRET` environment variable +5. Configure `ADMIN_USER_CODES` with actual admin user IDs + +## Troubleshooting + +### Issue: "Admin UI not loading" +- Verify container is running: `docker ps` +- Check logs: `docker-compose logs admin_ui` +- Clear browser cache (Ctrl+Shift+R) + +### Issue: "API connection refused" +- Ensure auth_api and transit_api are running +- Check environment variables are set correctly +- Verify network connectivity between containers + +### Issue: "Permission denied" on admin operations +- Verify JWT token includes valid `userCode` +- Check `ADMIN_USER_CODES` environment variable +- Ensure database user has appropriate permissions + +## Deployment + +See [DEPLOYMENT.md](../DEPLOYMENT.md) for comprehensive deployment guide. + +### Production Checklist + +- [ ] Remove development authentication helpers +- [ ] Set strong JWT_SECRET +- [ ] Configure proper ADMIN_USER_CODES +- [ ] Enable HTTPS/SSL +- [ ] Configure CORS for production domain +- [ ] Set up monitoring and logging +- [ ] Configure database backups +- [ ] Document admin procedures + +## Support + +For issues or questions: +1. Check service logs: `docker-compose logs admin_ui` +2. Verify API connectivity: Visit API URLs directly +3. Check browser console for client-side errors +4. Review DEPLOYMENT.md for troubleshooting + +## License + +Part of the XTransit project + diff --git a/admin_ui/docker-compose.yml b/admin_ui/docker-compose.yml new file mode 100644 index 0000000..6f5941b --- /dev/null +++ b/admin_ui/docker-compose.yml @@ -0,0 +1,9 @@ +version: '3.8' +services: + admin-ui: + build: . + ports: + - "5174:80" + environment: + VITE_AUTH_API: ${VITE_AUTH_API:-http://localhost:5000} + VITE_TRANSIT_API: ${VITE_TRANSIT_API:-http://localhost:5001} diff --git a/admin_ui/docker-entrypoint.sh b/admin_ui/docker-entrypoint.sh new file mode 100644 index 0000000..19e1e7d --- /dev/null +++ b/admin_ui/docker-entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -e + +# Create runtime env-config.js for the static app +cat > /usr/share/nginx/html/env-config.js <<'EOF' +window.__ENV__ = { + VITE_AUTH_API: "${VITE_AUTH_API:-http://localhost:5000}", + VITE_TRANSIT_API: "${VITE_TRANSIT_API:-http://localhost:5001}" +} +EOF + +exec nginx -g 'daemon off;' diff --git a/admin_ui/index.html b/admin_ui/index.html new file mode 100644 index 0000000..45615e2 --- /dev/null +++ b/admin_ui/index.html @@ -0,0 +1,13 @@ + + + + + + Transit Admin + + +
+ + + + diff --git a/admin_ui/package-lock.json b/admin_ui/package-lock.json new file mode 100644 index 0000000..6474801 --- /dev/null +++ b/admin_ui/package-lock.json @@ -0,0 +1,1992 @@ +{ + "name": "transit-admin-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "transit-admin-ui", + "version": "0.1.0", + "dependencies": { + "axios": "1.4.0", + "react": "18.2.0", + "react-dom": "18.2.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "5.0.0", + "vite": "5.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", + "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", + "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", + "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", + "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", + "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", + "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", + "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", + "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", + "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", + "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", + "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", + "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", + "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", + "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", + "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", + "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", + "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", + "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", + "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", + "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", + "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", + "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", + "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.30", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.30.tgz", + "integrity": "sha512-whXaSoNUFiyDAjkUF8OBpOm77Szdbk5lGNqFe6CbVbJFrhCCPinCbRA3NjawwlNHla1No7xvXXh+CpSxnPfUEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.0.tgz", + "integrity": "sha512-Jx9JfsTa05bYkS9xo0hkofp2dCmp1blrKjw9JONs5BTHOvJCgLbaPSuZLGSVJW6u2qe0tc4eevY0+gSNNi0YCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.30", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", + "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", + "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "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==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "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==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.351", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.351.tgz", + "integrity": "sha512-9D7Iqx8RImSvCnOsj86rCH6eQjZFQoM04Jn6HnZVM0Nu/G58/gmKYQ1d12MZTbjQbQSTGI8nwEy07ErsA2slLA==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "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==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "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==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", + "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.19.12", + "@esbuild/android-arm": "0.19.12", + "@esbuild/android-arm64": "0.19.12", + "@esbuild/android-x64": "0.19.12", + "@esbuild/darwin-arm64": "0.19.12", + "@esbuild/darwin-x64": "0.19.12", + "@esbuild/freebsd-arm64": "0.19.12", + "@esbuild/freebsd-x64": "0.19.12", + "@esbuild/linux-arm": "0.19.12", + "@esbuild/linux-arm64": "0.19.12", + "@esbuild/linux-ia32": "0.19.12", + "@esbuild/linux-loong64": "0.19.12", + "@esbuild/linux-mips64el": "0.19.12", + "@esbuild/linux-ppc64": "0.19.12", + "@esbuild/linux-riscv64": "0.19.12", + "@esbuild/linux-s390x": "0.19.12", + "@esbuild/linux-x64": "0.19.12", + "@esbuild/netbsd-x64": "0.19.12", + "@esbuild/openbsd-x64": "0.19.12", + "@esbuild/sunos-x64": "0.19.12", + "@esbuild/win32-arm64": "0.19.12", + "@esbuild/win32-ia32": "0.19.12", + "@esbuild/win32-x64": "0.19.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.0.tgz", + "integrity": "sha512-ESJVM59mdyGpsiNAeHQOR/0fqNoOyWPYesFto8FFZugfmhdHx8Fzd8sF3Q/xkVhZsyOxHfdM7ieiVAorI9RjFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.19.3", + "postcss": "^8.4.31", + "rollup": "^4.2.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/admin_ui/package.json b/admin_ui/package.json new file mode 100644 index 0000000..7c4354d --- /dev/null +++ b/admin_ui/package.json @@ -0,0 +1,19 @@ +{ + "name": "transit-admin-ui", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "18.2.0", + "react-dom": "18.2.0", + "axios": "1.4.0" + }, + "devDependencies": { + "vite": "5.0.0", + "@vitejs/plugin-react": "5.0.0" + } +} diff --git a/admin_ui/public/env-config.js b/admin_ui/public/env-config.js new file mode 100644 index 0000000..cdfc3c7 --- /dev/null +++ b/admin_ui/public/env-config.js @@ -0,0 +1,4 @@ +window.__ENV__ = { + VITE_AUTH_API: 'http://localhost:5000', + VITE_TRANSIT_API: 'http://localhost:5001' +} diff --git a/admin_ui/src/App.jsx b/admin_ui/src/App.jsx new file mode 100644 index 0000000..a943b36 --- /dev/null +++ b/admin_ui/src/App.jsx @@ -0,0 +1,50 @@ +import React, { useState, useEffect } from 'react' +import Login from './components/Login' +import StopsManager from './components/StopsManager' +import LinkStop from './components/LinkStop' +import BusLinesManager from './components/BusLinesManager' +import FleetManager from './components/FleetManager' + +export default function App() { + const [token, setToken] = useState(localStorage.getItem('token')) + const [user, setUser] = useState(JSON.parse(localStorage.getItem('user') || 'null')) + + useEffect(() => { + if (token) localStorage.setItem('token', token) + else localStorage.removeItem('token') + }, [token]) + + useEffect(() => { + if (user) localStorage.setItem('user', JSON.stringify(user)) + else localStorage.removeItem('user') + }, [user]) + + if (!token) return { setToken(t); setUser(u); }} /> + + return ( +
+
+
+

Transit operations

+

Admin control center

+
+
+ {user?.userCode ? `User ${user.userCode}` : 'User'} + +
+
+
+
+

Fleet, lijnen en stops

+

Beheer haltes, buslijnen, gekoppelde stops en voertuigen vanuit één overzicht.

+
+
+
+ + + + +
+
+ ) +} diff --git a/admin_ui/src/api.js b/admin_ui/src/api.js new file mode 100644 index 0000000..ba779d3 --- /dev/null +++ b/admin_ui/src/api.js @@ -0,0 +1,11 @@ +import axios from 'axios' + +const RUNTIME = typeof window !== 'undefined' && window.__ENV__ ? window.__ENV__ : {} +const AUTH_API = RUNTIME.VITE_AUTH_API || import.meta.env.VITE_AUTH_API || 'http://localhost:5000' +const TRANSIT_API = RUNTIME.VITE_TRANSIT_API || import.meta.env.VITE_TRANSIT_API || 'http://localhost:5001' + +export const authClient = axios.create({ baseURL: AUTH_API }) +export const transitClient = (token) => axios.create({ + baseURL: TRANSIT_API, + headers: { Authorization: `Bearer ${token}` } +}) diff --git a/admin_ui/src/components/BusLinesManager.jsx b/admin_ui/src/components/BusLinesManager.jsx new file mode 100644 index 0000000..817933c --- /dev/null +++ b/admin_ui/src/components/BusLinesManager.jsx @@ -0,0 +1,90 @@ +import React, { useEffect, useState } from 'react' +import { transitClient } from '../api' + +export default function BusLinesManager({ token, user }) { + const client = transitClient(token) + const [busLines, setBusLines] = useState([]) + const [lineNumber, setLineNumber] = useState('') + const [startStop, setStartStop] = useState('') + const [endStop, setEndStop] = useState('') + const [estimatedDurationMinutes, setEstimatedDurationMinutes] = useState('') + const [description, setDescription] = useState('') + const [message, setMessage] = useState(null) + const [isAdmin] = useState(Boolean(user?.userCode)) + + const loadBusLines = async () => { + try { + const res = await client.get('/admin/bus-lines') + setBusLines(res.data.busLines || []) + } catch (err) { + setMessage('Error: ' + (err.response?.data?.error || err.message)) + } + } + + useEffect(() => { + loadBusLines() + }, []) + + const createBusLine = async (e) => { + e.preventDefault() + setMessage(null) + try { + const res = await client.post('/admin/bus-lines', { + lineNumber: Number(lineNumber), + startStop, + endStop, + estimatedDurationMinutes: Number(estimatedDurationMinutes), + description, + }) + setLineNumber('') + setStartStop('') + setEndStop('') + setEstimatedDurationMinutes('') + setDescription('') + setMessage('Bus line created: #' + (res.data.busLine?.id || 'ok')) + await loadBusLines() + } catch (err) { + setMessage('Error: ' + (err.response?.data?.error || err.message)) + } + } + + return ( +
+
+
+

Buslijnen

+

Maak en bewerk buslijnen.

+
+ +
+ +
+ {busLines.map(line => ( +
+ #{line.id} Lijn {line.lineNumber} +
{line.startStop} β†’ {line.endStop}
+
{line.estimatedDurationMinutes} min
+ {line.description ?
{line.description}
: null} +
+ ))} +
+ + {isAdmin ? ( +
+
+ setLineNumber(e.target.value)} /> + setStartStop(e.target.value)} /> + setEndStop(e.target.value)} /> +
+
+ setEstimatedDurationMinutes(e.target.value)} /> + setDescription(e.target.value)} /> + +
+
+ ) :
You must be admin to create bus lines.
} + + {message &&
{message}
} +
+ ) +} diff --git a/admin_ui/src/components/FleetManager.jsx b/admin_ui/src/components/FleetManager.jsx new file mode 100644 index 0000000..ced9c68 --- /dev/null +++ b/admin_ui/src/components/FleetManager.jsx @@ -0,0 +1,88 @@ +import React, { useEffect, useState } from 'react' +import { transitClient } from '../api' + +export default function FleetManager({ token, user }) { + const client = transitClient(token) + const [buses, setBuses] = useState([]) + const [name, setName] = useState('') + const [seatCapacity, setSeatCapacity] = useState('') + const [licensePlate, setLicensePlate] = useState('') + const [message, setMessage] = useState(null) + const [canCreateBus, setCanCreateBus] = useState(true) + const [isAdmin] = useState(Boolean(user?.userCode)) + + const loadBuses = async () => { + try { + const res = await client.get('/admin/fleet/buses') + setBuses(res.data.buses || []) + } catch (err) { + setMessage('Error: ' + (err.response?.data?.error || err.message)) + } + } + + useEffect(() => { + loadBuses() + }, []) + + const createBus = async (e) => { + e.preventDefault() + setMessage(null) + try { + const res = await client.post('/admin/fleet/buses', { + name, + seatCapacity: Number(seatCapacity), + licensePlate, + }) + setName('') + setSeatCapacity('') + setLicensePlate('') + setMessage('Bus created: #' + (res.data.bus?.id || 'ok')) + await loadBuses() + } catch (err) { + const details = err.response?.data?.details || err.response?.data?.error || err.message + if (String(details).includes('permission denied') || err.response?.status === 403) { + setCanCreateBus(false) + setMessage('Fleet create is read-only on this database user.') + return + } + setMessage('Error: ' + details) + } + } + + return ( +
+
+
+

Fleet

+

Beheer voertuigen voor de vloot.

+
+ +
+ +
+ {buses.map(bus => ( +
+ #{bus.id} {bus.name} +
Seats: {bus.seatCapacity}
+
Plate: {bus.licensePlate}
+
+ ))} +
+ + {isAdmin && canCreateBus ? ( +
+
+ setName(e.target.value)} /> + setSeatCapacity(e.target.value)} /> + setLicensePlate(e.target.value)} /> + +
+
+ ) : ( +
Fleet create is read-only on this database user.
+ )} + + {message &&
{message}
} +
+ ) +} diff --git a/admin_ui/src/components/LinkStop.jsx b/admin_ui/src/components/LinkStop.jsx new file mode 100644 index 0000000..c2b80d3 --- /dev/null +++ b/admin_ui/src/components/LinkStop.jsx @@ -0,0 +1,104 @@ +import React, { useEffect, useState } from 'react' +import { transitClient } from '../api' + +export default function LinkStop({ token, user }) { + const client = transitClient(token) + const [busLines, setBusLines] = useState([]) + const [busLineId, setBusLineId] = useState('') + const [stopId, setStopId] = useState('') + const [order, setOrder] = useState('') + const [eta, setEta] = useState('') + const [message, setMessage] = useState(null) + const [stops, setStops] = useState([]) + + const isAdmin = Boolean(user?.userCode) + + const loadBusLines = async () => { + try { + const res = await client.get('/admin/bus-lines') + setBusLines(res.data.busLines || []) + } catch (err) { + setMessage('Error: ' + (err.response?.data?.error || err.message)) + } + } + + const loadStopsForLine = async (lineId = busLineId) => { + if (!lineId) return + setMessage(null) + try { + const res = await client.get(`/bus-lines/${lineId}/stops`) + setStops(res.data.stops || []) + } catch (err) { + setMessage('Error: ' + (err.response?.data?.error || err.message)) + } + } + + useEffect(() => { + loadBusLines() + }, []) + + const link = async (e) => { + e.preventDefault() + setMessage(null) + try { + const res = await client.post(`/admin/bus-lines/${busLineId}/stops`, { + stopId: parseInt(stopId,10), + stopOrder: parseInt(order,10), + estimatedArrivalMinutes: eta ? parseInt(eta,10) : null + }) + setMessage('Linked: ' + res.data.routeStop.id) + await loadStopsForLine() + } catch (err) { + if (err.response?.status === 403) setMessage('Forbidden: not admin') + else setMessage('Error: ' + (err.response?.data?.error || err.message)) + } + } + + return ( +
+
+
+

Stops per lijn

+

Koppel stops aan lijnen en bekijk de volgorde.

+
+ +
+ +
+ { + setBusLineId(e.target.value) + }} + /> + +
+ + {busLines.length > 0 && ( +
+ Known lines: {busLines.map(line => `#${line.id} (lijn ${line.lineNumber})`).join(', ')} +
+ )} + +
    + {stops.map(s => ( +
  • {s.order}. {s.name} ({s.latitude}, {s.longitude})
  • + ))} +
+ +

Link a Stop to Line

+ {isAdmin ? ( +
+ setStopId(e.target.value)} /> + setOrder(e.target.value)} /> + setEta(e.target.value)} /> + +
+ ) : ( +
You must be admin to link stops.
+ )} + {message &&
{message}
} +
+ ) +} diff --git a/admin_ui/src/components/Login.jsx b/admin_ui/src/components/Login.jsx new file mode 100644 index 0000000..4f70fec --- /dev/null +++ b/admin_ui/src/components/Login.jsx @@ -0,0 +1,61 @@ +import React, { useState } from 'react' +import { authClient } from '../api' + +export default function Login({ onLogin }) { + const [userCode, setUserCode] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const submit = async (e) => { + e.preventDefault() + setError(null) + setLoading(true) + try { + const res = await authClient.post('/login', { userCode, password }) + const token = res.data.token + // fetch /me to get payload + const me = await authClient.get('/me', { headers: { Authorization: `Bearer ${token}` } }) + onLogin(token, me.data.user) + } catch (err) { + setError(err.response?.data?.error || 'login_failed') + } finally { setLoading(false) } + } + + const devToken = async () => { + setError(null) + setLoading(true) + try { + const res = await authClient.post('/dev/token', { userId: 1, userCode: 42 }) + const token = res.data.token + const me = await authClient.get('/me', { headers: { Authorization: `Bearer ${token}` } }) + onLogin(token, me.data.user) + } catch (err) { + setError(err.response?.data?.error || 'dev_token_failed') + } finally { setLoading(false) } + } + + return ( +
+

Admin Login

+
+ + + + {error &&
{error}
} +
+

Be sure `ADMIN_USER_CODES` contains your `userCode` for admin actions.

+
+
+ +
Uses `/dev/token` on the auth API for quick local testing.
+
+
+ ) +} diff --git a/admin_ui/src/components/StopsManager.jsx b/admin_ui/src/components/StopsManager.jsx new file mode 100644 index 0000000..164e845 --- /dev/null +++ b/admin_ui/src/components/StopsManager.jsx @@ -0,0 +1,84 @@ +import React, { useEffect, useState } from 'react' +import { transitClient } from '../api' + +export default function StopsManager({ token, user }) { + const client = transitClient(token) + const [stops, setStops] = useState([]) + const [name, setName] = useState('') + const [lat, setLat] = useState('') + const [lon, setLon] = useState('') + const [message, setMessage] = useState(null) + const [isAdmin] = useState(Boolean(user?.userCode)) + + const loadStops = async () => { + try { + const res = await client.get('/admin/stops') + setStops(res.data.stops || []) + } catch (err) { + setMessage('Error: ' + (err.response?.data?.error || err.message)) + } + } + + useEffect(() => { + loadStops() + }, []) + + const createStop = async (e) => { + e.preventDefault() + setMessage(null) + try { + const res = await client.post('/admin/stops', { + name, + latitude: parseFloat(lat), + longitude: parseFloat(lon) + }) + setMessage('Stop created: #' + res.data.stop.id) + setName(''); setLat(''); setLon('') + await loadStops() + } catch (err) { + const errMsg = err.response?.data?.error || err.message + if (err.response?.status === 401) { + setMessage('Unauthorized: invalid token') + } else if (err.response?.status === 403) { + setMessage('Forbidden: you are not an admin') + } else { + setMessage('Error: ' + errMsg) + } + } + } + + return ( +
+
+
+

Stops

+

Maak herbruikbare haltes aan.

+
+ +
+
+ {stops.map(stop => ( +
+ #{stop.id} {stop.name} +
{stop.latitude}, {stop.longitude}
+
+ ))} +
+ {isAdmin ? ( +
+
+ setName(e.target.value)} /> + setLat(e.target.value)} /> + setLon(e.target.value)} /> + +
+
+ ) : ( +
You must be admin to create stops.
+ )} + + {message &&
{message}
} +

Note: To view stops per line, use the Bus Line Stops tool below.

+
+ ) +} diff --git a/admin_ui/src/main.jsx b/admin_ui/src/main.jsx new file mode 100644 index 0000000..83d0bca --- /dev/null +++ b/admin_ui/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' +import App from './App' +import './styles.css' + +createRoot(document.getElementById('root')).render( + + + +) diff --git a/admin_ui/src/styles.css b/admin_ui/src/styles.css new file mode 100644 index 0000000..33d6750 --- /dev/null +++ b/admin_ui/src/styles.css @@ -0,0 +1,174 @@ +body { + margin: 0; + color: #0f172a; + background: + radial-gradient(circle at top left, rgba(99, 102, 241, 0.12), transparent 28%), + radial-gradient(circle at top right, rgba(14, 165, 233, 0.12), transparent 24%), + linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%); + font-family: Inter, system-ui, Arial, sans-serif; +} + +.app { + max-width: 1180px; + margin: 0 auto; + padding: 28px 18px 48px; +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 20px; +} + +.topbar h1 { + margin: 4px 0 0; + font-size: 2.2rem; +} + +.eyebrow { + margin: 0; + text-transform: uppercase; + letter-spacing: 0.12em; + color: #4f46e5; + font-size: 0.75rem; + font-weight: 700; +} + +.userbox { + display: flex; + gap: 12px; + align-items: center; + background: rgba(255, 255, 255, 0.75); + border: 1px solid rgba(148, 163, 184, 0.25); + border-radius: 14px; + padding: 10px 14px; + box-shadow: 0 8px 30px rgba(15, 23, 42, 0.06); +} + +.hero { + background: linear-gradient(135deg, rgba(15, 23, 42, 0.96), rgba(37, 99, 235, 0.88)); + color: white; + border-radius: 24px; + padding: 24px 26px; + margin-bottom: 22px; + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.18); +} + +.hero h2 { + margin: 0 0 6px; + font-size: 1.5rem; +} + +.hero p, +.card p { + margin: 0; + color: inherit; +} + +main { + display: grid; + gap: 18px; +} + +.card, +.login { + background: rgba(255, 255, 255, 0.84); + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 22px; + padding: 18px; + box-shadow: 0 18px 32px rgba(15, 23, 42, 0.07); + backdrop-filter: blur(14px); +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 14px; +} + +.form-row { + display: flex; + gap: 10px; + margin: 10px 0; + flex-wrap: wrap; +} + +.stack-form { + margin-top: 10px; +} + +input, +select, +button { + border-radius: 12px; + border: 1px solid rgba(148, 163, 184, 0.45); + padding: 11px 12px; + font: inherit; +} + +input, +select { + background: white; + min-width: 180px; + flex: 1; +} + +button { + background: #111827; + color: white; + cursor: pointer; + transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease; +} + +button:hover { + transform: translateY(-1px); + box-shadow: 0 10px 24px rgba(17, 24, 39, 0.16); +} + +button.secondary { + background: white; + color: #0f172a; +} + +.message { + margin-top: 10px; + color: #047857; + font-weight: 600; +} + +.error { + color: #b91c1c; + margin-top: 10px; +} + +.list-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 10px; + margin: 10px 0 16px; +} + +.mini-card { + background: rgba(248, 250, 252, 0.96); + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 16px; + padding: 12px 14px; +} + +.mini-card strong { + display: block; + margin-bottom: 6px; +} + +.muted { + color: #64748b; + font-size: 0.95rem; +} + +.login { + max-width: 540px; + margin: 60px auto; +} diff --git a/admin_ui/vite.config.mjs b/admin_ui/vite.config.mjs new file mode 100644 index 0000000..6ddd140 --- /dev/null +++ b/admin_ui/vite.config.mjs @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173 + } +}) diff --git a/auth_api/index.js b/auth_api/index.js index c8429fb..cb9e377 100644 --- a/auth_api/index.js +++ b/auth_api/index.js @@ -7,6 +7,17 @@ require('dotenv').config(); const app = express(); app.use(express.json()); +// Simple CORS middleware for browser-based admin UI testing. +app.use((req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + if (req.method === 'OPTIONS') { + return res.sendStatus(204); + } + next(); +}); + const JWT_SECRET = process.env.JWT_SECRET; @@ -44,6 +55,17 @@ app.get('/me', async (req, res) => { } }); +// Development helper: issue a token without DB checks when DEV_BYPASS=1 +if (process.env.DEV_BYPASS === '1') { + app.post('/dev/token', (req, res) => { + const { userId, userCode } = req.body || {}; + if (!userId || !userCode) return res.status(400).json({ error: 'userId and userCode required' }); + const payload = { userId: Number(userId), userCode: Number(userCode) }; + const token = jwt.sign(payload, JWT_SECRET || 'devsecret', { expiresIn: '7d' }); + res.json({ token, user: payload }); + }); +} + const PORT = process.env.PORT || 5000; app.listen(PORT, () => console.log(`Auth API listening on ${PORT}`)); diff --git a/bus_terminal/lib/models/navigation.dart b/bus_terminal/lib/models/navigation.dart index b2ecd55..d0ae054 100644 --- a/bus_terminal/lib/models/navigation.dart +++ b/bus_terminal/lib/models/navigation.dart @@ -56,18 +56,65 @@ class RouteLeg { String getDistanceKm() => (distance / 1000).toStringAsFixed(1); String getDurationMinutes() => (duration ~/ 60).toString(); - factory RouteLeg.fromJson(Map json) { - final coords = json['geometry']['coordinates'] as List; + factory RouteLeg.fromCoordinates( + List coordinates, { + double distance = 0.0, + int duration = 0, + String summary = 'Route', + }) { return RouteLeg( - points: coords - .map((coord) => NavigationPoint( - latitude: coord[1] as double, - longitude: coord[0] as double, - )) + points: coordinates + .map((coord) { + if (coord is List && coord.length >= 2) { + return NavigationPoint( + latitude: (coord[1] as num).toDouble(), + longitude: (coord[0] as num).toDouble(), + ); + } + return null; + }) + .whereType() .toList(), - distance: (json['distance'] as num).toDouble(), - duration: (json['duration'] as num).toInt(), - summary: json['summary'] as String? ?? 'Route', + distance: distance, + duration: duration, + summary: summary, + ); + } + + factory RouteLeg.fromPoints({ + required List points, + required double distance, + required int duration, + String summary = 'Route', + }) { + return RouteLeg( + points: points, + distance: distance, + duration: duration, + summary: summary, + ); + } + + factory RouteLeg.fromJson(Map json) { + final geometry = json['geometry'] as Map?; + final coordinates = geometry?['coordinates'] as List? ?? []; + + return RouteLeg( + points: coordinates + .map((coord) { + if (coord is List && coord.length >= 2) { + return NavigationPoint( + latitude: (coord[1] as num).toDouble(), + longitude: (coord[0] as num).toDouble(), + ); + } + return null; + }) + .whereType() + .toList(), + distance: (json['distance'] as num?)?.toDouble() ?? 0.0, + duration: (json['duration'] as num?)?.toInt() ?? 0, + summary: (json['summary'] as String?) ?? 'Route', ); } } @@ -108,22 +155,57 @@ class NavigationRoute { } factory NavigationRoute.fromJson(Map json) { - final routes = json['routes'] as List; - if (routes.isEmpty) { - throw Exception('No route found'); + final routes = json['routes'] as List?; + if (routes == null || routes.isEmpty) { + throw Exception('No route found in response'); } - final routeData = routes[0] as Map; - final legs = (routeData['legs'] as List) - .map((leg) => RouteLeg.fromJson(leg as Map)) - .toList(); + final routeData = routes[0] as Map?; + if (routeData == null) { + throw Exception('Invalid route data'); + } + + return NavigationRoute.fromOsrmRouteData( + routeData, + start: NavigationPoint(latitude: 0, longitude: 0), + end: NavigationPoint(latitude: 0, longitude: 0), + ); + } + + factory NavigationRoute.fromOsrmRouteData( + Map routeData, { + required NavigationPoint start, + required NavigationPoint end, + }) { + final geometry = routeData['geometry'] as Map?; + final coordinates = geometry?['coordinates'] as List? ?? []; + final distance = (routeData['distance'] as num?)?.toDouble() ?? 0.0; + final duration = (routeData['duration'] as num?)?.toInt() ?? 0; + + final leg = RouteLeg.fromCoordinates( + coordinates, + distance: distance, + duration: duration, + summary: (routeData['summary'] as String?) ?? 'Route', + ); return NavigationRoute( - start: NavigationPoint(latitude: 0, longitude: 0), // TODO: Set from request - end: NavigationPoint(latitude: 0, longitude: 0), // TODO: Set from request - legs: legs, - totalDistance: (routeData['distance'] as num).toDouble(), - totalDuration: (routeData['duration'] as num).toInt(), + start: start, + end: end, + legs: [leg], + totalDistance: distance, + totalDuration: duration, ); } } + +/// Result of an optimized trip calculation with reordered stops. +class OptimizedRouteResult { + final NavigationRoute route; + final List orderedStops; + + OptimizedRouteResult({ + required this.route, + required this.orderedStops, + }); +} diff --git a/bus_terminal/lib/screens/daily_schedule_screen.dart b/bus_terminal/lib/screens/daily_schedule_screen.dart index 7caf49f..2c7b4f0 100644 --- a/bus_terminal/lib/screens/daily_schedule_screen.dart +++ b/bus_terminal/lib/screens/daily_schedule_screen.dart @@ -28,6 +28,7 @@ class _DailyScheduleScreenState extends State { } Future _selectDate() async { + final colorScheme = Theme.of(context).colorScheme; final picked = await showDatePicker( context: context, initialDate: _selectedDate, @@ -36,7 +37,7 @@ class _DailyScheduleScreenState extends State { builder: (context, child) { return Theme( data: Theme.of(context).copyWith( - dialogBackgroundColor: Colors.white, + dialogBackgroundColor: colorScheme.surface, ), child: child!, ); @@ -53,236 +54,210 @@ class _DailyScheduleScreenState extends State { @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; final dateStr = '${_selectedDate.year}-${_selectedDate.month.toString().padLeft(2, '0')}-${_selectedDate.day.toString().padLeft(2, '0')}'; - final isToday = DateTime( - _selectedDate.year, - _selectedDate.month, - _selectedDate.day, - ) == - DateTime( - DateTime.now().year, - DateTime.now().month, - DateTime.now().day, - ); + final today = DateTime.now(); + final isToday = DateTime(_selectedDate.year, _selectedDate.month, _selectedDate.day) == + DateTime(today.year, today.month, today.day); return Scaffold( appBar: AppBar( - title: const Text('Your Schedule'), + title: const Text('Today on the Road'), elevation: 0, ), - body: Column( - children: [ - // Date selector - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - isToday ? 'Today' : 'Scheduled Date', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Colors.grey[600], - ), - ), - Text( - dateStr, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ], + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: Theme.of(context).brightness == Brightness.dark + ? const [Color(0xFF0B1220), Color(0xFF111827)] + : const [Color(0xFFF1F5F9), Color(0xFFE2E8F0)], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Column( + children: [ + Container( + margin: const EdgeInsets.fromLTRB(16, 16, 16, 12), + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0F172A), Color(0xFF1D4ED8)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, ), - OutlinedButton.icon( - onPressed: _selectDate, - icon: const Icon(Icons.calendar_today), - label: const Text('Change'), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.16), + blurRadius: 22, + offset: const Offset(0, 10), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isToday ? 'Vandaag' : 'Geplande datum', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.white.withOpacity(0.78), + ), + ), + const SizedBox(height: 6), + Text( + dateStr, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 6), + Text( + 'Routes, stops en status in één overzicht', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.white.withOpacity(0.84), + ), + ), + const SizedBox(height: 14), + Align( + alignment: Alignment.centerRight, + child: SizedBox( + height: 48, + child: OutlinedButton.icon( + onPressed: _selectDate, + icon: const Icon(Icons.calendar_today, size: 18), + label: const Text('Wijzig'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: BorderSide(color: Colors.white.withOpacity(0.28)), + padding: const EdgeInsets.symmetric(horizontal: 16), + ), + ), ), ), - ), - ], + ], + ), ), - ), - const Divider(), + Expanded( + child: FutureBuilder( + future: _scheduleFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } - // Routes list - Expanded( - child: FutureBuilder( - future: _scheduleFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center( - child: CircularProgressIndicator(), - ); - } + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: colorScheme.error, + ), + const SizedBox(height: 16), + Text( + 'Failed to load schedule', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + snapshot.error.toString(), + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _refreshSchedule, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + ); + } - if (snapshot.hasError) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + if (!snapshot.hasData || snapshot.data!.routes.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.calendar_today, + size: 64, + color: colorScheme.outline, + ), + const SizedBox(height: 16), + Text( + 'No routes scheduled', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Check back later for your schedule', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + final routes = snapshot.data!.routes; + final scheduledRoutes = routes.where((r) => r.status == 'scheduled').toList(); + final inProgressRoutes = routes.where((r) => r.status == 'in_progress').toList(); + final completedRoutes = routes.where((r) => r.status == 'completed').toList(); + + return RefreshIndicator( + onRefresh: _refreshSchedule, + child: ListView( + padding: const EdgeInsets.only(bottom: 16), children: [ - Icon( - Icons.error_outline, - size: 64, - color: Colors.red[300], - ), - const SizedBox(height: 16), - Text( - 'Failed to load schedule', - style: Theme.of(context).textTheme.titleMedium, - ), + if (inProgressRoutes.isNotEmpty) ...[ + _sectionHeader('In Progress', colorScheme.tertiary), + ...inProgressRoutes.map((route) => RouteCard(route: route, onTap: () {})), + ], + if (scheduledRoutes.isNotEmpty) ...[ + _sectionHeader('Scheduled', colorScheme.primary), + ...scheduledRoutes.map((route) => RouteCard(route: route, onTap: () {})), + ], + if (completedRoutes.isNotEmpty) ...[ + _sectionHeader('Completed', colorScheme.secondary), + ...completedRoutes.map((route) => RouteCard(route: route, onTap: () {})), + ], const SizedBox(height: 8), - Text( - snapshot.error.toString(), - style: Theme.of(context).textTheme.bodySmall, - textAlign: TextAlign.center, - ), - const SizedBox(height: 24), - FilledButton.icon( - onPressed: _refreshSchedule, - icon: const Icon(Icons.refresh), - label: const Text('Retry'), - ), ], ), ); - } - - if (!snapshot.hasData || snapshot.data!.routes.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.calendar_today, - size: 64, - color: Colors.grey[300], - ), - const SizedBox(height: 16), - Text( - 'No routes scheduled', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Text( - 'Check back later for your schedule', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.grey[600], - ), - ), - ], - ), - ); - } - - final schedule = snapshot.data!; - final routes = schedule.routes; - - // Separate routes by status - final scheduledRoutes = routes - .where((r) => r.status == 'scheduled') - .toList(); - final inProgressRoutes = routes - .where((r) => r.status == 'in_progress') - .toList(); - final completedRoutes = routes - .where((r) => r.status == 'completed') - .toList(); - - return RefreshIndicator( - onRefresh: _refreshSchedule, - child: ListView( - children: [ - // In Progress section - if (inProgressRoutes.isNotEmpty) ...[ - Padding( - padding: const EdgeInsets.only( - left: 16, - top: 24, - bottom: 8, - ), - child: Text( - 'In Progress', - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith( - fontWeight: FontWeight.bold, - color: Colors.orange, - ), - ), - ), - ...inProgressRoutes.map((route) => RouteCard( - route: route, - onTap: () {}, - )), - ], - - // Scheduled section - if (scheduledRoutes.isNotEmpty) ...[ - Padding( - padding: const EdgeInsets.only( - left: 16, - top: 24, - bottom: 8, - ), - child: Text( - 'Scheduled', - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith( - fontWeight: FontWeight.bold, - color: Colors.blue, - ), - ), - ), - ...scheduledRoutes.map((route) => RouteCard( - route: route, - onTap: () {}, - )), - ], - - // Completed section - if (completedRoutes.isNotEmpty) ...[ - Padding( - padding: const EdgeInsets.only( - left: 16, - top: 24, - bottom: 8, - ), - child: Text( - 'Completed', - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith( - fontWeight: FontWeight.bold, - color: Colors.green, - ), - ), - ), - ...completedRoutes.map((route) => RouteCard( - route: route, - onTap: () {}, - )), - ], - - const SizedBox(height: 16), - ], - ), - ); - }, + }, + ), + ), + ], + ), + ), + ); + } + + Widget _sectionHeader(String title, Color color) { + return Padding( + padding: const EdgeInsets.only(left: 16, top: 24, bottom: 8), + child: Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: color, ), - ), - ], ), ); } diff --git a/bus_terminal/lib/screens/login_screen.dart b/bus_terminal/lib/screens/login_screen.dart index 5808210..ec35aed 100644 --- a/bus_terminal/lib/screens/login_screen.dart +++ b/bus_terminal/lib/screens/login_screen.dart @@ -40,7 +40,7 @@ class _LoginScreenState extends State { throw Exception('User code must be a number'); } - final response = await _authService.login( + await _authService.login( userCode: parsedUserCode, password: _passwordController.text, ); @@ -85,105 +85,214 @@ class _LoginScreenState extends State { @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Scaffold( - appBar: AppBar( - title: const Text('Bus Terminal Login'), - elevation: 0, - ), - body: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 32), - - // Title - Text( - 'Driver Login', - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 12), - Text( - 'Enter your credentials to continue', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.grey[600], - ), - ), - const SizedBox(height: 48), - - // Error message - if (_errorMessage != null) - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.red[50], - border: Border.all(color: Colors.red[300]!), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - _errorMessage!, - style: TextStyle( - color: Colors.red[700], - fontSize: 14, - ), - ), - ), - if (_errorMessage != null) const SizedBox(height: 24), - - // User Code Field - CustomInputField( - label: 'User Code', - hint: 'Enter your user code', - controller: _userCodeController, - validator: _validateUserCode, - ), - const SizedBox(height: 24), - - // Password Field - CustomInputField( - label: 'Password', - hint: 'Enter your password', - controller: _passwordController, - obscureText: true, - validator: _validatePassword, - ), - const SizedBox(height: 40), - - // Login Button - Large for touch accessibility - FilledButton( - onPressed: _isLoading ? null : _handleLogin, - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(56), // Large touch target - disabledBackgroundColor: Colors.grey[400], - ), - child: _isLoading - ? const SizedBox( - height: 24, - width: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: - AlwaysStoppedAnimation(Colors.white), - ), - ) - : const Text( - 'Login', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: Theme.of(context).brightness == Brightness.dark + ? const [Color(0xFF0B1220), Color(0xFF111827), Color(0xFF1D4ED8)] + : const [Color(0xFFF1F5F9), Color(0xFFE2E8F0), Color(0xFFBAE6FD)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: SafeArea( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0F172A), Color(0xFF1D4ED8)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, ), + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.18), + blurRadius: 24, + offset: const Offset(0, 12), + ), + ], + ), + child: Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.16), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.directions_bus, + color: Colors.white, + size: 30, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Bus Terminal', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + 'Driver access dashboard', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.white.withOpacity(0.82), + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 18), + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colorScheme.surface.withOpacity(0.92), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: colorScheme.outline.withOpacity(0.14)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 22, + offset: const Offset(0, 10), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Driver Login', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8), + Text( + 'Sign in to view your route, stops and live navigation.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 28), + + if (_errorMessage != null) ...[ + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colorScheme.error.withOpacity(0.12), + border: Border.all(color: colorScheme.error.withOpacity(0.35)), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: colorScheme.error), + const SizedBox(width: 10), + Expanded( + child: Text( + _errorMessage!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + ], + + CustomInputField( + label: 'User Code', + hint: 'Enter your user code', + controller: _userCodeController, + validator: _validateUserCode, + ), + const SizedBox(height: 20), + CustomInputField( + label: 'Password', + hint: 'Enter your password', + controller: _passwordController, + obscureText: true, + validator: _validatePassword, + ), + const SizedBox(height: 28), + + FilledButton( + onPressed: _isLoading ? null : _handleLogin, + child: _isLoading + ? SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(colorScheme.onPrimary), + ), + ) + : const Text('Login'), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _statusPill(Icons.shield_outlined, 'Secure access'), + const SizedBox(width: 10), + _statusPill(Icons.route_outlined, 'Live route data'), + ], + ), + ], + ), + ), + ], ), - ], + ), ), ), ), ), ); } + + Widget _statusPill(IconData icon, String label) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.72), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16), + const SizedBox(width: 6), + Text( + label, + style: Theme.of(context).textTheme.labelMedium, + ), + ], + ), + ); + } } diff --git a/bus_terminal/lib/screens/navigation_screen.dart b/bus_terminal/lib/screens/navigation_screen.dart index 1da8c0d..35d078c 100644 --- a/bus_terminal/lib/screens/navigation_screen.dart +++ b/bus_terminal/lib/screens/navigation_screen.dart @@ -7,6 +7,7 @@ import 'package:latlong2/latlong.dart'; import '../models/navigation.dart'; import '../models/schedule.dart' as schedule; import '../services/navigation_service.dart'; +import '../services/schedule_service.dart'; class NavigationScreen extends StatefulWidget { final schedule.Route route; @@ -22,6 +23,7 @@ class NavigationScreen extends StatefulWidget { class _NavigationScreenState extends State { final _navigationService = NavigationService(); + final _scheduleService = ScheduleService(); late MapController _mapController; NavigationRoute? _currentRoute; @@ -29,6 +31,10 @@ class _NavigationScreenState extends State { bool _isLoading = false; String? _errorMessage; List? _alternativeRoutes; + List? _stops; + String? _stopsFetchError; + int _currentStopIndex = 0; + final Set _completedStops = {}; @override void initState() { @@ -48,31 +54,79 @@ class _NavigationScreenState extends State { final currentLoc = await _navigationService.getCurrentLocation(); setState(() => _currentLocation = currentLoc); - // For demo: use the bus line start stop coordinates - // In production, fetch actual GPS coordinates for stops - final startPoint = NavigationPoint( - latitude: 52.5200, - longitude: 13.4050, - name: widget.route.busLine.startStop, - ); + // Fetch stops for the bus line from the API (if available) + try { + _stops = await _scheduleService.getStops(widget.route.busLine.id); + _stopsFetchError = null; + } catch (e) { + _stops = []; + _stopsFetchError = e.toString().replaceAll('Exception: ', ''); + } - final endPoint = NavigationPoint( - latitude: 52.5300, - longitude: 13.4150, - name: widget.route.busLine.endStop, - ); + // Determine start and end points: prefer stop locations if available. + // If no stops are returned from the API, geocode the stop names. + late NavigationPoint startPoint; + late NavigationPoint endPoint; - // Get route - final navRoute = await _navigationService.getRoute( - start: startPoint, - end: endPoint, - ); + if (_stops != null && _stops!.length >= 2) { + final optimized = await _navigationService.getOptimizedRouteThroughStops(stops: _stops!); + _stops = optimized.orderedStops; + startPoint = _stops!.first; + endPoint = _stops!.last; + + if (mounted) { + setState(() { + _currentRoute = optimized.route; + _alternativeRoutes = null; + _isLoading = false; + }); + + _fitMapToRoute(optimized.route); + } + + return; + } else if (_stops != null && _stops!.length == 1) { + // With exactly one linked stop, force that stop into the route as start + // so the marker is guaranteed to be in-view. + startPoint = _stops!.first; + + try { + endPoint = await _navigationService.geocodePlace(widget.route.busLine.endStop); + } catch (_) { + endPoint = NavigationPoint( + latitude: startPoint.latitude + 0.01, + longitude: startPoint.longitude + 0.01, + name: widget.route.busLine.endStop, + ); + } + } else { + // Try geocoding names; fall back to conservative defaults on failure + try { + startPoint = await _navigationService.geocodePlace(widget.route.busLine.startStop); + } catch (_) { + startPoint = NavigationPoint( + latitude: 52.5200, + longitude: 13.4050, + name: widget.route.busLine.startStop, + ); + } + + try { + endPoint = await _navigationService.geocodePlace(widget.route.busLine.endStop); + } catch (_) { + endPoint = NavigationPoint( + latitude: 52.5300, + longitude: 13.4150, + name: widget.route.busLine.endStop, + ); + } + } + + // Get route without waypoints + final navRoute = await _navigationService.getRoute(start: startPoint, end: endPoint); // Get alternative routes - final alternatives = await _navigationService.getMultipleRoutes( - start: startPoint, - end: endPoint, - ); + final alternatives = await _navigationService.getMultipleRoutes(start: startPoint, end: endPoint); if (mounted) { setState(() { @@ -95,7 +149,11 @@ class _NavigationScreenState extends State { } void _fitMapToRoute(NavigationRoute route) { - final allPoints = route.getAllPoints(); + final allPoints = [ + ...route.getAllPoints().map((point) => LatLng(point.latitude, point.longitude)), + ...(_stops ?? []).map((stop) => LatLng(stop.latitude, stop.longitude)), + if (_currentLocation != null) LatLng(_currentLocation!.latitude, _currentLocation!.longitude), + ]; if (allPoints.isEmpty) return; double minLat = allPoints.first.latitude; @@ -121,6 +179,126 @@ class _NavigationScreenState extends State { ); } + int _nextUncompletedStopIndex([int startIndex = 0]) { + if (_stops == null || _stops!.isEmpty) return 0; + + for (var index = startIndex; index < _stops!.length; index++) { + if (!_completedStops.contains(index)) { + return index; + } + } + + return _stops!.length - 1; + } + + Future _refreshRouteFromIndex(int index) async { + if (_stops == null || _stops!.isEmpty) return; + if (index < 0 || index >= _stops!.length) return; + + setState(() { + _isLoading = true; + _errorMessage = null; + _currentStopIndex = index; + }); + + try { + final remainingStops = _stops!.sublist(index); + final navRoute = remainingStops.length >= 2 + ? await _navigationService.getRouteThroughStops(stops: remainingStops) + : _currentRoute; + + if (mounted) { + setState(() { + if (navRoute != null) { + _currentRoute = navRoute; + } + _alternativeRoutes = null; + _isLoading = false; + }); + + if (navRoute != null) { + _fitMapToRoute(navRoute); + } + } + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = e.toString().replaceAll('Exception: ', ''); + _isLoading = false; + }); + } + } + } + + Future _focusStop(int index) async { + if (_stops == null || _stops!.isEmpty) return; + if (index < 0 || index >= _stops!.length) return; + await _refreshRouteFromIndex(index); + } + + Future _markStopCompleted(int index) async { + if (_stops == null || _stops!.isEmpty) return; + if (index < 0 || index >= _stops!.length) return; + + setState(() { + _completedStops.add(index); + }); + + final nextIndex = _nextUncompletedStopIndex(index + 1); + await _refreshRouteFromIndex(nextIndex); + } + + double get _routeProgress { + if (_stops == null || _stops!.isEmpty) return 0; + if (_stops!.length == 1) return 1; + return _currentStopIndex / (_stops!.length - 1); + } + + Color get _routeColor => Color.lerp(Colors.blue, Colors.orange, _routeProgress) ?? Colors.blue; + + Color _stopColor(int index) { + if (_completedStops.contains(index) || index < _currentStopIndex) return Colors.green; + if (index == _currentStopIndex) return Colors.orange; + return Colors.blueGrey; + } + + IconData _stopIcon(int index) { + if (_completedStops.contains(index) || index < _currentStopIndex) return Icons.check_circle; + if (index == _currentStopIndex) return Icons.radio_button_checked; + return Icons.location_on; + } + + bool _isStopCompleted(int index) => _completedStops.contains(index) || index < _currentStopIndex; + + bool _hasStopNearPoint(NavigationPoint point, {double threshold = 0.00005}) { + if (_stops == null || _stops!.isEmpty) return false; + + for (final stop in _stops!) { + final latDiff = (stop.latitude - point.latitude).abs(); + final lonDiff = (stop.longitude - point.longitude).abs(); + if (latDiff <= threshold && lonDiff <= threshold) { + return true; + } + } + return false; + } + + NavigationPoint? get _currentStopPoint { + if (_stops == null || _stops!.isEmpty) return null; + return _stops![_currentStopIndex.clamp(0, _stops!.length - 1)]; + } + + NavigationPoint? get _nextStopPoint { + if (_stops == null || _stops!.isEmpty) return null; + final nextIndex = _nextUncompletedStopIndex(_currentStopIndex + 1); + return _stops![nextIndex.clamp(0, _stops!.length - 1)]; + } + + double get _completionRatio { + if (_stops == null || _stops!.isEmpty) return 0; + return _completedStops.length / _stops!.length; + } + @override Widget build(BuildContext context) { return Scaffold( @@ -128,309 +306,529 @@ class _NavigationScreenState extends State { title: Text('Route ${widget.route.busLine.lineNumber} Navigation'), elevation: 0, ), - body: Stack( + body: Row( children: [ - // Map - if (_currentRoute != null) - FlutterMap( - mapController: _mapController, - options: MapOptions( - initialCenter: LatLng( - _currentRoute!.start.latitude, - _currentRoute!.start.longitude, - ), - initialZoom: 13.0, - ), - children: [ - TileLayer( - urlTemplate: - 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: 'com.example.bus_terminal', - ), - MarkerLayer( - markers: [ - // Start marker - Marker( - point: LatLng( + // Map area (left pane) + Expanded( + flex: 6, + child: _currentRoute != null + ? FlutterMap( + mapController: _mapController, + options: MapOptions( + initialCenter: LatLng( _currentRoute!.start.latitude, _currentRoute!.start.longitude, ), - width: 40, - height: 40, - child: Container( - decoration: BoxDecoration( - color: Colors.green, - shape: BoxShape.circle, - border: Border.all( - color: Colors.white, - width: 2, - ), - ), - child: const Icon( - Icons.location_on, - color: Colors.white, - size: 20, - ), - ), + initialZoom: 13.0, ), - // End marker - Marker( - point: LatLng( - _currentRoute!.end.latitude, - _currentRoute!.end.longitude, + children: [ + TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.example.bus_terminal', ), - width: 40, - height: 40, - child: Container( - decoration: BoxDecoration( - color: Colors.red, - shape: BoxShape.circle, - border: Border.all( - color: Colors.white, - width: 2, - ), - ), - child: const Icon( - Icons.location_on, - color: Colors.white, - size: 20, - ), - ), - ), - // Current location marker - if (_currentLocation != null) - Marker( - point: LatLng( - _currentLocation!.latitude, - _currentLocation!.longitude, - ), - width: 30, - height: 30, - child: Container( - decoration: BoxDecoration( - color: Colors.blue, - shape: BoxShape.circle, - border: Border.all( - color: Colors.white, - width: 2, + MarkerLayer( + markers: [ + ...(_stops ?? []).asMap().entries.map((entry) { + final index = entry.key; + final stop = entry.value; + final color = _stopColor(index); + + return Marker( + point: LatLng(stop.latitude, stop.longitude), + width: 46, + height: 46, + child: Container( + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all( + color: Colors.white, + width: 3, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.22), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Stack( + alignment: Alignment.center, + children: [ + Icon( + _stopIcon(index), + color: Colors.white, + size: 20, + ), + Positioned( + bottom: 4, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.35), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '${index + 1}', + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + ], + ), + ), + ); + }), + // Start marker + if (!_hasStopNearPoint(_currentRoute!.start)) + Marker( + point: LatLng( + _currentRoute!.start.latitude, + _currentRoute!.start.longitude, + ), + width: 40, + height: 40, + child: Container( + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + border: Border.all( + color: Colors.white, + width: 2, + ), + ), + child: const Icon( + Icons.location_on, + color: Colors.white, + size: 20, + ), + ), ), - ), - child: const Icon( - Icons.directions_bus, - color: Colors.white, - size: 16, - ), - ), + // End marker + if (!_hasStopNearPoint(_currentRoute!.end)) + Marker( + point: LatLng( + _currentRoute!.end.latitude, + _currentRoute!.end.longitude, + ), + width: 40, + height: 40, + child: Container( + decoration: BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + border: Border.all( + color: Colors.white, + width: 2, + ), + ), + child: const Icon( + Icons.location_on, + color: Colors.white, + size: 20, + ), + ), + ), + // Current location marker + if (_currentLocation != null) + Marker( + point: LatLng( + _currentLocation!.latitude, + _currentLocation!.longitude, + ), + width: 30, + height: 30, + child: Container( + decoration: BoxDecoration( + color: Colors.blue, + shape: BoxShape.circle, + border: Border.all( + color: Colors.white, + width: 2, + ), + ), + child: const Icon( + Icons.directions_bus, + color: Colors.white, + size: 16, + ), + ), + ), + ], ), - ], - ), - PolylineLayer( - polylines: [ - // Main route - Polyline( - points: _currentRoute! - .getAllPoints() - .map((p) => LatLng(p.latitude, p.longitude)) - .toList(), - color: Colors.blue, - strokeWidth: 4, - ), - // Alternative routes (lighter) - if (_alternativeRoutes != null) - ...List.generate( - _alternativeRoutes!.length, - (i) => Polyline( - points: _alternativeRoutes![i] - .getAllPoints() - .map((p) => LatLng(p.latitude, p.longitude)) - .toList(), - color: Colors.grey.withOpacity(0.5), - strokeWidth: 2, - ), + PolylineLayer( + polylines: [ + // Main route + Polyline( + points: _currentRoute! + .getAllPoints() + .map((p) => LatLng(p.latitude, p.longitude)) + .toList(), + color: _routeColor, + strokeWidth: 5, + ), + // Alternative routes (lighter) + if (_alternativeRoutes != null) + ...List.generate( + _alternativeRoutes!.length, + (i) => Polyline( + points: _alternativeRoutes![i] + .getAllPoints() + .map((p) => LatLng(p.latitude, p.longitude)) + .toList(), + color: Colors.grey.withOpacity(0.5), + strokeWidth: 2, + ), + ), + ], ), - ], + ], + ) + : (_isLoading + ? const Center(child: CircularProgressIndicator()) + : Center( + child: _errorMessage != null + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: Colors.red[300], + ), + const SizedBox(height: 16), + Text( + 'Failed to load route', + style: + Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + _errorMessage!, + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], + ) + : const Text('No route'))), + ), + + // Vertical divider + Container(width: 1, color: Colors.grey[200]), + + // Stops / Info panel (right pane) + Expanded( + flex: 4, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + bottomLeft: Radius.circular(16), ), - ], - ) - else if (_isLoading) - const Center(child: CircularProgressIndicator()) - else if (_errorMessage != null) - Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.error_outline, - size: 64, - color: Colors.red[300], - ), - const SizedBox(height: 16), - Text( - 'Failed to load route', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Text( - _errorMessage!, - style: Theme.of(context).textTheme.bodySmall, - textAlign: TextAlign.center, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(-2, 0), ), ], ), - ), - - // Route info panel - if (_currentRoute != null) - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 10, - offset: const Offset(0, -2), - ), - ], - ), - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Route title - Text( - 'Route Summary', - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith(fontWeight: FontWeight.bold), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0F172A), Color(0xFF1D4ED8)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, ), - const SizedBox(height: 16), - - // Route info row - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Column( - children: [ - Icon( - Icons.straighten, - size: 32, - color: Colors.blue, - ), - const SizedBox(height: 8), - Text( - '${_currentRoute!.getTotalDistanceKm()} km', - style: Theme.of(context) - .textTheme - .titleSmall - ?.copyWith(fontWeight: FontWeight.bold), - ), - ], - ), - Column( - children: [ - Icon( - Icons.timer, - size: 32, - color: Colors.orange, - ), - const SizedBox(height: 8), - Text( - _currentRoute!.getTotalDurationHM(), - style: Theme.of(context) - .textTheme - .titleSmall - ?.copyWith(fontWeight: FontWeight.bold), - ), - ], - ), - Column( - children: [ - Icon( - Icons.speed, - size: 32, - color: Colors.green, - ), - const SizedBox(height: 8), - Text( - '${(_currentRoute!.totalDistance / _currentRoute!.totalDuration * 3.6).toStringAsFixed(0)} km/h', - style: Theme.of(context) - .textTheme - .titleSmall - ?.copyWith(fontWeight: FontWeight.bold), - ), - ], - ), - ], - ), - const SizedBox(height: 16), - - // Route details - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.grey[100], - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(18), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.12), + blurRadius: 18, + offset: const Offset(0, 8), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ - Row( - children: [ - Icon( - Icons.location_on, - color: Colors.green, - size: 20, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - 'From: ${_currentRoute!.start.name}', - style: Theme.of(context) - .textTheme - .bodyMedium, - ), - ), - ], + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.18), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.route, + color: Colors.white, + ), ), - const SizedBox(height: 8), - Row( - children: [ - Icon( - Icons.location_on, - color: Colors.red, - size: 20, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - 'To: ${_currentRoute!.end.name}', - style: Theme.of(context) - .textTheme - .bodyMedium, + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Route ${widget.route.busLine.lineNumber}', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), ), - ), - ], + const SizedBox(height: 2), + Text( + '${widget.route.busLine.startStop} β†’ ${widget.route.busLine.endStop}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.white.withOpacity(0.85), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), ), ], ), - ), - ], + const SizedBox(height: 16), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + minHeight: 10, + value: _completionRatio, + backgroundColor: Colors.white.withOpacity(0.12), + valueColor: const AlwaysStoppedAnimation(Colors.amberAccent), + ), + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${(_completionRatio * 100).round()}% afgerond', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + Text( + '${_completedStops.length}/${_stops?.length ?? 0} stops', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.white.withOpacity(0.85), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildSummaryPill( + 'Huidig', + _currentStopPoint?.name ?? 'Geen stop', + Icons.radio_button_checked, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildSummaryPill( + 'Volgend', + _nextStopPoint?.name ?? 'Einde route', + Icons.skip_next, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: const Color(0xFF0F172A), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + onPressed: _stops == null || _stops!.isEmpty || _isStopCompleted(_currentStopIndex) + ? null + : () => _markStopCompleted(_currentStopIndex), + icon: const Icon(Icons.check_circle_outline), + label: const Text('Markeer huidige stop afgerond'), + ), + ), + ], + ), ), - ), + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + _legendDot(Colors.green, 'Afgerond'), + const SizedBox(width: 12), + _legendDot(Colors.orange, 'Actief'), + const SizedBox(width: 12), + _legendDot(Colors.blueGrey, 'Te doen'), + ], + ), + ), + if (_stopsFetchError != null) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.red.withOpacity(0.25)), + ), + child: Text( + 'Stops konden niet geladen worden voor buslijn ${widget.route.busLine.id}: $_stopsFetchError', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + const SizedBox(height: 12), + Text( + 'Stops', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Expanded( + child: _stops == null || _stops!.isEmpty + ? Center( + child: Text( + 'No stops available for this line', + style: Theme.of(context).textTheme.bodySmall, + ), + ) + : ListView.separated( + itemCount: _stops!.length, + separatorBuilder: (_, __) => const Divider(), + itemBuilder: (context, index) { + final s = _stops![index]; + final color = _stopColor(index); + final completed = _isStopCompleted(index); + final isCurrent = index == _currentStopIndex; + return ListTile( + leading: CircleAvatar( + radius: 14, + backgroundColor: color, + child: Icon( + _stopIcon(index), + color: Colors.white, + size: 16, + ), + ), + title: Text(s.name ?? 'Stop ${index + 1}'), + subtitle: Text('${s.latitude.toStringAsFixed(5)}, ${s.longitude.toStringAsFixed(5)}'), + trailing: completed + ? const Chip( + label: Text('Afgerond'), + visualDensity: VisualDensity.compact, + ) + : TextButton.icon( + onPressed: () => _markStopCompleted(index), + icon: const Icon(Icons.check, size: 18), + label: Text(isCurrent ? 'Afronden' : 'Klaarzetten'), + ), + onTap: () => _focusStop(index), + ); + }, + ), + ), + ], ), ), ), + ), + ], + ), + ); + } + + Widget _legendDot(Color color, String label) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + label, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ); + } + + Widget _buildSummaryPill(String title, String value, IconData icon) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.12), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white.withOpacity(0.16)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: Colors.white, size: 18), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.white.withOpacity(0.75), + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 4), + Text( + value, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), ], ), ); diff --git a/bus_terminal/lib/services/auth_service.dart b/bus_terminal/lib/services/auth_service.dart index 9358b40..d572bbc 100644 --- a/bus_terminal/lib/services/auth_service.dart +++ b/bus_terminal/lib/services/auth_service.dart @@ -1,5 +1,4 @@ import 'dart:convert'; -import 'dart:ffi'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import '../models/user.dart'; diff --git a/bus_terminal/lib/services/navigation_service.dart b/bus_terminal/lib/services/navigation_service.dart index c9d55ff..3ddaa8c 100644 --- a/bus_terminal/lib/services/navigation_service.dart +++ b/bus_terminal/lib/services/navigation_service.dart @@ -47,21 +47,25 @@ class NavigationService { final response = await http.get(Uri.parse(url)); if (response.statusCode == 200) { - final data = jsonDecode(response.body) as Map; - - if (data['code'] != 'Ok') { - throw Exception('Route not found: ${data['message']}'); + final data = jsonDecode(response.body) as Map?; + if (data == null) { + throw Exception('Empty response from routing service'); } - final route = NavigationRoute.fromJson(data); + if (data['code'] != 'Ok') { + throw Exception('Route not found: ${data['message'] ?? 'Unknown error'}'); + } - // Set start and end points properly - return NavigationRoute( + final routes = data['routes'] as List?; + if (routes == null || routes.isEmpty) { + throw Exception('No route found in response'); + } + + final routeData = routes.first as Map; + return NavigationRoute.fromOsrmRouteData( + routeData, start: start, end: end, - legs: route.legs, - totalDistance: route.totalDistance, - totalDuration: route.totalDuration, ); } else { throw Exception('Failed to get route: ${response.statusCode}'); @@ -71,6 +75,144 @@ class NavigationService { } } + /// Get a route that passes through the provided stops in the given order. + Future getRouteThroughStops({ + required List stops, + }) async { + try { + if (stops.length < 2) { + throw Exception('At least two stops are required'); + } + + final coordinates = stops + .map((stop) => '${stop.longitude},${stop.latitude}') + .join(';'); + final url = + '$_osrmBaseUrl/$coordinates?overview=full&steps=true&geometries=geojson&source=first&destination=last'; + + final response = await http.get(Uri.parse(url)); + + if (response.statusCode != 200) { + throw Exception('Failed to get route: ${response.statusCode}'); + } + + final data = jsonDecode(response.body) as Map?; + if (data == null) { + throw Exception('Empty response from routing service'); + } + + if (data['code'] != 'Ok') { + throw Exception('Route not found: ${data['message'] ?? 'Unknown error'}'); + } + + final routes = data['routes'] as List?; + if (routes == null || routes.isEmpty) { + throw Exception('No route found in response'); + } + + final routeData = routes.first as Map; + return NavigationRoute.fromOsrmRouteData( + routeData, + start: stops.first, + end: stops.last, + ); + } catch (e) { + throw Exception('Navigation error: $e'); + } + } + + /// Get an optimized trip through multiple stops. + /// Keeps the first and last stops fixed and lets OSRM order the intermediates. + Future getOptimizedRouteThroughStops({ + required List stops, + }) async { + try { + if (stops.length < 2) { + throw Exception('At least two stops are required'); + } + + final coordinates = stops + .map((stop) => '${stop.longitude},${stop.latitude}') + .join(';'); + final url = + 'https://router.project-osrm.org/trip/v1/car/$coordinates?overview=full&steps=true&geometries=geojson&source=first&destination=last&roundtrip=false'; + + final response = await http.get(Uri.parse(url)); + + if (response.statusCode != 200) { + throw Exception('Failed to get optimized route: ${response.statusCode}'); + } + + final data = jsonDecode(response.body) as Map?; + if (data == null) { + throw Exception('Empty response from routing service'); + } + + if (data['code'] != 'Ok') { + throw Exception('Optimized route not found: ${data['message'] ?? 'Unknown error'}'); + } + + final trips = data['trips'] as List?; + if (trips == null || trips.isEmpty) { + throw Exception('No optimized trip found'); + } + + final trip = trips.first as Map; + final geometry = trip['geometry'] as Map?; + final coordinatesData = geometry?['coordinates'] as List? ?? []; + final points = coordinatesData + .map((coord) { + if (coord is List && coord.length >= 2) { + return NavigationPoint( + latitude: (coord[1] as num).toDouble(), + longitude: (coord[0] as num).toDouble(), + ); + } + return null; + }) + .whereType() + .toList(); + + final waypointItems = (data['waypoints'] as List?) ?? []; + final orderedPairs = >[]; + + for (var i = 0; i < waypointItems.length && i < stops.length; i++) { + final waypoint = waypointItems[i]; + if (waypoint is! Map) continue; + final index = waypoint['waypoint_index'] as num?; + if (index == null) continue; + orderedPairs.add(MapEntry(index.toInt(), stops[i])); + } + + orderedPairs.sort((a, b) => a.key.compareTo(b.key)); + final orderedStops = orderedPairs.isNotEmpty + ? orderedPairs.map((entry) => entry.value).toList() + : stops; + + final route = NavigationRoute( + start: orderedStops.first, + end: orderedStops.last, + legs: [ + RouteLeg.fromPoints( + points: points, + distance: (trip['distance'] as num?)?.toDouble() ?? 0.0, + duration: (trip['duration'] as num?)?.toInt() ?? 0, + summary: 'Optimized trip', + ), + ], + totalDistance: (trip['distance'] as num?)?.toDouble() ?? 0.0, + totalDuration: (trip['duration'] as num?)?.toInt() ?? 0, + ); + + return OptimizedRouteResult( + route: route, + orderedStops: orderedStops.isNotEmpty ? orderedStops : stops, + ); + } catch (e) { + throw Exception('Navigation error: $e'); + } + } + /// Get multiple routes (alternatives) Future> getMultipleRoutes({ required NavigationPoint start, @@ -86,26 +228,30 @@ class NavigationService { final response = await http.get(Uri.parse(url)); if (response.statusCode == 200) { - final data = jsonDecode(response.body) as Map; - - if (data['code'] != 'Ok') { - throw Exception('Routes not found: ${data['message']}'); + final data = jsonDecode(response.body) as Map?; + if (data == null) { + throw Exception('Empty response from routing service'); } - final routes = (data['routes'] as List) + if (data['code'] != 'Ok') { + throw Exception('Routes not found: ${data['message'] ?? 'Unknown error'}'); + } + + final routesData = data['routes'] as List?; + if (routesData == null || routesData.isEmpty) { + throw Exception('No routes found in response'); + } + + final routes = routesData .map((routeData) { - return NavigationRoute( + if (routeData is! Map) return null; + return NavigationRoute.fromOsrmRouteData( + routeData, start: start, end: end, - legs: (routeData['legs'] as List) - .map((leg) => - RouteLeg.fromJson(leg as Map)) - .toList(), - totalDistance: - (routeData['distance'] as num).toDouble(), - totalDuration: (routeData['duration'] as num).toInt(), ); }) + .whereType() .toList(); return routes; @@ -125,4 +271,37 @@ class NavigationService { LatLng(p2.latitude, p2.longitude), ); } + + /// Geocode a place name to coordinates using Nominatim (OpenStreetMap) + Future geocodePlace(String name) async { + try { + final uri = Uri.https('nominatim.openstreetmap.org', '/search', { + 'q': name, + 'format': 'json', + 'limit': '1', + }); + + final response = await http.get(uri, headers: { + 'User-Agent': 'bus-terminal-app', + }); + + if (response.statusCode != 200) { + throw Exception('Geocoding failed: ${response.statusCode}'); + } + + final list = jsonDecode(response.body) as List?; + if (list == null || list.isEmpty) { + throw Exception('No geocoding result for "$name"'); + } + + final item = list[0] as Map; + final lat = double.parse(item['lat'] as String); + final lon = double.parse(item['lon'] as String); + final display = item['display_name'] as String? ?? name; + + return NavigationPoint(latitude: lat, longitude: lon, name: display); + } catch (e) { + throw Exception('Geocode error: $e'); + } + } } diff --git a/bus_terminal/lib/services/schedule_service.dart b/bus_terminal/lib/services/schedule_service.dart index e8b3634..fd3a08f 100644 --- a/bus_terminal/lib/services/schedule_service.dart +++ b/bus_terminal/lib/services/schedule_service.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import '../models/schedule.dart'; +import '../models/navigation.dart'; import 'auth_service.dart'; /// Schedule/Route Service @@ -82,4 +83,42 @@ class ScheduleService { throw Exception('Status update error: $e'); } } + + /// Get stops for a bus line + Future> getStops(int busLineId) async { + try { + final headers = await _authService.getAuthHeaders(); + + final response = await http.get( + Uri.parse('$_apiBaseUrl/bus-lines/$busLineId/stops'), + headers: headers, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as Map; + final stops = (data['stops'] as List?) + ?.map((s) => s as Map) + .map((s) { + final lat = (s['latitude'] ?? s['lat']) as num?; + final lon = (s['longitude'] ?? s['lon']) as num?; + final name = (s['name'] ?? s['stop_name'] ?? s['stopName']) as String?; + if (lat == null || lon == null) return null; + return NavigationPoint( + latitude: lat.toDouble(), + longitude: lon.toDouble(), + name: name, + ); + }) + .whereType() + .toList() ?? []; + return stops; + } else if (response.statusCode == 401) { + throw Exception('Unauthorized - Please login again'); + } else { + throw Exception('Failed to load stops: ${response.statusCode}'); + } + } catch (e) { + throw Exception('Get stops error: $e'); + } + } } diff --git a/bus_terminal/lib/theme/app_theme.dart b/bus_terminal/lib/theme/app_theme.dart index 1f7b3f1..b69b45f 100644 --- a/bus_terminal/lib/theme/app_theme.dart +++ b/bus_terminal/lib/theme/app_theme.dart @@ -2,23 +2,203 @@ import 'package:flutter/material.dart'; /// Material 3 Theme configuratie voor bus_terminal class AppTheme { - // TODO: Implement Material 3 theme for bus drivers - // - Large touch targets - // - High contrast - // - Clear visual hierarchy + static const Color _navy = Color(0xFF0F172A); + static const Color _surface = Color(0xFFF8FAFC); + static const Color _surfaceDark = Color(0xFF111827); + static const Color _amber = Color(0xFFF59E0B); + static const Color _cyan = Color(0xFF38BDF8); + static const Color _success = Color(0xFF22C55E); + static const Color _error = Color(0xFFEF4444); + + static TextTheme _textTheme(ColorScheme colorScheme) { + return TextTheme( + displayLarge: const TextStyle(fontSize: 46, fontWeight: FontWeight.w800, letterSpacing: -1.2), + displayMedium: const TextStyle(fontSize: 38, fontWeight: FontWeight.w800, letterSpacing: -0.9), + headlineLarge: const TextStyle(fontSize: 30, fontWeight: FontWeight.w800, letterSpacing: -0.4), + headlineMedium: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, letterSpacing: -0.3), + titleLarge: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700), + titleMedium: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + titleSmall: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700), + bodyLarge: TextStyle(fontSize: 16, height: 1.45, color: colorScheme.onSurface), + bodyMedium: TextStyle(fontSize: 14, height: 1.45, color: colorScheme.onSurfaceVariant), + bodySmall: TextStyle(fontSize: 12, height: 1.35, color: colorScheme.onSurfaceVariant), + labelLarge: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700), + labelMedium: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + labelSmall: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 0.25), + ); + } + + static ColorScheme _buildLightScheme() { + return ColorScheme.fromSeed( + seedColor: _cyan, + brightness: Brightness.light, + primary: _navy, + secondary: _amber, + tertiary: _cyan, + surface: _surface, + onSurface: _navy, + error: _error, + ).copyWith( + surfaceContainerHighest: const Color(0xFFE2E8F0), + outline: const Color(0xFF94A3B8), + ); + } + + static ColorScheme _buildDarkScheme() { + return ColorScheme.fromSeed( + seedColor: _cyan, + brightness: Brightness.dark, + primary: _cyan, + secondary: _amber, + tertiary: _success, + surface: _surfaceDark, + onSurface: Colors.white, + error: _error, + ).copyWith( + surfaceContainerHighest: const Color(0xFF1F2937), + outline: const Color(0xFF334155), + ); + } static ThemeData lightTheme() { + final colorScheme = _buildLightScheme(); return ThemeData( useMaterial3: true, - // TODO: Configure Material 3 theme + brightness: Brightness.light, + colorScheme: colorScheme, + scaffoldBackgroundColor: const Color(0xFFF1F5F9), + textTheme: _textTheme(colorScheme), + appBarTheme: AppBarTheme( + backgroundColor: colorScheme.surface, + foregroundColor: colorScheme.onSurface, + elevation: 0, + centerTitle: false, + titleTextStyle: _textTheme(colorScheme).titleLarge?.copyWith(color: colorScheme.onSurface), + ), + cardTheme: CardThemeData( + color: colorScheme.surface, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + margin: EdgeInsets.zero, + ), + dividerTheme: DividerThemeData(color: colorScheme.outline.withOpacity(0.18), thickness: 1), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.25))), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.25))), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.primary, width: 2)), + errorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.error)), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18), + labelStyle: TextStyle(color: colorScheme.onSurfaceVariant), + hintStyle: TextStyle(color: colorScheme.onSurfaceVariant.withOpacity(0.8)), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(56), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.secondary, + foregroundColor: colorScheme.onSecondary, + minimumSize: const Size.fromHeight(52), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.onSurface, + minimumSize: const Size.fromHeight(52), + side: BorderSide(color: colorScheme.outline.withOpacity(0.35)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + ), + chipTheme: ChipThemeData( + backgroundColor: colorScheme.surfaceContainerHighest, + labelStyle: TextStyle(color: colorScheme.onSurface, fontWeight: FontWeight.w600), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), + ), + progressIndicatorTheme: ProgressIndicatorThemeData( + color: colorScheme.primary, + linearTrackColor: colorScheme.primary.withOpacity(0.12), + ), ); } static ThemeData darkTheme() { + final colorScheme = _buildDarkScheme(); return ThemeData( useMaterial3: true, brightness: Brightness.dark, - // TODO: Configure Material 3 dark theme + colorScheme: colorScheme, + scaffoldBackgroundColor: const Color(0xFF0B1220), + textTheme: _textTheme(colorScheme).apply(bodyColor: Colors.white, displayColor: Colors.white), + appBarTheme: AppBarTheme( + backgroundColor: const Color(0xFF0B1220), + foregroundColor: Colors.white, + elevation: 0, + centerTitle: false, + titleTextStyle: _textTheme(colorScheme).titleLarge?.copyWith(color: Colors.white), + ), + cardTheme: CardThemeData( + color: const Color(0xFF111827), + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + margin: EdgeInsets.zero, + ), + dividerTheme: const DividerThemeData(color: Color(0xFF243244), thickness: 1), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: const Color(0xFF111827), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF334155))), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF334155))), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.primary, width: 2)), + errorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.error)), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18), + labelStyle: const TextStyle(color: Color(0xFFCBD5E1)), + hintStyle: const TextStyle(color: Color(0xFF94A3B8)), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: Colors.black, + minimumSize: const Size.fromHeight(56), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.secondary, + foregroundColor: Colors.black, + minimumSize: const Size.fromHeight(52), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(52), + side: const BorderSide(color: Color(0xFF334155)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + ), + chipTheme: ChipThemeData( + backgroundColor: const Color(0xFF1E293B), + labelStyle: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), + ), + progressIndicatorTheme: ProgressIndicatorThemeData( + color: colorScheme.primary, + linearTrackColor: colorScheme.primary.withOpacity(0.16), + ), ); } } diff --git a/bus_terminal/lib/widgets/custom_input_field.dart b/bus_terminal/lib/widgets/custom_input_field.dart index 6437ef7..8ba4532 100644 --- a/bus_terminal/lib/widgets/custom_input_field.dart +++ b/bus_terminal/lib/widgets/custom_input_field.dart @@ -37,6 +37,8 @@ class _CustomInputFieldState extends State { @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -54,13 +56,30 @@ class _CustomInputFieldState extends State { validator: widget.validator, decoration: InputDecoration( hintText: widget.hint, - hintStyle: Theme.of(context).textTheme.bodyMedium, + hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 16, // Larger vertical padding for touch targets ), + prefixIcon: Icon( + widget.obscureText ? Icons.lock_outline : Icons.badge_outlined, + color: colorScheme.primary, + ), + filled: true, + fillColor: colorScheme.surface, border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.25)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.25)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide(color: colorScheme.primary, width: 2), ), suffixIcon: widget.obscureText ? GestureDetector( @@ -70,6 +89,7 @@ class _CustomInputFieldState extends State { child: Icon( _obscureText ? Icons.visibility_off : Icons.visibility, size: 28, // Larger icon for accessibility + color: colorScheme.primary, ), ) : null, diff --git a/bus_terminal/lib/widgets/route_card.dart b/bus_terminal/lib/widgets/route_card.dart index d773844..d82f9da 100644 --- a/bus_terminal/lib/widgets/route_card.dart +++ b/bus_terminal/lib/widgets/route_card.dart @@ -49,6 +49,7 @@ class RouteCard extends StatelessWidget { @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; final statusColor = _getStatusColor(route.status); final startTimeStr = _formatTime(route.startTime); @@ -63,6 +64,9 @@ class RouteCard extends StatelessWidget { }, child: Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + elevation: 0, + color: colorScheme.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), child: Padding( padding: const EdgeInsets.all(16), child: Column( @@ -77,7 +81,11 @@ class RouteCard extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( - color: Colors.blue, + gradient: LinearGradient( + colors: [colorScheme.primary, colorScheme.tertiary], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), borderRadius: BorderRadius.circular(8), ), child: Text( @@ -94,7 +102,7 @@ class RouteCard extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( - color: statusColor.withOpacity(0.2), + color: statusColor.withOpacity(0.14), border: Border.all(color: statusColor), borderRadius: BorderRadius.circular(20), ), @@ -134,7 +142,7 @@ class RouteCard extends StatelessWidget { ), Icon( Icons.arrow_forward, - color: Colors.grey[400], + color: colorScheme.outline, ), Expanded( child: Column( @@ -181,7 +189,7 @@ class RouteCard extends StatelessWidget { Text( route.busType.licensePlate, style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: Colors.grey[600], + color: colorScheme.onSurfaceVariant, ), ), ], @@ -199,14 +207,14 @@ class RouteCard extends StatelessWidget { style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.bold, - color: Colors.blue, + color: colorScheme.primary, ), ), const SizedBox(height: 4), Text( '${route.busLine.estimatedDuration} min duration', style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: Colors.grey[600], + color: colorScheme.onSurfaceVariant, ), ), ], diff --git a/docker-compose.yml b/docker-compose.yml index 326425d..9174bfd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,7 @@ services: PG_DB: ${POSTGRES_DB} PG_USER: ${POSTGRES_USER_AUTH} PG_PASS: ${POSTGRES_PASSWORD_AUTH} + DEV_BYPASS: ${DEV_BYPASS:-0} restart: unless-stopped employee_api: @@ -43,4 +44,19 @@ services: PG_DB: ${POSTGRES_DB} PG_USER: ${POSTGRES_USER_TRANSIT} PG_PASS: ${POSTGRES_PASSWORD_TRANSIT} + ADMIN_USER_CODES: ${ADMIN_USER_CODES} + DEV_MOCK: ${DEV_MOCK:-0} + restart: unless-stopped + + admin_ui: + image: ghcr.io/sven491/xtransit/admin_ui:latest + container_name: admin_ui + ports: + - "5174:80" + environment: + VITE_AUTH_API: http://auth_api:5000 + VITE_TRANSIT_API: http://transit_api:5001 + depends_on: + - auth_api + - transit_api restart: unless-stopped \ No newline at end of file diff --git a/scripts/build-and-push.sh b/scripts/build-and-push.sh new file mode 100755 index 0000000..33b02b5 --- /dev/null +++ b/scripts/build-and-push.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# Build and push Docker images to registry +# Usage: ./scripts/build-and-push.sh [registry-url] + +set -e + +REGISTRY=${1:-ghcr.io/sven491/xtransit} +VERSION=${2:-latest} + +echo "πŸ”¨ Building Docker images..." + +# Build auth_api +echo " Building auth_api..." +docker build -t ${REGISTRY}/auth_api:${VERSION} ./auth_api + +# Build transit_api +echo " Building transit_api..." +docker build -t ${REGISTRY}/transit_api:${VERSION} ./transit_api + +# Build employee_api +echo " Building employee_api..." +docker build -t ${REGISTRY}/employee_api:${VERSION} ./employee_api + +# Build admin_ui +echo " Building admin_ui..." +docker build -t ${REGISTRY}/admin_ui:${VERSION} ./admin_ui + +echo "βœ… Build complete!" +echo "" +echo "πŸš€ Pushing images to registry: $REGISTRY" + +docker push ${REGISTRY}/auth_api:${VERSION} +echo " βœ“ Pushed auth_api:${VERSION}" + +docker push ${REGISTRY}/transit_api:${VERSION} +echo " βœ“ Pushed transit_api:${VERSION}" + +docker push ${REGISTRY}/employee_api:${VERSION} +echo " βœ“ Pushed employee_api:${VERSION}" + +docker push ${REGISTRY}/admin_ui:${VERSION} +echo " βœ“ Pushed admin_ui:${VERSION}" + +echo "" +echo "βœ… All images pushed successfully!" +echo "" +echo "Update docker-compose.yml with:" +echo " image: ${REGISTRY}/auth_api:${VERSION}" +echo " image: ${REGISTRY}/transit_api:${VERSION}" +echo " image: ${REGISTRY}/employee_api:${VERSION}" +echo " image: ${REGISTRY}/admin_ui:${VERSION}" diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..c49e83c --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# Quick deployment helper script +# Usage: ./scripts/deploy.sh [start|stop|restart|logs|status] + +set -e + +COMMAND=${1:-status} + +case $COMMAND in + start) + echo "πŸš€ Starting XTransit services..." + docker-compose up -d + echo "βœ… Services started!" + echo "" + echo "πŸ“‹ Service URLs:" + echo " Admin UI: http://localhost:5174" + echo " Auth API: http://localhost:5000" + echo " Transit API: http://localhost:5001" + echo " Employee API: http://localhost:4000" + ;; + + stop) + echo "⏹️ Stopping XTransit services..." + docker-compose down + echo "βœ… Services stopped!" + ;; + + restart) + echo "πŸ”„ Restarting XTransit services..." + docker-compose restart + echo "βœ… Services restarted!" + ;; + + logs) + SERVICE=${2:-admin_ui} + echo "πŸ“‹ Logs for $SERVICE (last 100 lines):" + docker-compose logs --tail=100 -f $SERVICE + ;; + + status) + echo "πŸ“Š XTransit Services Status:" + docker-compose ps + ;; + + build) + echo "πŸ”¨ Building all services..." + docker-compose build + echo "βœ… Build complete!" + ;; + + clean) + echo "🧹 Cleaning up containers and volumes..." + docker-compose down -v + echo "βœ… Cleanup complete!" + ;; + + *) + echo "Usage: $0 [start|stop|restart|logs|status|build|clean]" + echo "" + echo "Commands:" + echo " start - Start all services" + echo " stop - Stop all services" + echo " restart - Restart all services" + echo " logs [svc] - View logs (optional service name)" + echo " status - Show service status" + echo " build - Build all Docker images locally" + echo " clean - Remove containers and volumes" + exit 1 + ;; +esac diff --git a/scripts/init.sh b/scripts/init.sh new file mode 100755 index 0000000..cb12e4f --- /dev/null +++ b/scripts/init.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# XTransit Project - Initial Setup Script +# This script prepares the environment for local development or deployment + +set -e + +echo "πŸš€ XTransit Transit Project - Setup Script" +echo "==========================================" +echo "" + +# Check prerequisites +echo "πŸ“‹ Checking prerequisites..." + +if ! command -v docker &> /dev/null; then + echo "❌ Docker is not installed. Please install Docker." + exit 1 +fi +echo "βœ… Docker found: $(docker --version)" + +if ! command -v docker-compose &> /dev/null; then + echo "❌ Docker Compose is not installed. Please install Docker Compose." + exit 1 +fi +echo "βœ… Docker Compose found: $(docker-compose --version)" + +echo "" +echo "πŸ“ Setting up configuration..." + +# Create .env if it doesn't exist +if [ -f .env ]; then + echo "⚠️ .env file already exists, skipping creation" +else + echo "Creating .env from .env.example..." + cp .env.example .env + echo "βœ… .env created. Please edit it with your database credentials." + echo "" + echo "Required values to set:" + echo " - POSTGRES_HOST: Your PostgreSQL server hostname" + echo " - POSTGRES_PORT: PostgreSQL port (default: 5432)" + echo " - POSTGRES_DB: Database name (default: XTransit)" + echo " - POSTGRES_USER_AUTH: Auth API database user" + echo " - POSTGRES_PASSWORD_AUTH: Auth API database password" + echo " - POSTGRES_USER_TRANSIT: Transit API database user" + echo " - POSTGRES_PASSWORD_TRANSIT: Transit API database password" + echo " - POSTGRES_USER_EMPLOYEE: Employee API database user" + echo " - POSTGRES_PASSWORD_EMPLOYEE: Employee API database password" + echo " - JWT_SECRET: JWT signing secret" + echo " - ADMIN_USER_CODES: Comma-separated admin user codes" + echo "" + echo "✏️ Edit .env file now? (y/n)" + read -r response + if [[ "$response" =~ ^[Yy]$ ]]; then + ${EDITOR:-nano} .env + fi +fi + +echo "" +echo "πŸ“¦ Building Docker images..." +docker-compose build + +echo "" +echo "βœ… Setup complete!" +echo "" +echo "Next steps:" +echo " 1. Review and update .env with your database credentials" +echo " 2. Start services: ./scripts/deploy.sh start" +echo " 3. Access Admin UI: http://localhost:5174" +echo "" +echo "Available commands:" +echo " ./scripts/deploy.sh start - Start all services" +echo " ./scripts/deploy.sh stop - Stop all services" +echo " ./scripts/deploy.sh logs - View logs" +echo " ./scripts/deploy.sh status - Show service status" +echo "" +echo "Documentation:" +echo " - DEPLOYMENT.md - Comprehensive deployment guide" +echo " - DOCKER_README.md - Docker specific instructions" +echo " - admin_ui/README.md - Admin UI documentation" diff --git a/transit_api/README.md b/transit_api/README.md index f7f7c9b..e15a213 100644 --- a/transit_api/README.md +++ b/transit_api/README.md @@ -42,6 +42,7 @@ PG_USER=your_user PG_PASS=your_password PG_DB=your_database JWT_SECRET=your_jwt_secret +ADMIN_USER_CODES=12345,67890 PORT=5001 ``` @@ -168,6 +169,32 @@ Alle endpoints vereisen een `Authorization: Bearer {token}` header. - Body: `{ "status": "in_progress" }` - Valid statuses: `scheduled`, `in_progress`, `completed`, `cancelled` +### Admin + +Alle admin-endpoints vereisen naast een geldig JWT-token ook dat je `userCode` voorkomt in `ADMIN_USER_CODES`. + +**POST** `/admin/stops` +- Maak een herbruikbare stop aan +- Body: +```json +{ + "name": "Central Station", + "latitude": 52.5200, + "longitude": 13.4050 +} +``` + +**POST** `/admin/bus-lines/:busLineId/stops` +- Koppel een bestaande stop aan een buslijn +- Body: +```json +{ + "stopId": 1, + "stopOrder": 1, + "estimatedArrivalMinutes": 0 +} +``` + ## Database Schema ### bus_types @@ -183,6 +210,18 @@ Alle endpoints vereisen een `Authorization: Bearer {token}` header. - `end_stop` - End stop name - `estimated_duration_minutes` - Expected route duration +### stops +- `id` - Primary key +- `name` - Stop name +- `latitude`, `longitude` - Stop location + +### route_stops +- `id` - Primary key +- `bus_line_id` - Which bus line the stop belongs to +- `stop_id` - Reusable stop reference +- `stop_order` - Stop sequence on that bus line +- `estimated_arrival_minutes` - Optional ETA per stop + ### routes - `id` - Primary key - `user_id` - Chauffeur (references workers.users) diff --git a/transit_api/db.js b/transit_api/db.js index f3e1119..96d4f0c 100644 --- a/transit_api/db.js +++ b/transit_api/db.js @@ -6,6 +6,8 @@ const pool = new Pool({ user: process.env.PG_USER, password: process.env.PG_PASS, database: process.env.PG_DB, + // Ensure unqualified table names resolve to transit schema first. + options: '-c search_path=transit,public', max: 10, idleTimeoutMillis: 30000 }); diff --git a/transit_api/index.js b/transit_api/index.js index 75a5d23..50f8294 100644 --- a/transit_api/index.js +++ b/transit_api/index.js @@ -9,6 +9,37 @@ const app = express(); app.use(express.json()); app.use(cors()); +// Development mock storage when DEV_MOCK=1 +const DEV_MOCK = process.env.DEV_MOCK === '1'; +const _mock = { + stops: [], + routeStops: [], + busLines: [], + fleetBuses: [], + nextStopId: 1, + nextRouteStopId: 1, + nextBusLineId: 1, + nextFleetBusId: 1, +}; + +const adminUserCodes = new Set( + (process.env.ADMIN_USER_CODES || '') + .split(',') + .map(code => code.trim()) + .filter(Boolean) + .map(code => Number(code)) + .filter(code => Number.isFinite(code)) +); + +const adminMiddleware = (req, res, next) => { + const userCode = Number(req.user?.userCode); + if (!Number.isFinite(userCode) || !adminUserCodes.has(userCode)) { + return res.status(403).json({ error: 'forbidden' }); + } + + next(); +}; + // ============================================ // ROUTES - Get daily schedule // ============================================ @@ -141,18 +172,33 @@ app.get('/routes/:routeId', authMiddleware, async (req, res) => { app.get('/bus-lines/:busLineId/stops', authMiddleware, async (req, res) => { try { const { busLineId } = req.params; + if (DEV_MOCK) { + const stops = _mock.routeStops + .filter(rs => String(rs.busLineId) === String(busLineId)) + .sort((a,b) => a.stopOrder - b.stopOrder) + .map(rs => ({ + id: rs.id, + order: rs.stopOrder, + name: rs.name, + latitude: rs.latitude, + longitude: rs.longitude, + estimatedArrivalMinutes: rs.estimatedArrivalMinutes, + })); + return res.json({ stops }); + } const result = await db.query(` - SELECT - id, - stop_order, - stop_name, - latitude, - longitude, - estimated_arrival_minutes - FROM route_stops - WHERE bus_line_id = $1 - ORDER BY stop_order ASC + SELECT + rs.id, + rs.stop_order, + s.name as stop_name, + s.latitude, + s.longitude, + rs.estimated_arrival_minutes + FROM transit.route_stops rs + JOIN transit.stops s ON s.id = rs.stop_id + WHERE rs.bus_line_id = $1 + ORDER BY rs.stop_order ASC `, [busLineId]); const stops = result.rows.map(row => ({ @@ -171,6 +217,353 @@ app.get('/bus-lines/:busLineId/stops', authMiddleware, async (req, res) => { } }); +// ============================================ +// ADMIN - Create reusable stop +// ============================================ +app.get('/admin/stops', authMiddleware, adminMiddleware, async (req, res) => { + try { + if (DEV_MOCK) { + return res.json({ stops: _mock.stops.slice().sort((a, b) => a.id - b.id) }); + } + + const result = await db.query(` + SELECT id, name, latitude, longitude, created_at, updated_at + FROM transit.stops + ORDER BY id ASC + `); + + res.json({ + stops: result.rows.map(row => ({ + id: row.id, + name: row.name, + latitude: parseFloat(row.latitude), + longitude: parseFloat(row.longitude), + createdAt: row.created_at, + updatedAt: row.updated_at, + })), + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'internal_error', details: err.message }); + } +}); + +app.post('/admin/stops', authMiddleware, adminMiddleware, async (req, res) => { + try { + const { name, latitude, longitude } = req.body; + + if (!name || latitude === undefined || longitude === undefined) { + return res.status(400).json({ error: 'missing_required_fields' }); + } + + if (DEV_MOCK) { + const stop = { + id: _mock.nextStopId++, + name, + latitude: parseFloat(latitude), + longitude: parseFloat(longitude), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + _mock.stops.push(stop); + return res.status(201).json({ stop }); + } + + const result = await db.query(` + WITH next_id AS ( + SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.stops + ) + INSERT INTO transit.stops (id, name, latitude, longitude) + SELECT next_id.id, $1, $2, $3 + FROM next_id + RETURNING id, name, latitude, longitude, created_at, updated_at + `, [name, latitude, longitude]); + + const stop = result.rows[0]; + res.status(201).json({ + stop: { + id: stop.id, + name: stop.name, + latitude: parseFloat(stop.latitude), + longitude: parseFloat(stop.longitude), + createdAt: stop.created_at, + updatedAt: stop.updated_at, + }, + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'internal_error', details: err.message }); + } +}); + +// ============================================ +// ADMIN - Manage bus lines +// ============================================ +app.get('/admin/bus-lines', authMiddleware, adminMiddleware, async (req, res) => { + try { + if (DEV_MOCK) { + return res.json({ busLines: _mock.busLines.slice().sort((a, b) => a.lineNumber - b.lineNumber) }); + } + + const result = await db.query(` + SELECT id, line_number, start_stop, end_stop, estimated_duration_minutes, description + FROM transit.bus_lines + ORDER BY line_number ASC, id ASC + `); + + res.json({ + busLines: result.rows.map(row => ({ + id: row.id, + lineNumber: row.line_number, + startStop: row.start_stop, + endStop: row.end_stop, + estimatedDurationMinutes: row.estimated_duration_minutes, + description: row.description, + })), + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'internal_error', details: err.message }); + } +}); + +app.post('/admin/bus-lines', authMiddleware, adminMiddleware, async (req, res) => { + try { + const { lineNumber, startStop, endStop, estimatedDurationMinutes, description } = req.body; + + if (lineNumber === undefined || !startStop || !endStop || estimatedDurationMinutes === undefined) { + return res.status(400).json({ error: 'missing_required_fields' }); + } + + if (DEV_MOCK) { + const busLine = { + id: _mock.nextBusLineId++, + lineNumber: Number(lineNumber), + startStop, + endStop, + estimatedDurationMinutes: Number(estimatedDurationMinutes), + description: description || '', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + _mock.busLines.push(busLine); + return res.status(201).json({ busLine }); + } + + const result = await db.query(` + WITH next_id AS ( + SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.bus_lines + ) + INSERT INTO transit.bus_lines (id, line_number, start_stop, end_stop, estimated_duration_minutes, description) + SELECT next_id.id, $1, $2, $3, $4, $5 + FROM next_id + RETURNING id, line_number, start_stop, end_stop, estimated_duration_minutes, description + `, [lineNumber, startStop, endStop, estimatedDurationMinutes, description || null]); + + const busLine = result.rows[0]; + res.status(201).json({ + busLine: { + id: busLine.id, + lineNumber: busLine.line_number, + startStop: busLine.start_stop, + endStop: busLine.end_stop, + estimatedDurationMinutes: busLine.estimated_duration_minutes, + description: busLine.description, + }, + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'internal_error', details: err.message }); + } +}); + +app.patch('/admin/bus-lines/:busLineId', authMiddleware, adminMiddleware, async (req, res) => { + try { + const { busLineId } = req.params; + const { lineNumber, startStop, endStop, estimatedDurationMinutes, description } = req.body; + + if (DEV_MOCK) { + const existing = _mock.busLines.find(line => String(line.id) === String(busLineId)); + if (!existing) return res.status(404).json({ error: 'bus_line_not_found' }); + if (lineNumber !== undefined) existing.lineNumber = Number(lineNumber); + if (startStop !== undefined) existing.startStop = startStop; + if (endStop !== undefined) existing.endStop = endStop; + if (estimatedDurationMinutes !== undefined) existing.estimatedDurationMinutes = Number(estimatedDurationMinutes); + if (description !== undefined) existing.description = description; + existing.updatedAt = new Date().toISOString(); + return res.json({ busLine: existing }); + } + + const result = await db.query(` + UPDATE transit.bus_lines + SET + line_number = COALESCE($1, line_number), + start_stop = COALESCE($2, start_stop), + end_stop = COALESCE($3, end_stop), + estimated_duration_minutes = COALESCE($4, estimated_duration_minutes), + description = COALESCE($5, description) + WHERE id = $6 + RETURNING id, line_number, start_stop, end_stop, estimated_duration_minutes, description + `, [lineNumber ?? null, startStop ?? null, endStop ?? null, estimatedDurationMinutes ?? null, description ?? null, busLineId]); + + if (!result.rows[0]) return res.status(404).json({ error: 'bus_line_not_found' }); + + const busLine = result.rows[0]; + res.json({ + busLine: { + id: busLine.id, + lineNumber: busLine.line_number, + startStop: busLine.start_stop, + endStop: busLine.end_stop, + estimatedDurationMinutes: busLine.estimated_duration_minutes, + description: busLine.description, + }, + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'internal_error', details: err.message }); + } +}); + +// ============================================ +// ADMIN - Manage fleet buses +// ============================================ +app.get('/admin/fleet/buses', authMiddleware, adminMiddleware, async (req, res) => { + try { + if (DEV_MOCK) { + return res.json({ buses: _mock.fleetBuses.slice().sort((a, b) => a.id - b.id) }); + } + + const result = await db.query(` + SELECT id, name, seat_capacity, license_plate + FROM fleet.bus + ORDER BY id ASC + `); + + res.json({ + buses: result.rows.map(row => ({ + id: row.id, + name: row.name, + seatCapacity: row.seat_capacity, + licensePlate: row.license_plate, + })), + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'internal_error', details: err.message }); + } +}); + +app.post('/admin/fleet/buses', authMiddleware, adminMiddleware, async (req, res) => { + try { + const { name, seatCapacity, licensePlate } = req.body; + + if (!name || seatCapacity === undefined || !licensePlate) { + return res.status(400).json({ error: 'missing_required_fields' }); + } + + if (DEV_MOCK) { + const bus = { + id: _mock.nextFleetBusId++, + name, + seatCapacity: Number(seatCapacity), + licensePlate, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + _mock.fleetBuses.push(bus); + return res.status(201).json({ bus }); + } + + const result = await db.query(` + WITH next_id AS ( + SELECT COALESCE(MAX(id), 0) + 1 AS id FROM fleet.bus + ) + INSERT INTO fleet.bus (id, name, seat_capacity, license_plate) + SELECT next_id.id, $1, $2, $3 + FROM next_id + RETURNING id, name, seat_capacity, license_plate + `, [name, seatCapacity, licensePlate]); + + const bus = result.rows[0]; + res.status(201).json({ + bus: { + id: bus.id, + name: bus.name, + seatCapacity: bus.seat_capacity, + licensePlate: bus.license_plate, + }, + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'internal_error', details: err.message }); + } +}); + +// ============================================ +// ADMIN - Link stop to bus line +// ============================================ +app.post('/admin/bus-lines/:busLineId/stops', authMiddleware, adminMiddleware, async (req, res) => { + try { + const { busLineId } = req.params; + const { stopId, stopOrder, estimatedArrivalMinutes } = req.body; + + if (!stopId || stopOrder === undefined) { + return res.status(400).json({ error: 'missing_required_fields' }); + } + + if (DEV_MOCK) { + const stop = _mock.stops.find(s => String(s.id) === String(stopId)); + const routeStop = { + id: _mock.nextRouteStopId++, + busLineId: Number(busLineId), + stopId: Number(stopId), + stopOrder: Number(stopOrder), + estimatedArrivalMinutes: estimatedArrivalMinutes ?? null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + name: stop ? stop.name : `stop-${stopId}`, + latitude: stop ? stop.latitude : 0, + longitude: stop ? stop.longitude : 0, + }; + // remove existing with same busLineId & stopOrder + _mock.routeStops = _mock.routeStops.filter(rs => !(rs.busLineId === routeStop.busLineId && rs.stopOrder === routeStop.stopOrder)); + _mock.routeStops.push(routeStop); + return res.status(201).json({ routeStop }); + } + + const result = await db.query(` + WITH next_id AS ( + SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.route_stops + ) + INSERT INTO transit.route_stops (id, bus_line_id, stop_id, stop_order, estimated_arrival_minutes) + SELECT next_id.id, $1, $2, $3, $4 + FROM next_id + ON CONFLICT (bus_line_id, stop_order) DO UPDATE SET + stop_id = EXCLUDED.stop_id, + estimated_arrival_minutes = EXCLUDED.estimated_arrival_minutes, + updated_at = NOW() + RETURNING id, bus_line_id, stop_id, stop_order, estimated_arrival_minutes, created_at, updated_at + `, [busLineId, stopId, stopOrder, estimatedArrivalMinutes ?? null]); + + const link = result.rows[0]; + res.status(201).json({ + routeStop: { + id: link.id, + busLineId: link.bus_line_id, + stopId: link.stop_id, + stopOrder: link.stop_order, + estimatedArrivalMinutes: link.estimated_arrival_minutes, + createdAt: link.created_at, + updatedAt: link.updated_at, + }, + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'internal_error', details: err.message }); + } +}); + // ============================================ // NAVIGATION - Save calculated route // ============================================