TODO: mise a jour 2026-06-04 - cleanup massif, helms ansible generés
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
*/node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
*.pyc
|
||||
__pycache__/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
*.env
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
185
TODO.md
185
TODO.md
@@ -1,100 +1,129 @@
|
||||
# Smart City Digital Twin — TODO List
|
||||
|
||||
> Dernière mise à jour : 2026-06-01 22:00 (session continue - smart app + ditto)
|
||||
> Dernière mise à jour : 2026-06-04 00:30 (finalisation documentation)
|
||||
|
||||
## ✅ Complété (session 2026-06-01)
|
||||
## ✅ Complété (session 2026-06-03 / 06-04)
|
||||
|
||||
| ID | Tâche | Détail |
|
||||
|----|-------|--------|
|
||||
| jupyterhub-fix | JupyterHub DB path | `sqlite:////srv/jupyterhub/jupyterhub.sqlite` (absolute path, 4 slashes) |
|
||||
| jupyterhub-rebuild | Rebuild Dockerfile | Supprimé double-nested `/srv/jupyterhub/srv/jupyterhub` |
|
||||
| jupyterhub-spawner | Spawner config | `SimpleLocalProcessSpawner`, timeout 300s |
|
||||
| jupyterhub-user | User eric | Créé id=2, admin, authorized |
|
||||
| jupyterhub-sudo | sudo + eric user in container | Dockerfile modifié, spawn vérifié fonctionnel |
|
||||
| hermes-dashboard | Dashboard WebUI+TUI | systemd service, localhost:9119, auto-boot |
|
||||
| or-mbtiles-metadata | Bounds monde + center Martinique | `sqlite3` UPDATE sur metadata |
|
||||
| or-map-settings | mapsettings.json vérifié | center=[-61,14.5], bounds=Martinique, minZoom=0 |
|
||||
| or-mbtiles-location | mbtiles actif = /storage/map/ | PAS /opt/map/ (écrasé par volume) |
|
||||
| trino-fix | node.properties créé | `node.environment=production`, `node.id=trino-lakehouse-01` |
|
||||
| trino-config | config.properties nettoyé | `plugin.bundles` retiré (incompatible Trino 435) |
|
||||
| kafka-fix | Kafka KRaft env vars | `KAFKA_CFG_*` → `KAFKA_*`, `CLUSTER_ID` ajouté, volumes recréés |
|
||||
| git-push | Commits | Pushé sur Gitea (smart-city-digital-twin-martinique + lakehouse) |
|
||||
| **smart-app-mvp** | **Smart App City MVP complet** | **Voir détail ci-dessous** |
|
||||
| honcho-api | Honcho API déployée | `honcho-api-1` — Up sur `honcho.digitribe.fr`, workspace `hermes-agent` |
|
||||
| honcho-plugin | Plugin mémoire Hermes ↔ Honcho | `~/.hermes/honcho.json` configuré, baseUrl `http://127.0.0.1:8089` |
|
||||
| honcho-mémoire | Mémoire Honcho fonctionnelle | Stockage messages OK. Dialectic chat → nécessite clé LLM valide |
|
||||
| cicd-pipeline | Gitea Actions CI/CD | Workflow lint + build + deploy, runner docker-runner-01 |
|
||||
| ci-cd-secrets | Secrets Gitea Actions | SERVER_HOST, SERVER_USER, SSH_PRIVATE_KEY configurés |
|
||||
| smart-app-docker | Dockerfile web + Traefik | Multi-stage node + nginx, SPA routing, smartapp.digitribe.fr |
|
||||
| smart-app-deploy | Script de déploiement | `deploy.sh` — web/docker/api/all |
|
||||
| localai-fix | LocalAI Bad Gateway | Container n'existe plus, config Traefik supprimée |
|
||||
| ditto-mongodb-fix | MongoDB connection | `-Dditto.mongodb.uri` dans JAVA_TOOL_OPTIONS |
|
||||
| ditto-secrets | Nouveaux secrets JWT/devops | Générés aléatoirement, sauvegardés `.env.ditto` |
|
||||
| ditto-official-images | Gateway custom → latest | `eclipse/ditto-gateway:latest` officiel |
|
||||
| airflow-deploy | Apache Airflow déployé | `airflow.digitribe.fr` — Python 3.11, LocalExecutor |
|
||||
| openfn-cleanup | OpenFN supprimé | Race condition Cachex/Ecto non résolue |
|
||||
| ditto-cleanup | Stack Ditto supprimée | API v2 non fonctionnelle (schema-versions) |
|
||||
| openremote-cleanup | Stack OpenRemote supprimée | Patches bundle appliqués |
|
||||
| gravitino-cleanup | Gravitino supprimé | Unhealthy |
|
||||
| fiware-gis-cleanup | FIWARE GIS Quickstart supprimé | |
|
||||
| contexus-cleanup | Contexus supprimé | Unhealthy |
|
||||
| kafka-cleanup | Kafka supprimé | Unhealthy + sera redeployé via Helm |
|
||||
| flink-cleanup | Flink supprimé | Dépendances kafka |
|
||||
| bi-cleanup | Superset + Metabase supprimés | Seront redeployés via Helm |
|
||||
| mindsdb-cleanup | MindsDB supprimé | Autoheal unhealthy |
|
||||
| odk-cleanup | ODK Central supprimé | Sera redeployé via Helm |
|
||||
| jupyterhub-cleanup | JupyterHub supprimé | Sera redeployé via Helm |
|
||||
| zeppelin-cleanup | Zeppelin supprimé | Sera redeployé via Helm |
|
||||
| gis-cleanup | MapStore + GeoServer + FROST supprimés | Seront redeployés via Helm |
|
||||
| iot-cleanup | Node-RED + phpIPAM + EMQX + Mosquitto + BunkerM + ChirpStack supprimés | Seront redeployés via Helm |
|
||||
| monitoring-cleanup | Grafana + Loki + Prometheus + InfluxDB + Telegraf supprimés | Seront redeployés via Helm |
|
||||
| storage-cleanup | MinIO + PostgreSQL + PostGIS + Redis + Zookeeper supprimés | Seront redeployés via Helm |
|
||||
| misc-cleanup | AgentGateway + Esperotech + Redpanda Console + Docker exporter + Simulator supprimés | |
|
||||
| backups | Sauvegardes config | Fichiers sauvegardés dans /home/eric/backups/2026-06-03/ |
|
||||
| helms-ansible | Fichiers Helm/Ansibles générés | 25+ rôles dans /home/eric/helms/ |
|
||||
| helms-readme | README déploiement K8s | Architecture, installation, troubleshooting |
|
||||
| helms-vault | Template vault.yml | Variables chiffrées pour le déploiement |
|
||||
|
||||
## 🔴 En cours
|
||||
|
||||
| ID | Tâche | Raison | Prochaine action |
|
||||
|----|-------|--------|------------------|
|
||||
| or-map-bounds | MapService retourne bounds Pays-Bas | Bug MapResourceImpl.java: mbtiles metadata bounds prioritaire sur mapsettings.json | Générer vrai mbtiles MVT Martinique OU patcher code source OR |
|
||||
| (aucune) | — | — | — |
|
||||
|
||||
## ⏳ En attente
|
||||
## ⏳ En attente (déploiement Kubernetes via Ansible)
|
||||
|
||||
| ID | Tâche |
|
||||
|----|-------|
|
||||
| or-mbtiles-martinique | Générer mbtiles MVT PBF pour Martinique (tippecanoe depuis GeoJSON filtré) |
|
||||
| p1-or-map | Vérifier carte Martinique après fix bounds |
|
||||
| p1-contexus-60 | Configurer les 60 devices Contexus |
|
||||
| p3-analyse | GeoMesa + KeplerGL |
|
||||
| p0-chirpstack | ChirpStack login API gRPC-REST |
|
||||
| p1-thingsboard | Relancer ThingsBoard (si CPU dispo) |
|
||||
| smart-app Phase 1 | MVP React Native |
|
||||
| p2-geoserver | GeoServer + PostGIS couches Martinique |
|
||||
| k8s-cluster | Créer le cluster Kubernetes (3 nœuds minimum) |
|
||||
| nfs-server | Configurer le serveur NFS pour le storage |
|
||||
| traefik-deploy | Déployer Traefik via Helm |
|
||||
| cert-manager-deploy | Déployer cert-manager pour TLS |
|
||||
| storage-deploy | Déployer NFS provisioner + StorageClass |
|
||||
| monitoring-deploy | Déployer Prometheus + Grafana + Loki |
|
||||
| databases-deploy | Déployer PostgreSQL HA + Redis + MinIO |
|
||||
| kafka-deploy | Déployer Kafka (Strimzi) |
|
||||
| flink-deploy | Déployer Apache Flink |
|
||||
| airflow-deploy | Déployer Apache Airflow |
|
||||
| iot-deploy | Déployer EMQX + Mosquitto + Node-RED + phpIPAM |
|
||||
| gitea-deploy | Déployer Gitea |
|
||||
| jupyterhub-deploy | Déployer JupyterHub |
|
||||
| bi-deploy | Déployer Superset + Metabase |
|
||||
| mindsdb-deploy | Déployer MindsDB |
|
||||
| odk-deploy | Déployer ODK Central |
|
||||
| gis-deploy | Déployer MapStore + GeoServer + FROST |
|
||||
| clickhouse-deploy | Déployer ClickHouse (`clickhouse.digitribe.fr`) |
|
||||
| starrocks-deploy | Déployer StarRocks (`starrocks.digitribe.fr`) |
|
||||
| trino-deploy | Déployer Trino (`trino.digitribe.fr`) |
|
||||
| deltalake-deploy | Déployer Delta Lake (`deltalake.digitribe.fr`) |
|
||||
| streamlit-deploy | Déployer Streamlit (`streamlit.digitribe.fr`) |
|
||||
| duckdb-deploy | Déployer DuckDB (`duckdb.digitribe.fr`) |
|
||||
| smartapp-deploy | Déployer Smart App (`smartapp.digitribe.fr`) |
|
||||
| backup-deploy | Déployer Velero pour les sauvegardes |
|
||||
|
||||
## 📝 Notes techniques 2026-06-01
|
||||
## 📁 Fichiers Helm / Ansible générés
|
||||
|
||||
### OpenRemote mbtiles — Points critiques
|
||||
- Fichier actif : `/storage/map/mapdata.mbtiles` (volume Docker), PAS `/opt/map/`
|
||||
- OR 1.24.0 ne sert que du **PBF vectoriel** — PNG raster = 404
|
||||
- Bug : MapService.java donne priorité aux bounds du mbtiles metadata sur mapsettings.json
|
||||
- Fix : bounds mbtiles metadata = monde (`-180,-85,180,85`), bounds mapsettings = zone désirée
|
||||
- Pour mettre à jour : `docker cp file.mbtiles openremote-manager:/storage/map/mapdata.mbtiles`
|
||||
```
|
||||
helms/
|
||||
├── README.md # Documentation déploiement
|
||||
├── deploy.yml # Playbook principal
|
||||
├── undeploy.yml # Playbook de suppression
|
||||
├── inventory/
|
||||
│ └── hosts.yml # Inventory des nœuds K8s
|
||||
├── group_vars/
|
||||
│ ├── all.yml # Variables globales
|
||||
│ └── vault.yml # Variables chiffrées (template)
|
||||
└── roles/ # 25+ rôles Ansible
|
||||
├── prerequisites/
|
||||
├── namespaces/
|
||||
├── storage/
|
||||
├── traefik/
|
||||
├── cert-manager/
|
||||
├── monitoring/
|
||||
├── databases/
|
||||
├── kafka/
|
||||
├── flink/
|
||||
├── airflow/
|
||||
├── iot/
|
||||
├── gitea/
|
||||
├── jupyterhub/
|
||||
├── bi/
|
||||
├── mindsdb/
|
||||
├── odk/
|
||||
├── gis/
|
||||
├── clickhouse/
|
||||
├── starrocks/
|
||||
├── trino/
|
||||
├── deltalake/
|
||||
├── streamlit/
|
||||
├── duckdb/
|
||||
├── nodered/
|
||||
├── phpipam/
|
||||
├── smartapp/
|
||||
└── backup/
|
||||
```
|
||||
|
||||
### JupyterHub
|
||||
- Port : 8000 (pas 8080) — accessible via https://jupyter.digitribe.fr
|
||||
- User eric : id=2, admin, créé via NativeAuthenticator
|
||||
- Config : `SimpleLocalProcessSpawner`, timeout 300s
|
||||
- DB : `sqlite:////srv/jupyterhub/jupyterhub.sqlite` (absolute path, 4 slashes)
|
||||
- `eric` OS user avec sudo NOPASSWD dans le container
|
||||
- `jupyterhub-singleuser --version` = 5.3.0, `jupyter-lab --version` = 4.5.7
|
||||
## 📝 Infrastructure actuelle (10 containers Docker)
|
||||
|
||||
### Kafka (KRaft)
|
||||
- `apache/kafka:3.9.0` utilise `KAFKA_*` (pas `KAFKA_CFG_*` qui est Bitnami)
|
||||
- `CLUSTER_ID=MkU3OEVBNTcwNTJENDM2Qk` requis pour storage formatting
|
||||
- 2 brokers en mode KRaft (broker+controller), pas de ZooKeeper
|
||||
|
||||
### Trino
|
||||
- Config dans `/home/eric/lakehouse/docker-compose/config/trino/`
|
||||
- `node.id=trino-lakehouse-01` (pas `_internal_`)
|
||||
- `plugin.bundles` retiré de config.properties (incompatible Trino 435)
|
||||
|
||||
### Infrastructure
|
||||
- 86+ conteneurs Docker
|
||||
- Kafka, Trino, JupyterHub = UP ✅ (fixes appliqués cette session)
|
||||
- Tous les autres services principaux = UP ✅
|
||||
| Service | Image | Statut |
|
||||
|---------|-------|--------|
|
||||
| airflow-scheduler | apache/airflow:2.9.3-python3.11 | ✅ healthy |
|
||||
| airflow-webserver | apache/airflow:2.9.3-python3.11 | ✅ healthy |
|
||||
| airflow-init | apache/airflow:2.9.3-python3.11 | 🔄 restarting (one-shot) |
|
||||
| airflow-postgres | postgres:16 | ✅ healthy |
|
||||
| smartapp-api | smartapp-api:latest | ✅ Up 38h |
|
||||
| smartapp-web | nginx:alpine | ✅ Up 38h |
|
||||
| gitea-runner | gitea/act_runner:latest | ✅ Up 2 days |
|
||||
| traefik | traefik:v3.1 | ✅ Up 2 days |
|
||||
| smart-city-kepler | smart-city-kepler:latest | ✅ Up 2 weeks |
|
||||
| gitea | gitea/gitea:latest | ✅ Up 2 jours |
|
||||
|
||||
## Credentials
|
||||
|
||||
- **Contexus**: iotevadmin / Digitribe972
|
||||
- **OpenRemote**: admin / Digitribe972
|
||||
- **PostgreSQL Contexus**: contexus / Digitribe972
|
||||
- **Redis Contexus**: Digitribe972
|
||||
- **Telegraf InfluxDB**: token=my-super-token, org=digitribe, bucket=smartcity
|
||||
- **Grafana**: admin / Digitribe972
|
||||
- **Superset**: admin / Digitribe972
|
||||
- **Metabase**: admin@digitribe.fr / Digitribe972
|
||||
- **BunkerM MQTT**: bunker / bunker
|
||||
- **ChirpStack**: admin / Digitribe972
|
||||
- **ODK Central**: efelixine@digitribe.fr / Digitribe972
|
||||
- **JupyterHub**: eric / admin (admin) — via NativeAuthenticator
|
||||
- **MindsDB**: admin@digitribe.fr / Digitribe972
|
||||
- **Gitea** : eric / (voir config)
|
||||
- **Airflow** : admin / (changé par Eric)
|
||||
|
||||
@@ -1,61 +1,183 @@
|
||||
# Smart App City — Mobile Application
|
||||
# Smart App City — Application Mobile
|
||||
|
||||
> Multi-platform mobile application for Smart City Digital Twin Martinique
|
||||
> Application mobile multi-platforme pour le Smart City Digital Twin Martinique
|
||||
|
||||
## Quick Start
|
||||
## 📱 Présentation
|
||||
|
||||
### Prerequisites
|
||||
Smart App City est une application mobile (React Native + Expo) qui permet aux citoyens de :
|
||||
- **Visualiser les données IoT** en temps réel (capteurs de température, humidité, qualité de l'air, bruit, trafic, énergie)
|
||||
- **Accéder à une marketplace** de services et produits locaux
|
||||
- **Interagir avec un assistant IA** (météo, transports, énergie, alertes)
|
||||
- **Gérer ses notifications** et **profil utilisateur**
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
```
|
||||
smart-app-city/
|
||||
├── frontend/ # Application React Native + Expo (TypeScript)
|
||||
│ ├── src/
|
||||
│ │ ├── screens/ # 15 écrans (Auth, Dashboard, Map, Marketplace, Chat, Profile, IoT, Notifications)
|
||||
│ │ ├── components/ # Composants réutilisables (Card, Button, Charts, Maps)
|
||||
│ │ ├── stores/ # State management (Zustand) — authStore, iotStore, notificationStore, uiStore
|
||||
│ │ ├── hooks/ # Hooks personnalisés (useSensors, useAlerts, useNotifications, useLocation)
|
||||
│ │ ├── services/ # Services API (auth, iot, notification, via Axios)
|
||||
│ │ ├── i18n/ # Internationalisation (FR/EN/ES/DE)
|
||||
│ │ ├── theme/ # Design system (Blue Ocean palette)
|
||||
│ │ └── utils/ # Utilitaires (formatters, validators, constants)
|
||||
│ ├── dist/ # Build web statique (nginx)
|
||||
│ ├── Dockerfile # Multi-stage build (node + nginx)
|
||||
│ ├── package.json # Dépendances (Expo 51, React Native 0.74)
|
||||
│ └── app.json # Configuration Expo
|
||||
├── backend/ # Backend microservices (Node.js)
|
||||
├── ai/ # Services IA (RAG, Agent)
|
||||
├── design/ # Maquettes HTML/CSS (28 screenshots, PDF, Figma JSON, Penpot SVG)
|
||||
├── docs/ # Documentation
|
||||
└── docker-compose.yml # Orchestration Docker
|
||||
```
|
||||
|
||||
## 🚀 Développement
|
||||
|
||||
### Prérequis
|
||||
- Node.js 20+
|
||||
- Expo CLI : `npm install -g expo-cli`
|
||||
- Docker & Docker Compose
|
||||
- Docker & Docker Compose (pour le déploiement)
|
||||
|
||||
### Development
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
cd frontend
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npx expo start
|
||||
|
||||
# Run on device
|
||||
# Scan QR code with Expo Go app
|
||||
npm install --legacy-peer-deps
|
||||
```
|
||||
|
||||
### Backend
|
||||
### Lancement en mode développement
|
||||
|
||||
```bash
|
||||
# Start all services
|
||||
cd ..
|
||||
docker-compose up -d
|
||||
# Mode natif (iOS/Android)
|
||||
npm start
|
||||
# Scanner le QR code avec l'app Expo Go
|
||||
|
||||
# Start individual service
|
||||
cd backend/auth-service
|
||||
npm run start:dev
|
||||
# Mode web
|
||||
npm run web
|
||||
```
|
||||
|
||||
### AI Services
|
||||
### Build web statique
|
||||
|
||||
```bash
|
||||
# Start RAG service
|
||||
cd ai/rag-service
|
||||
pip install -r requirements.txt
|
||||
uvicorn main:app --reload --port 8001
|
||||
# Sans Docker
|
||||
cd frontend
|
||||
npx expo export --platform web --output-dir dist
|
||||
|
||||
# Start Agent service
|
||||
cd ai/agent-service
|
||||
pip install -r requirements.txt
|
||||
uvicorn main:app --reload --port 8002
|
||||
# Avec Docker
|
||||
docker build -t smartapp-web:latest .
|
||||
```
|
||||
|
||||
## Documentation
|
||||
### Déploiement
|
||||
|
||||
- [Architecture](docs/ARCHITECTURE.md)
|
||||
- [Beckn Integration](docs/BECKN_INTEGRATION.md)
|
||||
- [AI Architecture](docs/AI_ARCHITECTURE.md)
|
||||
- [Internationalization](docs/I18N.md)
|
||||
```bash
|
||||
cd smart-app-city
|
||||
docker compose -f docker-compose.web.yml up -d
|
||||
```
|
||||
|
||||
## License
|
||||
L'application est accessible sur : **https://smartapp.digitribe.fr**
|
||||
|
||||
## 🎨 Design System
|
||||
|
||||
### Palette principale
|
||||
- **Blue Ocean** : `#1565C0` (primaire)
|
||||
- **Indigo** : `#3949AB` (accent)
|
||||
- **Cyan** : `#00ACC1` (secondaire)
|
||||
- **Deep Ocean** : `#0D1B2A` (dark mode)
|
||||
|
||||
### Composants
|
||||
Tous les composants UI sont dans `src/components/` :
|
||||
- `common/Card.tsx` — Carte réutilisable (3 variants : default, elevated, outlined)
|
||||
- `common/Button.tsx` — Bouton (5 variants, 3 tailles)
|
||||
- `common/Header.tsx` — En-tête de section
|
||||
- `common/LoadingSpinner.tsx` — Indicateur de chargement
|
||||
- `common/ErrorBoundary.tsx` — Gestion d'erreurs React
|
||||
- `cards/SensorCard.tsx` — Carte capteur IoT
|
||||
- `cards/StatsCard.tsx` — Carte statistiques
|
||||
- `cards/AlertCard.tsx` — Carte alerte
|
||||
- `cards/ZoneCard.tsx` — Carte zone
|
||||
- `charts/LineChart.tsx` — Graphique linéaire
|
||||
- `charts/BarChart.tsx` — Graphique barres
|
||||
- `charts/GaugeChart.tsx` — Jauge semi-circulaire
|
||||
- `maps/MapView.tsx` — Vue carte (placeholder pour react-native-maps)
|
||||
- `maps/MarkerPopup.tsx` — Popup marqueur
|
||||
|
||||
## 📊 State Management (Zustand)
|
||||
|
||||
### Stores
|
||||
- **authStore** : Authentification utilisateur (login, register, logout, refresh token)
|
||||
- **iotStore** : Données IoT (6 capteurs, 3 zones, 2 alertes mock)
|
||||
- **notificationStore** : Notifications (5 mock notifications)
|
||||
- **uiStore** : Thème + i18n (FR/EN/ES/DE)
|
||||
|
||||
### Hooks
|
||||
- **useSensors()** : Liste des capteurs, filtrage par type/zone
|
||||
- **useAlerts()** : Alertes actives/critiques, acknowledge
|
||||
- **useNotifications()** : CRUD notifications
|
||||
- **useLocation()** : GPS avec expo-location (défaut : Fort-de-France)
|
||||
|
||||
## 🌐 Internationalisation
|
||||
|
||||
4 langues supportées : Français (défaut), English, Español, Deutsch.
|
||||
|
||||
Fichier de traductions : `src/stores/uiStore.ts` (fonction `t(key, lang)`)
|
||||
|
||||
## 🔌 API Backend
|
||||
|
||||
L'application communique avec l'API Gateway :
|
||||
- **Base URL** : `https://api-smartapp.digitribe.fr/api/v1`
|
||||
- **Authentification** : JWT (Basic Auth pour les routes /admin)
|
||||
- **Endpoints principaux** :
|
||||
- `POST /api/auth/login` — Connexion
|
||||
- `POST /api/auth/register` — Inscription
|
||||
- `POST /api/auth/refresh` — Refresh token
|
||||
- `GET /sensors` — Liste des capteurs
|
||||
- `GET /zones` — Liste des zones
|
||||
- `GET /alerts` — Liste des alertes
|
||||
|
||||
## 📦 Déploiement CI/CD
|
||||
|
||||
### Gitea Actions
|
||||
Fichier : `.gitea/workflows/build-and-deploy.yml`
|
||||
|
||||
Pipeline :
|
||||
1. **Lint** : `tsc --noEmit` (TypeScript check)
|
||||
2. **Build** : `npx expo export --platform web`
|
||||
3. **Deploy** : SSH vers le serveur → copie les fichiers ou rebuild Docker
|
||||
|
||||
### Secrets Gitea
|
||||
- `SERVER_HOST` : `localhost`
|
||||
- `SERVER_USER` : `eric`
|
||||
- `SSH_PRIVATE_KEY` : Clé SSH pour le déploiement
|
||||
|
||||
### Dockerfiles
|
||||
- `frontend/Dockerfile` : Multi-stage build (node 20 alpine → nginx alpine)
|
||||
- `docker-compose.web.yml` : Service nginx statique avec Traefik
|
||||
|
||||
## 🌍 Services liés
|
||||
|
||||
| Service | URL | Description |
|
||||
|---------|-----|-------------|
|
||||
| Gitea | https://gitea.digitribe.fr | Git + CI/CD |
|
||||
| Honcho | https://honcho.digitribe.fr | Mémoire agent IA |
|
||||
| Grafana | http://127.0.0.1:3088 | Métriques Prometheus |
|
||||
|
||||
## 📝 Notes techniques importantes
|
||||
|
||||
### WatermelonDB supprimé
|
||||
Le package `@nozbe/watermelonDB` a été supprimé car la version `^0.27.0` n'existe plus. Pas de remplacement nécessaire — la mémoire locale utilise Zustand + AsyncStorage.
|
||||
|
||||
### Build web Expo
|
||||
- Le build `npx expo export:web` nécessite `@expo/webpack-config@~19.0.1`
|
||||
- Si le build échoue avec "Lock compromised" : supprimer `package-lock.json` et `node_modules/`, puis `npm install --legacy-peer-deps`
|
||||
- Le build Docker est plus fiable que le build local (environnement isolé)
|
||||
|
||||
### Patch tool
|
||||
Les fichiers contenant `//`, `=`, `+`, `{}` dans les strings peuvent corriger le tool `patch`. Utiliser `replace_all=true` ou échapper les caractères spéciaux.
|
||||
|
||||
## 📄 Licence
|
||||
|
||||
Smart City Digital Twin Martinique — Projet public
|
||||
|
||||
6
smart-app-city/backend/auth-service/Dockerfile
Normal file
6
smart-app-city/backend/auth-service/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY server.js .
|
||||
RUN npm init -y && npm install express bcryptjs jsonwebtoken cors
|
||||
EXPOSE 3001
|
||||
CMD ["node", "server.js"]
|
||||
175
smart-app-city/backend/auth-service/server.js
Normal file
175
smart-app-city/backend/auth-service/server.js
Normal file
@@ -0,0 +1,175 @@
|
||||
// Smart App City — Auth Service (Node.js + Express)
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const cors = require('cors');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const app = express();
|
||||
const PORT = 3001;
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'smart-app-city-jwt-secret-2024';
|
||||
const DB_PATH = path.join(__dirname, 'users.json');
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// ── Database helpers ──
|
||||
function loadUsers() {
|
||||
if (!fs.existsSync(DB_PATH)) {
|
||||
fs.writeFileSync(DB_PATH, JSON.stringify({ users: [] }, null, 2));
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(DB_PATH, 'utf8'));
|
||||
}
|
||||
|
||||
function saveUsers(db) {
|
||||
fs.writeFileSync(DB_PATH, JSON.stringify(db, null, 2));
|
||||
}
|
||||
|
||||
// ── Create admin user on startup ──
|
||||
function createAdminUser() {
|
||||
const db = loadUsers();
|
||||
|
||||
// Admin user
|
||||
if (!db.users.find(u => u.email === 'admin@digitribe.fr')) {
|
||||
const hashedPassword = bcrypt.hashSync('Digitribe972', 10);
|
||||
db.users.push({
|
||||
id: 'admin-001',
|
||||
email: 'admin@digitribe.fr',
|
||||
password: hashedPassword,
|
||||
firstName: 'Admin',
|
||||
lastName: 'Digitribe',
|
||||
roles: ['admin', 'user'],
|
||||
createdAt: new Date().toISOString()
|
||||
});
|
||||
saveUsers(db);
|
||||
console.log('✅ Admin user created: admin@digitribe.fr / Digitribe972');
|
||||
} else {
|
||||
console.log('ℹ️ Admin user already exists');
|
||||
}
|
||||
|
||||
// Eric user
|
||||
if (!db.users.find(u => u.email === 'eric@digitribe.fr')) {
|
||||
const hashedPassword = bcrypt.hashSync('Digitribe972', 10);
|
||||
db.users.push({
|
||||
id: 'eric-001',
|
||||
email: 'eric@digitribe.fr',
|
||||
password: hashedPassword,
|
||||
firstName: 'Eric',
|
||||
lastName: 'Felixine',
|
||||
roles: ['admin', 'user'],
|
||||
createdAt: new Date().toISOString()
|
||||
});
|
||||
saveUsers(db);
|
||||
console.log('✅ Eric user created: eric@digitribe.fr / Digitribe972');
|
||||
} else {
|
||||
console.log('ℹ️ Eric user already exists');
|
||||
}
|
||||
|
||||
// Erol user
|
||||
if (!db.users.find(u => u.email === 'erol@digitribe.fr')) {
|
||||
const hashedPassword = bcrypt.hashSync('erol', 10);
|
||||
db.users.push({
|
||||
id: 'erol-001',
|
||||
email: 'erol@digitribe.fr',
|
||||
password: hashedPassword,
|
||||
firstName: 'Erol',
|
||||
lastName: 'Digitribe',
|
||||
roles: ['user'],
|
||||
createdAt: new Date().toISOString()
|
||||
});
|
||||
saveUsers(db);
|
||||
console.log('✅ Erol user created: erol@digitribe.fr / erol');
|
||||
} else {
|
||||
console.log('ℹ️ Erol user already exists');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Routes ──
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', service: 'auth-service' });
|
||||
});
|
||||
|
||||
// Register
|
||||
app.post('/api/auth/register', async (req, res) => {
|
||||
try {
|
||||
const { email, password, firstName, lastName } = req.body;
|
||||
if (!email || !password || !firstName || !lastName) {
|
||||
return res.status(400).json({ message: 'Tous les champs sont requis' });
|
||||
}
|
||||
if (password.length < 8) {
|
||||
return res.status(400).json({ message: 'Le mot de passe doit contenir au moins 8 caractères' });
|
||||
}
|
||||
const db = loadUsers();
|
||||
if (db.users.find(u => u.email === email)) {
|
||||
return res.status(409).json({ message: 'Un compte avec cet email existe déjà' });
|
||||
}
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
const user = {
|
||||
id: 'user-' + Date.now(),
|
||||
email,
|
||||
password: hashedPassword,
|
||||
firstName,
|
||||
lastName,
|
||||
roles: ['user'],
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
db.users.push(user);
|
||||
saveUsers(db);
|
||||
const token = jwt.sign({ id: user.id, email: user.email, roles: user.roles }, JWT_SECRET, { expiresIn: '7d' });
|
||||
res.json({ accessToken: token, user: { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, roles: user.roles } });
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
// Login
|
||||
app.post('/api/auth/login', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ message: 'Email et mot de passe requis' });
|
||||
}
|
||||
const db = loadUsers();
|
||||
const user = db.users.find(u => u.email === email);
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: 'Identifiants incorrects' });
|
||||
}
|
||||
const valid = await bcrypt.compare(password, user.password);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ message: 'Identifiants incorrects' });
|
||||
}
|
||||
const token = jwt.sign({ id: user.id, email: user.email, roles: user.roles }, JWT_SECRET, { expiresIn: '7d' });
|
||||
res.json({ accessToken: token, user: { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, roles: user.roles } });
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get current user
|
||||
app.get('/api/auth/me', (req, res) => {
|
||||
try {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
if (!token) return res.status(401).json({ message: 'Non authentifié' });
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
const db = loadUsers();
|
||||
const user = db.users.find(u => u.id === decoded.id);
|
||||
if (!user) return res.status(404).json({ message: 'Utilisateur non trouvé' });
|
||||
res.json({ id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, roles: user.roles });
|
||||
} catch (e) {
|
||||
res.status(401).json({ message: 'Token invalide' });
|
||||
}
|
||||
});
|
||||
|
||||
// Logout (client-side, but endpoint for completeness)
|
||||
app.post('/api/auth/logout', (req, res) => {
|
||||
res.json({ message: 'Déconnexion réussie' });
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 Auth service running on port ${PORT}`);
|
||||
createAdminUser();
|
||||
});
|
||||
35
smart-app-city/docker-compose.web.yml
Normal file
35
smart-app-city/docker-compose.web.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
services:
|
||||
web:
|
||||
image: nginx:alpine
|
||||
container_name: smartapp-web
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- traefik-public
|
||||
volumes:
|
||||
- /home/eric/smart-city-digital-twin-martinique/smart-app-city/frontend/dist:/usr/share/nginx/html
|
||||
labels:
|
||||
- "traefik.enable=false"
|
||||
expose:
|
||||
- "80"
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ./backend/auth-service
|
||||
dockerfile: Dockerfile
|
||||
container_name: smartapp-api
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- traefik-public
|
||||
volumes:
|
||||
- /home/eric/smart-city-digital-twin-martinique/smart-app-city/backend/auth-service/users.json:/app/users.json
|
||||
expose:
|
||||
- "3001"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3001/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
external: true
|
||||
705
smart-app-city/frontend/dist/index.html
vendored
Normal file
705
smart-app-city/frontend/dist/index.html
vendored
Normal file
@@ -0,0 +1,705 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>Smart City Martinique</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
|
||||
:root {
|
||||
--primary: #1565C0; --primary-dark: #0D47A1; --accent: #00ACC1;
|
||||
--bg: #0D1B2A; --bg-card: #1E3350; --bg-input: #1B2838;
|
||||
--text: #E8EAF6; --text-secondary: #9FA8DA; --text-muted: #5C6BC0;
|
||||
--danger: #D32F2F; --success: #2E7D32; --warning: #F57C00;
|
||||
--radius: 16px; --radius-sm: 8px; --radius-full: 9999px;
|
||||
}
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg); color: var(--text); min-height: 100%;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Top Nav ── */
|
||||
.top-nav {
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
text-decoration: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
.nav-item.active { color: var(--accent); }
|
||||
.nav-item-icon { font-size: 20px; }
|
||||
.nav-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 4px;
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
padding: 0 4px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
min-width: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.header {
|
||||
background: linear-gradient(135deg, var(--primary), var(--accent));
|
||||
padding: 30px 20px 50px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logo { width: 70px; height: 70px; background: rgba(255,255,255,0.15); border-radius: 18px; margin: 0 auto 12px; display: flex; align-items: center; justify-content: center; font-size: 36px; }
|
||||
.header h1 { font-size: 24px; font-weight: 700; color: white; margin-bottom: 2px; }
|
||||
.header p { font-size: 13px; color: rgba(255,255,255,0.8); }
|
||||
|
||||
/* ── Form ── */
|
||||
.form-container { flex: 1; padding: 20px; max-width: 420px; margin: 0 auto; width: 100%; }
|
||||
.form-title { font-size: 22px; font-weight: 700; margin-bottom: 24px; }
|
||||
.input-group { margin-bottom: 16px; }
|
||||
.input-group label { display: block; font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }
|
||||
.input-wrapper { display: flex; align-items: center; background: var(--bg-input); border-radius: var(--radius-sm); border: 1px solid transparent; transition: border 0.2s; }
|
||||
.input-wrapper:focus-within { border-color: var(--accent); }
|
||||
.input-wrapper .icon { padding: 0 12px; font-size: 16px; }
|
||||
.input-wrapper input { flex: 1; background: none; border: none; outline: none; color: var(--text); font-size: 15px; padding: 14px 12px 14px 0; }
|
||||
.input-wrapper input::placeholder { color: var(--text-muted); }
|
||||
.toggle-pw { padding: 0 12px; cursor: pointer; font-size: 16px; }
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; padding: 14px; border: none; border-radius: var(--radius-sm); font-size: 15px; font-weight: 700; cursor: pointer; transition: opacity 0.2s, transform 0.1s; }
|
||||
.btn:active { transform: scale(0.98); opacity: 0.9; }
|
||||
.btn-primary { background: var(--primary); color: white; }
|
||||
.btn-secondary { background: var(--bg-card); color: var(--text); border: 1px solid var(--primary-dark); }
|
||||
.btn-google { background: #fff; color: #333; }
|
||||
.btn-apple { background: #000; color: #fff; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn .btn-icon { font-size: 18px; }
|
||||
|
||||
/* ── Links ── */
|
||||
.link { color: var(--accent); text-decoration: none; font-size: 14px; font-weight: 600; }
|
||||
.link:hover { text-decoration: underline; }
|
||||
.text-center { text-align: center; }
|
||||
.mt-12 { margin-top: 12px; }
|
||||
.mt-24 { margin-top: 24px; }
|
||||
.mb-8 { margin-bottom: 8px; }
|
||||
|
||||
/* ── Divider ── */
|
||||
.divider { display: flex; align-items: center; gap: 16px; margin: 24px 0; }
|
||||
.divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: var(--bg-card); }
|
||||
.divider span { font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
/* ── Error/Success ── */
|
||||
.alert { padding: 12px; border-radius: var(--radius-sm); font-size: 13px; margin-bottom: 16px; display: none; }
|
||||
.alert.show { display: block; }
|
||||
.alert-error { background: rgba(211,47,47,0.15); color: #EF5350; border: 1px solid rgba(211,47,47,0.3); }
|
||||
.alert-success { background: rgba(46,125,50,0.15); color: #66BB6A; border: 1px solid rgba(46,125,50,0.3); }
|
||||
|
||||
/* ── Checkbox ── */
|
||||
.checkbox { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-secondary); cursor: pointer; }
|
||||
.checkbox input { width: 18px; height: 18px; accent-color: var(--accent); }
|
||||
|
||||
/* ── Loading ── */
|
||||
.spinner { width: 20px; height: 20px; border: 2px solid rgba(255,255,255,0.3); border-top-color: white; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Transitions ── */
|
||||
.screen { display: none; min-height: 100vh; flex-direction: column; }
|
||||
.screen.active { display: flex; }
|
||||
|
||||
/* ── Home Dashboard ── */
|
||||
.dashboard-header {
|
||||
background: linear-gradient(135deg, var(--primary), var(--accent));
|
||||
padding: 20px 16px 50px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dashboard-header-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.greeting { font-size: 13px; color: rgba(255,255,255,0.85); }
|
||||
.user-name { font-size: 20px; font-weight: 700; color: white; }
|
||||
.notification-btn { position: relative; background: none; border: none; font-size: 24px; cursor: pointer; }
|
||||
.notification-badge { position: absolute; top: -4px; right: -4px; background: var(--danger); color: white; border-radius: 10px; padding: 1px 5px; font-size: 10px; font-weight: 700; }
|
||||
.search-bar { display: flex; align-items: center; background: rgba(255,255,255,0.2); border-radius: var(--radius-full); padding: 10px 16px; gap: 8px; }
|
||||
.search-bar input { flex: 1; background: none; border: none; outline: none; color: white; font-size: 14px; }
|
||||
.search-bar input::placeholder { color: rgba(255,255,255,0.5); }
|
||||
|
||||
/* ── Metrics Row ── */
|
||||
.metrics-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; padding: 0 16px; margin-top: -25px; }
|
||||
.metric-card { background: var(--bg-card); border-radius: var(--radius); padding: 12px 8px; text-align: center; border: 1px solid rgba(255,255,255,0.05); }
|
||||
.metric-value { font-size: 20px; font-weight: 700; }
|
||||
.metric-unit { font-size: 10px; color: var(--text-muted); }
|
||||
.metric-label { font-size: 9px; color: var(--text-muted); margin-top: 2px; }
|
||||
|
||||
/* ── Section ── */
|
||||
.section { padding: 16px; }
|
||||
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.section-title { font-size: 16px; font-weight: 700; }
|
||||
.see-all { font-size: 13px; color: var(--accent); text-decoration: none; }
|
||||
|
||||
/* ── Cards ── */
|
||||
.card { background: var(--bg-card); border-radius: var(--radius); padding: 14px; margin-bottom: 10px; border: 1px solid rgba(255,255,255,0.05); }
|
||||
.card-title { font-size: 14px; font-weight: 700; margin-bottom: 4px; }
|
||||
.card-subtitle { font-size: 12px; color: var(--text-secondary); }
|
||||
.card-row { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; }
|
||||
|
||||
/* ── Quick Actions ── */
|
||||
.quick-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
|
||||
.quick-action { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 12px 8px; background: var(--bg-card); border-radius: var(--radius); text-decoration: none; color: var(--text); }
|
||||
.quick-action-icon { width: 48px; height: 48px; border-radius: 14px; display: flex; align-items: center; justify-content: center; font-size: 22px; }
|
||||
.quick-action-label { font-size: 10px; color: var(--text-secondary); text-align: center; }
|
||||
|
||||
/* ── Scrollable content ── */
|
||||
.scrollable { flex: 1; overflow-y: auto; }
|
||||
|
||||
/* ── Badge ── */
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: var(--radius-full); font-size: 10px; font-weight: 600; cursor: pointer; }
|
||||
.badge-green { background: rgba(46,125,50,0.2); color: #66BB6A; }
|
||||
.badge-orange { background: rgba(245,124,0,0.2); color: #FFA726; }
|
||||
.badge-red { background: rgba(211,47,47,0.2); color: #EF5350; }
|
||||
.badge-blue { background: rgba(21,101,192,0.2); color: #64B5F6; }
|
||||
|
||||
/* ── Chat ── */
|
||||
.chat-message { padding: 10px 14px; border-radius: var(--radius); margin-bottom: 8px; font-size: 14px; line-height: 1.5; max-width: 80%; }
|
||||
.chat-message-bot { background: var(--bg-card); align-self: flex-start; }
|
||||
.chat-message-user { background: var(--primary); align-self: flex-end; margin-left: auto; }
|
||||
.chat-input { display: flex; gap: 8px; padding: 12px 16px; background: var(--bg-card); border-top: 1px solid rgba(255,255,255,0.05); flex-shrink: 0; }
|
||||
.chat-input input { flex: 1; background: var(--bg); border: none; border-radius: var(--radius-full); padding: 10px 16px; color: var(--text); font-size: 14px; outline: none; }
|
||||
.chat-input button { background: var(--primary); border: none; border-radius: 50%; width: 40px; height: 40px; color: white; font-size: 18px; cursor: pointer; }
|
||||
|
||||
/* ── Map ── */
|
||||
.map-placeholder { width: 100%; height: 200px; background: #E8F5E9; border-radius: var(--radius); display: flex; flex-direction: column; align-items: center; justify-content: center; color: #4CAF50; margin-bottom: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
SCREEN: LOGIN
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
<div id="screen-login" class="screen active">
|
||||
<div class="header">
|
||||
<div class="logo">🏙️</div>
|
||||
<h1>Smart City Martinique</h1>
|
||||
<p>Martinique</p>
|
||||
</div>
|
||||
<div class="form-container">
|
||||
<div class="alert alert-error" id="login-error"></div>
|
||||
<div class="form-title">Connexion</div>
|
||||
<div class="input-group">
|
||||
<label>Email</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="icon">📧</span>
|
||||
<input type="email" id="login-email" placeholder="votre@email.com" autocomplete="email">
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>Mot de passe</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="icon">🔒</span>
|
||||
<input type="password" id="login-password" placeholder="••••••••" autocomplete="current-password">
|
||||
<button type="button" class="toggle-pw" onclick="togglePw('login-password')" style="background:none;border:none;cursor:pointer;font-size:16px;padding:0 12px">👁️</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center mt-12">
|
||||
<a href="#" class="link" onclick="forgotPassword()">Mot de passe oublié ?</a>
|
||||
</div>
|
||||
<button class="btn btn-primary mt-24" id="btn-login" onclick="handleLogin()">
|
||||
<span>Se connecter</span>
|
||||
</button>
|
||||
<div class="divider"><span>ou</span></div>
|
||||
<div style="display:flex;gap:12px">
|
||||
<button class="btn btn-google" style="flex:1" onclick="socialLogin('google')"><span class="btn-icon">🔵</span> Google</button>
|
||||
<button class="btn btn-apple" style="flex:1" onclick="socialLogin('apple')"><span class="btn-icon">🍎</span> Apple</button>
|
||||
</div>
|
||||
<div class="text-center mt-24">
|
||||
<span style="font-size:14px;color:var(--text-secondary)">Pas encore de compte ? </span>
|
||||
<a href="#" class="link" onclick="showScreen('register')">S'inscrire</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
SCREEN: REGISTER
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
<div id="screen-register" class="screen">
|
||||
<div class="header" style="padding-bottom:30px">
|
||||
<div style="text-align:left;margin-bottom:16px"><a href="#" class="link" style="color:rgba(255,255,255,0.8)" onclick="showScreen('login')">← Retour</a></div>
|
||||
<h1>Créer un compte</h1>
|
||||
<p>Rejoignez Smart City Martinique</p>
|
||||
</div>
|
||||
<div class="form-container">
|
||||
<div class="alert alert-error" id="register-error"></div>
|
||||
<div class="alert alert-success" id="register-success"></div>
|
||||
<div style="display:flex;gap:12px">
|
||||
<div class="input-group" style="flex:1">
|
||||
<label>Prénom</label>
|
||||
<div class="input-wrapper"><input type="text" id="reg-firstname" placeholder="Jean"></div>
|
||||
</div>
|
||||
<div class="input-group" style="flex:1">
|
||||
<label>Nom</label>
|
||||
<div class="input-wrapper"><input type="text" id="reg-lastname" placeholder="Dupont"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>Email</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="icon">📧</span>
|
||||
<input type="email" id="reg-email" placeholder="votre@email.com">
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>Mot de passe</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="icon">🔒</span>
|
||||
<input type="password" id="reg-password" placeholder="Min. 8 caractères">
|
||||
<button type="button" class="toggle-pw" onclick="togglePw('reg-password')" style="background:none;border:none;cursor:pointer;font-size:16px;padding:0 12px">👁️</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>Confirmer</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="icon">🔒</span>
|
||||
<input type="password" id="reg-confirm" placeholder="Retapez le mot de passe">
|
||||
</div>
|
||||
</div>
|
||||
<div class="checkbox mb-8">
|
||||
<input type="checkbox" id="reg-terms">
|
||||
<span>J'accepte les <a href="#" class="link">conditions d'utilisation</a></span>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="btn-register" onclick="handleRegister()">
|
||||
<span>S'inscrire</span>
|
||||
</button>
|
||||
<div class="text-center mt-24">
|
||||
<span style="font-size:14px;color:var(--text-secondary)">Déjà un compte ? </span>
|
||||
<a href="#" class="link" onclick="showScreen('login')">Se connecter</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
SCREEN: HOME DASHBOARD
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
<div id="screen-home" class="screen">
|
||||
<div class="dashboard-header">
|
||||
<div class="dashboard-header-top">
|
||||
<div>
|
||||
<div class="greeting">Bonjour 👋</div>
|
||||
<div class="user-name" id="home-user-name">Utilisateur</div>
|
||||
</div>
|
||||
<button class="notification-btn" onclick="showScreen('notifications')">🔔<span class="notification-badge">3</span></button>
|
||||
</div>
|
||||
<div class="search-bar">
|
||||
<span>🔍</span>
|
||||
<input type="text" placeholder="Rechercher un service, un lieu...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="scrollable" style="padding-top:60px">
|
||||
<div class="metrics-row">
|
||||
<div class="metric-card"><div class="metric-value" style="color:var(--accent)">28°</div><div class="metric-unit">C</div><div class="metric-label">Temp.</div></div>
|
||||
<div class="metric-card"><div class="metric-value" style="color:#64B5F6">72%</div><div class="metric-unit"></div><div class="metric-label">Humidité</div></div>
|
||||
<div class="metric-card"><div class="metric-value" style="color:#FFA726">42</div><div class="metric-unit">AQI</div><div class="metric-label">Air</div></div>
|
||||
<div class="metric-card"><div class="metric-value" style="color:#EF5350">55</div><div class="metric-unit">dB</div><div class="metric-label">Bruit</div></div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-header"><div class="section-title">Actions rapides</div></div>
|
||||
<div class="quick-actions">
|
||||
<a href="#" class="quick-action" onclick="showScreen('signalements')"><div class="quick-action-icon" style="background:rgba(211,47,47,0.15)">🚨</div><div class="quick-action-label">Signaler</div></a>
|
||||
<a href="#" class="quick-action" onclick="showScreen('map')"><div class="quick-action-icon" style="background:rgba(21,101,192,0.15)">🚌</div><div class="quick-action-label">Transport</div></a>
|
||||
<a href="#" class="quick-action" onclick="showScreen('marketplace')"><div class="quick-action-icon" style="background:rgba(46,125,50,0.15)">⚡</div><div class="quick-action-label">Énergie</div></a>
|
||||
<a href="#" class="quick-action" onclick="showScreen('sante')"><div class="quick-action-icon" style="background:rgba(245,124,0,0.15)">🏥</div><div class="quick-action-label">Santé</div></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-header"><div class="section-title">Météo</div><a href="#" class="see-all" onclick="showScreen('meteo')">Voir tout →</a></div>
|
||||
<div class="card" style="background:linear-gradient(135deg,#1565C0,#0288D1);color:white">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div>
|
||||
<div style="font-size:36px;font-weight:700">28°C</div>
|
||||
<div style="font-size:13px;opacity:0.8">Fort-de-France</div>
|
||||
<div style="font-size:12px;opacity:0.7;margin-top:4px">Ensoleillé • Humidité 72%</div>
|
||||
</div>
|
||||
<div style="font-size:48px">☀️</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-header"><div class="section-title">Capteurs en direct</div><a href="#" class="see-all" onclick="showScreen('sensors')">Voir tout →</a></div>
|
||||
<div id="sensors-list"></div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-header"><div class="section-title">Alertes</div></div>
|
||||
<div class="card">
|
||||
<div style="display:flex;gap:10px;align-items:flex-start">
|
||||
<span style="font-size:20px">⚠️</span>
|
||||
<div>
|
||||
<div class="card-title">Qualité air — Schoelcher</div>
|
||||
<div class="card-subtitle">AQI élevé détecté. Évitez les efforts prolongés.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="top-nav">
|
||||
<a href="#" class="nav-item active" onclick="showScreen('home')"><span class="nav-item-icon">🏠</span>Accueil</a>
|
||||
<a href="#" class="nav-item" onclick="showScreen('map')"><span class="nav-item-icon">🗺️</span>Carte</a>
|
||||
<a href="#" class="nav-item" onclick="showScreen('marketplace')"><span class="nav-item-icon">🛒</span>Market</a>
|
||||
<a href="#" class="nav-item" onclick="showScreen('chat')"><span class="nav-item-icon">🤖</span>IA Chat</a>
|
||||
<a href="#" class="nav-item" onclick="showScreen('profile')"><span class="nav-item-icon">👤</span>Profil</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
SCREEN: MAP
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
<div id="screen-map" class="screen">
|
||||
<div class="dashboard-header" style="padding-bottom:16px">
|
||||
<div class="section-header"><div class="section-title">Carte Interactive</div><span style="font-size:13px;color:rgba(255,255,255,0.7)">Martinique</span></div>
|
||||
</div>
|
||||
<div class="scrollable" style="padding-top:60px">
|
||||
<div class="map-placeholder">
|
||||
<div style="font-size:48px;margin-bottom:8px">🗺️</div>
|
||||
<div style="font-size:14px;font-weight:600">Carte OpenStreetMap</div>
|
||||
<div style="font-size:12px;color:#81C784">14.6°N, 61.0°W</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-header"><div class="section-title">Couches</div></div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<span class="badge badge-blue" onclick="toggleLayer(this)">📡 Capteurs</span>
|
||||
<span class="badge badge-green" onclick="toggleLayer(this)">🌬️ Qualité Air</span>
|
||||
<span class="badge badge-orange" onclick="toggleLayer(this)">🔊 Bruit</span>
|
||||
<span class="badge" onclick="toggleLayer(this)">🚗 Trafic</span>
|
||||
<span class="badge badge-blue" onclick="toggleLayer(this)">🌤️ Météo</span>
|
||||
<span class="badge badge-red" onclick="toggleLayer(this)">🎉 Événements</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-header"><div class="section-title">Capteurs à proximité</div></div>
|
||||
<div id="nearby-sensors"></div>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="top-nav">
|
||||
<a href="#" class="nav-item" onclick="showScreen('home')"><span class="nav-item-icon">🏠</span>Accueil</a>
|
||||
<a href="#" class="nav-item active" onclick="showScreen('map')"><span class="nav-item-icon">🗺️</span>Carte</a>
|
||||
<a href="#" class="nav-item" onclick="showScreen('marketplace')"><span class="nav-item-icon">🛒</span>Market</a>
|
||||
<a href="#" class="nav-item" onclick="showScreen('chat')"><span class="nav-item-icon">🤖</span>IA Chat</a>
|
||||
<a href="#" class="nav-item" onclick="showScreen('profile')"><span class="nav-item-icon">👤</span>Profil</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
SCREEN: MARKETPLACE
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
<div id="screen-marketplace" class="screen">
|
||||
<div class="dashboard-header" style="padding-bottom:16px">
|
||||
<div class="section-header"><div class="section-title">Marketplace</div><span style="font-size:13px;color:rgba(255,255,255,0.7)">Services & Produits</span></div>
|
||||
<div class="search-bar" style="margin-top:12px"><span>🔍</span><input type="text" placeholder="Rechercher un service..."></div>
|
||||
</div>
|
||||
<div class="scrollable" style="padding-top:60px">
|
||||
<div class="section">
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px">
|
||||
<span class="badge badge-blue" onclick="filterCategory(this)">Tous</span>
|
||||
<span class="badge" onclick="filterCategory(this)">🍎 Alimentation</span>
|
||||
<span class="badge" onclick="filterCategory(this)">🚌 Transport</span>
|
||||
<span class="badge" onclick="filterCategory(this)">⚡ Énergie</span>
|
||||
<span class="badge" onclick="filterCategory(this)">🏥 Santé</span>
|
||||
</div>
|
||||
<div id="marketplace-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="top-nav">
|
||||
<a href="#" class="nav-item" onclick="showScreen('home')"><span class="nav-item-icon">🏠</span>Accueil</a>
|
||||
<a href="#" class="nav-item" onclick="showScreen('map')"><span class="nav-item-icon">🗺️</span>Carte</a>
|
||||
<a href="#" class="nav-item active" onclick="showScreen('marketplace')"><span class="nav-item-icon">🛒</span>Market</a>
|
||||
<a href="#" class="nav-item" onclick="showScreen('chat')"><span class="nav-item-icon">🤖</span>IA Chat</a>
|
||||
<a href="#" class="nav-item" onclick="showScreen('profile')"><span class="nav-item-icon">👤</span>Profil</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
SCREEN: AI CHAT
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
<div id="screen-chat" class="screen">
|
||||
<div class="dashboard-header" style="padding-bottom:16px;flex-shrink:0">
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<div style="width:44px;height:44px;border-radius:14px;background:rgba(255,255,255,0.15);display:flex;align-items:center;justify-content:center;font-size:24px">🤖</div>
|
||||
<div><div style="font-size:16px;font-weight:700;color:white">Smart City IA</div><div style="font-size:12px;color:rgba(255,255,255,0.7)">En ligne</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scrollable" style="padding:16px;display:flex;flex-direction:column" id="chat-messages">
|
||||
<div class="chat-message chat-message-bot">Bonjour ! Je suis Smart City IA 🤖<br><br>Je peux vous aider avec :<br>• 🌤️ Météo en temps réel<br>• 🚌 Transports<br>• ⚡ Énergie<br>• 🚨 Alertes<br><br>Comment puis-je vous aider ?</div>
|
||||
</div>
|
||||
<div style="padding:8px 16px;display:flex;gap:8px;flex-wrap:wrap;background:var(--bg-card);border-top:1px solid rgba(255,255,255,0.05)">
|
||||
<span class="badge badge-blue" onclick="quickPrompt('Météo aujourd\\'hui')">🌤️ Météo</span>
|
||||
<span class="badge badge-green" onclick="quickPrompt('Prochain bus')">🚌 Bus</span>
|
||||
<span class="badge badge-orange" onclick="quickPrompt('Consommation énergie')">⚡ Énergie</span>
|
||||
<span class="badge badge-red" onclick="quickPrompt('Alertes en cours')">🚨 Alertes</span>
|
||||
</div>
|
||||
<div class="chat-input">
|
||||
<input type="text" id="chat-input" placeholder="Posez votre question..." onkeypress="if(event.key==='Enter')sendChat()">
|
||||
<button onclick="sendChat()">➤</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
SCREEN: PROFILE
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
<div id="screen-profile" class="screen">
|
||||
<div class="scrollable" style="padding-top:60px">
|
||||
<div class="dashboard-header" style="text-align:center;padding-bottom:24px">
|
||||
<div style="width:70px;height:70px;border-radius:18px;background:rgba(255,255,255,0.15);margin:0 auto 10px;display:flex;align-items:center;justify-content:center;font-size:36px">👤</div>
|
||||
<div style="font-size:18px;font-weight:700;color:white" id="profile-name">Utilisateur</div>
|
||||
<div style="font-size:12px;color:rgba(255,255,255,0.7)" id="profile-email">-</div>
|
||||
<div style="display:flex;gap:6px;justify-content:center;margin-top:10px">
|
||||
<span class="badge badge-blue">🏙️ Citoyen</span>
|
||||
<span class="badge badge-green">✅ Vérifié</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:16px">
|
||||
<div class="card" style="text-align:center"><div style="font-size:20px;font-weight:700;color:var(--primary)">12</div><div style="font-size:10px;color:var(--text-muted)">Signalements</div></div>
|
||||
<div class="card" style="text-align:center"><div style="font-size:20px;font-weight:700;color:var(--primary)">45</div><div style="font-size:10px;color:var(--text-muted)">Points</div></div>
|
||||
<div class="card" style="text-align:center"><div style="font-size:20px;font-weight:700;color:var(--primary)">5</div><div style="font-size:10px;color:var(--text-muted)">Abonnements</div></div>
|
||||
</div>
|
||||
<div id="profile-menu"></div>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="top-nav">
|
||||
<a href="#" class="nav-item" onclick="showScreen('home')"><span class="nav-item-icon">🏠</span>Accueil</a>
|
||||
<a href="#" class="nav-item" onclick="showScreen('map')"><span class="nav-item-icon">🗺️</span>Carte</a>
|
||||
<a href="#" class="nav-item" onclick="showScreen('marketplace')"><span class="nav-item-icon">🛒</span>Market</a>
|
||||
<a href="#" class="nav-item" onclick="showScreen('chat')"><span class="nav-item-icon">🤖</span>IA Chat</a>
|
||||
<a href="#" class="nav-item active" onclick="showScreen('profile')"><span class="nav-item-icon">👤</span>Profil</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
SCREEN: NOTIFICATIONS
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
<div id="screen-notifications" class="screen">
|
||||
<div class="dashboard-header" style="padding-bottom:24px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div class="section-header"><div class="section-title">Notifications</div></div>
|
||||
<button class="link" style="font-size:13px" onclick="markAllRead()">Tout lu</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scrollable" style="padding-top:60px">
|
||||
<div class="section" id="notifications-list"></div>
|
||||
</div>
|
||||
<nav class="top-nav">
|
||||
<a href="#" class="nav-item active" onclick="showScreen('home')"><span class="nav-item-icon">🏠</span>Accueil</a>
|
||||
<a href="#" class="nav-item" onclick="showScreen('map')"><span class="nav-item-icon">🗺️</span>Carte</a>
|
||||
<a href="#" class="nav-item" onclick="showScreen('marketplace')"><span class="nav-item-icon">🛒</span>Market</a>
|
||||
<a href="#" class="nav-item" onclick="showScreen('chat')"><span class="nav-item-icon">🤖</span>IA Chat</a>
|
||||
<a href="#" class="nav-item" onclick="showScreen('profile')"><span class="nav-item-icon">👤</span>Profil</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
PLACEHOLDER SCREENS
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
<div id="screen-signalements" class="screen"><div class="scrollable" style="padding:60px 20px;text-align:center"><div style="font-size:48px;margin-bottom:16px">🚨</div><h2>Signalements</h2><p style="color:var(--text-secondary);margin-top:8px">Écran en cours de développement</p><button class="btn btn-primary" style="margin-top:24px" onclick="showScreen('home')">← Retour</button></div><nav class="top-nav"><a href="#" class="nav-item active" onclick="showScreen('home')"><span class="nav-item-icon">🏠</span>Accueil</a><a href="#" class="nav-item" onclick="showScreen('map')"><span class="nav-item-icon">🗺️</span>Carte</a><a href="#" class="nav-item" onclick="showScreen('marketplace')"><span class="nav-item-icon">🛒</span>Market</a><a href="#" class="nav-item" onclick="showScreen('chat')"><span class="nav-item-icon">🤖</span>IA Chat</a><a href="#" class="nav-item" onclick="showScreen('profile')"><span class="nav-item-icon">👤</span>Profil</a></nav></div>
|
||||
<div id="screen-sensors" class="screen"><div class="scrollable" style="padding:60px 20px;text-align:center"><div style="font-size:48px;margin-bottom:16px">📡</div><h2>Capteurs IoT</h2><p style="color:var(--text-secondary);margin-top:8px">Écran en cours de développement</p><button class="btn btn-primary" style="margin-top:24px" onclick="showScreen('home')">← Retour</button></div><nav class="top-nav"><a href="#" class="nav-item active" onclick="showScreen('home')"><span class="nav-item-icon">🏠</span>Accueil</a><a href="#" class="nav-item" onclick="showScreen('map')"><span class="nav-item-icon">🗺️</span>Carte</a><a href="#" class="nav-item" onclick="showScreen('marketplace')"><span class="nav-item-icon">🛒</span>Market</a><a href="#" class="nav-item" onclick="showScreen('chat')"><span class="nav-item-icon">🤖</span>IA Chat</a><a href="#" class="nav-item" onclick="showScreen('profile')"><span class="nav-item-icon">👤</span>Profil</a></nav></div>
|
||||
<div id="screen-meteo" class="screen"><div class="scrollable" style="padding:60px 20px;text-align:center"><div style="font-size:48px;margin-bottom:16px">🌤️</div><h2>Météo</h2><p style="color:var(--text-secondary);margin-top:8px">Écran en cours de développement</p><button class="btn btn-primary" style="margin-top:24px" onclick="showScreen('home')">← Retour</button></div><nav class="top-nav"><a href="#" class="nav-item active" onclick="showScreen('home')"><span class="nav-item-icon">🏠</span>Accueil</a><a href="#" class="nav-item" onclick="showScreen('map')"><span class="nav-item-icon">🗺️</span>Carte</a><a href="#" class="nav-item" onclick="showScreen('marketplace')"><span class="nav-item-icon">🛒</span>Market</a><a href="#" class="nav-item" onclick="showScreen('chat')"><span class="nav-item-icon">🤖</span>IA Chat</a><a href="#" class="nav-item" onclick="showScreen('profile')"><span class="nav-item-icon">👤</span>Profil</a></nav></div>
|
||||
<div id="screen-sante" class="screen"><div class="scrollable" style="padding:60px 20px;text-align:center"><div style="font-size:48px;margin-bottom:16px">🏥</div><h2>Santé</h2><p style="color:var(--text-secondary);margin-top:8px">Écran en cours de développement</p><button class="btn btn-primary" style="margin-top:24px" onclick="showScreen('home')">← Retour</button></div><nav class="top-nav"><a href="#" class="nav-item active" onclick="showScreen('home')"><span class="nav-item-icon">🏠</span>Accueil</a><a href="#" class="nav-item" onclick="showScreen('map')"><span class="nav-item-icon">🗺️</span>Carte</a><a href="#" class="nav-item" onclick="showScreen('marketplace')"><span class="nav-item-icon">🛒</span>Market</a><a href="#" class="nav-item" onclick="showScreen('chat')"><span class="nav-item-icon">🤖</span>IA Chat</a><a href="#" class="nav-item" onclick="showScreen('profile')"><span class="nav-item-icon">👤</span>Profil</a></nav></div>
|
||||
|
||||
<script>
|
||||
// ── Auth Token ──
|
||||
let authToken = localStorage.getItem('authToken') || null;
|
||||
|
||||
// ── Screen Navigation ──
|
||||
function showScreen(id) {
|
||||
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
|
||||
document.getElementById('screen-' + id).classList.add('active');
|
||||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||||
const scrollable = document.querySelector('#screen-' + id + ' .scrollable');
|
||||
if (scrollable) scrollable.scrollTop = 0;
|
||||
}
|
||||
|
||||
// ── Toggle Password ──
|
||||
function togglePw(id) {
|
||||
const el = document.getElementById(id);
|
||||
el.type = el.type === 'password' ? 'text' : 'password';
|
||||
}
|
||||
|
||||
// ── API ──
|
||||
async function apiCall(method, path, body, auth) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (auth && authToken) headers['Authorization'] = 'Bearer ' + authToken;
|
||||
const opts = { method, headers };
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
const resp = await fetch('https://api-smartapp.digitribe.fr/api' + path, opts);
|
||||
const data = await resp.json().catch(() => null);
|
||||
return { ok: resp.ok, status: resp.status, data };
|
||||
}
|
||||
|
||||
// ── Login ──
|
||||
async function handleLogin() {
|
||||
const btn = document.getElementById('btn-login');
|
||||
const email = document.getElementById('login-email').value.trim();
|
||||
const password = document.getElementById('login-password').value;
|
||||
const err = document.getElementById('login-error');
|
||||
if (!email || !password) { err.textContent = 'Remplissez tous les champs'; err.classList.add('show'); return; }
|
||||
btn.disabled = true; btn.innerHTML = '<div class="spinner"></div>';
|
||||
try {
|
||||
const { ok, data } = await apiCall('POST', '/auth/login', { email, password }, false);
|
||||
if (ok && data?.accessToken) {
|
||||
authToken = data.accessToken;
|
||||
localStorage.setItem('authToken', authToken);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
loadDashboard(); showScreen('home');
|
||||
} else { err.textContent = data?.message || 'Identifiants incorrects'; err.classList.add('show'); }
|
||||
} catch(e) { err.textContent = 'Erreur de connexion'; err.classList.add('show'); }
|
||||
btn.disabled = false; btn.innerHTML = '<span>Se connecter</span>';
|
||||
}
|
||||
|
||||
// ── Register ──
|
||||
async function handleRegister() {
|
||||
const btn = document.getElementById('btn-register');
|
||||
const fn = document.getElementById('reg-firstname').value.trim();
|
||||
const ln = document.getElementById('reg-lastname').value.trim();
|
||||
const em = document.getElementById('reg-email').value.trim();
|
||||
const pw = document.getElementById('reg-password').value;
|
||||
const cf = document.getElementById('reg-confirm').value;
|
||||
const err = document.getElementById('register-error');
|
||||
const ok = document.getElementById('register-success');
|
||||
err.classList.remove('show'); ok.classList.remove('show');
|
||||
if (!fn||!ln||!em||!pw) { err.textContent = 'Remplissez tous les champs'; err.classList.add('show'); return; }
|
||||
if (pw.length < 8) { err.textContent = 'Min. 8 caractères'; err.classList.add('show'); return; }
|
||||
if (pw !== cf) { err.textContent = 'Mots de passe différents'; err.classList.add('show'); return; }
|
||||
btn.disabled = true; btn.innerHTML = '<div class="spinner"></div>';
|
||||
try {
|
||||
const { ok: success, data } = await apiCall('POST', '/auth/register', { email:em, password:pw, firstName:fn, lastName:ln }, false);
|
||||
if (success) { ok.textContent = 'Compte créé ! Connexion...'; ok.classList.add('show'); setTimeout(()=>{ handleLogin(); }, 1000); }
|
||||
else { err.textContent = data?.message || 'Erreur'; err.classList.add('show'); }
|
||||
} catch(e) { err.textContent = 'Erreur de connexion'; err.classList.add('show'); }
|
||||
btn.disabled = false; btn.innerHTML = '<span>S\'inscrire</span>';
|
||||
}
|
||||
|
||||
function socialLogin(p) { alert(p + ' — bientôt disponible'); }
|
||||
function forgotPassword() { const e = prompt('Email :'); if(e) alert('Lien envoyé à ' + e); }
|
||||
|
||||
// ── Dashboard ──
|
||||
function loadDashboard() {
|
||||
const u = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
document.getElementById('home-user-name').textContent = u.firstName || 'Utilisateur';
|
||||
document.getElementById('profile-name').textContent = (u.firstName||'') + ' ' + (u.lastName||'');
|
||||
document.getElementById('profile-email').textContent = u.email || '-';
|
||||
renderSensors(); renderMarketplace(); renderNotifications(); renderProfileMenu();
|
||||
}
|
||||
|
||||
function renderSensors() {
|
||||
const s = [
|
||||
{n:'Centre-Ville',l:'Fort-de-France',v:'28.3',u:'°C',i:'🌡️',c:'badge-green'},
|
||||
{n:'Port',l:'Baie de FDF',v:'72',u:'%',i:'💧',c:'badge-green'},
|
||||
{n:'Qualité Air',l:'Schoelcher',v:'85',u:'AQI',i:'🌬️',c:'badge-orange'},
|
||||
{n:'Bruit',l:'Centre-ville',v:'68',u:'dB',i:'🔊',c:'badge-red'},
|
||||
];
|
||||
document.getElementById('sensors-list').innerHTML = s.map(x=>`
|
||||
<div class="card"><div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div style="display:flex;gap:10px;align-items:center">
|
||||
<div style="width:40px;height:40px;border-radius:12px;background:rgba(21,101,192,0.1);display:flex;align-items:center;justify-content:center;font-size:18px">${x.i}</div>
|
||||
<div><div class="card-title">${x.n}</div><div class="card-subtitle">📍 ${x.l}</div></div>
|
||||
</div>
|
||||
<div style="text-align:right"><div style="font-size:20px;font-weight:700">${x.v}<span style="font-size:11px;color:var(--text-muted)"> ${x.u}</span></div><span class="badge ${x.c}">${x.u}</span></div>
|
||||
</div></div>
|
||||
`).join('');
|
||||
document.getElementById('nearby-sensors').innerHTML = document.getElementById('sensors-list').innerHTML;
|
||||
}
|
||||
|
||||
function renderMarketplace() {
|
||||
const p = [
|
||||
{n:'Panier Bio Local',pr:'AMAP Martinique',p:'15€',u:'/semaine',i:'🥬',r:4.8},
|
||||
{n:'Pass Transport',pr:'CFTA',p:'45€',u:'/mois',i:'🎫',r:4.2},
|
||||
{n:'Kit Solaire',pr:'EDF DOM',p:'299€',u:'',i:'☀️',r:4.6},
|
||||
{n:'Télémédecine',pr:'Santé+',p:'25€',u:'/consult',i:'🩺',r:4.5},
|
||||
];
|
||||
document.getElementById('marketplace-list').innerHTML = p.map(x=>`
|
||||
<div class="card"><div style="display:flex;gap:12px;align-items:center">
|
||||
<div style="width:52px;height:52px;border-radius:14px;background:rgba(21,101,192,0.1);display:flex;align-items:center;justify-content:center;font-size:24px;flex-shrink:0">${x.i}</div>
|
||||
<div style="flex:1"><div class="card-title">${x.n}</div><div class="card-subtitle">${x.pr}</div>
|
||||
<div class="card-row"><span style="font-size:16px;font-weight:700;color:var(--primary)">${x.p}</span><span style="font-size:12px;color:var(--text-muted)">${x.u}</span><span style="font-size:12px;color:var(--text-secondary)">⭐ ${x.r}</span></div></div>
|
||||
<button class="btn btn-primary" style="width:auto;padding:8px 16px;font-size:13px" onclick="alert('Ajouté')">+</button>
|
||||
</div></div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderNotifications() {
|
||||
const n = [
|
||||
{i:'🚨',t:'Alerte Qualité Air',b:'AQI élevé à Schoelcher (85).',h:'Il y a 30 min'},
|
||||
{i:'🎉',t:'Fête de la Musique',b:'21 juin au centre-ville.',h:'Il y a 2j'},
|
||||
{i:'ℹ️',t:'Nouveau service',b:'Suivi bus temps réel disponible !',h:'Il y a 5j'},
|
||||
];
|
||||
document.getElementById('notifications-list').innerHTML = n.map(x=>`
|
||||
<div class="card"><div style="display:flex;gap:12px;align-items:flex-start">
|
||||
<span style="font-size:24px">${x.i}</span>
|
||||
<div style="flex:1"><div class="card-title">${x.t}</div><div class="card-subtitle">${x.b}</div>
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${x.h}</div></div>
|
||||
</div></div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderProfileMenu() {
|
||||
const items = [
|
||||
{i:'🔔',l:'Notifications',a:"showScreen('notifications')"},
|
||||
{i:'🌍',l:'Langue',v:'Français',a:"alert('Bientôt')"},
|
||||
{i:'🌙',l:'Mode sombre',v:'Off',a:"alert('Bientôt')"},
|
||||
{i:'🔒',l:'Confidentialité',a:"alert('Bientôt')"},
|
||||
{i:'❓',l:'Aide & Support',a:"alert('Bientôt')"},
|
||||
{i:'ℹ️',l:'À propos',v:'v0.1.0',a:"alert('Smart City Martinique v0.1.0')"},
|
||||
{i:'🚪',l:'Déconnexion',a:'logout()',s:'color:var(--danger)'},
|
||||
];
|
||||
document.getElementById('profile-menu').innerHTML = items.map(x=>`
|
||||
<div class="card" onclick="${x.a}" style="cursor:pointer;display:flex;justify-content:space-between;align-items:center">
|
||||
<div style="display:flex;gap:12px;align-items:center"><span style="font-size:20px">${x.i}</span><span style="font-size:15px;${x.s||''}">${x.l}</span></div>
|
||||
<div style="display:flex;align-items:center;gap:8px">${x.v?`<span style="font-size:13px;color:var(--text-muted)">${x.v}</span>`:''}<span style="font-size:18px;color:var(--text-muted)">›</span></div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function logout() { authToken=null; localStorage.removeItem('authToken'); localStorage.removeItem('user'); showScreen('login'); }
|
||||
function toggleLayer(el) { el.style.opacity = el.style.opacity === '0.5' ? '1' : '0.5'; }
|
||||
function filterCategory(el) { document.querySelectorAll('[onclick^="filterCategory"]').forEach(e=>{e.style.background='';e.style.color='';}); el.style.background='var(--primary)'; el.style.color='white'; }
|
||||
function markAllRead() { alert('Tout marqué comme lu'); }
|
||||
|
||||
// ── Chat ──
|
||||
const chatResp = {
|
||||
'météo':'☀️ Fort-de-France : 28°C, ensoleillé. Humidité 72%, vent 12 km/h NE.',
|
||||
'bus':'🚌 L1 (Centre→Schoelcher) dans 8 min. L3 dans 12 min.',
|
||||
'énergie':'⚡ Conso semaine : 45 kWh (-12%). Estimation mensuelle : 180 kWh.',
|
||||
'alerte':'🚨 1 alerte : AQI élevé à Schoelcher (85).',
|
||||
};
|
||||
function sendChat() {
|
||||
const input = document.getElementById('chat-input');
|
||||
const msg = input.value.trim(); if (!msg) return;
|
||||
const c = document.getElementById('chat-messages');
|
||||
c.innerHTML += `<div class="chat-message chat-message-user">${msg}</div>`;
|
||||
input.value = '';
|
||||
setTimeout(()=>{
|
||||
const l = msg.toLowerCase();
|
||||
let r = 'Je consulte les données... 🔍';
|
||||
for (const [k,v] of Object.entries(chatResp)) { if(l.includes(k)) { r=v; break; } }
|
||||
c.innerHTML += `<div class="chat-message chat-message-bot">${r}</div>`;
|
||||
c.scrollTop = c.scrollHeight;
|
||||
}, 800);
|
||||
}
|
||||
function quickPrompt(t) { document.getElementById('chat-input').value = t; sendChat(); }
|
||||
|
||||
// ── Auto-login ──
|
||||
if (authToken) { loadDashboard(); showScreen('home'); }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
26
smart-app-city/frontend/nginx.conf
Normal file
26
smart-app-city/frontend/nginx.conf
Normal file
@@ -0,0 +1,26 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name smartapp.digitribe.fr;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
|
||||
|
||||
# SPA routing - must be first
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Static assets caching
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot|map)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Health check
|
||||
location /health {
|
||||
return 200 'OK';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user