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
|
# 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 |
|
| ID | Tâche | Détail |
|
||||||
|----|-------|--------|
|
|----|-------|--------|
|
||||||
| jupyterhub-fix | JupyterHub DB path | `sqlite:////srv/jupyterhub/jupyterhub.sqlite` (absolute path, 4 slashes) |
|
| airflow-deploy | Apache Airflow déployé | `airflow.digitribe.fr` — Python 3.11, LocalExecutor |
|
||||||
| jupyterhub-rebuild | Rebuild Dockerfile | Supprimé double-nested `/srv/jupyterhub/srv/jupyterhub` |
|
| openfn-cleanup | OpenFN supprimé | Race condition Cachex/Ecto non résolue |
|
||||||
| jupyterhub-spawner | Spawner config | `SimpleLocalProcessSpawner`, timeout 300s |
|
| ditto-cleanup | Stack Ditto supprimée | API v2 non fonctionnelle (schema-versions) |
|
||||||
| jupyterhub-user | User eric | Créé id=2, admin, authorized |
|
| openremote-cleanup | Stack OpenRemote supprimée | Patches bundle appliqués |
|
||||||
| jupyterhub-sudo | sudo + eric user in container | Dockerfile modifié, spawn vérifié fonctionnel |
|
| gravitino-cleanup | Gravitino supprimé | Unhealthy |
|
||||||
| hermes-dashboard | Dashboard WebUI+TUI | systemd service, localhost:9119, auto-boot |
|
| fiware-gis-cleanup | FIWARE GIS Quickstart supprimé | |
|
||||||
| or-mbtiles-metadata | Bounds monde + center Martinique | `sqlite3` UPDATE sur metadata |
|
| contexus-cleanup | Contexus supprimé | Unhealthy |
|
||||||
| or-map-settings | mapsettings.json vérifié | center=[-61,14.5], bounds=Martinique, minZoom=0 |
|
| kafka-cleanup | Kafka supprimé | Unhealthy + sera redeployé via Helm |
|
||||||
| or-mbtiles-location | mbtiles actif = /storage/map/ | PAS /opt/map/ (écrasé par volume) |
|
| flink-cleanup | Flink supprimé | Dépendances kafka |
|
||||||
| trino-fix | node.properties créé | `node.environment=production`, `node.id=trino-lakehouse-01` |
|
| bi-cleanup | Superset + Metabase supprimés | Seront redeployés via Helm |
|
||||||
| trino-config | config.properties nettoyé | `plugin.bundles` retiré (incompatible Trino 435) |
|
| mindsdb-cleanup | MindsDB supprimé | Autoheal unhealthy |
|
||||||
| kafka-fix | Kafka KRaft env vars | `KAFKA_CFG_*` → `KAFKA_*`, `CLUSTER_ID` ajouté, volumes recréés |
|
| odk-cleanup | ODK Central supprimé | Sera redeployé via Helm |
|
||||||
| git-push | Commits | Pushé sur Gitea (smart-city-digital-twin-martinique + lakehouse) |
|
| jupyterhub-cleanup | JupyterHub supprimé | Sera redeployé via Helm |
|
||||||
| **smart-app-mvp** | **Smart App City MVP complet** | **Voir détail ci-dessous** |
|
| zeppelin-cleanup | Zeppelin supprimé | Sera redeployé via Helm |
|
||||||
| honcho-api | Honcho API déployée | `honcho-api-1` — Up sur `honcho.digitribe.fr`, workspace `hermes-agent` |
|
| gis-cleanup | MapStore + GeoServer + FROST supprimés | Seront redeployés via Helm |
|
||||||
| honcho-plugin | Plugin mémoire Hermes ↔ Honcho | `~/.hermes/honcho.json` configuré, baseUrl `http://127.0.0.1:8089` |
|
| iot-cleanup | Node-RED + phpIPAM + EMQX + Mosquitto + BunkerM + ChirpStack supprimés | Seront redeployés via Helm |
|
||||||
| honcho-mémoire | Mémoire Honcho fonctionnelle | Stockage messages OK. Dialectic chat → nécessite clé LLM valide |
|
| monitoring-cleanup | Grafana + Loki + Prometheus + InfluxDB + Telegraf supprimés | Seront redeployés via Helm |
|
||||||
| cicd-pipeline | Gitea Actions CI/CD | Workflow lint + build + deploy, runner docker-runner-01 |
|
| storage-cleanup | MinIO + PostgreSQL + PostGIS + Redis + Zookeeper supprimés | Seront redeployés via Helm |
|
||||||
| ci-cd-secrets | Secrets Gitea Actions | SERVER_HOST, SERVER_USER, SSH_PRIVATE_KEY configurés |
|
| misc-cleanup | AgentGateway + Esperotech + Redpanda Console + Docker exporter + Simulator supprimés | |
|
||||||
| smart-app-docker | Dockerfile web + Traefik | Multi-stage node + nginx, SPA routing, smartapp.digitribe.fr |
|
| backups | Sauvegardes config | Fichiers sauvegardés dans /home/eric/backups/2026-06-03/ |
|
||||||
| smart-app-deploy | Script de déploiement | `deploy.sh` — web/docker/api/all |
|
| helms-ansible | Fichiers Helm/Ansibles générés | 25+ rôles dans /home/eric/helms/ |
|
||||||
| localai-fix | LocalAI Bad Gateway | Container n'existe plus, config Traefik supprimée |
|
| helms-readme | README déploiement K8s | Architecture, installation, troubleshooting |
|
||||||
| ditto-mongodb-fix | MongoDB connection | `-Dditto.mongodb.uri` dans JAVA_TOOL_OPTIONS |
|
| helms-vault | Template vault.yml | Variables chiffrées pour le déploiement |
|
||||||
| 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 |
|
|
||||||
|
|
||||||
## 🔴 En cours
|
## 🔴 En cours
|
||||||
|
|
||||||
| ID | Tâche | Raison | Prochaine action |
|
| 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 |
|
| ID | Tâche |
|
||||||
|----|-------|
|
|----|-------|
|
||||||
| or-mbtiles-martinique | Générer mbtiles MVT PBF pour Martinique (tippecanoe depuis GeoJSON filtré) |
|
| k8s-cluster | Créer le cluster Kubernetes (3 nœuds minimum) |
|
||||||
| p1-or-map | Vérifier carte Martinique après fix bounds |
|
| nfs-server | Configurer le serveur NFS pour le storage |
|
||||||
| p1-contexus-60 | Configurer les 60 devices Contexus |
|
| traefik-deploy | Déployer Traefik via Helm |
|
||||||
| p3-analyse | GeoMesa + KeplerGL |
|
| cert-manager-deploy | Déployer cert-manager pour TLS |
|
||||||
| p0-chirpstack | ChirpStack login API gRPC-REST |
|
| storage-deploy | Déployer NFS provisioner + StorageClass |
|
||||||
| p1-thingsboard | Relancer ThingsBoard (si CPU dispo) |
|
| monitoring-deploy | Déployer Prometheus + Grafana + Loki |
|
||||||
| smart-app Phase 1 | MVP React Native |
|
| databases-deploy | Déployer PostgreSQL HA + Redis + MinIO |
|
||||||
| p2-geoserver | GeoServer + PostGIS couches Martinique |
|
| 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/`
|
helms/
|
||||||
- OR 1.24.0 ne sert que du **PBF vectoriel** — PNG raster = 404
|
├── README.md # Documentation déploiement
|
||||||
- Bug : MapService.java donne priorité aux bounds du mbtiles metadata sur mapsettings.json
|
├── deploy.yml # Playbook principal
|
||||||
- Fix : bounds mbtiles metadata = monde (`-180,-85,180,85`), bounds mapsettings = zone désirée
|
├── undeploy.yml # Playbook de suppression
|
||||||
- Pour mettre à jour : `docker cp file.mbtiles openremote-manager:/storage/map/mapdata.mbtiles`
|
├── 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
|
## 📝 Infrastructure actuelle (10 containers Docker)
|
||||||
- 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
|
|
||||||
|
|
||||||
### Kafka (KRaft)
|
| Service | Image | Statut |
|
||||||
- `apache/kafka:3.9.0` utilise `KAFKA_*` (pas `KAFKA_CFG_*` qui est Bitnami)
|
|---------|-------|--------|
|
||||||
- `CLUSTER_ID=MkU3OEVBNTcwNTJENDM2Qk` requis pour storage formatting
|
| airflow-scheduler | apache/airflow:2.9.3-python3.11 | ✅ healthy |
|
||||||
- 2 brokers en mode KRaft (broker+controller), pas de ZooKeeper
|
| airflow-webserver | apache/airflow:2.9.3-python3.11 | ✅ healthy |
|
||||||
|
| airflow-init | apache/airflow:2.9.3-python3.11 | 🔄 restarting (one-shot) |
|
||||||
### Trino
|
| airflow-postgres | postgres:16 | ✅ healthy |
|
||||||
- Config dans `/home/eric/lakehouse/docker-compose/config/trino/`
|
| smartapp-api | smartapp-api:latest | ✅ Up 38h |
|
||||||
- `node.id=trino-lakehouse-01` (pas `_internal_`)
|
| smartapp-web | nginx:alpine | ✅ Up 38h |
|
||||||
- `plugin.bundles` retiré de config.properties (incompatible Trino 435)
|
| gitea-runner | gitea/act_runner:latest | ✅ Up 2 days |
|
||||||
|
| traefik | traefik:v3.1 | ✅ Up 2 days |
|
||||||
### Infrastructure
|
| smart-city-kepler | smart-city-kepler:latest | ✅ Up 2 weeks |
|
||||||
- 86+ conteneurs Docker
|
| gitea | gitea/gitea:latest | ✅ Up 2 jours |
|
||||||
- Kafka, Trino, JupyterHub = UP ✅ (fixes appliqués cette session)
|
|
||||||
- Tous les autres services principaux = UP ✅
|
|
||||||
|
|
||||||
## Credentials
|
## Credentials
|
||||||
|
|
||||||
- **Contexus**: iotevadmin / Digitribe972
|
- **Gitea** : eric / (voir config)
|
||||||
- **OpenRemote**: admin / Digitribe972
|
- **Airflow** : admin / (changé par Eric)
|
||||||
- **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
|
|
||||||
|
|||||||
@@ -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+
|
- Node.js 20+
|
||||||
- Expo CLI: `npm install -g expo-cli`
|
- Expo CLI : `npm install -g expo-cli`
|
||||||
- Docker & Docker Compose
|
- Docker & Docker Compose (pour le déploiement)
|
||||||
|
|
||||||
### Development
|
### Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm install --legacy-peer-deps
|
||||||
|
|
||||||
# Start development server
|
|
||||||
npx expo start
|
|
||||||
|
|
||||||
# Run on device
|
|
||||||
# Scan QR code with Expo Go app
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backend
|
### Lancement en mode développement
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start all services
|
# Mode natif (iOS/Android)
|
||||||
cd ..
|
npm start
|
||||||
docker-compose up -d
|
# Scanner le QR code avec l'app Expo Go
|
||||||
|
|
||||||
# Start individual service
|
# Mode web
|
||||||
cd backend/auth-service
|
npm run web
|
||||||
npm run start:dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### AI Services
|
### Build web statique
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start RAG service
|
# Sans Docker
|
||||||
cd ai/rag-service
|
cd frontend
|
||||||
pip install -r requirements.txt
|
npx expo export --platform web --output-dir dist
|
||||||
uvicorn main:app --reload --port 8001
|
|
||||||
|
|
||||||
# Start Agent service
|
# Avec Docker
|
||||||
cd ai/agent-service
|
docker build -t smartapp-web:latest .
|
||||||
pip install -r requirements.txt
|
|
||||||
uvicorn main:app --reload --port 8002
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Documentation
|
### Déploiement
|
||||||
|
|
||||||
- [Architecture](docs/ARCHITECTURE.md)
|
```bash
|
||||||
- [Beckn Integration](docs/BECKN_INTEGRATION.md)
|
cd smart-app-city
|
||||||
- [AI Architecture](docs/AI_ARCHITECTURE.md)
|
docker compose -f docker-compose.web.yml up -d
|
||||||
- [Internationalization](docs/I18N.md)
|
```
|
||||||
|
|
||||||
## 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
|
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