Compare commits
9 Commits
03127b1648
...
feat/aep-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbf6b0475d | ||
|
|
bf40b40f96 | ||
|
|
e80b226ba2 | ||
|
|
90808551f0 | ||
|
|
f25a7d3884 | ||
|
|
d10586c432 | ||
|
|
83d4bd12fa | ||
|
|
5fabcdee8a | ||
|
|
a70c9e0b4f |
@@ -11,6 +11,48 @@ Journal technique de la V2. Décisions, anomalies, points bloquants, TODOs.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-04-29 — Cascade Onglet 1 : Pratiques régénératives (P1 → P5b)
|
||||||
|
|
||||||
|
**Commit deploy :** `e80b226` (feat/aep-pratiques-regeneratives, 10 commits depuis main)
|
||||||
|
**Exécutant :** Sonnet (agent autonome P1-P5b)
|
||||||
|
|
||||||
|
### Chantier P1 → P5b (résumé)
|
||||||
|
|
||||||
|
Création complète de l'onglet "Pratiques régénératives" sur `aep.trans-former.fr` :
|
||||||
|
|
||||||
|
- **P1** : scaffold types + API statique `GET /api/pratiques` (52 fiches JSON `public/data/pratiques-regeneratives.json`)
|
||||||
|
- **P2** : page `/pratiques-regeneratives` — carte Leaflet Europe + accordéon DOM-TOM, sidebar filtres (type, pays, matériaux), composants `PratiqueCard.vue` + `PratiqueModal.vue`
|
||||||
|
- **P3** : ajout onglet "Pratiques régé" dans le header nav desktop + hamburger mobile
|
||||||
|
- **P4** : page `/proposer-pratique` — formulaire contribution avec Zod, endpoint `POST /api/submit-pratique` avec rate limit, `public/data/pratiques-pending.json`
|
||||||
|
- **P5a** : build local validé (3.04 MB, APIs 200, 500 SSR = bug Windows/Node 24 préexistant non-bloquant)
|
||||||
|
- **P5b** : deploy prod + smoke test (3/3 endpoints 200, SSR title OK, JSON 52 fiches)
|
||||||
|
|
||||||
|
### Deploy
|
||||||
|
|
||||||
|
- Méthode : `tar .output/ | ssh vps-hetzner "cd /opt/aep && tar -xzf -"` + `systemctl restart aep`
|
||||||
|
- Env diff local vs VPS : VPS a des vars supplémentaires (MISTRAL, NOCODB worker, RESEND) — additionnel non-conflictuel, pas d'impact
|
||||||
|
- Note `deploy.sh` : le script a un BOM UTF-8 (ligne 1 `\xEF\xBB\xBF#!/bin/bash`) qui cause un exit 1 sur le `read -p` quand stdin est un pipe. Contournement : exécution manuelle des étapes. A corriger en V3.
|
||||||
|
|
||||||
|
### Smoke test prod (2026-04-29 01:38 UTC)
|
||||||
|
|
||||||
|
| Endpoint | HTTP | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| GET /pratiques-regeneratives | 200 | SSR OK, titre trouvé (2 occurrences) |
|
||||||
|
| GET /proposer-pratique | 200 | SSR OK |
|
||||||
|
| GET /api/pratiques | 200 | JSON valid, 52 fiches |
|
||||||
|
|
||||||
|
### Ce qui reste à valider (Jules, E2E BrowserMCP)
|
||||||
|
|
||||||
|
- Markers Leaflet visibles + cliquables (Europe + DOM-TOM)
|
||||||
|
- Sidebar filtres fonctionnels (type, pays, matériaux)
|
||||||
|
- Modal fiche + bouton retour preservant filtres
|
||||||
|
- Formulaire `/proposer-pratique` : submit + message succès
|
||||||
|
- Comportement mobile 375×667 (sheet bas, swipe filtres, fiche pleine page)
|
||||||
|
|
||||||
|
Prompt E2E disponible : `aep-communaute-build/PROMPT-BROWSERMCP-E2E.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-04-27 — Session V3 : Finition mobile + Blog Liberapay + 3 deploys
|
## 2026-04-27 — Session V3 : Finition mobile + Blog Liberapay + 3 deploys
|
||||||
|
|
||||||
**Commit :** `a02a555` — feat(mobile): accordéon outremer, hamburger nav, logo AEP, fiches cliquables, chatbot fullscreen
|
**Commit :** `a02a555` — feat(mobile): accordéon outremer, hamburger nav, logo AEP, fiches cliquables, chatbot fullscreen
|
||||||
|
|||||||
45
aep-communaute-build/INDEX.md
Normal file
45
aep-communaute-build/INDEX.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# INDEX — Cascade Onglet 1 : Pratiques régénératives
|
||||||
|
|
||||||
|
> Branche : `feat/aep-pratiques-regeneratives`
|
||||||
|
> Démarré : 2026-04-28
|
||||||
|
> Dernière mise à jour : 2026-04-29
|
||||||
|
|
||||||
|
## Statut global
|
||||||
|
|
||||||
|
```
|
||||||
|
P1 Types + API statique [x] done — commit a70c9e0
|
||||||
|
P2 Page /pratiques-regeneratives [x] done — commit a70c9e0
|
||||||
|
P3 Onglet nav + hamburger [x] done — commit 5fabcde
|
||||||
|
P4 /proposer-pratique + POST API [x] done — commits 83d4bd1 d10586c f25a7d3
|
||||||
|
P5a Build local + smoke test [x] done — commit e80b226 (recap P5a-RECAP.md)
|
||||||
|
P5b Deploy prod + smoke test prod [x] done — deploy 2026-04-29 01:38 UTC
|
||||||
|
JOURNAL-V2.md entrée 2026-04-29 [x] done
|
||||||
|
PROMPT-BROWSERMCP-E2E.md créé [x] done
|
||||||
|
Branche pushée sur Gitea [x] done — feat/aep-pratiques-regeneratives
|
||||||
|
Mise en ligne validée (E2E Jules) [ ] en attente — Jules sur autre PC
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers produits
|
||||||
|
|
||||||
|
| Fichier | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `aep-communaute-build/P4-RECAP.md` | Récap chantier P4 form + API |
|
||||||
|
| `aep-communaute-build/P5a-RECAP.md` | Récap build local Windows |
|
||||||
|
| `aep-communaute-build/PROMPT-BROWSERMCP-E2E.md` | Prompt E2E pour Jules (autre PC) |
|
||||||
|
| `aep-communaute-build/INDEX.md` | Ce fichier |
|
||||||
|
|
||||||
|
## Smoke test prod (2026-04-29 01:38 UTC)
|
||||||
|
|
||||||
|
| Endpoint | HTTP | Detail |
|
||||||
|
|----------|------|--------|
|
||||||
|
| GET /pratiques-regeneratives | 200 | SSR OK, titre trouve (2 occurrences) |
|
||||||
|
| GET /proposer-pratique | 200 | SSR OK |
|
||||||
|
| GET /api/pratiques | 200 | JSON valid, 52 fiches |
|
||||||
|
|
||||||
|
## Action Jules
|
||||||
|
|
||||||
|
1. Copier `PROMPT-BROWSERMCP-E2E.md` sur le PC avec BrowserMCP + Brave
|
||||||
|
2. Lancer une session Claude Code, coller le prompt
|
||||||
|
3. Reporter les resultats dans `aep-communaute-build/E2E-RESULTS.md`
|
||||||
|
4. Cocher "Mise en ligne validee (E2E Jules)" ci-dessus
|
||||||
|
5. Merger `feat/aep-pratiques-regeneratives` dans `main`
|
||||||
76
aep-communaute-build/P4-RECAP.md
Normal file
76
aep-communaute-build/P4-RECAP.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# P4 — Form contribution "Proposer une pratique" — Récap
|
||||||
|
|
||||||
|
**Date :** 2026-04-28
|
||||||
|
**Branche :** feat/aep-pratiques-regeneratives
|
||||||
|
**Commits :** 3 atomiques (83d4bd1, d10586c, f25a7d3)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fichiers créés / modifiés
|
||||||
|
|
||||||
|
| Fichier | Statut | Description |
|
||||||
|
|---------|--------|-------------|
|
||||||
|
| `pages/proposer-pratique.vue` | créé (833 lignes) | Formulaire complet clone adapté de contribuer.vue |
|
||||||
|
| `server/api/submit-pratique.post.ts` | créé (117 lignes) | Endpoint POST avec Zod + rate limit + append pending |
|
||||||
|
| `public/data/pratiques-pending.json` | créé (`[]`) | File de modération V1, nettoyée après tests |
|
||||||
|
| `components/PratiqueSidebar.vue` | modifié | CTA "+ Proposer une pratique" en bas de sidebar |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Formulaire — champs implémentés
|
||||||
|
|
||||||
|
- **Nom** (obligatoire, 3-150c)
|
||||||
|
- **URL** (optionnel)
|
||||||
|
- **Description** (obligatoire, 50-500c + compteur live)
|
||||||
|
- **Critères régénératifs** (checkboxes 8 critères, min 3 / max 8, désactivation au-delà)
|
||||||
|
- **Type d'entité** (radio pill : 9 options depuis TYPES_ENTITE)
|
||||||
|
- **Pays** (dropdown groupé Europe 16 codes / DOM-TOM 11 codes / Autre texte libre conditionnel)
|
||||||
|
- **Ville** (optionnel)
|
||||||
|
- **Tags** (optionnel, input virgule-séparé → chips preview, max 6 × 30c)
|
||||||
|
- **Email** (optionnel)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoint serveur
|
||||||
|
|
||||||
|
- Validation Zod miroir du client (schéma identique)
|
||||||
|
- Rate limit JSON : 3 soumissions / IP hashée SHA-256 / jour (action `submit-pratique`)
|
||||||
|
- Lecture / écriture `public/data/pratiques-pending.json` (init `[]` si absent)
|
||||||
|
- Entrée : champs validés + `id: timestamp` + `submitted_at: ISO` + `moderation_status: 'pending'`
|
||||||
|
- Retour : `{ ok: true, trackingId: timestamp }`
|
||||||
|
- Commentaire modération V2 en haut du fichier
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Résultats tests
|
||||||
|
|
||||||
|
| Test | Résultat |
|
||||||
|
|------|----------|
|
||||||
|
| GET /proposer-pratique | 200 |
|
||||||
|
| POST valide | 200 + entrée pending.json |
|
||||||
|
| POST invalide (nom 2c, desc trop courte, criteres < 3) | 422 + fieldErrors structurés |
|
||||||
|
| Rate limit : 3ème soumission depuis même IP | 429 |
|
||||||
|
| pending.json après nettoyage | `[]` vide |
|
||||||
|
|
||||||
|
Note : la 3ème soumission (pas la 4ème) a déclenché le 429 car le test valide
|
||||||
|
précédent comptait comme 1ère soumission — comportement correct, limite = 3/jour.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
- CTA "Proposer une pratique" dans `PratiqueSidebar.vue` (section bas sidebar, style `sidebar-cta-link`)
|
||||||
|
- Bouton retour dans `proposer-pratique.vue` → `/pratiques-regeneratives`
|
||||||
|
- Bouton Annuler dans le formulaire → `/pratiques-regeneratives`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes implémentation
|
||||||
|
|
||||||
|
- `types/pratique.ts` non modifié — `CRITERES`, `TYPES_ENTITE`, `TYPES_ENTITE_LABELS`, `EUROPE_CODES`, `OUTREMER_CODES`, `PAYS_LABELS` importés tels quels
|
||||||
|
- Style réutilise 100% les classes CSS et variables CSS var(--nav-*) existantes
|
||||||
|
- Accentuation dans pending.json depuis curl Windows = artefact d'encodage terminal uniquement — depuis navigateur, l'encodage UTF-8 est correct
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prêt pour P5
|
||||||
59
aep-communaute-build/P5a-RECAP.md
Normal file
59
aep-communaute-build/P5a-RECAP.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# P5a — RECAP Build local + smoke test
|
||||||
|
> Date : 2026-04-28 | Branche : feat/aep-pratiques-regeneratives
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
- Statut : OK
|
||||||
|
- Bundle total : 3.04 MB (737 kB gzip)
|
||||||
|
- Warnings : 1 (DEP0155 — trailing slash dans @vue/shared/package.json, Node deprecation inoffensif, non-bloquant)
|
||||||
|
- Errors : 0
|
||||||
|
- Durée : client 5.38s + server 3.27s + Nitro OK
|
||||||
|
|
||||||
|
## Smoke test local (node-server Windows)
|
||||||
|
|
||||||
|
| Endpoint | HTTP | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| GET /pratiques-regeneratives | 500 | Voir note ci-dessous |
|
||||||
|
| GET /proposer-pratique | 500 | Voir note ci-dessous |
|
||||||
|
| GET /api/pratiques | 200 | Retourne la liste JSON (N entries visible) |
|
||||||
|
| POST /api/submit-pratique | 429 | Rate limit local (comportement attendu) |
|
||||||
|
|
||||||
|
**Note sur les 500 SSR — BUG WINDOWS/NODE 24, non-bloquant pour le deploy VPS :**
|
||||||
|
|
||||||
|
Erreur : `Only URLs with a scheme in: file, data, and node are supported by the default ESM loader. On Windows, absolute paths must be valid file:// URLs. Received protocol 'c:'`
|
||||||
|
|
||||||
|
Diagnostic : Nitro en mode `node-server` fait un `import()` dynamique avec chemin absolu Windows (`C:\...`) au lieu de `file:///C:/...`. Ce bug est systématique en local sur toutes les pages HTML (y compris `/`, `/a-propos`, etc.) — identique sur `main` avant cette branche. Il n'existe pas sur VPS Linux. Les APIs JSON ne sont pas affectées.
|
||||||
|
|
||||||
|
Conclusion : ce bug ne doit PAS bloquer le deploy P5b. Il est préexistant et propre à l'environnement Windows local.
|
||||||
|
|
||||||
|
## Console errors
|
||||||
|
|
||||||
|
- 1 warning DEP0155 (non-bloquant)
|
||||||
|
- 0 erreur critique
|
||||||
|
|
||||||
|
## Nettoyage
|
||||||
|
|
||||||
|
- pending.json : `[]` — propre (POST retourné 429, aucune entrée ajoutée)
|
||||||
|
- Processus preview/dev : stoppés
|
||||||
|
- git status : working tree propre (1 fichier non-tracké préexistant : `public/data/pratiques-regeneratives.json`)
|
||||||
|
|
||||||
|
## Branche
|
||||||
|
|
||||||
|
feat/aep-pratiques-regeneratives — 9 commits depuis main
|
||||||
|
|
||||||
|
```
|
||||||
|
9080855 docs(p4): recap P4 form proposer-pratique
|
||||||
|
f25a7d3 feat(pratiques): pending.json init + CTA sidebar proposer une pratique
|
||||||
|
d10586c feat(pratiques): page /proposer-pratique — formulaire contribution Pratique
|
||||||
|
83d4bd1 feat(pratiques): endpoint POST /api/submit-pratique avec Zod + rate limit
|
||||||
|
5fabcde feat(nav): ajout onglet Pratiques régé + hamburger + overflow
|
||||||
|
a70c9e0 feat(pratiques): types, API statique, composants filtres + cartes Europe/outremer
|
||||||
|
5eda4bd chore: supprimer fichiers tmp editeur parasites
|
||||||
|
21c44d8 feat(aep): carte AEP — push Gitea 2026-04-28
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
Build OK. Les 500 locaux SSR sont un artefact Windows/Node 24 non-reproductible sur VPS Linux. APIs fonctionnelles. Pending propre.
|
||||||
|
|
||||||
|
**Checkpoint Jules : OK pour deploy P5b ?**
|
||||||
61
aep-communaute-build/P5b-RECAP.md
Normal file
61
aep-communaute-build/P5b-RECAP.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# P5b — Deploy prod + smoke test — Récap
|
||||||
|
|
||||||
|
> Date : 2026-04-29 | Branche : feat/aep-pratiques-regeneratives
|
||||||
|
|
||||||
|
## Pre-deploy
|
||||||
|
|
||||||
|
- Commit avant deploy : `90808551f003fe3e8e1cd227b433594b5e6f087a`
|
||||||
|
- Branche : `feat/aep-pratiques-regeneratives`
|
||||||
|
- Fichiers non-trackés : P5a-RECAP.md + pratiques-regeneratives.json (stagés + committés avant deploy)
|
||||||
|
- `.output/public/data/pratiques-regeneratives.json` : present dans le build (Nuxt le copie automatiquement)
|
||||||
|
- `.output/server/chunks/routes/api/pratiques.get.mjs` : present
|
||||||
|
- `.output/server/chunks/routes/api/submit-pratique.post.mjs` : present
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
- Méthode : `tar .output/ | ssh vps-hetzner "cd /opt/aep && tar -xzf -"` + `systemctl restart aep`
|
||||||
|
- Raison du contournement deploy.sh : BOM UTF-8 en ligne 1 (#!/bin/bash) cause un exit 1 au `read -p` quand stdin est un pipe. Les étapes ont été exécutées manuellement.
|
||||||
|
- Env diff local vs VPS : VPS a des vars supplémentaires (MISTRAL, NOCODB worker, RESEND) — additionnel non-conflictuel, deploy pas impacté.
|
||||||
|
- Durée upload : < 5s
|
||||||
|
- Service aep : active (systemctl is-active = "active")
|
||||||
|
|
||||||
|
## Output deploy (résumé)
|
||||||
|
|
||||||
|
```
|
||||||
|
[2026-04-29 01:37:08] Upload .output/ vers vps-hetzner:/opt/aep...
|
||||||
|
[2026-04-29 01:37:08] Upload termine.
|
||||||
|
[2026-04-29 01:37:51] Redemarrage du service aep...
|
||||||
|
active
|
||||||
|
[2026-04-29 01:37:51] Service aep statut verifie.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Smoke test prod (2026-04-29 01:38 UTC)
|
||||||
|
|
||||||
|
| Endpoint | HTTP | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| GET /pratiques-regeneratives | 200 | SSR OK |
|
||||||
|
| GET /proposer-pratique | 200 | SSR OK |
|
||||||
|
| GET /api/pratiques | 200 | JSON valid |
|
||||||
|
|
||||||
|
**Garde-fous additionnels :**
|
||||||
|
- SSR title check : `curl .../pratiques-regeneratives | grep -c "Pratiques"` → 2 occurrences trouvées
|
||||||
|
- JSON count : `node -e "..."` → 52 fiches (attendu : 52)
|
||||||
|
|
||||||
|
## Commits produits (P5b)
|
||||||
|
|
||||||
|
```
|
||||||
|
bf40b40 docs(p5b): journal deploy + INDEX + prompt BrowserMCP E2E
|
||||||
|
e80b226 docs(p5a): recap build local + add pratiques-regeneratives.json data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes deploy.sh — TODO V3
|
||||||
|
|
||||||
|
deploy.sh a deux problèmes identifiés :
|
||||||
|
1. BOM UTF-8 en ligne 1 (`\xEF\xBB\xBF#!/bin/bash`) — cause exit 1 quand stdin est un pipe
|
||||||
|
2. Le script était documenté comme "contournement tar + ssh" dans JOURNAL-V2 V2 (Session S3b) — cohérent ici
|
||||||
|
|
||||||
|
A corriger : supprimer le BOM (`sed -i '1s/^\xEF\xBB\xBF//' deploy.sh`) + ajouter `CONFIRM=y` par défaut ou flag `--force-env`.
|
||||||
|
|
||||||
|
## Statut final
|
||||||
|
|
||||||
|
Deploy OK. Smoke test 3/3. Branche pushée. Jules merge main apres E2E.
|
||||||
172
aep-communaute-build/PROMPT-BROWSERMCP-E2E.md
Normal file
172
aep-communaute-build/PROMPT-BROWSERMCP-E2E.md
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
# PROMPT BrowserMCP — E2E Onglet Pratiques régénératives
|
||||||
|
> Cascade onglet 1 — à coller dans une session Claude Code avec BrowserMCP attaché à Brave.
|
||||||
|
> Date de création : 2026-04-29
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
Tester en E2E les deux nouvelles pages de `aep.trans-former.fr` liées à l'onglet "Pratiques régénératives" : la carte principale `/pratiques-regeneratives` et le formulaire de contribution `/proposer-pratique`. Couvrir desktop ET mobile. Reporter tous les bugs trouvés (console JS, visuels, fonctionnels).
|
||||||
|
|
||||||
|
**URL de base :** `https://aep.trans-former.fr`
|
||||||
|
**Navigateur :** Brave avec extension BrowserMCP
|
||||||
|
**Mode :** Full auto, rapport final structuré
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup initial
|
||||||
|
|
||||||
|
1. Ouvre BrowserMCP et navigue vers `https://aep.trans-former.fr/pratiques-regeneratives`
|
||||||
|
2. Attends que la page soit fully loaded (pas de spinner Leaflet)
|
||||||
|
3. Prends un screenshot de référence `desktop-initial.png`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 1 — Carte desktop (chargement + structure)
|
||||||
|
|
||||||
|
**Objectif :** vérifier que la carte s'affiche correctement avec markers et sidebar.
|
||||||
|
|
||||||
|
Étapes :
|
||||||
|
1. `navigate` vers `https://aep.trans-former.fr/pratiques-regeneratives`
|
||||||
|
2. Attendre 3s (Leaflet init)
|
||||||
|
3. `snapshot` — décrire ce qui est visible : carte Europe, sidebar gauche, onglet header actif
|
||||||
|
4. Vérifier dans le DOM :
|
||||||
|
- Présence d'au moins 1 marker Leaflet (chercher `.leaflet-marker-icon` ou `.leaflet-pane`)
|
||||||
|
- Sidebar visible avec des cartes de pratiques (chercher titres/noms)
|
||||||
|
- Bandeau DOM-TOM présent en bas (textes Guadeloupe/Martinique/Réunion...)
|
||||||
|
5. Ouvrir la console (`evaluate` `window.__AEP_DEBUG__ || 'no debug'`) — noter les erreurs JS si présentes
|
||||||
|
6. `evaluate` `fetch('/api/pratiques').then(r=>r.json()).then(d=>d.list.length)` — doit retourner 52
|
||||||
|
|
||||||
|
Bugs à surveiller :
|
||||||
|
- Carte blanche (Leaflet tiles non chargés)
|
||||||
|
- Markers manquants ou hors viewport
|
||||||
|
- CSS `--nav-primary-solid` non résolu (barre nav sans couleur)
|
||||||
|
- Erreurs console "leaflet", "vue warn", "hydration mismatch"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 2 — Filtres sidebar (criteres + pays + type)
|
||||||
|
|
||||||
|
**Objectif :** vérifier que les filtres réduisent bien les résultats.
|
||||||
|
|
||||||
|
Étapes :
|
||||||
|
1. Depuis `/pratiques-regeneratives` desktop, noter le nombre de fiches dans la sidebar (label "X résultats" ou count visible)
|
||||||
|
2. Cliquer sur le chip "Matériaux" dans les filtres critères
|
||||||
|
3. Attendre 500ms — `snapshot` — noter le nouveau count
|
||||||
|
4. Cliquer sur le chip "Pays" (ou sélectionner France dans le filtre pays si c'est un select)
|
||||||
|
5. `snapshot` — noter le count (doit être inférieur ou égal à l'étape 3)
|
||||||
|
6. Cliquer sur le type "Coopérative" si accessible
|
||||||
|
7. `snapshot` — noter le count
|
||||||
|
8. Cliquer "Réinitialiser" ou le bouton reset — vérifier que le count revient au total (52)
|
||||||
|
|
||||||
|
Bugs à surveiller :
|
||||||
|
- Filtre appliqué mais count ne change pas
|
||||||
|
- Markers sur la carte non synchronisés avec la sidebar
|
||||||
|
- Reset qui ne fonctionne pas
|
||||||
|
- Chip actif sans indicateur visuel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 3 — Fiche pratique (clic marker ou card + retour)
|
||||||
|
|
||||||
|
**Objectif :** vérifier que l'ouverture d'une fiche fonctionne et que le retour préserve les filtres.
|
||||||
|
|
||||||
|
Étapes :
|
||||||
|
1. Appliquer un filtre (ex: "Matériaux") pour avoir < 52 résultats
|
||||||
|
2. Cliquer sur une card dans la sidebar OU un marker sur la carte
|
||||||
|
3. `snapshot` — décrire ce qui est affiché : modal ? Page fiche ? URL change ?
|
||||||
|
4. Vérifier que la fiche contient : nom, description, critères, type, pays, URL (si disponible)
|
||||||
|
5. Cliquer le bouton "Retour" ou `navigateBack`
|
||||||
|
6. `snapshot` — vérifier que le filtre "Matériaux" est toujours actif et le count identique à avant
|
||||||
|
|
||||||
|
Bugs à surveiller :
|
||||||
|
- Clic sur card ne navigue pas
|
||||||
|
- Page fiche vide ou 404
|
||||||
|
- Bouton retour recharge sans filtres
|
||||||
|
- Scroll position perdu
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 4 — Formulaire de contribution
|
||||||
|
|
||||||
|
**Objectif :** soumettre une proposition valide et vérifier le message de succès.
|
||||||
|
|
||||||
|
Étapes :
|
||||||
|
1. `navigate` vers `https://aep.trans-former.fr/proposer-pratique`
|
||||||
|
2. `snapshot` — décrire le formulaire visible
|
||||||
|
3. Remplir les champs suivants :
|
||||||
|
- `#nom` : "Test BrowserMCP E2E"
|
||||||
|
- `#url` : "https://example.com/test-e2e"
|
||||||
|
- `#description_user` : "Pratique de test soumise automatiquement par BrowserMCP pour valider le pipeline de contribution de l'onglet Pratiques régénératives. Cette description fait plus de 100 caractères."
|
||||||
|
- Critères : cocher au moins 3 checkboxes (ex: "Matériaux", "Posture", "Vivant")
|
||||||
|
- Type d'entité : sélectionner "Collectif"
|
||||||
|
- Pays : sélectionner "France" dans le select
|
||||||
|
4. `snapshot` avant envoi — vérifier que les champs sont remplis
|
||||||
|
5. Cliquer le bouton `submit` du formulaire
|
||||||
|
6. Attendre 3s
|
||||||
|
7. `snapshot` — chercher le message de succès "Merci !" + "Ta proposition est en attente de modération."
|
||||||
|
|
||||||
|
Bugs à surveiller :
|
||||||
|
- Validation côté client ne se déclenche pas
|
||||||
|
- Submit retourne une erreur réseau (verifier dans console : `fetch POST /api/submit-pratique`)
|
||||||
|
- Message succès ne s'affiche pas
|
||||||
|
- Rate limit 429 (normal si on a soumis plusieurs fois — attendre 1h ou ignorer si premier test)
|
||||||
|
|
||||||
|
Note : si rate limit 429, c'est le comportement attendu. Réessayer en changeant légèrement le nom.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 5 — Mobile (viewport 375x667)
|
||||||
|
|
||||||
|
**Objectif :** valider l'expérience mobile de base.
|
||||||
|
|
||||||
|
Étapes :
|
||||||
|
1. Changer le viewport à 375x667 (iPhone SE) via `setViewport` ou équivalent BrowserMCP
|
||||||
|
2. `navigate` vers `https://aep.trans-former.fr/pratiques-regeneratives`
|
||||||
|
3. Attendre 3s
|
||||||
|
4. `snapshot` — décrire : sheet en bas ? Carte en fond plein écran ? Header avec hamburger ?
|
||||||
|
5. Tenter d'interagir avec la sheet du bas (si elle existe) : tapper sur le header de la sheet pour l'agrandir
|
||||||
|
6. `snapshot` — sheet en mode "half" ou "full" ?
|
||||||
|
7. Vérifier que les filtres sont accessibles (dans la sheet ou dans un drawer)
|
||||||
|
8. Cliquer une card si visible
|
||||||
|
9. `snapshot` — fiche pleine page ou modal plein écran ?
|
||||||
|
10. `navigate` vers `https://aep.trans-former.fr/proposer-pratique`
|
||||||
|
11. `snapshot` — formulaire lisible et accessible sur mobile ?
|
||||||
|
|
||||||
|
Bugs à surveiller :
|
||||||
|
- Sheet absente ou non draggable
|
||||||
|
- Carte non visible derrière la sheet
|
||||||
|
- Hamburger nav manquant
|
||||||
|
- Formulaire /proposer-pratique avec overflow horizontal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Format de récap final attendu
|
||||||
|
|
||||||
|
```
|
||||||
|
Cascade onglet 1 — E2E [PASS / FAIL avec [N] bugs H]
|
||||||
|
|
||||||
|
Scénarios : [N]/5 OK
|
||||||
|
Bugs : [N] High / [N] Medium / [N] Low
|
||||||
|
Erreurs console : [N] (liste si > 0)
|
||||||
|
|
||||||
|
Détail par scénario :
|
||||||
|
S1 Carte desktop : [PASS/FAIL] — [note courte]
|
||||||
|
S2 Filtres : [PASS/FAIL] — [note courte]
|
||||||
|
S3 Fiche + retour: [PASS/FAIL] — [note courte]
|
||||||
|
S4 Form submit : [PASS/FAIL] — [note courte]
|
||||||
|
S5 Mobile : [PASS/FAIL] — [note courte]
|
||||||
|
|
||||||
|
Bugs H :
|
||||||
|
- [description + étape]
|
||||||
|
|
||||||
|
Bugs M :
|
||||||
|
- [description + étape]
|
||||||
|
|
||||||
|
Bugs L :
|
||||||
|
- [description + étape]
|
||||||
|
|
||||||
|
Screenshots : [N] pris
|
||||||
|
```
|
||||||
|
|
||||||
|
Envoyer ce récap à Jules (copier dans la session pilote ou dans `aep-communaute-build/E2E-RESULTS.md`).
|
||||||
30
app.vue
30
app.vue
@@ -34,26 +34,20 @@
|
|||||||
>
|
>
|
||||||
Écosystème Entraide Architecture
|
Écosystème Entraide Architecture
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/pratiques-regeneratives"
|
||||||
|
class="nav-tab"
|
||||||
|
:class="{ 'nav-tab--active': route.path.startsWith('/pratiques-regeneratives') || route.path.startsWith('/pratique/') }"
|
||||||
|
>
|
||||||
|
Pratiques régé
|
||||||
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/agences"
|
to="/agences"
|
||||||
class="nav-tab"
|
class="nav-tab"
|
||||||
:class="{ 'nav-tab--active': route.path === '/agences' }"
|
:class="{ 'nav-tab--active': route.path === '/agences' }"
|
||||||
>
|
>
|
||||||
Réseaux AEP
|
Agences Inspirantes
|
||||||
</NuxtLink>
|
<span class="nav-tab-badge">en construction</span>
|
||||||
<NuxtLink
|
|
||||||
to="/trouver-du-taf"
|
|
||||||
class="nav-tab"
|
|
||||||
:class="{ 'nav-tab--active': route.path === '/trouver-du-taf' }"
|
|
||||||
>
|
|
||||||
Jobs
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
|
||||||
to="/codev"
|
|
||||||
class="nav-tab"
|
|
||||||
:class="{ 'nav-tab--active': route.path.startsWith('/codev') }"
|
|
||||||
>
|
|
||||||
Codev
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/rag"
|
to="/rag"
|
||||||
@@ -178,9 +172,9 @@
|
|||||||
@click="hamburgerOpen = false"
|
@click="hamburgerOpen = false"
|
||||||
>
|
>
|
||||||
<NuxtLink to="/" class="block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Écosystème Entraide Architecture</NuxtLink>
|
<NuxtLink to="/" class="block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Écosystème Entraide Architecture</NuxtLink>
|
||||||
<NuxtLink to="/agences" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">Réseaux AEP</NuxtLink>
|
<NuxtLink to="/pratiques-regeneratives" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/pratiques-regeneratives') || route.path.startsWith('/pratique/') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Pratiques régé</NuxtLink>
|
||||||
|
<NuxtLink to="/agences" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">Agences Inspirantes</NuxtLink>
|
||||||
<NuxtLink to="/rag" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">RAG</NuxtLink>
|
<NuxtLink to="/rag" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">RAG</NuxtLink>
|
||||||
<NuxtLink to="/codev" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/codev') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Codev</NuxtLink>
|
|
||||||
<div style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div>
|
<div style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div>
|
||||||
<NuxtLink to="/a-propos" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">À propos</NuxtLink>
|
<NuxtLink to="/a-propos" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">À propos</NuxtLink>
|
||||||
<NuxtLink to="/signaler" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">Signaler</NuxtLink>
|
<NuxtLink to="/signaler" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">Signaler</NuxtLink>
|
||||||
@@ -190,7 +184,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Contenu page (flex-1 pour remplir l'espace) -->
|
<!-- Contenu page (flex-1 pour remplir l'espace) -->
|
||||||
<div class="flex-1" :class="route.path === '/' ? 'overflow-hidden' : 'overflow-y-auto'">
|
<div class="flex-1" :class="(route.path === '/' || route.path === '/pratiques-regeneratives') ? 'overflow-hidden' : 'overflow-y-auto'">
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
41
components/CritereFilter.vue
Normal file
41
components/CritereFilter.vue
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<span class="text-xs font-bold uppercase tracking-wide block mb-1.5" style="color: var(--nav-text-muted);">CRITÈRES RÉGÉ</span>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
v-for="critere in CRITERES"
|
||||||
|
:key="critere.id"
|
||||||
|
type="button"
|
||||||
|
class="px-2 py-0.5 rounded-full text-xs transition-all"
|
||||||
|
:style="modelValue.includes(critere.id)
|
||||||
|
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||||
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||||
|
@click="toggle(critere.id)"
|
||||||
|
>
|
||||||
|
{{ critere.label }}
|
||||||
|
<span v-if="counts && counts[critere.id] !== undefined" class="ml-1 opacity-60 text-xs">{{ counts[critere.id] }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { CRITERES } from '~/types/pratique'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: number[]
|
||||||
|
counts?: Record<number, number>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: number[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function toggle(id: number) {
|
||||||
|
if (props.modelValue.includes(id)) {
|
||||||
|
emit('update:modelValue', props.modelValue.filter(v => v !== id))
|
||||||
|
} else {
|
||||||
|
emit('update:modelValue', [...props.modelValue, id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
224
components/EuropeMap.vue
Normal file
224
components/EuropeMap.vue
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative w-full h-full">
|
||||||
|
<div ref="mapContainer" class="w-full h-full rounded-none" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Map, Marker, DivIcon } from 'leaflet'
|
||||||
|
|
||||||
|
interface Pratique {
|
||||||
|
id: number
|
||||||
|
nom: string
|
||||||
|
lat?: number | null
|
||||||
|
lng?: number | null
|
||||||
|
pays?: string
|
||||||
|
ville?: string
|
||||||
|
type?: string
|
||||||
|
score?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
orgs: Pratique[]
|
||||||
|
selectedId?: number | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'select-org': [id: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const mapContainer = ref<HTMLElement | null>(null)
|
||||||
|
let mapInstance: Map | null = null
|
||||||
|
let clusterGroup: any = null
|
||||||
|
const markers = new Map<number, Marker>()
|
||||||
|
let tileLayerInstance: any = null
|
||||||
|
|
||||||
|
function createPinIcon(score: number, isSelected = false): DivIcon {
|
||||||
|
const L = (window as any).L
|
||||||
|
// Couleur selon score (1-5) : du pale au vif
|
||||||
|
const bg = score >= 4 ? '#f5b342' : score >= 3 ? 'rgba(26,34,56,0.75)' : 'rgba(26,34,56,0.5)'
|
||||||
|
const border = isSelected ? '#f5b342' : '#ffffff'
|
||||||
|
const size = isSelected ? 18 : 14
|
||||||
|
const shadow = isSelected ? '0 0 0 4px rgba(245,179,66,0.5)' : 'none'
|
||||||
|
|
||||||
|
return L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<div style="
|
||||||
|
width: ${size}px;
|
||||||
|
height: ${size}px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: ${bg};
|
||||||
|
border: 2px solid ${border};
|
||||||
|
box-shadow: ${shadow};
|
||||||
|
transition: all 0.2s;
|
||||||
|
"></div>`,
|
||||||
|
iconSize: [size, size],
|
||||||
|
iconAnchor: [size / 2, size / 2],
|
||||||
|
popupAnchor: [0, -(size / 2 + 4)],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initMap() {
|
||||||
|
if (!mapContainer.value) return
|
||||||
|
|
||||||
|
const Lmod = await import('leaflet')
|
||||||
|
const L: any = (Lmod as any).default || Lmod
|
||||||
|
await import('leaflet/dist/leaflet.css')
|
||||||
|
// @ts-ignore
|
||||||
|
await import('leaflet.markercluster/dist/MarkerCluster.css')
|
||||||
|
// @ts-ignore
|
||||||
|
await import('leaflet.markercluster/dist/MarkerCluster.Default.css')
|
||||||
|
|
||||||
|
;(window as any).L = L
|
||||||
|
// @ts-ignore
|
||||||
|
await import('leaflet.markercluster')
|
||||||
|
const MarkerClusterGroup = L.MarkerClusterGroup
|
||||||
|
|
||||||
|
mapInstance = L.map(mapContainer.value, {
|
||||||
|
center: [50.0, 10.0],
|
||||||
|
zoom: 4,
|
||||||
|
zoomControl: true,
|
||||||
|
attributionControl: true,
|
||||||
|
maxBounds: [[30.0, -15.0], [72.0, 40.0]],
|
||||||
|
maxBoundsViscosity: 0.8,
|
||||||
|
minZoom: 3,
|
||||||
|
maxZoom: 18,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
||||||
|
const tileUrl = isDark
|
||||||
|
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||||
|
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||||
|
|
||||||
|
tileLayerInstance = L.tileLayer(tileUrl, {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
||||||
|
maxZoom: 19,
|
||||||
|
})
|
||||||
|
tileLayerInstance.addTo(mapInstance!)
|
||||||
|
|
||||||
|
clusterGroup = new MarkerClusterGroup({
|
||||||
|
disableClusteringAtZoom: 12,
|
||||||
|
maxClusterRadius: 50,
|
||||||
|
showCoverageOnHover: false,
|
||||||
|
iconCreateFunction: (cluster: any) => {
|
||||||
|
const count = cluster.getChildCount()
|
||||||
|
return L.divIcon({
|
||||||
|
html: `<div style="
|
||||||
|
width: 36px; height: 36px; border-radius: 50%;
|
||||||
|
background: var(--nav-primary);
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-weight: 700; font-size: 13px;
|
||||||
|
border: 2px solid white;
|
||||||
|
font-family: var(--nav-font);
|
||||||
|
">${count}</div>`,
|
||||||
|
className: '',
|
||||||
|
iconSize: [36, 36],
|
||||||
|
iconAnchor: [18, 18],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
mapInstance.addLayer(clusterGroup)
|
||||||
|
updateMarkers(L)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMarkers(L?: any) {
|
||||||
|
if (!mapInstance || !clusterGroup) return
|
||||||
|
const leaflet = L || (window as any).L
|
||||||
|
if (!leaflet) return
|
||||||
|
|
||||||
|
clusterGroup.clearLayers()
|
||||||
|
markers.clear()
|
||||||
|
|
||||||
|
const orgsWithCoords = props.orgs.filter(
|
||||||
|
(o) => o.lat != null && o.lng != null
|
||||||
|
)
|
||||||
|
|
||||||
|
orgsWithCoords.forEach((org) => {
|
||||||
|
const isSelected = org.id === props.selectedId
|
||||||
|
const icon = createPinIcon(org.score ?? 1, isSelected)
|
||||||
|
|
||||||
|
const marker = leaflet.marker([org.lat!, org.lng!], { icon })
|
||||||
|
|
||||||
|
marker.bindPopup(`
|
||||||
|
<div style="font-family: var(--nav-font); min-width: 180px; padding: 4px 0;">
|
||||||
|
<div style="font-weight: 700; color: var(--nav-text); margin-bottom: 4px;">${org.nom}</div>
|
||||||
|
${org.pays ? `<div style="font-size: 11px; color: var(--nav-text-muted);">${org.pays}${org.ville ? ' · ' + org.ville : ''}</div>` : ''}
|
||||||
|
${org.type ? `<div style="font-size: 11px; color: var(--nav-text-muted); margin-top: 2px;">${org.type}</div>` : ''}
|
||||||
|
<a href="/pratique/${org.id}" style="
|
||||||
|
display: inline-block; margin-top: 8px; font-size: 12px;
|
||||||
|
color: var(--nav-primary-solid); text-decoration: underline;
|
||||||
|
">Voir la fiche →</a>
|
||||||
|
</div>
|
||||||
|
`, { maxWidth: 240 })
|
||||||
|
|
||||||
|
marker.on('click', () => emit('select-org', org.id))
|
||||||
|
|
||||||
|
markers.set(org.id, marker)
|
||||||
|
clusterGroup.addLayer(marker)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.orgs,
|
||||||
|
() => updateMarkers(),
|
||||||
|
{ deep: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.selectedId,
|
||||||
|
(newId, oldId) => {
|
||||||
|
if (!mapInstance) return
|
||||||
|
const leaflet = (window as any).L
|
||||||
|
if (!leaflet) return
|
||||||
|
|
||||||
|
if (oldId != null) {
|
||||||
|
const oldMarker = markers.get(oldId)
|
||||||
|
const oldOrg = props.orgs.find(o => o.id === oldId)
|
||||||
|
if (oldMarker && oldOrg) {
|
||||||
|
oldMarker.setIcon(createPinIcon(oldOrg.score ?? 1, false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newId != null) {
|
||||||
|
const newMarker = markers.get(newId)
|
||||||
|
const newOrg = props.orgs.find(o => o.id === newId)
|
||||||
|
if (newMarker && newOrg) {
|
||||||
|
newMarker.setIcon(createPinIcon(newOrg.score ?? 1, true))
|
||||||
|
const latLng = newMarker.getLatLng()
|
||||||
|
mapInstance.panTo(latLng, { animate: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function updateTileTheme(dark: boolean) {
|
||||||
|
if (!mapInstance || !tileLayerInstance) return
|
||||||
|
const L = (window as any).L
|
||||||
|
if (!L) return
|
||||||
|
const url = dark
|
||||||
|
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||||
|
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||||
|
tileLayerInstance.setUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
let themeObserver: MutationObserver | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initMap()
|
||||||
|
|
||||||
|
themeObserver = new MutationObserver(() => {
|
||||||
|
const dark = document.documentElement.classList.contains('dark')
|
||||||
|
updateTileTheme(dark)
|
||||||
|
})
|
||||||
|
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
themeObserver?.disconnect()
|
||||||
|
if (mapInstance) {
|
||||||
|
mapInstance.remove()
|
||||||
|
mapInstance = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -221,12 +221,7 @@ function updateTileTheme(dark: boolean) {
|
|||||||
let themeObserver: MutationObserver | null = null
|
let themeObserver: MutationObserver | null = null
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Double rAF : laisser le browser calculer la hauteur du conteneur avant Leaflet
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
initMap()
|
initMap()
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Observer les changements de classe dark sur <html>
|
// Observer les changements de classe dark sur <html>
|
||||||
themeObserver = new MutationObserver(() => {
|
themeObserver = new MutationObserver(() => {
|
||||||
|
|||||||
@@ -125,8 +125,8 @@
|
|||||||
<span
|
<span
|
||||||
v-for="fn in orgFonctions(org)"
|
v-for="fn in orgFonctions(org)"
|
||||||
:key="fn"
|
:key="fn"
|
||||||
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
class="px-1.5 py-0.5 rounded text-xs"
|
||||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted); border: 1px solid var(--nav-bg-alt); letter-spacing: 0.01em;"
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
>{{ fn }}</span>
|
>{{ fn }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="org.localisation_ville" class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">
|
<div v-if="org.localisation_ville" class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">
|
||||||
|
|||||||
276
components/OutremerMapPratiques.vue
Normal file
276
components/OutremerMapPratiques.vue
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
<template>
|
||||||
|
<div class="outremer-accordion">
|
||||||
|
<div
|
||||||
|
v-for="dom in DOM_TOM_PRATIQUES"
|
||||||
|
:key="dom.code"
|
||||||
|
class="outremer-item"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="outremer-header"
|
||||||
|
@click="toggle(dom.code)"
|
||||||
|
:aria-expanded="openDom === dom.code"
|
||||||
|
>
|
||||||
|
<span class="outremer-title">{{ dom.label }}</span>
|
||||||
|
<span class="outremer-meta">
|
||||||
|
<span class="outremer-count-badge" :style="orgCounts[dom.code] === 0 ? 'opacity:0.4' : ''">
|
||||||
|
{{ orgCounts[dom.code] ?? 0 }} fiche{{ (orgCounts[dom.code] ?? 0) > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="outremer-chevron"
|
||||||
|
:class="{ 'outremer-chevron--open': openDom === dom.code }"
|
||||||
|
>
|
||||||
|
<polyline points="6 9 12 15 18 9"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-show="openDom === dom.code"
|
||||||
|
class="outremer-map-container"
|
||||||
|
>
|
||||||
|
<div :ref="el => { if (el) mapRefs[dom.code] = el as HTMLElement }" class="outremer-map" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Map as LeafletMap, TileLayer } from 'leaflet'
|
||||||
|
|
||||||
|
interface Pratique {
|
||||||
|
id: number
|
||||||
|
nom: string
|
||||||
|
lat?: number | null
|
||||||
|
lng?: number | null
|
||||||
|
pays?: string
|
||||||
|
ville?: string
|
||||||
|
type?: string
|
||||||
|
score?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOM_TOM_PRATIQUES = [
|
||||||
|
{ code: 'GP', label: 'Guadeloupe', center: [16.25, -61.58] as [number, number], zoom: 9 },
|
||||||
|
{ code: 'MQ', label: 'Martinique', center: [14.65, -61.02] as [number, number], zoom: 9 },
|
||||||
|
{ code: 'GF', label: 'Guyane', center: [4.0, -53.0] as [number, number], zoom: 6 },
|
||||||
|
{ code: 'RE', label: 'La Réunion', center: [-21.11, 55.53] as [number, number], zoom: 9 },
|
||||||
|
{ code: 'YT', label: 'Mayotte', center: [-12.83, 45.16] as [number, number], zoom: 10 },
|
||||||
|
{ code: 'PF', label: 'Polynésie française', center: [-17.5, -149.5] as [number, number], zoom: 8 },
|
||||||
|
{ code: 'NC', label: 'Nouvelle-Calédonie', center: [-20.9, 165.6] as [number, number], zoom: 7 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
orgs: Pratique[]
|
||||||
|
selectedId?: number | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'select-org': [id: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const mapRefs: Record<string, HTMLElement> = {}
|
||||||
|
const mapInstances: Record<string, LeafletMap> = {}
|
||||||
|
const tileLayers: Record<string, TileLayer> = {}
|
||||||
|
|
||||||
|
const openDom = ref<string | null>(null)
|
||||||
|
|
||||||
|
const orgCounts = computed<Record<string, number>>(() => {
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
DOM_TOM_PRATIQUES.forEach(d => { counts[d.code] = 0 })
|
||||||
|
props.orgs.forEach(o => {
|
||||||
|
if (o.pays && counts[o.pays] !== undefined) {
|
||||||
|
counts[o.pays]++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return counts
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggle(code: string) {
|
||||||
|
openDom.value = openDom.value === code ? null : code
|
||||||
|
nextTick(() => {
|
||||||
|
if (openDom.value === code && !mapInstances[code]) {
|
||||||
|
initSingleMap(code)
|
||||||
|
} else if (openDom.value === code) {
|
||||||
|
mapInstances[code]?.invalidateSize()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPinIcon(L: any, score: number, isSelected = false) {
|
||||||
|
const bg = score >= 4 ? '#f5b342' : 'rgba(26, 34, 56, 0.6)'
|
||||||
|
const border = isSelected ? '#f5b342' : '#ffffff'
|
||||||
|
const size = isSelected ? 16 : 12
|
||||||
|
return L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<div style="width:${size}px;height:${size}px;border-radius:50%;background:${bg};border:2px solid ${border};"></div>`,
|
||||||
|
iconSize: [size, size],
|
||||||
|
iconAnchor: [size / 2, size / 2],
|
||||||
|
popupAnchor: [0, -(size / 2 + 4)],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTileUrl(dark: boolean) {
|
||||||
|
return dark
|
||||||
|
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||||
|
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initSingleMap(code: string) {
|
||||||
|
const dom = DOM_TOM_PRATIQUES.find(d => d.code === code)
|
||||||
|
if (!dom) return
|
||||||
|
const Lmod = await import('leaflet')
|
||||||
|
const L: any = (Lmod as any).default || Lmod
|
||||||
|
await import('leaflet/dist/leaflet.css')
|
||||||
|
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
||||||
|
const el = mapRefs[code]
|
||||||
|
if (!el) return
|
||||||
|
const map = L.map(el, {
|
||||||
|
center: dom.center, zoom: dom.zoom,
|
||||||
|
zoomControl: false, attributionControl: false,
|
||||||
|
dragging: true, scrollWheelZoom: true, doubleClickZoom: true,
|
||||||
|
touchZoom: true, keyboard: false,
|
||||||
|
})
|
||||||
|
const tileLayer = L.tileLayer(getTileUrl(isDark), {
|
||||||
|
attribution: '© OpenStreetMap contributors © CARTO', maxZoom: 19,
|
||||||
|
})
|
||||||
|
tileLayer.addTo(map)
|
||||||
|
tileLayers[code] = tileLayer as unknown as TileLayer
|
||||||
|
mapInstances[code] = map as unknown as LeafletMap
|
||||||
|
renderPins(L, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTheme(dark: boolean) {
|
||||||
|
const url = getTileUrl(dark)
|
||||||
|
Object.values(tileLayers).forEach(tl => {
|
||||||
|
(tl as any).setUrl(url)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPins(L: any, code: string) {
|
||||||
|
const map = mapInstances[code] as any
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
if (map._navMarkers) {
|
||||||
|
map._navMarkers.forEach((m: any) => m.remove())
|
||||||
|
}
|
||||||
|
map._navMarkers = []
|
||||||
|
|
||||||
|
const domOrgs = props.orgs.filter(o => o.pays === code && o.lat != null && o.lng != null)
|
||||||
|
domOrgs.forEach(org => {
|
||||||
|
const icon = createPinIcon(L, org.score ?? 1, org.id === props.selectedId)
|
||||||
|
const marker = L.marker([org.lat!, org.lng!], { icon })
|
||||||
|
|
||||||
|
marker.bindPopup(`
|
||||||
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-width:160px;padding:4px 0;">
|
||||||
|
<div style="font-weight:700;color:#1a2238;margin-bottom:2px;">${org.nom}</div>
|
||||||
|
${org.ville ? `<div style="font-size:11px;color:rgba(26,34,56,0.55);">${org.ville}</div>` : ''}
|
||||||
|
${org.type ? `<div style="font-size:11px;color:rgba(26,34,56,0.55);margin-top:2px;">${org.type}</div>` : ''}
|
||||||
|
<a href="/pratique/${org.id}" style="display:inline-block;margin-top:8px;font-size:12px;color:#1a2238;text-decoration:underline;">Voir la fiche →</a>
|
||||||
|
</div>
|
||||||
|
`, { maxWidth: 200 })
|
||||||
|
|
||||||
|
marker.on('click', () => emit('select-org', org.id))
|
||||||
|
marker.addTo(map)
|
||||||
|
map._navMarkers.push(marker)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.orgs, () => {
|
||||||
|
DOM_TOM_PRATIQUES.forEach(dom => {
|
||||||
|
if (mapInstances[dom.code]) {
|
||||||
|
import('leaflet').then(L => renderPins(L, dom.code))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, { deep: false })
|
||||||
|
|
||||||
|
watch(() => props.selectedId, () => {
|
||||||
|
DOM_TOM_PRATIQUES.forEach(dom => {
|
||||||
|
if (mapInstances[dom.code]) {
|
||||||
|
import('leaflet').then(L => renderPins(L, dom.code))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
let themeObserver: MutationObserver | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
themeObserver = new MutationObserver(() => {
|
||||||
|
const dark = document.documentElement.classList.contains('dark')
|
||||||
|
updateTheme(dark)
|
||||||
|
})
|
||||||
|
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
themeObserver?.disconnect()
|
||||||
|
Object.values(mapInstances).forEach(m => (m as any).remove())
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.outremer-accordion {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-item {
|
||||||
|
border-bottom: 1px solid var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--nav-surface);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-header:hover {
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nav-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-count-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-chevron {
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-chevron--open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-map-container {
|
||||||
|
height: 220px;
|
||||||
|
border-top: 1px solid var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-map {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
60
components/PaysFilter.vue
Normal file
60
components/PaysFilter.vue
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Groupe Europe -->
|
||||||
|
<span class="text-xs font-bold uppercase tracking-wide block mb-1.5" style="color: var(--nav-text-muted);">PAYS — EUROPE</span>
|
||||||
|
<div class="flex flex-wrap gap-1 mb-2">
|
||||||
|
<button
|
||||||
|
v-for="code in EUROPE_CODES"
|
||||||
|
:key="code"
|
||||||
|
type="button"
|
||||||
|
class="px-2 py-0.5 rounded-full text-xs transition-all"
|
||||||
|
:style="modelValue.includes(code)
|
||||||
|
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||||
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||||
|
@click="toggle(code)"
|
||||||
|
>
|
||||||
|
{{ PAYS_LABELS[code] ?? code }}
|
||||||
|
<span v-if="counts && counts[code] !== undefined" class="ml-1 opacity-60 text-xs">{{ counts[code] }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Groupe Outre-mer -->
|
||||||
|
<span class="text-xs font-bold uppercase tracking-wide block mb-1.5" style="color: var(--nav-text-muted);">OUTRE-MER</span>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
v-for="code in OUTREMER_CODES"
|
||||||
|
:key="code"
|
||||||
|
type="button"
|
||||||
|
class="px-2 py-0.5 rounded-full text-xs transition-all"
|
||||||
|
:style="modelValue.includes(code)
|
||||||
|
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||||
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||||
|
@click="toggle(code)"
|
||||||
|
>
|
||||||
|
{{ PAYS_LABELS[code] ?? code }}
|
||||||
|
<span v-if="counts && counts[code] !== undefined" class="ml-1 opacity-60 text-xs">{{ counts[code] }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { EUROPE_CODES, OUTREMER_CODES, PAYS_LABELS } from '~/types/pratique'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string[]
|
||||||
|
counts?: Record<string, number>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function toggle(code: string) {
|
||||||
|
if (props.modelValue.includes(code)) {
|
||||||
|
emit('update:modelValue', props.modelValue.filter(v => v !== code))
|
||||||
|
} else {
|
||||||
|
emit('update:modelValue', [...props.modelValue, code])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
274
components/PratiqueSidebar.vue
Normal file
274
components/PratiqueSidebar.vue
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
<template>
|
||||||
|
<aside
|
||||||
|
class="flex flex-col h-full overflow-hidden"
|
||||||
|
style="background: var(--nav-surface); border-right: 1px solid var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════ BARRE DE RECHERCHE -->
|
||||||
|
<div
|
||||||
|
class="shrink-0 px-4 pt-4 pb-3 border-b"
|
||||||
|
style="border-color: var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
|
<label class="sidebar-search-label" aria-label="Rechercher une pratique">
|
||||||
|
<svg
|
||||||
|
width="15" height="15" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="sidebar-search-icon"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8"/>
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
ref="searchInputEl"
|
||||||
|
:value="search"
|
||||||
|
type="search"
|
||||||
|
placeholder="Rechercher une pratique…"
|
||||||
|
class="sidebar-search-input"
|
||||||
|
autocomplete="off"
|
||||||
|
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="search"
|
||||||
|
type="button"
|
||||||
|
class="sidebar-search-clear"
|
||||||
|
aria-label="Effacer la recherche"
|
||||||
|
@click.stop="emit('update:search', '')"
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════ FILTRES -->
|
||||||
|
<div
|
||||||
|
class="shrink-0 px-4 pt-3 pb-3 space-y-4 border-b overflow-y-auto"
|
||||||
|
style="border-color: var(--nav-bg-alt); max-height: 280px;"
|
||||||
|
>
|
||||||
|
<!-- Critères régé -->
|
||||||
|
<CritereFilter
|
||||||
|
:modelValue="criteres"
|
||||||
|
:counts="critereCount"
|
||||||
|
@update:modelValue="emit('update:criteres', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Type entité -->
|
||||||
|
<TypeEntiteFilter
|
||||||
|
:modelValue="typesEntite"
|
||||||
|
:counts="typeCount"
|
||||||
|
@update:modelValue="emit('update:typesEntite', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════ LISTE FICHES -->
|
||||||
|
<div class="flex-1 flex flex-col min-h-0">
|
||||||
|
<div
|
||||||
|
class="shrink-0 flex items-center justify-between px-4 py-2 border-b"
|
||||||
|
style="border-color: var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
|
<span class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">
|
||||||
|
{{ resultCount }} résultat{{ resultCount > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-if="hasActiveFilters"
|
||||||
|
@click="emit('reset-filters')"
|
||||||
|
class="text-xs underline hover:opacity-70"
|
||||||
|
style="color: var(--nav-text-muted);"
|
||||||
|
>
|
||||||
|
Effacer les filtres
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto px-3 py-2 space-y-1.5">
|
||||||
|
<div
|
||||||
|
v-if="pending"
|
||||||
|
class="flex items-center justify-center py-8"
|
||||||
|
style="color: var(--nav-text-muted);"
|
||||||
|
>
|
||||||
|
Chargement…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="pratiques.length === 0" class="text-center py-8">
|
||||||
|
<p class="text-xs" style="color: var(--nav-text-muted);">Aucun résultat</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="pratique in pratiques"
|
||||||
|
:key="pratique.id"
|
||||||
|
class="rounded-lg px-3 py-2 cursor-pointer transition-all"
|
||||||
|
:style="selectedId === pratique.id
|
||||||
|
? 'background: var(--nav-bg-alt); border-left: 3px solid var(--nav-accent); padding-left: 9px;'
|
||||||
|
: 'background: var(--nav-bg); border-left: 3px solid transparent; padding-left: 9px;'"
|
||||||
|
@click="emit('select-pratique', pratique.id)"
|
||||||
|
@mouseenter="emit('hover-pratique', pratique.id)"
|
||||||
|
@mouseleave="emit('hover-pratique', null)"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-1.5">
|
||||||
|
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ pratique.nom }}</span>
|
||||||
|
<span
|
||||||
|
v-if="pratique.pays"
|
||||||
|
class="shrink-0 px-1.5 py-0.5 rounded-full text-xs"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted); margin-top: 1px;"
|
||||||
|
>{{ pratique.pays }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="pratique.criteres?.length" class="mt-1 flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="cId in pratique.criteres.slice(0, 3)"
|
||||||
|
:key="cId"
|
||||||
|
class="px-1.5 py-0.5 rounded text-xs"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
>{{ CRITERES.find(c => c.id === cId)?.label }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="pratique.ville" class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">
|
||||||
|
{{ pratique.ville }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════ CTA PROPOSER -->
|
||||||
|
<div
|
||||||
|
class="shrink-0 px-4 py-3 border-t"
|
||||||
|
style="border-color: var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
|
<NuxtLink
|
||||||
|
to="/proposer-pratique"
|
||||||
|
class="sidebar-cta-link"
|
||||||
|
>
|
||||||
|
+ Proposer une pratique
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { CRITERES } from '~/types/pratique'
|
||||||
|
|
||||||
|
interface Pratique {
|
||||||
|
id: number
|
||||||
|
nom: string
|
||||||
|
pays?: string
|
||||||
|
ville?: string
|
||||||
|
type?: string
|
||||||
|
criteres?: number[]
|
||||||
|
score?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
search: string
|
||||||
|
criteres: number[]
|
||||||
|
typesEntite: string[]
|
||||||
|
critereCount: Record<number, number>
|
||||||
|
typeCount: Record<string, number>
|
||||||
|
resultCount: number
|
||||||
|
pratiques: Pratique[]
|
||||||
|
selectedId: number | null
|
||||||
|
hasActiveFilters: boolean
|
||||||
|
pending?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:search': [value: string]
|
||||||
|
'update:criteres': [value: number[]]
|
||||||
|
'update:typesEntite': [value: string[]]
|
||||||
|
'select-pratique': [id: number]
|
||||||
|
'hover-pratique': [id: number | null]
|
||||||
|
'reset-filters': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const searchInputEl = ref<HTMLInputElement | null>(null)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sidebar-search-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1.5px solid var(--nav-bg-alt);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--nav-bg);
|
||||||
|
padding: 7px 10px;
|
||||||
|
cursor: text;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-label:focus-within {
|
||||||
|
border-color: var(--nav-primary);
|
||||||
|
background: var(--nav-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-icon {
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-label:focus-within .sidebar-search-icon {
|
||||||
|
color: var(--nav-primary-solid);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-input {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nav-text);
|
||||||
|
font-size: 13px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
font-family: var(--nav-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-input::placeholder {
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-input::-webkit-search-cancel-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-clear {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-clear:hover {
|
||||||
|
color: var(--nav-text);
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-cta-link {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nav-primary-solid);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--nav-primary-solid);
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-cta-link:hover {
|
||||||
|
background: var(--nav-primary);
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
41
components/TypeEntiteFilter.vue
Normal file
41
components/TypeEntiteFilter.vue
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<span class="text-xs font-bold uppercase tracking-wide block mb-1.5" style="color: var(--nav-text-muted);">TYPE D'ENTITÉ</span>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
v-for="type in TYPES_ENTITE"
|
||||||
|
:key="type"
|
||||||
|
type="button"
|
||||||
|
class="px-2 py-0.5 rounded-full text-xs transition-all"
|
||||||
|
:style="modelValue.includes(type)
|
||||||
|
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||||
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||||
|
@click="toggle(type)"
|
||||||
|
>
|
||||||
|
{{ TYPES_ENTITE_LABELS[type] ?? type }}
|
||||||
|
<span v-if="counts && counts[type] !== undefined" class="ml-1 opacity-60 text-xs">{{ counts[type] }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { TYPES_ENTITE, TYPES_ENTITE_LABELS } from '~/types/pratique'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string[]
|
||||||
|
counts?: Record<string, number>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function toggle(type: string) {
|
||||||
|
if (props.modelValue.includes(type)) {
|
||||||
|
emit('update:modelValue', props.modelValue.filter(v => v !== type))
|
||||||
|
} else {
|
||||||
|
emit('update:modelValue', [...props.modelValue, type])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,450 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div ref="container" class="codev-graph-wrap">
|
|
||||||
|
|
||||||
<!-- Placeholder si aucune fiche -->
|
|
||||||
<div v-if="fiches.length === 0" class="empty-state">
|
|
||||||
<p class="empty-msg">Encore personne. Sois la premiere fiche !</p>
|
|
||||||
<NuxtLink to="/codev/fiche" class="empty-link">Creer ma fiche →</NuxtLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SVG D3 -->
|
|
||||||
<svg v-else ref="svgEl" class="codev-svg">
|
|
||||||
<defs>
|
|
||||||
<marker
|
|
||||||
id="arrow-solution"
|
|
||||||
viewBox="0 0 10 10"
|
|
||||||
refX="18"
|
|
||||||
refY="5"
|
|
||||||
markerWidth="6"
|
|
||||||
markerHeight="6"
|
|
||||||
orient="auto-start-reverse"
|
|
||||||
>
|
|
||||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#22c55e" />
|
|
||||||
</marker>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import * as d3 from 'd3'
|
|
||||||
import type { CodevFiche, CodevMatch } from '~/types/codev'
|
|
||||||
|
|
||||||
// ── Props / Emits ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
fiches: CodevFiche[]
|
|
||||||
matches?: CodevMatch[]
|
|
||||||
mode?: 'none' | 'solution' | 'alliance' | 'surprise'
|
|
||||||
showLabels?: boolean
|
|
||||||
}>(), {
|
|
||||||
matches: () => [],
|
|
||||||
mode: 'none',
|
|
||||||
showLabels: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'select-fiche': [id: number]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// ── Refs ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const container = ref<HTMLDivElement | null>(null)
|
|
||||||
const svgEl = ref<SVGSVGElement | null>(null)
|
|
||||||
const width = ref(800)
|
|
||||||
const height = ref(600)
|
|
||||||
|
|
||||||
// ── State interne ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type SimNode = d3.SimulationNodeDatum & { id: number; nom: string; offre: string; besoin: string }
|
|
||||||
type SimLink = d3.SimulationLinkDatum<SimNode> & { score: number; mode: string }
|
|
||||||
|
|
||||||
let simulation: d3.Simulation<SimNode, SimLink> | null = null
|
|
||||||
let svgRoot: d3.Selection<SVGSVGElement, unknown, null, undefined> | null = null
|
|
||||||
let gLinks: d3.Selection<SVGGElement, unknown, null, undefined> | null = null
|
|
||||||
let gNodes: d3.Selection<SVGGElement, unknown, null, undefined> | null = null
|
|
||||||
|
|
||||||
const isMobile = computed(() => width.value < 600)
|
|
||||||
const nodeRadius = computed(() => isMobile.value ? 22 : 28)
|
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function truncate(str: string, max = 10): string {
|
|
||||||
if (!str) return ''
|
|
||||||
return str.length > max ? str.slice(0, max - 1) + '…' : str
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildNodes(): SimNode[] {
|
|
||||||
return props.fiches.map(f => ({
|
|
||||||
id: f.id,
|
|
||||||
nom: f.nom,
|
|
||||||
offre: f.offre,
|
|
||||||
besoin: f.besoin,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLinks(nodes: SimNode[]): SimLink[] {
|
|
||||||
if (!props.matches || props.matches.length === 0) return []
|
|
||||||
const nodeById = new Map(nodes.map(n => [n.id, n]))
|
|
||||||
return props.matches
|
|
||||||
.filter(m => nodeById.has(m.fromId) && nodeById.has(m.toId))
|
|
||||||
.map(m => ({
|
|
||||||
source: nodeById.get(m.fromId)!,
|
|
||||||
target: nodeById.get(m.toId)!,
|
|
||||||
score: m.score,
|
|
||||||
mode: m.mode,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function linkColor(mode: string): string {
|
|
||||||
if (mode === 'solution') return '#22c55e'
|
|
||||||
if (mode === 'alliance') return '#f97316'
|
|
||||||
if (mode === 'surprise') return '#3b82f6'
|
|
||||||
return '#ccc'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Drag handler ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function makeDrag(sim: d3.Simulation<SimNode, SimLink>): d3.DragBehavior<SVGGElement, SimNode, SimNode> {
|
|
||||||
return d3.drag<SVGGElement, SimNode>()
|
|
||||||
.on('start', (event, d) => {
|
|
||||||
if (!event.active) sim.alphaTarget(0.3).restart()
|
|
||||||
d.fx = d.x
|
|
||||||
d.fy = d.y
|
|
||||||
})
|
|
||||||
.on('drag', (event, d) => {
|
|
||||||
d.fx = event.x
|
|
||||||
d.fy = event.y
|
|
||||||
})
|
|
||||||
.on('end', (event, d) => {
|
|
||||||
if (!event.active) sim.alphaTarget(0)
|
|
||||||
d.fx = null
|
|
||||||
d.fy = null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Initialisation SVG ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function initSvg() {
|
|
||||||
if (!svgEl.value) return
|
|
||||||
|
|
||||||
svgRoot = d3.select(svgEl.value)
|
|
||||||
.attr('width', width.value)
|
|
||||||
.attr('height', height.value)
|
|
||||||
|
|
||||||
svgRoot.selectAll('*').remove()
|
|
||||||
|
|
||||||
gLinks = svgRoot.append('g').attr('class', 'links')
|
|
||||||
gNodes = svgRoot.append('g').attr('class', 'nodes')
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Rebuild liens (hook pour M4) ───────────────────────────────────────────
|
|
||||||
|
|
||||||
let currentNodes: SimNode[] = []
|
|
||||||
let currentLinks: SimLink[] = []
|
|
||||||
|
|
||||||
function rebuildLinks() {
|
|
||||||
currentLinks = buildLinks(currentNodes)
|
|
||||||
if (!gLinks || !simulation) return
|
|
||||||
|
|
||||||
// .join() moderne D3 pour garantir le re-rendu complet
|
|
||||||
gLinks
|
|
||||||
.selectAll<SVGLineElement, SimLink>('line')
|
|
||||||
.data(currentLinks)
|
|
||||||
.join(
|
|
||||||
enter => enter.append('line'),
|
|
||||||
update => update,
|
|
||||||
exit => exit.remove()
|
|
||||||
)
|
|
||||||
.attr('stroke', d => linkColor(d.mode))
|
|
||||||
.attr('stroke-width', d => 1 + d.score * 3)
|
|
||||||
.attr('stroke-opacity', 0.7)
|
|
||||||
.attr('marker-end', d => d.mode === 'solution' ? 'url(#arrow-solution)' : null)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Rendu complet ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
if (!svgEl.value || props.fiches.length === 0) return
|
|
||||||
|
|
||||||
initSvg()
|
|
||||||
|
|
||||||
currentNodes = buildNodes()
|
|
||||||
currentLinks = buildLinks(currentNodes)
|
|
||||||
|
|
||||||
const r = nodeRadius.value
|
|
||||||
const fontSize = isMobile.value ? 10 : 12
|
|
||||||
|
|
||||||
// Liens
|
|
||||||
gLinks!
|
|
||||||
.selectAll<SVGLineElement, SimLink>('line')
|
|
||||||
.data(currentLinks)
|
|
||||||
.join('line')
|
|
||||||
.attr('stroke', d => linkColor(d.mode))
|
|
||||||
.attr('stroke-width', d => 1 + d.score * 3)
|
|
||||||
.attr('stroke-opacity', 0.7)
|
|
||||||
.attr('marker-end', d => d.mode === 'solution' ? 'url(#arrow-solution)' : null)
|
|
||||||
|
|
||||||
// Noeuds = groupe <g> par personne
|
|
||||||
const nodeGroups = gNodes!
|
|
||||||
.selectAll<SVGGElement, SimNode>('g.node')
|
|
||||||
.data(currentNodes, d => String(d.id))
|
|
||||||
.join('g')
|
|
||||||
.attr('class', 'node')
|
|
||||||
.style('cursor', 'pointer')
|
|
||||||
.call(makeDrag(simulation!) as any)
|
|
||||||
.on('click', (_event, d) => emit('select-fiche', d.id))
|
|
||||||
|
|
||||||
// Cercle principal
|
|
||||||
nodeGroups.append('circle')
|
|
||||||
.attr('r', r)
|
|
||||||
.attr('fill', '#ffffff')
|
|
||||||
.attr('stroke', '#1B4436')
|
|
||||||
.attr('stroke-width', 2)
|
|
||||||
|
|
||||||
// Label nom
|
|
||||||
nodeGroups.append('text')
|
|
||||||
.attr('text-anchor', 'middle')
|
|
||||||
.attr('dominant-baseline', 'central')
|
|
||||||
.attr('font-size', fontSize)
|
|
||||||
.attr('font-weight', '700')
|
|
||||||
.attr('fill', '#1a1a2e')
|
|
||||||
.attr('pointer-events', 'none')
|
|
||||||
.text(d => truncate(d.nom, 10))
|
|
||||||
|
|
||||||
// Pastille offre (haut-droite, vert)
|
|
||||||
nodeGroups.append('circle')
|
|
||||||
.attr('r', 6)
|
|
||||||
.attr('cx', r * 0.65)
|
|
||||||
.attr('cy', -r * 0.65)
|
|
||||||
.attr('fill', '#22c55e')
|
|
||||||
.attr('stroke', '#fff')
|
|
||||||
.attr('stroke-width', 1.5)
|
|
||||||
|
|
||||||
// Pastille besoin (bas-droite, bleu)
|
|
||||||
nodeGroups.append('circle')
|
|
||||||
.attr('r', 6)
|
|
||||||
.attr('cx', r * 0.65)
|
|
||||||
.attr('cy', r * 0.65)
|
|
||||||
.attr('fill', '#3b82f6')
|
|
||||||
.attr('stroke', '#fff')
|
|
||||||
.attr('stroke-width', 1.5)
|
|
||||||
|
|
||||||
// Tooltip SVG natif <title>
|
|
||||||
nodeGroups.append('title')
|
|
||||||
.text(d => `${d.nom}\nOffre : ${d.offre}\nBesoin : ${d.besoin}`)
|
|
||||||
|
|
||||||
// Groupe label bulle (affiche si showLabels)
|
|
||||||
const labelGroups = nodeGroups.append('g')
|
|
||||||
.attr('class', 'label-bubble')
|
|
||||||
.attr('visibility', props.showLabels ? 'visible' : 'hidden')
|
|
||||||
|
|
||||||
// Fond bulle besoin (dessous du noeud)
|
|
||||||
labelGroups.append('rect')
|
|
||||||
.attr('class', 'bubble-besoin-bg')
|
|
||||||
.attr('x', -(r + 50))
|
|
||||||
.attr('y', r + 4)
|
|
||||||
.attr('width', 100)
|
|
||||||
.attr('height', 28)
|
|
||||||
.attr('rx', 6)
|
|
||||||
.attr('fill', '#eff6ff')
|
|
||||||
.attr('stroke', '#3b82f6')
|
|
||||||
.attr('stroke-width', 1)
|
|
||||||
|
|
||||||
// Texte besoin
|
|
||||||
labelGroups.append('text')
|
|
||||||
.attr('class', 'bubble-besoin-txt')
|
|
||||||
.attr('x', -(r) + 50)
|
|
||||||
.attr('y', r + 22)
|
|
||||||
.attr('text-anchor', 'middle')
|
|
||||||
.attr('font-size', 9)
|
|
||||||
.attr('fill', '#1e40af')
|
|
||||||
.attr('pointer-events', 'none')
|
|
||||||
.text(d => truncate(d.besoin, 18))
|
|
||||||
|
|
||||||
// Fond bulle offre (dessus du noeud)
|
|
||||||
labelGroups.append('rect')
|
|
||||||
.attr('class', 'bubble-offre-bg')
|
|
||||||
.attr('x', -(r + 50))
|
|
||||||
.attr('y', -(r + 32))
|
|
||||||
.attr('width', 100)
|
|
||||||
.attr('height', 28)
|
|
||||||
.attr('rx', 6)
|
|
||||||
.attr('fill', '#f0fdf4')
|
|
||||||
.attr('stroke', '#22c55e')
|
|
||||||
.attr('stroke-width', 1)
|
|
||||||
|
|
||||||
// Texte offre
|
|
||||||
labelGroups.append('text')
|
|
||||||
.attr('class', 'bubble-offre-txt')
|
|
||||||
.attr('x', -(r) + 50)
|
|
||||||
.attr('y', -(r + 14))
|
|
||||||
.attr('text-anchor', 'middle')
|
|
||||||
.attr('font-size', 9)
|
|
||||||
.attr('fill', '#166534')
|
|
||||||
.attr('pointer-events', 'none')
|
|
||||||
.text(d => truncate(d.offre, 18))
|
|
||||||
|
|
||||||
// Simulation
|
|
||||||
simulation = d3.forceSimulation<SimNode, SimLink>(currentNodes)
|
|
||||||
.force('link', d3.forceLink<SimNode, SimLink>(currentLinks)
|
|
||||||
.id(d => d.id)
|
|
||||||
.distance(120)
|
|
||||||
.strength(0.3))
|
|
||||||
.force('charge', d3.forceManyBody<SimNode>().strength(-400))
|
|
||||||
.force('center', d3.forceCenter(width.value / 2, height.value / 2))
|
|
||||||
.force('collide', d3.forceCollide<SimNode>().radius(r + 12))
|
|
||||||
.force('x', d3.forceX(width.value / 2).strength(0.05))
|
|
||||||
.force('y', d3.forceY(height.value / 2).strength(0.05))
|
|
||||||
.alphaDecay(0.02)
|
|
||||||
.on('tick', tick)
|
|
||||||
|
|
||||||
// Re-bind drag avec la nouvelle simulation
|
|
||||||
gNodes!.selectAll<SVGGElement, SimNode>('g.node')
|
|
||||||
.call(makeDrag(simulation) as any)
|
|
||||||
}
|
|
||||||
|
|
||||||
function tick() {
|
|
||||||
const r = nodeRadius.value
|
|
||||||
if (!gLinks || !gNodes) return
|
|
||||||
|
|
||||||
gLinks.selectAll<SVGLineElement, SimLink>('line')
|
|
||||||
.attr('x1', d => Math.max(r, Math.min(width.value - r, (d.source as SimNode).x ?? 0)))
|
|
||||||
.attr('y1', d => Math.max(r, Math.min(height.value - r, (d.source as SimNode).y ?? 0)))
|
|
||||||
.attr('x2', d => Math.max(r, Math.min(width.value - r, (d.target as SimNode).x ?? 0)))
|
|
||||||
.attr('y2', d => Math.max(r, Math.min(height.value - r, (d.target as SimNode).y ?? 0)))
|
|
||||||
|
|
||||||
gNodes.selectAll<SVGGElement, SimNode>('g.node')
|
|
||||||
.attr('transform', d => {
|
|
||||||
const x = Math.max(r, Math.min(width.value - r, d.x ?? 0))
|
|
||||||
const y = Math.max(r, Math.min(height.value - r, d.y ?? 0))
|
|
||||||
return `translate(${x},${y})`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Watch matches/mode (hook pour M4) ─────────────────────────────────────
|
|
||||||
|
|
||||||
watch(() => [props.matches, props.mode] as const, () => {
|
|
||||||
if (!simulation) return
|
|
||||||
rebuildLinks()
|
|
||||||
const newForce = d3.forceLink<SimNode, SimLink>(currentLinks)
|
|
||||||
.id(d => String(d.id))
|
|
||||||
.distance(120)
|
|
||||||
.strength(0.5)
|
|
||||||
simulation.force('link', newForce)
|
|
||||||
simulation.alpha(0.8).restart()
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
// ── Watch showLabels ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
watch(() => props.showLabels, (val) => {
|
|
||||||
if (!svgEl.value) return
|
|
||||||
d3.select(svgEl.value).selectAll('.label-bubble').attr('visibility', val ? 'visible' : 'hidden')
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Watch fiches (re-render si nouvelles fiches) ───────────────────────────
|
|
||||||
|
|
||||||
watch(() => props.fiches, () => {
|
|
||||||
if (simulation) {
|
|
||||||
simulation.stop()
|
|
||||||
simulation = null
|
|
||||||
}
|
|
||||||
render()
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
// ── ResizeObserver ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let ro: ResizeObserver | null = null
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (!container.value) return
|
|
||||||
width.value = container.value.clientWidth || 800
|
|
||||||
height.value = container.value.clientHeight || 600
|
|
||||||
|
|
||||||
render()
|
|
||||||
|
|
||||||
ro = new ResizeObserver(() => {
|
|
||||||
if (!container.value) return
|
|
||||||
width.value = container.value.clientWidth || 800
|
|
||||||
height.value = container.value.clientHeight || 600
|
|
||||||
if (svgRoot) {
|
|
||||||
svgRoot.attr('width', width.value).attr('height', height.value)
|
|
||||||
}
|
|
||||||
if (simulation) {
|
|
||||||
simulation.force('center', d3.forceCenter(width.value / 2, height.value / 2))
|
|
||||||
simulation.alpha(0.3).restart()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
ro.observe(container.value!)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (simulation) simulation.stop()
|
|
||||||
if (ro) ro.disconnect()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.codev-graph-wrap {
|
|
||||||
width: 100%;
|
|
||||||
height: 70vh;
|
|
||||||
min-height: 320px;
|
|
||||||
position: relative;
|
|
||||||
background: var(--nav-bg, #fafafa);
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codev-svg {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Etat vide ── */
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-msg {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
color: var(--nav-text-muted, #6b7280);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-link {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nav-primary-solid, #1B4436);
|
|
||||||
text-decoration: none;
|
|
||||||
border: 1.5px solid var(--nav-primary-solid, #1B4436);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.5rem 1.25rem;
|
|
||||||
transition: background 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-link:hover {
|
|
||||||
background: var(--nav-primary-solid, #1B4436);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Mobile ── */
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.codev-graph-wrap {
|
|
||||||
height: 65vh;
|
|
||||||
min-height: 260px;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,11 +1,6 @@
|
|||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
modules: ['@nuxtjs/tailwindcss'],
|
modules: ['@nuxtjs/tailwindcss'],
|
||||||
css: [
|
css: ['~/assets/css/main.css'],
|
||||||
'~/assets/css/main.css',
|
|
||||||
'leaflet/dist/leaflet.css',
|
|
||||||
'leaflet.markercluster/dist/MarkerCluster.css',
|
|
||||||
'leaflet.markercluster/dist/MarkerCluster.Default.css',
|
|
||||||
],
|
|
||||||
|
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
nocodbUrl: process.env.NOCODB_URL,
|
nocodbUrl: process.env.NOCODB_URL,
|
||||||
@@ -19,20 +14,16 @@ export default defineNuxtConfig({
|
|||||||
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
|
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
|
||||||
resendApiKey: process.env.RESEND_API_KEY,
|
resendApiKey: process.env.RESEND_API_KEY,
|
||||||
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
|
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
|
||||||
codevTableId: '', // NUXT_CODEV_TABLE_ID
|
|
||||||
codevPassword: 'merci', // NUXT_CODEV_PASSWORD - défaut "merci", overridable
|
|
||||||
codevBaseId: '', // NUXT_CODEV_BASE_ID - base NocoDB (ex: pipilvsi7dibo80)
|
|
||||||
codevAdminPassword: 'admin2026', // NUXT_CODEV_ADMIN_PASSWORD
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client
|
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client
|
||||||
ssr: true,
|
ssr: true,
|
||||||
|
|
||||||
vite: {
|
vite: {
|
||||||
cacheDir: 'C:/Users/jules/AppData/Local/nav-carte-vite-cache',
|
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
include: ['leaflet', 'leaflet.markercluster', 'd3'],
|
include: ['leaflet', 'leaflet.markercluster'],
|
||||||
},
|
},
|
||||||
|
// Éviter l'import SSR de Leaflet qui utilise window
|
||||||
ssr: {
|
ssr: {
|
||||||
noExternal: [],
|
noExternal: [],
|
||||||
},
|
},
|
||||||
|
|||||||
459
package-lock.json
generated
459
package-lock.json
generated
@@ -10,7 +10,6 @@
|
|||||||
"@headlessui/vue": "^1.7.23",
|
"@headlessui/vue": "^1.7.23",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/leaflet.markercluster": "^1.5.6",
|
"@types/leaflet.markercluster": "^1.5.6",
|
||||||
"d3": "^7.9.0",
|
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet.markercluster": "^1.5.3",
|
"leaflet.markercluster": "^1.5.3",
|
||||||
@@ -5313,416 +5312,6 @@
|
|||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/d3": {
|
|
||||||
"version": "7.9.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
|
|
||||||
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-array": "3",
|
|
||||||
"d3-axis": "3",
|
|
||||||
"d3-brush": "3",
|
|
||||||
"d3-chord": "3",
|
|
||||||
"d3-color": "3",
|
|
||||||
"d3-contour": "4",
|
|
||||||
"d3-delaunay": "6",
|
|
||||||
"d3-dispatch": "3",
|
|
||||||
"d3-drag": "3",
|
|
||||||
"d3-dsv": "3",
|
|
||||||
"d3-ease": "3",
|
|
||||||
"d3-fetch": "3",
|
|
||||||
"d3-force": "3",
|
|
||||||
"d3-format": "3",
|
|
||||||
"d3-geo": "3",
|
|
||||||
"d3-hierarchy": "3",
|
|
||||||
"d3-interpolate": "3",
|
|
||||||
"d3-path": "3",
|
|
||||||
"d3-polygon": "3",
|
|
||||||
"d3-quadtree": "3",
|
|
||||||
"d3-random": "3",
|
|
||||||
"d3-scale": "4",
|
|
||||||
"d3-scale-chromatic": "3",
|
|
||||||
"d3-selection": "3",
|
|
||||||
"d3-shape": "3",
|
|
||||||
"d3-time": "3",
|
|
||||||
"d3-time-format": "4",
|
|
||||||
"d3-timer": "3",
|
|
||||||
"d3-transition": "3",
|
|
||||||
"d3-zoom": "3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-array": {
|
|
||||||
"version": "3.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
|
||||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"internmap": "1 - 2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-axis": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-brush": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-dispatch": "1 - 3",
|
|
||||||
"d3-drag": "2 - 3",
|
|
||||||
"d3-interpolate": "1 - 3",
|
|
||||||
"d3-selection": "3",
|
|
||||||
"d3-transition": "3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-chord": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-path": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-color": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-contour": {
|
|
||||||
"version": "4.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
|
|
||||||
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-array": "^3.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-delaunay": {
|
|
||||||
"version": "6.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
|
||||||
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"delaunator": "5"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-dispatch": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-drag": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-dispatch": "1 - 3",
|
|
||||||
"d3-selection": "3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-dsv": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"commander": "7",
|
|
||||||
"iconv-lite": "0.6",
|
|
||||||
"rw": "1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"csv2json": "bin/dsv2json.js",
|
|
||||||
"csv2tsv": "bin/dsv2dsv.js",
|
|
||||||
"dsv2dsv": "bin/dsv2dsv.js",
|
|
||||||
"dsv2json": "bin/dsv2json.js",
|
|
||||||
"json2csv": "bin/json2dsv.js",
|
|
||||||
"json2dsv": "bin/json2dsv.js",
|
|
||||||
"json2tsv": "bin/json2dsv.js",
|
|
||||||
"tsv2csv": "bin/dsv2dsv.js",
|
|
||||||
"tsv2json": "bin/dsv2json.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-dsv/node_modules/commander": {
|
|
||||||
"version": "7.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
|
||||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-ease": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-fetch": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-dsv": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-force": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-dispatch": "1 - 3",
|
|
||||||
"d3-quadtree": "1 - 3",
|
|
||||||
"d3-timer": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-format": {
|
|
||||||
"version": "3.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
|
||||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-geo": {
|
|
||||||
"version": "3.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
|
||||||
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-array": "2.5.0 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-hierarchy": {
|
|
||||||
"version": "3.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
|
|
||||||
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-interpolate": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-color": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-path": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-polygon": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-quadtree": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-random": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-scale": {
|
|
||||||
"version": "4.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
|
||||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-array": "2.10.0 - 3",
|
|
||||||
"d3-format": "1 - 3",
|
|
||||||
"d3-interpolate": "1.2.0 - 3",
|
|
||||||
"d3-time": "2.1.1 - 3",
|
|
||||||
"d3-time-format": "2 - 4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-scale-chromatic": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-color": "1 - 3",
|
|
||||||
"d3-interpolate": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-selection": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-shape": {
|
|
||||||
"version": "3.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
|
||||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-path": "^3.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-time": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-array": "2 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-time-format": {
|
|
||||||
"version": "4.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
|
||||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-time": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-timer": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-transition": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-color": "1 - 3",
|
|
||||||
"d3-dispatch": "1 - 3",
|
|
||||||
"d3-ease": "1 - 3",
|
|
||||||
"d3-interpolate": "1 - 3",
|
|
||||||
"d3-timer": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"d3-selection": "2 - 3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-zoom": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-dispatch": "1 - 3",
|
|
||||||
"d3-drag": "2 - 3",
|
|
||||||
"d3-interpolate": "1 - 3",
|
|
||||||
"d3-selection": "2 - 3",
|
|
||||||
"d3-transition": "2 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/db0": {
|
"node_modules/db0": {
|
||||||
"version": "0.3.4",
|
"version": "0.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz",
|
||||||
@@ -5836,15 +5425,6 @@
|
|||||||
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/delaunator": {
|
|
||||||
"version": "5.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
|
|
||||||
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"robust-predicates": "^3.0.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/delegates": {
|
"node_modules/delegates": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
|
||||||
@@ -6900,18 +6480,6 @@
|
|||||||
"node": ">=16.17.0"
|
"node": ">=16.17.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/iconv-lite": {
|
|
||||||
"version": "0.6.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
|
||||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
@@ -6987,15 +6555,6 @@
|
|||||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/internmap": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ioredis": {
|
"node_modules/ioredis": {
|
||||||
"version": "5.10.1",
|
"version": "5.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
|
||||||
@@ -9921,12 +9480,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/robust-predicates": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
|
|
||||||
"license": "Unlicense"
|
|
||||||
},
|
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.60.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
||||||
@@ -10042,12 +9595,6 @@
|
|||||||
"queue-microtask": "^1.2.2"
|
"queue-microtask": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rw": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
|
||||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/safe-buffer": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -10086,12 +9633,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/safer-buffer": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/sax": {
|
"node_modules/sax": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
"@headlessui/vue": "^1.7.23",
|
"@headlessui/vue": "^1.7.23",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/leaflet.markercluster": "^1.5.6",
|
"@types/leaflet.markercluster": "^1.5.6",
|
||||||
"d3": "^7.9.0",
|
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet.markercluster": "^1.5.3",
|
"leaflet.markercluster": "^1.5.3",
|
||||||
|
|||||||
@@ -1,517 +1,39 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
|
<div class="flex flex-col items-center justify-center h-full gap-6" style="background: var(--nav-bg);">
|
||||||
|
<div class="text-center max-w-md px-6">
|
||||||
<!-- ═══════════════════════════════════════ SIDEBAR DESKTOP (>= 1024px) -->
|
|
||||||
<div class="hidden lg:block overflow-y-auto" style="width: 320px; min-width: 320px; flex-shrink: 0; border-right: 1px solid var(--nav-bg-alt); height: 100%;">
|
|
||||||
|
|
||||||
<!-- IntentionBanner s'auto-affiche via Teleport (overlay plein ecran) -->
|
|
||||||
<IntentionBanner />
|
|
||||||
|
|
||||||
<!-- Filtres familles + hashtags -->
|
|
||||||
<HashtagFilter
|
|
||||||
:allHashtags="allHashtags"
|
|
||||||
:selectedHashtags="selectedHashtags"
|
|
||||||
:selectedFamille="selectedFamille"
|
|
||||||
@update:selectedHashtags="selectedHashtags = $event"
|
|
||||||
@update:selectedFamille="selectedFamille = $event"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Separateur -->
|
|
||||||
<div style="height: 1px; background: var(--nav-bg-alt);"></div>
|
|
||||||
|
|
||||||
<!-- Barre de recherche -->
|
|
||||||
<div class="px-3 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
|
||||||
<label class="sidebar-search-label" aria-label="Rechercher une structure">
|
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="sidebar-search-icon">
|
|
||||||
<circle cx="11" cy="11" r="8"/>
|
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
v-model="search"
|
|
||||||
type="search"
|
|
||||||
placeholder="Rechercher une structure..."
|
|
||||||
class="sidebar-search-input"
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
v-if="search"
|
|
||||||
type="button"
|
|
||||||
class="sidebar-search-clear"
|
|
||||||
aria-label="Effacer"
|
|
||||||
@click.stop="search = ''"
|
|
||||||
>
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Header compteur + reset -->
|
|
||||||
<div class="flex items-center justify-between px-4 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
|
||||||
<span class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">
|
|
||||||
{{ filtered.length }} structure{{ filtered.length > 1 ? 's' : '' }}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
v-if="hasActiveFilters"
|
|
||||||
@click="resetFilters"
|
|
||||||
class="text-xs underline hover:opacity-70"
|
|
||||||
style="color: var(--nav-text-muted);"
|
|
||||||
>Effacer les filtres</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Liste fiches (sidebar entiere scroll - pas de scroll interne) -->
|
|
||||||
<div class="px-3 py-2 space-y-1.5">
|
|
||||||
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
|
|
||||||
Chargement...
|
|
||||||
</div>
|
|
||||||
<div v-else-if="filtered.length === 0" class="text-center py-8">
|
|
||||||
<p class="text-xs" style="color: var(--nav-text-muted);">Aucun résultat</p>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
v-for="structure in filtered"
|
class="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-5"
|
||||||
:key="structure.id"
|
style="background: var(--nav-bg-alt);"
|
||||||
class="rounded-lg px-3 py-2 cursor-pointer transition-all"
|
|
||||||
:style="selectedId === structure.id
|
|
||||||
? `background: var(--nav-bg-alt); border-left: 3px solid ${familleColor(structure.famille_principale)}; padding-left: 9px;`
|
|
||||||
: 'background: var(--nav-bg); border-left: 3px solid transparent; padding-left: 9px;'"
|
|
||||||
@click="onSelectStructure(structure.id)"
|
|
||||||
@mouseenter="hoveredId = structure.id"
|
|
||||||
@mouseleave="hoveredId = null"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between gap-1.5">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted);">
|
||||||
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ structure.nom }}</span>
|
<rect x="3" y="3" width="7" height="7"/>
|
||||||
<span
|
<rect x="14" y="3" width="7" height="7"/>
|
||||||
class="shrink-0 w-2.5 h-2.5 rounded-full mt-1"
|
<rect x="14" y="14" width="7" height="7"/>
|
||||||
:style="`background: ${familleColor(structure.famille_principale)};`"
|
<rect x="3" y="14" width="7" height="7"/>
|
||||||
/>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">{{ structure.type_principal }} - {{ structure.ville }}</div>
|
<h1 class="text-2xl font-bold mb-3" style="color: var(--nav-text);">Agences Inspirantes</h1>
|
||||||
<div v-if="structure.hashtags.length" class="mt-1 flex flex-wrap gap-1">
|
<p class="text-sm leading-relaxed mb-6" style="color: var(--nav-text-muted);">
|
||||||
<span
|
Cette section répertoriera les agences d'architecture qui incarnent une pratique engagée — écologie politique, auto-construction, architectures vernaculaires, sobriété.
|
||||||
v-for="tag in structure.hashtags.slice(0, 2)"
|
|
||||||
:key="tag"
|
|
||||||
class="text-xs"
|
|
||||||
style="color: var(--nav-text-muted);"
|
|
||||||
>{{ tag }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════ ZONE CENTRALE (carte) -->
|
|
||||||
<main class="flex-1 flex flex-col overflow-hidden relative">
|
|
||||||
|
|
||||||
<!-- ── VUE DESKTOP : Onglets Métro/Outre-mer ── -->
|
|
||||||
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
|
|
||||||
<!-- Onglets desktop -->
|
|
||||||
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
|
||||||
<button
|
|
||||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
|
||||||
:style="desktopMapView === 'metropole'
|
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
||||||
@click="desktopMapView = 'metropole'"
|
|
||||||
>Métropolitain</button>
|
|
||||||
<button
|
|
||||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
|
||||||
:style="desktopMapView === 'outremer'
|
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
||||||
@click="desktopMapView = 'outremer'"
|
|
||||||
>Outre-mer</button>
|
|
||||||
<button
|
|
||||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
|
||||||
:style="desktopMapView === 'graphe'
|
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
||||||
@click="desktopMapView = 'graphe'"
|
|
||||||
>Vue graphique</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Carte Métropole desktop -->
|
|
||||||
<div v-show="desktopMapView === 'metropole'" class="flex-1 flex flex-col overflow-hidden">
|
|
||||||
<div class="relative flex-1" style="min-height: 200px;">
|
|
||||||
<ClientOnly>
|
|
||||||
<NavMapV2
|
|
||||||
ref="navMapRef"
|
|
||||||
:structures="metropoleStructures"
|
|
||||||
:selectedId="selectedId"
|
|
||||||
@select-structure="onSelectStructure"
|
|
||||||
/>
|
|
||||||
<template #fallback>
|
|
||||||
<div
|
|
||||||
class="w-full h-full flex items-center justify-center"
|
|
||||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
|
||||||
>
|
|
||||||
Chargement de la carte…
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
|
||||||
<ChatbotPlaceholder
|
|
||||||
@highlightOrgs="() => {}"
|
|
||||||
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Carte Outre-mer desktop -->
|
|
||||||
<div v-show="desktopMapView === 'outremer'" class="flex-1 overflow-y-auto" style="background: var(--nav-bg);">
|
|
||||||
<ClientOnly>
|
|
||||||
<OutremerMap
|
|
||||||
:orgs="outremerOrgsLegacy"
|
|
||||||
:selectedId="selectedIdLegacyNum"
|
|
||||||
@select-org="() => {}"
|
|
||||||
/>
|
|
||||||
<template #fallback>
|
|
||||||
<div class="flex items-center justify-center h-full text-sm" style="color: var(--nav-text-muted);">
|
|
||||||
Chargement…
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Vue graphique desktop -->
|
|
||||||
<div v-show="desktopMapView === 'graphe'" class="flex-1 overflow-hidden flex flex-col">
|
|
||||||
<div class="flex-1 overflow-hidden relative">
|
|
||||||
<ClientOnly>
|
|
||||||
<GraphView
|
|
||||||
:data="bifurcationData"
|
|
||||||
:allHashtags="allHashtags"
|
|
||||||
:active="desktopMapView === 'graphe'"
|
|
||||||
@select-structure="onSelectStructure"
|
|
||||||
/>
|
|
||||||
<template #fallback>
|
|
||||||
<div class="flex items-center justify-center h-full" style="color: var(--nav-text-muted);">
|
|
||||||
Chargement du graphe...
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
|
||||||
<ChatbotPlaceholder
|
|
||||||
@highlightOrgs="() => {}"
|
|
||||||
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + sheet swipable ── -->
|
|
||||||
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
|
||||||
<button
|
|
||||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
|
||||||
:style="mobileMapView === 'metropole'
|
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
||||||
@click="mobileMapView = 'metropole'"
|
|
||||||
>Métropolitain</button>
|
|
||||||
<button
|
|
||||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
|
||||||
:style="mobileMapView === 'outremer'
|
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
||||||
@click="mobileMapView = 'outremer'"
|
|
||||||
>Outre-mer</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="lg:hidden flex-1 relative overflow-hidden">
|
|
||||||
<!-- Carte mobile Métropole -->
|
|
||||||
<div v-show="mobileMapView === 'metropole'" class="absolute inset-0">
|
|
||||||
<ClientOnly>
|
|
||||||
<NavMapV2
|
|
||||||
ref="navMapMobileRef"
|
|
||||||
:structures="metropoleStructures"
|
|
||||||
:selectedId="selectedId"
|
|
||||||
@select-structure="onSelectStructureMobile"
|
|
||||||
/>
|
|
||||||
<template #fallback>
|
|
||||||
<div class="w-full h-full flex items-center justify-center" style="background: var(--nav-bg-alt); color: var(--nav-text-muted);">
|
|
||||||
Chargement de la carte…
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Carte mobile Outre-mer -->
|
|
||||||
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
|
|
||||||
<ClientOnly>
|
|
||||||
<OutremerMap
|
|
||||||
:orgs="outremerOrgsLegacy"
|
|
||||||
:selectedId="selectedIdLegacyNum"
|
|
||||||
@select-org="() => {}"
|
|
||||||
/>
|
|
||||||
<template #fallback>
|
|
||||||
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
|
|
||||||
Chargement…
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bottom sheet swipable -->
|
|
||||||
<ClientOnly>
|
|
||||||
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
|
||||||
<!-- Bandeau intention mobile -->
|
|
||||||
<div class="px-3 py-2" style="background: var(--bifurc-banner-bg, #faf8f5); border-bottom: 1px solid var(--bifurc-banner-border, #e0d8cc);">
|
|
||||||
<p class="text-xs leading-relaxed" style="color: var(--bifurc-banner-text, #2c2416); margin: 0;">
|
|
||||||
120 réseaux, collectifs et agences où des pensées écologiques deviennent des pratiques d'architecture.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<p class="text-xs font-semibold uppercase tracking-widest mb-6" style="color: var(--nav-text-muted); opacity: 0.6;">
|
||||||
|
Bientôt disponible
|
||||||
<!-- Filtres hashtags mobile -->
|
</p>
|
||||||
<div class="px-3 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
<NuxtLink
|
||||||
<HashtagFilter
|
to="/"
|
||||||
:allHashtags="allHashtags"
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold transition-all hover:opacity-80"
|
||||||
:selectedHashtags="selectedHashtags"
|
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
|
||||||
:selectedFamille="selectedFamille"
|
|
||||||
@update:selectedHashtags="selectedHashtags = $event"
|
|
||||||
@update:selectedFamille="selectedFamille = $event"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Barre recherche mobile -->
|
|
||||||
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
|
||||||
<label class="mobile-search-label" aria-label="Rechercher une structure">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted); flex-shrink: 0;">
|
|
||||||
<circle cx="11" cy="11" r="8"/>
|
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
v-model="search"
|
|
||||||
type="search"
|
|
||||||
placeholder="Rechercher…"
|
|
||||||
class="mobile-search-input"
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
v-if="search"
|
|
||||||
type="button"
|
|
||||||
class="mobile-search-clear"
|
|
||||||
aria-label="Effacer"
|
|
||||||
@click.stop="search = ''"
|
|
||||||
>
|
>
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
<line x1="19" y1="12" x2="5" y2="12"/>
|
||||||
|
<polyline points="12 19 5 12 12 5"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
Retour à l'écosystème
|
||||||
</label>
|
</NuxtLink>
|
||||||
<button
|
|
||||||
v-if="hasActiveFilters"
|
|
||||||
@click="resetFilters"
|
|
||||||
class="mt-1 text-xs"
|
|
||||||
style="color: var(--nav-text-muted); text-decoration: underline;"
|
|
||||||
>Effacer les filtres</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Liste fiches mobile -->
|
|
||||||
<div class="px-3 py-2">
|
|
||||||
<div class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">
|
|
||||||
{{ filtered.length }} structure{{ filtered.length > 1 ? 's' : '' }}
|
|
||||||
</div>
|
|
||||||
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
|
|
||||||
Chargement des fiches…
|
|
||||||
</div>
|
|
||||||
<div v-else-if="filtered.length === 0" class="text-center py-8">
|
|
||||||
<p class="text-sm mb-2" style="color: var(--nav-text-muted);">Aucun résultat pour ces filtres.</p>
|
|
||||||
<button @click="resetFilters" class="text-sm underline" style="color: var(--nav-primary-solid);">
|
|
||||||
Effacer les filtres
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div
|
|
||||||
v-for="structure in filtered"
|
|
||||||
:key="structure.id"
|
|
||||||
class="block rounded-lg p-3 transition-all cursor-pointer"
|
|
||||||
:style="selectedId === structure.id
|
|
||||||
? `background: var(--nav-bg-alt); border-left: 3px solid ${familleColor(structure.famille_principale)};`
|
|
||||||
: 'background: var(--nav-surface); border-left: 3px solid transparent;'"
|
|
||||||
@click="onSelectStructureMobile(structure.id)"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-2">
|
|
||||||
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ structure.nom }}</span>
|
|
||||||
<span
|
|
||||||
class="shrink-0 w-2.5 h-2.5 rounded-full mt-1"
|
|
||||||
:style="`background: ${familleColor(structure.famille_principale)};`"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">{{ structure.type_principal }} · {{ structure.ville }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</MobileSheet>
|
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════ MODAL FICHE V2 (desktop) -->
|
|
||||||
<FicheModalV2
|
|
||||||
v-model="ficheModalOpen"
|
|
||||||
:structureId="ficheModalId"
|
|
||||||
:data="bifurcationData"
|
|
||||||
@update:structureId="ficheModalId = $event"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
|
|
||||||
<button
|
|
||||||
class="lg:hidden fixed bottom-6 right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
|
|
||||||
style="
|
|
||||||
height: 48px;
|
|
||||||
background: var(--nav-primary);
|
|
||||||
opacity: 0.92;
|
|
||||||
color: var(--nav-text-on-primary);
|
|
||||||
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
|
|
||||||
font-family: var(--nav-font);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
"
|
|
||||||
aria-label="Ouvrir l'assistant Chatbot"
|
|
||||||
@click="chatbotOpen = true"
|
|
||||||
>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
|
||||||
</svg>
|
|
||||||
<span>Chatbot</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════ CHATBOT BOTTOM SHEET (mobile) -->
|
|
||||||
<ChatbotSheet
|
|
||||||
:modelValue="chatbotOpen"
|
|
||||||
@update:modelValue="chatbotOpen = $event"
|
|
||||||
@highlightOrgs="() => {}"
|
|
||||||
/>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ReseauxBifurcationData, StructureV2 } from '~/types/structure-v2'
|
useHead({ title: 'Agences Inspirantes — AEP (bientôt disponible)' })
|
||||||
|
|
||||||
// ── Couleurs familles ──────────────────────────────────────────────────────
|
|
||||||
const FAMILLE_COLORS: Record<number, string> = {
|
|
||||||
1: '#a85d3e',
|
|
||||||
2: '#c4a472',
|
|
||||||
3: '#d4a017',
|
|
||||||
4: '#5a7a4a',
|
|
||||||
5: '#3d6a8c',
|
|
||||||
6: '#6b3fa0',
|
|
||||||
}
|
|
||||||
|
|
||||||
function familleColor(f: number): string {
|
|
||||||
return FAMILLE_COLORS[f] ?? '#888'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── État UI ────────────────────────────────────────────────────────────────
|
|
||||||
const selectedId = ref<string | null>(null)
|
|
||||||
const hoveredId = ref<string | null>(null)
|
|
||||||
const ficheModalOpen = ref(false)
|
|
||||||
const ficheModalId = ref<string | null>(null)
|
|
||||||
const chatbotOpen = ref(false)
|
|
||||||
const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
|
|
||||||
const desktopMapView = ref<'metropole' | 'outremer' | 'graphe'>('metropole')
|
|
||||||
|
|
||||||
// Filtres
|
|
||||||
const search = ref('')
|
|
||||||
const selectedFamille = ref<number | null>(null)
|
|
||||||
const selectedHashtags = ref<string[]>([])
|
|
||||||
|
|
||||||
// Refs cartes
|
|
||||||
const navMapRef = ref<any>(null)
|
|
||||||
const navMapMobileRef = ref<any>(null)
|
|
||||||
|
|
||||||
// ── Données V2 - JSON statique ─────────────────────────────────────────────
|
|
||||||
const bifurcationData = ref<ReseauxBifurcationData | null>(null)
|
|
||||||
const pending = ref(true)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
bifurcationData.value = await $fetch<ReseauxBifurcationData>('/data/reseaux-bifurcation.json')
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Erreur chargement reseaux-bifurcation.json', e)
|
|
||||||
} finally {
|
|
||||||
pending.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const structures = computed<StructureV2[]>(() => bifurcationData.value?.structures ?? [])
|
|
||||||
|
|
||||||
// Tous les hashtags uniques triés
|
|
||||||
const allHashtags = computed<string[]>(() => {
|
|
||||||
const set = new Set<string>()
|
|
||||||
structures.value.forEach(s => s.hashtags.forEach(h => set.add(h)))
|
|
||||||
return Array.from(set).sort()
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Filtrage ───────────────────────────────────────────────────────────────
|
|
||||||
const filtered = computed<StructureV2[]>(() => {
|
|
||||||
let result = structures.value
|
|
||||||
|
|
||||||
// Filtre texte
|
|
||||||
if (search.value.trim()) {
|
|
||||||
const q = search.value.toLowerCase()
|
|
||||||
result = result.filter(
|
|
||||||
s =>
|
|
||||||
s.nom.toLowerCase().includes(q) ||
|
|
||||||
s.ville.toLowerCase().includes(q) ||
|
|
||||||
s.description_courte.toLowerCase().includes(q) ||
|
|
||||||
s.hashtags.some(h => h.toLowerCase().includes(q))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtre famille - F6 = badge_f6_recherche_politique, pas famille_principale
|
|
||||||
if (selectedFamille.value !== null) {
|
|
||||||
if (selectedFamille.value === 6) {
|
|
||||||
result = result.filter(s => (s.badges as any)?.f6_recherche_politique === true)
|
|
||||||
} else {
|
|
||||||
result = result.filter(
|
|
||||||
s => s.famille_principale === selectedFamille.value ||
|
|
||||||
(s.familles_secondaires ?? []).includes(selectedFamille.value!)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtre hashtags (AND logique si plusieurs)
|
|
||||||
if (selectedHashtags.value.length) {
|
|
||||||
result = result.filter(
|
|
||||||
s => selectedHashtags.value.every(h => s.hashtags.includes(h))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
const hasActiveFilters = computed(
|
|
||||||
() => !!search.value || selectedFamille.value !== null || selectedHashtags.value.length > 0
|
|
||||||
)
|
|
||||||
|
|
||||||
function resetFilters() {
|
|
||||||
search.value = ''
|
|
||||||
selectedFamille.value = null
|
|
||||||
selectedHashtags.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Structures métropole (pays != DOM-TOM, et avec coordonnées)
|
|
||||||
// Pour simplifier : toutes les structures (la carte gère les sans-coords)
|
|
||||||
const metropoleStructures = computed<StructureV2[]>(() => filtered.value)
|
|
||||||
|
|
||||||
// Outre-mer : pas de structures V2 DOM-TOM pour l'instant - garder le composant existant vide
|
|
||||||
// OutremerMap attend le format Org legacy - on passe un tableau vide
|
|
||||||
const outremerOrgsLegacy = computed(() => [])
|
|
||||||
const selectedIdLegacyNum = computed(() => null)
|
|
||||||
|
|
||||||
// ── Sélection ─────────────────────────────────────────────────────────────
|
|
||||||
function onSelectStructure(id: string) {
|
|
||||||
selectedId.value = selectedId.value === id ? null : id
|
|
||||||
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
|
|
||||||
ficheModalId.value = id
|
|
||||||
ficheModalOpen.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSelectStructureMobile(id: string) {
|
|
||||||
selectedId.value = id
|
|
||||||
ficheModalId.value = id
|
|
||||||
ficheModalOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
useHead({ title: "AEP - Réseaux de bifurcation architecturale" })
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,550 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="codev-carto">
|
|
||||||
|
|
||||||
<header class="carto-header">
|
|
||||||
<h1>Carto entraide</h1>
|
|
||||||
<p class="carto-subtitle">
|
|
||||||
<template v-if="pending">Chargement...</template>
|
|
||||||
<template v-else>
|
|
||||||
{{ fiches.length }} fiche{{ fiches.length !== 1 ? 's' : '' }} - clique sur un nom pour voir le detail
|
|
||||||
</template>
|
|
||||||
</p>
|
|
||||||
<NuxtLink to="/codev/qr" class="qr-link" title="QR Code">[ QR ]</NuxtLink>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="codev-tabs">
|
|
||||||
<button :class="{ active: tab === 'carto' }" @click="tab = 'carto'" type="button">Carto</button>
|
|
||||||
<button :class="{ active: tab === 'annuaire' }" @click="tab = 'annuaire'" type="button">Annuaire</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="tab === 'carto'">
|
|
||||||
<div class="show-labels-bar">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
:class="{ active: showLabels }"
|
|
||||||
@click="showLabels = !showLabels"
|
|
||||||
>
|
|
||||||
{{ showLabels ? 'Masquer besoins/offres' : 'Montrer besoins/offres' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ClientOnly>
|
|
||||||
<CodevGraph
|
|
||||||
:fiches="fiches"
|
|
||||||
:matches="matches"
|
|
||||||
:mode="mode"
|
|
||||||
:show-labels="showLabels"
|
|
||||||
@select-fiche="onSelectFiche"
|
|
||||||
/>
|
|
||||||
<template #fallback>
|
|
||||||
<div class="graph-fallback">Chargement du graphe...</div>
|
|
||||||
</template>
|
|
||||||
</ClientOnly>
|
|
||||||
|
|
||||||
<!-- Bandeau info mode actif -->
|
|
||||||
<div v-if="mode !== 'none'" class="mode-banner">
|
|
||||||
<span>
|
|
||||||
Mode {{ MODE_LABELS[mode] }} actif -
|
|
||||||
{{ matches.length }} connexion{{ matches.length !== 1 ? 's' : '' }} trouvee{{ matches.length !== 1 ? 's' : '' }}.
|
|
||||||
</span>
|
|
||||||
<button class="banner-clear" @click="setMode('none')" type="button">Effacer</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Boutons matching -->
|
|
||||||
<div class="matching-controls">
|
|
||||||
<button
|
|
||||||
:class="{ active: mode === 'solution' }"
|
|
||||||
style="--mode-color: #22c55e"
|
|
||||||
@click="setMode('solution')"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Solution
|
|
||||||
<span class="hint">besoin - compétence</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="{ active: mode === 'alliance' }"
|
|
||||||
style="--mode-color: #f97316"
|
|
||||||
@click="setMode('alliance')"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Alliance
|
|
||||||
<span class="hint">besoins partagés</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="mode !== 'none'"
|
|
||||||
class="reset"
|
|
||||||
@click="setMode('none')"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Effacer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="tab === 'annuaire'" class="annuaire-wrap">
|
|
||||||
|
|
||||||
<div v-if="fiches.length === 0" class="list-empty">
|
|
||||||
Aucune fiche. <NuxtLink to="/codev/fiche">Ajouter la mienne</NuxtLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="annuaire-scroll">
|
|
||||||
<table class="annuaire-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="col-nom">Prénom</th>
|
|
||||||
<th class="col-besoin">Besoin</th>
|
|
||||||
<th class="col-offre">Ce que j'offre</th>
|
|
||||||
<th v-if="isAdmin" class="col-actions"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="f in fiches" :key="f.id" @click="navigateTo(`/codev/fiche?id=${f.id}`)" class="annuaire-row">
|
|
||||||
<td class="col-nom">{{ f.nom }}</td>
|
|
||||||
<td class="col-besoin">{{ f.besoin }}</td>
|
|
||||||
<td class="col-offre">{{ f.offre }}</td>
|
|
||||||
<td v-if="isAdmin" class="col-actions">
|
|
||||||
<button @click.stop="deleteFiche(f.id)" class="delete-btn" type="button" title="Supprimer">✕</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="annuaire-hint">Clique sur une ligne pour modifier la fiche</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- FAB ajouter une fiche -->
|
|
||||||
<NuxtLink to="/codev/fiche" class="fab-add" title="Ajouter ma fiche" aria-label="Ajouter une fiche">
|
|
||||||
+
|
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
<Transition name="sheet">
|
|
||||||
<div v-if="selectedFiche" class="bottom-sheet" @click.self="selectedFiche = null">
|
|
||||||
<div class="sheet-content">
|
|
||||||
<div class="sheet-handle"></div>
|
|
||||||
<div class="sheet-name">{{ selectedFiche.nom }}</div>
|
|
||||||
<div class="sheet-section">
|
|
||||||
<span class="sheet-label">Besoin</span>
|
|
||||||
<p class="sheet-text">{{ selectedFiche.besoin }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="sheet-section">
|
|
||||||
<span class="sheet-label">Ce que j'apporte</span>
|
|
||||||
<p class="sheet-text">{{ selectedFiche.offre }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="sheet-tags" v-if="selectedFiche.hashtags.length">
|
|
||||||
<span v-for="t in selectedFiche.hashtags" :key="t" class="sheet-tag">#{{ t }}</span>
|
|
||||||
</div>
|
|
||||||
<NuxtLink :to="`/codev/fiche?id=${selectedFiche.id}`" class="sheet-edit-btn">Modifier cette fiche</NuxtLink>
|
|
||||||
<button class="sheet-close" @click="selectedFiche = null" type="button">Fermer</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { CodevFiche, CodevMatch } from '~/types/codev'
|
|
||||||
import { computeMatches } from '~/utils/codev/matching'
|
|
||||||
|
|
||||||
useHead({ title: 'Carto - Co-developpement' })
|
|
||||||
|
|
||||||
const { data, pending, refresh } = await useFetch<{ list: CodevFiche[] }>('/api/codev/fiches')
|
|
||||||
const fiches = computed(() => data.value?.list ?? [])
|
|
||||||
|
|
||||||
const matches = ref<CodevMatch[]>([])
|
|
||||||
const mode = ref<'none' | 'solution' | 'alliance' | 'surprise'>('none')
|
|
||||||
const showLabels = ref(false)
|
|
||||||
const tab = ref<'carto' | 'annuaire'>('carto')
|
|
||||||
const selectedFiche = ref<CodevFiche | null>(null)
|
|
||||||
const isMobileView = typeof window !== 'undefined' ? window.innerWidth < 600 : false
|
|
||||||
|
|
||||||
const isAdmin = ref(false)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
const r = await $fetch<{ admin: boolean }>('/api/codev/me')
|
|
||||||
isAdmin.value = r.admin
|
|
||||||
} catch { isAdmin.value = false }
|
|
||||||
})
|
|
||||||
|
|
||||||
const MODE_LABELS: Record<string, string> = {
|
|
||||||
solution: 'Solution',
|
|
||||||
alliance: 'Alliance',
|
|
||||||
surprise: 'Surprise',
|
|
||||||
}
|
|
||||||
|
|
||||||
function setMode(newMode: 'none' | 'solution' | 'alliance' | 'surprise') {
|
|
||||||
mode.value = newMode
|
|
||||||
if (newMode === 'none') {
|
|
||||||
matches.value = []
|
|
||||||
} else {
|
|
||||||
matches.value = computeMatches(fiches.value, newMode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSelectFiche(id: number) {
|
|
||||||
if (isMobileView) {
|
|
||||||
selectedFiche.value = fiches.value.find(f => f.id === id) ?? null
|
|
||||||
} else {
|
|
||||||
navigateTo(`/codev/fiche?id=${id}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteFiche(id: number) {
|
|
||||||
if (!confirm('Supprimer la fiche ?')) return
|
|
||||||
await $fetch(`/api/codev/fiches/${id}`, { method: 'DELETE' })
|
|
||||||
await refresh()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.codev-carto {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--nav-bg, #fafafa);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 1.25rem 1rem 2rem;
|
|
||||||
gap: 1rem;
|
|
||||||
max-width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── En-tete ── */
|
|
||||||
|
|
||||||
.carto-header {
|
|
||||||
text-align: center;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carto-header h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--nav-text, #1a1a2e);
|
|
||||||
margin: 0 0 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carto-subtitle {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--nav-text-muted, #6b7280);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Fallback ── */
|
|
||||||
|
|
||||||
.graph-fallback {
|
|
||||||
width: 100%;
|
|
||||||
height: 70vh;
|
|
||||||
min-height: 320px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--nav-text-muted, #6b7280);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
background: var(--nav-bg-alt, #f3f4f6);
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Bandeau mode actif ── */
|
|
||||||
|
|
||||||
.mode-banner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.5rem 0.875rem;
|
|
||||||
background: #f0fdf4;
|
|
||||||
border: 1px solid #bbf7d0;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #166534;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-clear {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #166534;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid #166534;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.2rem 0.6rem;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-clear:hover {
|
|
||||||
background: #166534;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Boutons matching ── */
|
|
||||||
|
|
||||||
.matching-controls {
|
|
||||||
position: sticky;
|
|
||||||
bottom: 0;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
border-top: 1px solid #e5e7eb;
|
|
||||||
margin: 0 -1rem -2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button {
|
|
||||||
flex: 1;
|
|
||||||
padding: 12px 8px;
|
|
||||||
border: 1px solid #d0d4dc;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: white;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button .hint {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #6b7280;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button.active {
|
|
||||||
background: var(--mode-color, #1B4436);
|
|
||||||
color: white;
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button.active .hint {
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button.reset {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: #f3f4f6;
|
|
||||||
border-color: #d0d4dc;
|
|
||||||
color: #374151;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button.reset:hover {
|
|
||||||
background: #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 500px) {
|
|
||||||
.matching-controls {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
margin: 0 -0.75rem -1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button.reset {
|
|
||||||
grid-column: span 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Toggle besoins/offres ── */
|
|
||||||
|
|
||||||
.show-labels-bar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.show-labels-bar button {
|
|
||||||
border: 1px solid #d0d4dc;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: white;
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: #374151;
|
|
||||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.show-labels-bar button.active {
|
|
||||||
background: #1B4436;
|
|
||||||
color: white;
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── FAB ajouter ── */
|
|
||||||
|
|
||||||
.fab-add {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 80px;
|
|
||||||
right: 16px;
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #1B4436;
|
|
||||||
color: white;
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 300;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
text-decoration: none;
|
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
|
|
||||||
z-index: 100;
|
|
||||||
transition: transform 0.15s, opacity 0.15s;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fab-add:hover {
|
|
||||||
transform: scale(1.08);
|
|
||||||
opacity: 0.92;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Tabs ── */
|
|
||||||
|
|
||||||
.codev-tabs { display: flex; gap: 4px; background: #f3f4f6; border-radius: 10px; padding: 4px; }
|
|
||||||
.codev-tabs button { flex: 1; padding: 8px 4px; border: none; border-radius: 7px; background: transparent; font-size: 0.875rem; font-weight: 500; cursor: pointer; color: #6b7280; transition: all 0.15s; }
|
|
||||||
.codev-tabs button.active { background: white; color: #1a1a2e; font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
|
||||||
|
|
||||||
/* ── List view ── */
|
|
||||||
|
|
||||||
.list-view { display: flex; flex-direction: column; gap: 12px; padding: 8px 0; }
|
|
||||||
.list-card { background: white; border: 1px solid #e5e7eb; border-radius: 10px; padding: 14px 16px; display: flex; flex-direction: column; gap: 6px; }
|
|
||||||
.list-card-name { font-weight: 700; font-size: 0.95rem; color: #1a1a2e; }
|
|
||||||
.list-card-text { font-size: 0.875rem; color: #4b5563; margin: 0; line-height: 1.5; }
|
|
||||||
.list-card-link { font-size: 0.8rem; color: #1B4436; text-decoration: none; align-self: flex-end; }
|
|
||||||
.list-empty { text-align: center; color: #6b7280; font-size: 0.9rem; }
|
|
||||||
|
|
||||||
/* ── Bottom sheet ── */
|
|
||||||
|
|
||||||
.bottom-sheet { position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 200; display: flex; align-items: flex-end; }
|
|
||||||
.sheet-content { background: white; border-radius: 16px 16px 0 0; padding: 16px 20px 32px; width: 100%; display: flex; flex-direction: column; gap: 12px; max-height: 80vh; overflow-y: auto; }
|
|
||||||
.sheet-handle { width: 36px; height: 4px; background: #d1d5db; border-radius: 2px; align-self: center; margin-bottom: 4px; }
|
|
||||||
.sheet-name { font-size: 1.1rem; font-weight: 700; color: #1a1a2e; }
|
|
||||||
.sheet-section { display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.sheet-label { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; }
|
|
||||||
.sheet-text { font-size: 0.9rem; color: #374151; margin: 0; line-height: 1.5; }
|
|
||||||
.sheet-tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
||||||
.sheet-tag { font-size: 0.75rem; background: #f3f4f6; color: #374151; padding: 2px 8px; border-radius: 12px; }
|
|
||||||
.sheet-edit-btn { display: block; text-align: center; background: #1B4436; color: white; border-radius: 8px; padding: 12px; text-decoration: none; font-weight: 600; }
|
|
||||||
.sheet-close { background: transparent; border: 1px solid #d1d5db; border-radius: 8px; padding: 10px; color: #6b7280; cursor: pointer; font-size: 0.875rem; }
|
|
||||||
.sheet-enter-active, .sheet-leave-active { transition: opacity 0.2s; }
|
|
||||||
.sheet-enter-from, .sheet-leave-to { opacity: 0; }
|
|
||||||
|
|
||||||
/* ── QR link ── */
|
|
||||||
|
|
||||||
.qr-link {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #9ca3af;
|
|
||||||
text-decoration: none;
|
|
||||||
align-self: flex-end;
|
|
||||||
}
|
|
||||||
.qr-link:hover { color: #6b7280; }
|
|
||||||
|
|
||||||
/* ── Annuaire ── */
|
|
||||||
|
|
||||||
.annuaire-wrap {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.annuaire-scroll {
|
|
||||||
overflow-x: auto;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.annuaire-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
min-width: 480px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.annuaire-table thead tr {
|
|
||||||
background: #f9fafb;
|
|
||||||
border-bottom: 2px solid #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.annuaire-table th {
|
|
||||||
padding: 10px 14px;
|
|
||||||
text-align: left;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: #6b7280;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.annuaire-table td {
|
|
||||||
padding: 12px 14px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #374151;
|
|
||||||
vertical-align: top;
|
|
||||||
border-bottom: 1px solid #f3f4f6;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.annuaire-row {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.12s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.annuaire-row:hover { background: #f9fafb; }
|
|
||||||
.annuaire-row:last-child td { border-bottom: none; }
|
|
||||||
|
|
||||||
.col-nom {
|
|
||||||
position: sticky;
|
|
||||||
left: 0;
|
|
||||||
z-index: 2;
|
|
||||||
background: #ffffff;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1a1a2e !important;
|
|
||||||
white-space: nowrap;
|
|
||||||
min-width: 80px;
|
|
||||||
border-right: 2px solid #e5e7eb;
|
|
||||||
box-shadow: 2px 0 6px rgba(0,0,0,0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.annuaire-row:hover .col-nom { background: #f9fafb; }
|
|
||||||
thead tr .col-nom { background: #f9fafb; z-index: 3; }
|
|
||||||
|
|
||||||
.col-besoin { min-width: 200px; max-width: 260px; }
|
|
||||||
.col-offre { min-width: 200px; max-width: 260px; }
|
|
||||||
|
|
||||||
.annuaire-hint {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #9ca3af;
|
|
||||||
text-align: center;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-actions { width: 40px; text-align: center; }
|
|
||||||
.delete-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: #ef4444;
|
|
||||||
font-size: 1rem;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
.delete-btn:hover { background: #fef2f2; }
|
|
||||||
|
|
||||||
/* ── Mobile ── */
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.codev-carto {
|
|
||||||
padding: 1rem 0.75rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carto-header h1 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,383 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="codev-demo">
|
|
||||||
|
|
||||||
<header class="demo-header">
|
|
||||||
<span class="demo-badge">DEMO</span>
|
|
||||||
<h1>Co-developpement - exemple</h1>
|
|
||||||
<p class="subtitle">10 personnes fictives. Clique sur un mode pour voir les matchs.</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="codev-tabs">
|
|
||||||
<button :class="{ active: tab === 'carto' }" @click="tab = 'carto'" type="button">Carto</button>
|
|
||||||
<button :class="{ active: tab === 'annuaire' }" @click="tab = 'annuaire'" type="button">Annuaire</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="tab === 'carto'">
|
|
||||||
<ClientOnly>
|
|
||||||
<CodevGraph
|
|
||||||
:fiches="fiches"
|
|
||||||
:matches="matches"
|
|
||||||
:mode="mode"
|
|
||||||
/>
|
|
||||||
<template #fallback>
|
|
||||||
<div class="graph-fallback">Chargement du graphe...</div>
|
|
||||||
</template>
|
|
||||||
</ClientOnly>
|
|
||||||
|
|
||||||
<!-- Bandeau info mode actif -->
|
|
||||||
<div v-if="mode !== 'none'" class="mode-banner">
|
|
||||||
<span>
|
|
||||||
Mode {{ MODE_LABELS[mode] }} actif -
|
|
||||||
{{ matches.length }} connexion{{ matches.length !== 1 ? 's' : '' }} trouvee{{ matches.length !== 1 ? 's' : '' }}.
|
|
||||||
</span>
|
|
||||||
<button class="banner-clear" @click="setMode('none')" type="button">Effacer</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Boutons matching -->
|
|
||||||
<div class="matching-controls">
|
|
||||||
<button
|
|
||||||
:class="{ active: mode === 'solution' }"
|
|
||||||
style="--mode-color: #22c55e"
|
|
||||||
@click="setMode('solution')"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Solution
|
|
||||||
<span class="hint">besoin - offre</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="{ active: mode === 'alliance' }"
|
|
||||||
style="--mode-color: #f97316"
|
|
||||||
@click="setMode('alliance')"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Alliance
|
|
||||||
<span class="hint">besoins partages</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="mode !== 'none'"
|
|
||||||
class="reset"
|
|
||||||
@click="setMode('none')"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Effacer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="tab === 'annuaire'" class="annuaire-wrap">
|
|
||||||
<div class="annuaire-scroll">
|
|
||||||
<table class="annuaire-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="col-nom">Prénom</th>
|
|
||||||
<th class="col-besoin">Besoin</th>
|
|
||||||
<th class="col-offre">Ce que j'offre</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="f in fiches" :key="f.id" class="annuaire-row">
|
|
||||||
<td class="col-nom">{{ f.nom }}</td>
|
|
||||||
<td class="col-besoin">{{ f.besoin }}</td>
|
|
||||||
<td class="col-offre">{{ f.offre }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { CodevFiche, CodevMatch } from '~/types/codev'
|
|
||||||
import { computeMatches } from '~/utils/codev/matching'
|
|
||||||
|
|
||||||
const tab = ref<'carto' | 'annuaire'>('carto')
|
|
||||||
|
|
||||||
// 10 fiches sans hashtags — textes enrichis pour que scoreDirect discrimine bien les 3 modes :
|
|
||||||
//
|
|
||||||
// Solution (scoreDirect besoinA vs offreB) :
|
|
||||||
// Sami(besoin vendre formation) -> Ines(offre vente formations) ✓
|
|
||||||
// Nael(besoin site web formation) -> Sami(offre developpement web) ✓
|
|
||||||
// Eva(besoin coaching vente) -> Ines(offre vente formations) ✓
|
|
||||||
// Tom(besoin tiers-lieu) -> Zoe(offre facilitation tiers-lieux) ✓
|
|
||||||
//
|
|
||||||
// Alliance (besoins similaires) :
|
|
||||||
// Lea + Maya (coaching, lancer, offre) ✓
|
|
||||||
// Tom + Zoe (tiers-lieu, co-creer) ✓
|
|
||||||
// Sami + Kenji (vendre, formations) ✓
|
|
||||||
//
|
|
||||||
// Surprise (offres similaires) :
|
|
||||||
// Lea + Zoe (facilitation, groupes) ✓
|
|
||||||
// Tom + Roman (architecture) ✓
|
|
||||||
// Ines + Nael (marketing, formations) ✓
|
|
||||||
|
|
||||||
const FICHES_DEMO: CodevFiche[] = [
|
|
||||||
{
|
|
||||||
id: 1, nom: 'Lea',
|
|
||||||
besoin: 'Structurer et lancer mon offre de coaching professionnel cet automne',
|
|
||||||
offre: 'Facilitation de groupes et animation de cercles de parole',
|
|
||||||
hashtags: [],
|
|
||||||
created_at: '2026-05-08T10:00:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2, nom: 'Sami',
|
|
||||||
besoin: 'Vendre ma formation en ligne et attirer mes premiers clients',
|
|
||||||
offre: 'Developpement web sur mesure, creation de sites et applications',
|
|
||||||
hashtags: [],
|
|
||||||
created_at: '2026-05-08T10:01:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3, nom: 'Ines',
|
|
||||||
besoin: 'Ameliorer la facilitation de mes ateliers collaboratifs',
|
|
||||||
offre: 'Vente de formations en ligne et marketing pour formateurs',
|
|
||||||
hashtags: [],
|
|
||||||
created_at: '2026-05-08T10:02:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4, nom: 'Tom',
|
|
||||||
besoin: 'Trouver des associes pour co-creer un tiers-lieu rural',
|
|
||||||
offre: 'Architecture bioclimatique et eco-construction pour tiers-lieux',
|
|
||||||
hashtags: [],
|
|
||||||
created_at: '2026-05-08T10:03:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5, nom: 'Maya',
|
|
||||||
besoin: 'Creer et lancer mon offre de coaching en transition professionnelle',
|
|
||||||
offre: 'Accompagnement coaching de carriere et transitions professionnelles',
|
|
||||||
hashtags: [],
|
|
||||||
created_at: '2026-05-08T10:04:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6, nom: 'Kenji',
|
|
||||||
besoin: 'Apprendre a vendre mes formations sans pression commerciale',
|
|
||||||
offre: 'Photographie professionnelle et direction artistique editoriale',
|
|
||||||
hashtags: [],
|
|
||||||
created_at: '2026-05-08T10:05:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 7, nom: 'Zoe',
|
|
||||||
besoin: 'Co-creer un tiers-lieu avec des porteurs de projet alignes',
|
|
||||||
offre: 'Facilitation de collectifs et animation en intelligence collective',
|
|
||||||
hashtags: [],
|
|
||||||
created_at: '2026-05-08T10:06:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 8, nom: 'Nael',
|
|
||||||
besoin: 'Creer un site web pour presenter et vendre ma formation',
|
|
||||||
offre: 'Strategie marketing digital et lancement de produits en ligne',
|
|
||||||
hashtags: [],
|
|
||||||
created_at: '2026-05-08T10:07:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 9, nom: 'Eva',
|
|
||||||
besoin: 'Lancer mon coaching avec une page de vente qui convertit',
|
|
||||||
offre: 'Ecriture longue forme, articles de fond et tribunes editoriales',
|
|
||||||
hashtags: [],
|
|
||||||
created_at: '2026-05-08T10:08:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 10, nom: 'Roman',
|
|
||||||
besoin: 'Ecrire de meilleurs articles pour mon blog et ma newsletter',
|
|
||||||
offre: 'Architecture technique et plans pour renovation energetique',
|
|
||||||
hashtags: [],
|
|
||||||
created_at: '2026-05-08T10:09:00Z',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const fiches = ref(FICHES_DEMO)
|
|
||||||
const matches = ref<CodevMatch[]>([])
|
|
||||||
const mode = ref<'none' | 'solution' | 'alliance' | 'surprise'>('none')
|
|
||||||
|
|
||||||
const MODE_LABELS: Record<string, string> = {
|
|
||||||
solution: 'Solution',
|
|
||||||
alliance: 'Alliance',
|
|
||||||
surprise: 'Surprise',
|
|
||||||
}
|
|
||||||
|
|
||||||
useHead({ title: 'Demo - Co-developpement' })
|
|
||||||
|
|
||||||
function setMode(newMode: typeof mode.value) {
|
|
||||||
mode.value = newMode
|
|
||||||
if (newMode === 'none') {
|
|
||||||
matches.value = []
|
|
||||||
} else {
|
|
||||||
matches.value = computeMatches(fiches.value, newMode, 0.12)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.codev-demo {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--nav-bg, #fafafa);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 1.25rem 1rem 2rem;
|
|
||||||
gap: 1rem;
|
|
||||||
max-width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── En-tete ── */
|
|
||||||
|
|
||||||
.demo-header {
|
|
||||||
text-align: center;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-badge {
|
|
||||||
display: inline-block;
|
|
||||||
background: #f97316;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-header h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--nav-text, #1a1a2e);
|
|
||||||
margin: 0 0 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--nav-text-muted, #6b7280);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Fallback ── */
|
|
||||||
|
|
||||||
.graph-fallback {
|
|
||||||
width: 100%;
|
|
||||||
height: 70vh;
|
|
||||||
min-height: 320px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--nav-text-muted, #6b7280);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
background: var(--nav-bg-alt, #f3f4f6);
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Bandeau mode actif ── */
|
|
||||||
|
|
||||||
.mode-banner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.5rem 0.875rem;
|
|
||||||
background: #f0fdf4;
|
|
||||||
border: 1px solid #bbf7d0;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #166534;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-clear {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #166534;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid #166534;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.2rem 0.6rem;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-clear:hover {
|
|
||||||
background: #166534;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Boutons matching ── */
|
|
||||||
|
|
||||||
.matching-controls {
|
|
||||||
position: sticky;
|
|
||||||
bottom: 0;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
border-top: 1px solid #e5e7eb;
|
|
||||||
margin: 0 -1rem -2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button {
|
|
||||||
flex: 1;
|
|
||||||
padding: 12px 8px;
|
|
||||||
border: 1px solid #d0d4dc;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: white;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button .hint {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #6b7280;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button.active {
|
|
||||||
background: var(--mode-color, #1B4436);
|
|
||||||
color: white;
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button.active .hint {
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button.reset {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: #f3f4f6;
|
|
||||||
border-color: #d0d4dc;
|
|
||||||
color: #374151;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button.reset:hover {
|
|
||||||
background: #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 500px) {
|
|
||||||
.matching-controls {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
margin: 0 -0.75rem -1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button.reset {
|
|
||||||
grid-column: span 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Mobile ── */
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.codev-demo {
|
|
||||||
padding: 1rem 0.75rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-header h1 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,415 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="fiche-page">
|
|
||||||
<div class="fiche-inner">
|
|
||||||
|
|
||||||
<!-- En-tête -->
|
|
||||||
<div class="fiche-header">
|
|
||||||
<NuxtLink to="/codev/carto" class="back-link">← Retour à la carte</NuxtLink>
|
|
||||||
<h1>{{ isEdit ? 'Modifier ma fiche' : 'Ma fiche' }}</h1>
|
|
||||||
<p class="fiche-lead">3 lignes pour te présenter. Le reste se passe entre nous.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Formulaire -->
|
|
||||||
<form class="fiche-form" @submit.prevent="submit" novalidate>
|
|
||||||
|
|
||||||
<!-- Nom -->
|
|
||||||
<div class="field-group">
|
|
||||||
<label for="nom">
|
|
||||||
Prénom <span class="required">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="nom"
|
|
||||||
v-model="form.nom"
|
|
||||||
type="text"
|
|
||||||
placeholder="Ex : Camille"
|
|
||||||
required
|
|
||||||
minlength="2"
|
|
||||||
maxlength="50"
|
|
||||||
:disabled="loading"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Besoin -->
|
|
||||||
<div class="field-group">
|
|
||||||
<div class="label-row">
|
|
||||||
<label for="besoin">
|
|
||||||
Mon besoin actuel <span class="required">*</span>
|
|
||||||
</label>
|
|
||||||
<button type="button" class="tooltip-trigger" @click="toggleTip('besoin')" aria-label="C'est quoi un besoin ?">?</button>
|
|
||||||
</div>
|
|
||||||
<details v-if="activeTip === 'besoin'" class="tooltip-block" open>
|
|
||||||
<summary class="sr-only">Aide</summary>
|
|
||||||
<p>Un besoin, c'est ce qui te manque pour avancer. Ca peut etre concret (un coup de main sur un dossier) ou plus large (clarifier ou tu vas). Pas grave si c'est flou - la rencontre IRL aide a le preciser.</p>
|
|
||||||
</details>
|
|
||||||
<textarea
|
|
||||||
id="besoin"
|
|
||||||
v-model="form.besoin"
|
|
||||||
rows="3"
|
|
||||||
placeholder="Ex : J'ai besoin d'aide pour structurer mon offre de prestation"
|
|
||||||
required
|
|
||||||
minlength="5"
|
|
||||||
maxlength="300"
|
|
||||||
:disabled="loading"
|
|
||||||
/>
|
|
||||||
<span class="char-count" :class="{ 'char-warn': form.besoin.length > 260 }">
|
|
||||||
{{ form.besoin.length }}/300
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Offre -->
|
|
||||||
<div class="field-group">
|
|
||||||
<div class="label-row">
|
|
||||||
<label for="offre">
|
|
||||||
Ce que j'offre a la communaute <span class="required">*</span>
|
|
||||||
</label>
|
|
||||||
<button type="button" class="tooltip-trigger" @click="toggleTip('offre')" aria-label="C'est quoi une offre ?">?</button>
|
|
||||||
</div>
|
|
||||||
<details v-if="activeTip === 'offre'" class="tooltip-block" open>
|
|
||||||
<summary class="sr-only">Aide</summary>
|
|
||||||
<p>Une offre, c'est une competence, une experience ou une qualite que tu peux partager. Ce que les autres viennent chercher chez toi naturellement.</p>
|
|
||||||
</details>
|
|
||||||
<textarea
|
|
||||||
id="offre"
|
|
||||||
v-model="form.offre"
|
|
||||||
rows="3"
|
|
||||||
placeholder="Ex : Je peux partager mon expérience en facilitation de groupe"
|
|
||||||
required
|
|
||||||
minlength="5"
|
|
||||||
maxlength="300"
|
|
||||||
:disabled="loading"
|
|
||||||
/>
|
|
||||||
<span class="char-count" :class="{ 'char-warn': form.offre.length > 260 }">
|
|
||||||
{{ form.offre.length }}/300
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hashtags -->
|
|
||||||
<div class="field-group">
|
|
||||||
<label for="hashtags">
|
|
||||||
Mots-clés
|
|
||||||
<span class="label-hint">(optionnel, 3 max, séparés par des virgules)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="hashtags"
|
|
||||||
v-model="form.hashtagsRaw"
|
|
||||||
type="text"
|
|
||||||
placeholder="Ex : business, écriture, écologie"
|
|
||||||
maxlength="120"
|
|
||||||
:disabled="loading"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Erreur serveur -->
|
|
||||||
<div v-if="error" class="server-error" role="alert">
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bouton -->
|
|
||||||
<button type="submit" class="submit-btn" :disabled="loading">
|
|
||||||
{{ isEdit ? (loading ? 'Modification...' : 'Enregistrer les modifications') : (loading ? 'Envoi en cours...' : 'Ajouter ma fiche') }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<NuxtLink to="/codev/carto" class="skip-link">
|
|
||||||
Voir la carte sans créer de fiche →
|
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
</form>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const route = useRoute()
|
|
||||||
const editId = computed(() => route.query.id ? Number(route.query.id) : null)
|
|
||||||
const isEdit = computed(() => editId.value !== null)
|
|
||||||
|
|
||||||
const form = ref({ nom: '', besoin: '', offre: '', hashtagsRaw: '' })
|
|
||||||
const error = ref('')
|
|
||||||
const loading = ref(false)
|
|
||||||
const activeTip = ref<'besoin' | 'offre' | null>(null)
|
|
||||||
|
|
||||||
useHead({ title: computed(() => isEdit.value ? 'Modifier ma fiche — Co-développement' : 'Ma fiche — Co-développement') })
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (!isEdit.value) return
|
|
||||||
try {
|
|
||||||
const fiche = await $fetch<any>(`/api/codev/fiches/${editId.value}`)
|
|
||||||
form.value.nom = fiche.nom
|
|
||||||
form.value.besoin = fiche.besoin
|
|
||||||
form.value.offre = fiche.offre
|
|
||||||
form.value.hashtagsRaw = fiche.hashtags.join(', ')
|
|
||||||
} catch {
|
|
||||||
error.value = 'Impossible de charger la fiche, elle a peut-etre ete supprimee.'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function toggleTip(field: 'besoin' | 'offre') {
|
|
||||||
activeTip.value = activeTip.value === field ? null : field
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
error.value = ''
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const hashtags = form.value.hashtagsRaw
|
|
||||||
.split(',')
|
|
||||||
.map((h) => h.trim().toLowerCase().replace(/^#/, ''))
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 3)
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
nom: form.value.nom,
|
|
||||||
besoin: form.value.besoin,
|
|
||||||
offre: form.value.offre,
|
|
||||||
hashtags,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEdit.value) {
|
|
||||||
await $fetch(`/api/codev/fiches/${editId.value}`, { method: 'PATCH', body: payload })
|
|
||||||
} else {
|
|
||||||
await $fetch('/api/codev/fiches', { method: 'POST', body: payload })
|
|
||||||
}
|
|
||||||
await navigateTo('/codev/carto')
|
|
||||||
} catch (e: any) {
|
|
||||||
error.value = e?.data?.message || e?.statusMessage || 'Erreur, reessaie'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* ── Layout ── */
|
|
||||||
|
|
||||||
.fiche-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--nav-bg, #fafafa);
|
|
||||||
padding: 1.5rem 1rem 4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fiche-inner {
|
|
||||||
max-width: 480px;
|
|
||||||
margin: 0 auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── En-tête ── */
|
|
||||||
|
|
||||||
.back-link {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--nav-text-muted, #6b7280);
|
|
||||||
text-decoration: none;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-link:hover {
|
|
||||||
color: var(--nav-primary-solid, #1B4436);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fiche-header h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--nav-text, #1a1a2e);
|
|
||||||
margin: 0 0 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fiche-lead {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--nav-text-muted, #6b7280);
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Formulaire ── */
|
|
||||||
|
|
||||||
.fiche-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Champ ── */
|
|
||||||
|
|
||||||
.field-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-group label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nav-text, #1a1a2e);
|
|
||||||
}
|
|
||||||
|
|
||||||
.label-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label-hint {
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--nav-text-muted, #6b7280);
|
|
||||||
margin-left: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.required {
|
|
||||||
color: #c0392b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-group input[type="text"],
|
|
||||||
.field-group input[type="password"],
|
|
||||||
.field-group textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem 0.875rem;
|
|
||||||
border: 1px solid var(--border-color, #d0d4dc);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--nav-text, #1a1a2e);
|
|
||||||
background: var(--nav-surface, #ffffff);
|
|
||||||
font-family: inherit;
|
|
||||||
transition: border-color 0.15s, box-shadow 0.15s;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-group input:focus,
|
|
||||||
.field-group textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--nav-primary-solid, #1B4436);
|
|
||||||
box-shadow: 0 0 0 2px rgba(27, 68, 54, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-group input:disabled,
|
|
||||||
.field-group textarea:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-group textarea {
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.char-count {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--nav-text-muted, #6b7280);
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.char-warn {
|
|
||||||
color: #e67e22;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Tooltip ── */
|
|
||||||
|
|
||||||
.tooltip-trigger {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
background: var(--nav-surface, #ffffff);
|
|
||||||
border: 1px solid var(--border-color, #d0d4dc);
|
|
||||||
border-radius: 50%;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--nav-text-muted, #6b7280);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
line-height: 1;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: border-color 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-trigger:hover {
|
|
||||||
border-color: var(--nav-primary-solid, #1B4436);
|
|
||||||
color: var(--nav-primary-solid, #1B4436);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-block {
|
|
||||||
background: var(--nav-surface, #ffffff);
|
|
||||||
border: 1px solid var(--border-color, #d0d4dc);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.75rem 0.875rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--nav-text-muted, #6b7280);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-block p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sr-only {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Erreur serveur ── */
|
|
||||||
|
|
||||||
.server-error {
|
|
||||||
padding: 0.75rem 0.875rem;
|
|
||||||
background: #fdf0ee;
|
|
||||||
border: 1px solid #e74c3c;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #c0392b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Bouton ── */
|
|
||||||
|
|
||||||
.submit-btn {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.875rem 1rem;
|
|
||||||
background: var(--nav-primary-solid, #1B4436);
|
|
||||||
color: #ffffff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit-btn:hover:not(:disabled) {
|
|
||||||
opacity: 0.88;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit-btn:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skip-link {
|
|
||||||
display: block;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.825rem;
|
|
||||||
color: var(--nav-text-muted, #9ca3af);
|
|
||||||
text-decoration: none;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
.skip-link:hover { color: var(--nav-text, #1a1a2e); }
|
|
||||||
|
|
||||||
/* ── Responsive ── */
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.fiche-page {
|
|
||||||
padding: 1.25rem 1rem 3rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="lock-page">
|
|
||||||
<div class="lock-inner">
|
|
||||||
|
|
||||||
<div class="lock-header">
|
|
||||||
<h1>Co-développement</h1>
|
|
||||||
<p class="lock-subtitle">Entraide entre pairs</p>
|
|
||||||
<p class="lock-intro">Cet espace est un cercle. Pour entrer, il y a un mot.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form class="lock-form" @submit.prevent="submit" novalidate>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
v-model="password"
|
|
||||||
type="password"
|
|
||||||
placeholder="Mot de passe"
|
|
||||||
autocomplete="current-password"
|
|
||||||
required
|
|
||||||
:disabled="loading"
|
|
||||||
class="lock-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="error" class="lock-error" role="alert">
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="lock-btn" :disabled="loading || !password">
|
|
||||||
{{ loading ? 'Vérification...' : 'Entrer' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="lock-footer">
|
|
||||||
<NuxtLink to="/codev/demo" class="demo-link">Voir l'exemple →</NuxtLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const password = ref('')
|
|
||||||
const error = ref('')
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
useHead({ title: 'Co-développement — Entraide entre pairs' })
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
error.value = ''
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
await $fetch('/api/codev/auth', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { password: password.value },
|
|
||||||
})
|
|
||||||
await navigateTo('/codev/fiche')
|
|
||||||
} catch (e: any) {
|
|
||||||
error.value = e?.statusMessage || 'Mauvais mot de passe'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* ── Layout ── */
|
|
||||||
|
|
||||||
.lock-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--nav-bg, #fafafa);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 1.5rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lock-inner {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── En-tête ── */
|
|
||||||
|
|
||||||
.lock-header {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lock-header h1 {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--nav-text, #1a1a2e);
|
|
||||||
margin: 0 0 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lock-subtitle {
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--nav-text-muted, #6b7280);
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lock-intro {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--nav-text-muted, #6b7280);
|
|
||||||
line-height: 1.5;
|
|
||||||
margin: 0;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Formulaire ── */
|
|
||||||
|
|
||||||
.lock-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lock-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.875rem 1rem;
|
|
||||||
border: 1px solid var(--border-color, #d0d4dc);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--nav-text, #1a1a2e);
|
|
||||||
background: var(--nav-surface, #ffffff);
|
|
||||||
font-family: inherit;
|
|
||||||
text-align: center;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
transition: border-color 0.15s, box-shadow 0.15s;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lock-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--nav-primary-solid, #1B4436);
|
|
||||||
box-shadow: 0 0 0 2px rgba(27, 68, 54, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.lock-input:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Erreur ── */
|
|
||||||
|
|
||||||
.lock-error {
|
|
||||||
padding: 0.625rem 0.875rem;
|
|
||||||
background: #fdf0ee;
|
|
||||||
border: 1px solid #e74c3c;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #c0392b;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Bouton ── */
|
|
||||||
|
|
||||||
.lock-btn {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.875rem 1rem;
|
|
||||||
background: var(--nav-primary-solid, #1B4436);
|
|
||||||
color: #ffffff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lock-btn:hover:not(:disabled) {
|
|
||||||
opacity: 0.88;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lock-btn:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Pied de page ── */
|
|
||||||
|
|
||||||
.lock-footer {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-link {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--nav-text-muted, #6b7280);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-link:hover {
|
|
||||||
color: var(--nav-primary-solid, #1B4436);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Responsive ── */
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.lock-page {
|
|
||||||
padding: 1.25rem 1rem 2.5rem;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding-top: 3rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="qr-page">
|
|
||||||
<div class="qr-card">
|
|
||||||
<h1>Co-développement</h1>
|
|
||||||
<p class="qr-subtitle">Scanne pour rejoindre la session</p>
|
|
||||||
|
|
||||||
<img
|
|
||||||
:src="`https://api.qrserver.com/v1/create-qr-code/?size=280x280&data=${encodeURIComponent(APP_URL)}&bgcolor=ffffff&color=1B4436&margin=2`"
|
|
||||||
alt="QR code aep.trans-former.fr/codev"
|
|
||||||
class="qr-img"
|
|
||||||
width="280"
|
|
||||||
height="280"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p class="qr-url">{{ APP_URL }}</p>
|
|
||||||
<p class="qr-password">Mot de passe : <strong>merci</strong></p>
|
|
||||||
|
|
||||||
<a :href="`https://api.qrserver.com/v1/create-qr-code/?size=600x600&data=${encodeURIComponent(APP_URL)}&bgcolor=ffffff&color=1B4436&margin=2`"
|
|
||||||
download="codev-qr.png"
|
|
||||||
class="qr-download"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
Télécharger le QR code
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const APP_URL = 'https://aep.trans-former.fr/codev'
|
|
||||||
useHead({ title: 'QR Code — Co-développement' })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.qr-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--nav-bg, #fafafa);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 2rem 1rem;
|
|
||||||
}
|
|
||||||
.qr-card {
|
|
||||||
background: white;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 2rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
max-width: 360px;
|
|
||||||
width: 100%;
|
|
||||||
box-shadow: 0 4px 24px rgba(0,0,0,0.08);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.qr-card h1 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1a1a2e;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.qr-subtitle {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #6b7280;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.qr-img {
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 2px solid #e5e7eb;
|
|
||||||
}
|
|
||||||
.qr-url {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #9ca3af;
|
|
||||||
margin: 0;
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
.qr-password {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
color: #374151;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.qr-download {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 10px 20px;
|
|
||||||
background: #1B4436;
|
|
||||||
color: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
.qr-download:hover { opacity: 0.88; }
|
|
||||||
</style>
|
|
||||||
@@ -40,28 +40,10 @@
|
|||||||
Mode dev — données seed
|
Mode dev — données seed
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── VUE DESKTOP : Onglets Métropole / Outre-mer ── -->
|
<!-- ── VUE DESKTOP : Métropole pleine largeur + DOM-TOM row en bas ── -->
|
||||||
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
|
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
|
||||||
<!-- Barre onglets desktop -->
|
<!-- Carte Métropole — pleine largeur -->
|
||||||
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
<div class="flex flex-col flex-1 overflow-hidden">
|
||||||
<button
|
|
||||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
|
||||||
:style="desktopMapView === 'metropole'
|
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
||||||
@click="desktopMapView = 'metropole'"
|
|
||||||
>Métropolitain</button>
|
|
||||||
<button
|
|
||||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
|
||||||
:style="desktopMapView === 'outremer'
|
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
||||||
@click="desktopMapView = 'outremer'"
|
|
||||||
>Outre-mer</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Carte Métropole desktop -->
|
|
||||||
<div v-show="desktopMapView === 'metropole'" class="flex-1 flex flex-col overflow-hidden">
|
|
||||||
<div class="relative flex-1" style="min-height: 200px;">
|
<div class="relative flex-1" style="min-height: 200px;">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<NavMap
|
<NavMap
|
||||||
@@ -71,16 +53,23 @@
|
|||||||
@select-org="onSelectOrg"
|
@select-org="onSelectOrg"
|
||||||
/>
|
/>
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
<div class="w-full h-full flex items-center justify-center" style="background: var(--nav-bg-alt); color: var(--nav-text-muted);">Chargement de la carte…</div>
|
<div
|
||||||
|
class="w-full h-full flex items-center justify-center"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
>
|
||||||
|
Chargement de la carte…
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
|
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Carte Outre-mer desktop -->
|
<!-- Bandeau DOM-TOM — row horizontale pleine largeur, hauteur fixe -->
|
||||||
<div v-show="desktopMapView === 'outremer'" class="flex-1 flex flex-col overflow-hidden">
|
<div
|
||||||
<div class="flex-1 overflow-y-auto">
|
class="shrink-0"
|
||||||
|
style="height: 140px; border-top: 1px solid var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<OutremerMap
|
<OutremerMap
|
||||||
:orgs="outremerOrgs"
|
:orgs="outremerOrgs"
|
||||||
@@ -88,12 +77,15 @@
|
|||||||
@select-org="onSelectOrg"
|
@select-org="onSelectOrg"
|
||||||
/>
|
/>
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
<div class="flex items-center justify-center h-full text-sm" style="color: var(--nav-text-muted);">Chargement…</div>
|
<div
|
||||||
|
class="flex items-center justify-center h-full text-sm"
|
||||||
|
style="color: var(--nav-text-muted);"
|
||||||
|
>
|
||||||
|
Chargement…
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + carte pleine hauteur + sheet swipable ── -->
|
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + carte pleine hauteur + sheet swipable ── -->
|
||||||
@@ -338,7 +330,6 @@ const territoireMode = ref<string>(
|
|||||||
(route.query.mode as string) === 'outremer' ? 'outremer' : 'metropole'
|
(route.query.mode as string) === 'outremer' ? 'outremer' : 'metropole'
|
||||||
)
|
)
|
||||||
|
|
||||||
const desktopMapView = ref<'metropole' | 'outremer'>('metropole')
|
|
||||||
const selectedId = ref<number | null>(null)
|
const selectedId = ref<number | null>(null)
|
||||||
const chatbotOpen = ref(false)
|
const chatbotOpen = ref(false)
|
||||||
const ficheModalOpen = ref(false)
|
const ficheModalOpen = ref(false)
|
||||||
|
|||||||
170
pages/pratique/[id].vue
Normal file
170
pages/pratique/[id].vue
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen" style="background: var(--nav-bg);">
|
||||||
|
<div class="max-w-4xl mx-auto px-4 py-6">
|
||||||
|
|
||||||
|
<!-- ── Bouton retour carte (préserve filtres URL) ─── -->
|
||||||
|
<NuxtLink
|
||||||
|
:to="retourUrl"
|
||||||
|
class="inline-flex items-center gap-1.5 text-sm mb-6 rounded-lg px-3 py-1.5 transition-colors"
|
||||||
|
style="color: var(--nav-text); background: var(--nav-bg-alt);"
|
||||||
|
aria-label="Retour aux pratiques régénératives"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<line x1="19" y1="12" x2="5" y2="12"/>
|
||||||
|
<polyline points="12 19 5 12 12 5"/>
|
||||||
|
</svg>
|
||||||
|
Retour aux pratiques régénératives
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<!-- ── Chargement ──────────────────────────────────── -->
|
||||||
|
<div v-if="pending" class="py-16 text-center text-sm" style="color: var(--nav-text-muted);">
|
||||||
|
Chargement de la fiche…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Erreur ──────────────────────────────────────── -->
|
||||||
|
<div v-else-if="!pratique" class="py-16 text-center">
|
||||||
|
<p class="text-lg font-semibold mb-2" style="color: var(--nav-text);">Fiche introuvable</p>
|
||||||
|
<p class="text-sm" style="color: var(--nav-text-muted);">La pratique demandée n'existe pas ou a été supprimée.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Contenu ─────────────────────────────────────── -->
|
||||||
|
<template v-else>
|
||||||
|
|
||||||
|
<!-- Header fiche -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-start justify-between gap-4 mb-2">
|
||||||
|
<h1 class="text-2xl font-bold leading-tight" style="color: var(--nav-text);">{{ pratique.nom }}</h1>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<span
|
||||||
|
class="px-2 py-1 rounded-full text-xs font-semibold uppercase tracking-wide"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
>{{ TYPES_ENTITE_LABELS[pratique.type] ?? pratique.type }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 flex-wrap mb-3">
|
||||||
|
<span class="text-sm font-medium" style="color: var(--nav-text-muted);">
|
||||||
|
{{ PAYS_LABELS[pratique.pays] ?? pratique.pays }}
|
||||||
|
<template v-if="pratique.ville"> · {{ pratique.ville }}</template>
|
||||||
|
</span>
|
||||||
|
<span v-if="pratique.score" class="px-2 py-0.5 rounded text-xs" style="background: var(--nav-accent); color: var(--nav-text);">
|
||||||
|
Score {{ pratique.score }}/5
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
v-if="pratique.url"
|
||||||
|
:href="pratique.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-sm underline"
|
||||||
|
style="color: var(--nav-primary-solid);"
|
||||||
|
>Site web →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<p v-if="pratique.description" class="text-sm leading-relaxed" style="color: var(--nav-text);">
|
||||||
|
{{ pratique.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Séparateur -->
|
||||||
|
<div class="mb-6" style="height: 1px; background: var(--nav-bg-alt);"></div>
|
||||||
|
|
||||||
|
<!-- Critères régénératifs -->
|
||||||
|
<div v-if="pratique.criteres?.length" class="mb-6">
|
||||||
|
<h2 class="text-xs font-bold uppercase tracking-wide mb-3" style="color: var(--nav-text-muted);">Critères régénératifs</h2>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
v-for="cId in pratique.criteres"
|
||||||
|
:key="cId"
|
||||||
|
class="px-3 py-1 rounded-full text-sm font-medium"
|
||||||
|
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
|
||||||
|
>
|
||||||
|
{{ CRITERES.find(c => c.id === cId)?.label ?? `Critère ${cId}` }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div v-if="pratique.tags?.length" class="mb-6">
|
||||||
|
<h2 class="text-xs font-bold uppercase tracking-wide mb-3" style="color: var(--nav-text-muted);">Tags</h2>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
v-for="tag in pratique.tags"
|
||||||
|
:key="tag"
|
||||||
|
class="px-2 py-0.5 rounded text-xs"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
>{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Métadonnées -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="text-xs font-bold uppercase tracking-wide mb-3" style="color: var(--nav-text-muted);">Informations</h2>
|
||||||
|
<dl class="space-y-1.5">
|
||||||
|
<div v-if="pratique.passe" class="flex gap-2 text-sm">
|
||||||
|
<dt style="color: var(--nav-text-muted);">Passe :</dt>
|
||||||
|
<dd style="color: var(--nav-text);">{{ pratique.passe }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="pratique.source" class="flex gap-2 text-sm">
|
||||||
|
<dt style="color: var(--nav-text-muted);">Source :</dt>
|
||||||
|
<dd style="color: var(--nav-text);">{{ pratique.source }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="pratique.lat != null && pratique.lng != null" class="flex gap-2 text-sm">
|
||||||
|
<dt style="color: var(--nav-text-muted);">Coordonnées :</dt>
|
||||||
|
<dd style="color: var(--nav-text);">{{ pratique.lat }}, {{ pratique.lng }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Pratique } from '~/types/pratique'
|
||||||
|
import { CRITERES, TYPES_ENTITE_LABELS, PAYS_LABELS } from '~/types/pratique'
|
||||||
|
|
||||||
|
// ── Params & route ────────────────────────────────────────────────────
|
||||||
|
const route = useRoute()
|
||||||
|
const pratiqueId = route.params.id as string
|
||||||
|
|
||||||
|
// ── Retour carte — préserve les filtres via sessionStorage ────────────
|
||||||
|
const retourUrl = ref('/pratiques-regeneratives')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const stored = sessionStorage.getItem('pratiques_back_filters')
|
||||||
|
if (stored) {
|
||||||
|
retourUrl.value = `/pratiques-regeneratives?${stored}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Fetch toutes les pratiques et trouver la bonne ───────────────────
|
||||||
|
const { data, pending } = await useFetch<{ list: Pratique[]; source: string }>('/api/pratiques', {
|
||||||
|
key: `pratiques-all`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const pratique = computed<Pratique | null>(() => {
|
||||||
|
const id = parseInt(pratiqueId, 10)
|
||||||
|
if (isNaN(id)) return null
|
||||||
|
return data.value?.list?.find(p => p.id === id) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── SEO dynamiques ────────────────────────────────────────────────────
|
||||||
|
useHead({
|
||||||
|
title: computed(() =>
|
||||||
|
pratique.value ? `${pratique.value.nom} — Pratiques régénératives — AEP` : 'Pratique régénérative — AEP'
|
||||||
|
),
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
name: 'description',
|
||||||
|
content: computed(() =>
|
||||||
|
pratique.value?.description?.substring(0, 160).trim() ?? 'Pratique régénérative — AEP'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
</script>
|
||||||
469
pages/pratiques-regeneratives.vue
Normal file
469
pages/pratiques-regeneratives.vue
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════ SIDEBAR DESKTOP (≥ 1024px) -->
|
||||||
|
<div class="hidden lg:flex w-80 shrink-0 flex-col overflow-hidden">
|
||||||
|
<PratiqueSidebar
|
||||||
|
:search="search"
|
||||||
|
:criteres="criteres"
|
||||||
|
:typesEntite="typesEntite"
|
||||||
|
:critereCount="critereCount"
|
||||||
|
:typeCount="typeCount"
|
||||||
|
:resultCount="filtered.length"
|
||||||
|
:pratiques="filtered"
|
||||||
|
:selectedId="selectedId"
|
||||||
|
:hasActiveFilters="hasActiveFilters"
|
||||||
|
:pending="pending"
|
||||||
|
@update:search="onSearch"
|
||||||
|
@update:criteres="onCriteres"
|
||||||
|
@update:typesEntite="onTypesEntite"
|
||||||
|
@select-pratique="onSelectPratique"
|
||||||
|
@hover-pratique="onHoverPratique"
|
||||||
|
@reset-filters="resetFilters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════ ZONE CENTRALE (carte) -->
|
||||||
|
<main class="flex-1 flex flex-col overflow-hidden relative">
|
||||||
|
|
||||||
|
<!-- ── VUE DESKTOP : Europe pleine largeur + DOM-TOM row en bas ── -->
|
||||||
|
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
|
||||||
|
<!-- Carte Europe — pleine largeur -->
|
||||||
|
<div class="flex flex-col flex-1 overflow-hidden">
|
||||||
|
<div class="relative flex-1" style="min-height: 200px;">
|
||||||
|
<ClientOnly>
|
||||||
|
<EuropeMap
|
||||||
|
ref="europeMapRef"
|
||||||
|
:orgs="europeOrgs"
|
||||||
|
:selectedId="selectedId"
|
||||||
|
@select-org="onSelectPratique"
|
||||||
|
/>
|
||||||
|
<template #fallback>
|
||||||
|
<div
|
||||||
|
class="w-full h-full flex items-center justify-center"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
>
|
||||||
|
Chargement de la carte…
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bandeau DOM-TOM — row horizontale pleine largeur, hauteur fixe -->
|
||||||
|
<div
|
||||||
|
class="shrink-0"
|
||||||
|
style="height: 140px; border-top: 1px solid var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
|
<ClientOnly>
|
||||||
|
<OutremerMapPratiques
|
||||||
|
:orgs="outremerOrgs"
|
||||||
|
:selectedId="selectedId"
|
||||||
|
@select-org="onSelectPratique"
|
||||||
|
/>
|
||||||
|
<template #fallback>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center h-full text-sm"
|
||||||
|
style="color: var(--nav-text-muted);"
|
||||||
|
>
|
||||||
|
Chargement…
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── VUE MOBILE : Onglets Europe/Outre-mer + carte pleine hauteur + sheet swipable ── -->
|
||||||
|
|
||||||
|
<!-- Onglets Europe / Outre-mer -->
|
||||||
|
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
||||||
|
<button
|
||||||
|
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||||
|
:style="mobileMapView === 'europe'
|
||||||
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
|
@click="mobileMapView = 'europe'"
|
||||||
|
>Europe</button>
|
||||||
|
<button
|
||||||
|
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||||
|
:style="mobileMapView === 'outremer'
|
||||||
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
|
@click="mobileMapView = 'outremer'"
|
||||||
|
>Outre-mer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:hidden flex-1 relative overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Carte Europe -->
|
||||||
|
<div v-show="mobileMapView === 'europe'" class="absolute inset-0">
|
||||||
|
<ClientOnly>
|
||||||
|
<EuropeMap
|
||||||
|
ref="europeMapMobileRef"
|
||||||
|
:orgs="europeOrgs"
|
||||||
|
:selectedId="selectedId"
|
||||||
|
@select-org="onSelectPratiqueMobile"
|
||||||
|
/>
|
||||||
|
<template #fallback>
|
||||||
|
<div
|
||||||
|
class="w-full h-full flex items-center justify-center"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
>
|
||||||
|
Chargement de la carte…
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Carte Outre-mer -->
|
||||||
|
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
|
||||||
|
<ClientOnly>
|
||||||
|
<OutremerMapPratiques
|
||||||
|
:orgs="outremerOrgs"
|
||||||
|
:selectedId="selectedId"
|
||||||
|
@select-org="onSelectPratiqueMobile"
|
||||||
|
/>
|
||||||
|
<template #fallback>
|
||||||
|
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
|
||||||
|
Chargement…
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom sheet swipable (Europe et Outre-mer) -->
|
||||||
|
<ClientOnly>
|
||||||
|
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
||||||
|
<!-- Barre recherche -->
|
||||||
|
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
||||||
|
<label class="mobile-search-label" aria-label="Rechercher une pratique">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted); flex-shrink: 0;">
|
||||||
|
<circle cx="11" cy="11" r="8"/>
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
v-model="mobileSearch"
|
||||||
|
type="search"
|
||||||
|
placeholder="Rechercher…"
|
||||||
|
class="mobile-search-input"
|
||||||
|
autocomplete="off"
|
||||||
|
@input="onSearch(mobileSearch)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="mobileSearch"
|
||||||
|
type="button"
|
||||||
|
class="mobile-search-clear"
|
||||||
|
aria-label="Effacer"
|
||||||
|
@click.stop="mobileSearch = ''; onSearch('')"
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Filtres CRITÈRES — chips -->
|
||||||
|
<div class="mt-2">
|
||||||
|
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">CRITÈRES</span>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="c in CRITERES"
|
||||||
|
:key="c.id"
|
||||||
|
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
|
||||||
|
:style="criteres.includes(c.id)
|
||||||
|
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||||
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||||
|
@click="toggleCritere(c.id)"
|
||||||
|
>{{ c.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtres TYPE — chips -->
|
||||||
|
<div class="mt-2">
|
||||||
|
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">TYPE</span>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="t in TYPES_ENTITE"
|
||||||
|
:key="t"
|
||||||
|
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
|
||||||
|
:style="typesEntite.includes(t)
|
||||||
|
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||||
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||||
|
@click="toggleType(t)"
|
||||||
|
>{{ TYPES_ENTITE_LABELS[t] ?? t }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="hasActiveFilters"
|
||||||
|
@click="resetFilters"
|
||||||
|
class="mt-2 text-xs"
|
||||||
|
style="color: var(--nav-text-muted); text-decoration: underline;"
|
||||||
|
>Effacer les filtres</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compteur + Liste fiches -->
|
||||||
|
<div class="px-3 py-2">
|
||||||
|
<div class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">
|
||||||
|
{{ filtered.length }} résultat{{ filtered.length > 1 ? 's' : '' }}
|
||||||
|
</div>
|
||||||
|
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
|
||||||
|
Chargement des fiches…
|
||||||
|
</div>
|
||||||
|
<div v-else-if="filtered.length === 0" class="text-center py-8">
|
||||||
|
<p class="text-sm mb-2" style="color: var(--nav-text-muted);">Aucun résultat pour ces filtres.</p>
|
||||||
|
<button @click="resetFilters" class="text-sm underline" style="color: var(--nav-primary-solid);">
|
||||||
|
Effacer les filtres
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="pratique in filtered"
|
||||||
|
:key="pratique.id"
|
||||||
|
class="block rounded-lg p-3 transition-all cursor-pointer"
|
||||||
|
:style="selectedId === pratique.id
|
||||||
|
? 'background: var(--nav-bg-alt); border-left: 3px solid var(--nav-accent);'
|
||||||
|
: 'background: var(--nav-surface); border-left: 3px solid transparent;'"
|
||||||
|
@click="onSelectPratiqueMobile(pratique.id)"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ pratique.nom }}</span>
|
||||||
|
<span
|
||||||
|
v-if="pratique.pays"
|
||||||
|
class="shrink-0 px-2 py-0.5 rounded-full text-xs font-medium"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
>{{ pratique.pays }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="pratique.criteres?.length" class="mt-1 flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="cId in pratique.criteres.slice(0, 3)"
|
||||||
|
:key="cId"
|
||||||
|
class="px-1.5 py-0.5 rounded text-xs"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
>{{ CRITERES.find(c => c.id === cId)?.label }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="pratique.ville" class="mt-1 text-xs" style="color: var(--nav-text-muted);">
|
||||||
|
{{ pratique.ville }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MobileSheet>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) — désactivé V1 -->
|
||||||
|
<button
|
||||||
|
v-if="false"
|
||||||
|
class="lg:hidden fixed bottom-6 right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
|
||||||
|
style="
|
||||||
|
height: 48px;
|
||||||
|
background: var(--nav-primary);
|
||||||
|
opacity: 0.5;
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
|
||||||
|
font-family: var(--nav-font);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
"
|
||||||
|
aria-label="Chatbot (bientôt disponible)"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
<span>Chatbot</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Pratique } from '~/types/pratique'
|
||||||
|
import { CRITERES, TYPES_ENTITE, TYPES_ENTITE_LABELS, EUROPE_CODES, OUTREMER_CODES } from '~/types/pratique'
|
||||||
|
|
||||||
|
// ── URL query params sync ─────────────────────────────────────────────────
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const search = ref<string>((route.query.q as string) ?? '')
|
||||||
|
const criteres = ref<number[]>(
|
||||||
|
route.query.criteres
|
||||||
|
? (route.query.criteres as string).split(',').map(Number).filter(Boolean)
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
const typesEntite = ref<string[]>(
|
||||||
|
route.query.types
|
||||||
|
? (route.query.types as string).split(',').filter(Boolean)
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
const pays = ref<string[]>(
|
||||||
|
route.query.pays
|
||||||
|
? (route.query.pays as string).split(',').filter(Boolean)
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedId = ref<number | null>(null)
|
||||||
|
const mobileMapView = ref<'europe' | 'outremer'>('europe')
|
||||||
|
|
||||||
|
// Refs vers les instances EuropeMap
|
||||||
|
const europeMapRef = ref<any>(null)
|
||||||
|
const europeMapMobileRef = ref<any>(null)
|
||||||
|
|
||||||
|
// Ref locale barre de recherche mobile
|
||||||
|
const mobileSearch = ref<string>((route.query.q as string) ?? '')
|
||||||
|
|
||||||
|
// Sync URL <-> état filtres
|
||||||
|
function syncUrl() {
|
||||||
|
const q: Record<string, string> = {}
|
||||||
|
if (search.value) q.q = search.value
|
||||||
|
if (criteres.value.length) q.criteres = criteres.value.join(',')
|
||||||
|
if (typesEntite.value.length) q.types = typesEntite.value.join(',')
|
||||||
|
if (pays.value.length) q.pays = pays.value.join(',')
|
||||||
|
router.replace({ query: Object.keys(q).length ? q : undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sauvegarde filtres pour bouton retour des fiches
|
||||||
|
function storeFiltersForBack() {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
const q: Record<string, string> = {}
|
||||||
|
if (search.value) q.q = search.value
|
||||||
|
if (criteres.value.length) q.criteres = criteres.value.join(',')
|
||||||
|
if (typesEntite.value.length) q.types = typesEntite.value.join(',')
|
||||||
|
if (pays.value.length) q.pays = pays.value.join(',')
|
||||||
|
const qs = new URLSearchParams(q).toString()
|
||||||
|
sessionStorage.setItem('pratiques_back_filters', qs)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearch(v: string) { search.value = v; syncUrl(); storeFiltersForBack() }
|
||||||
|
function onCriteres(v: number[]) { criteres.value = v; syncUrl(); storeFiltersForBack() }
|
||||||
|
function onTypesEntite(v: string[]) { typesEntite.value = v; syncUrl(); storeFiltersForBack() }
|
||||||
|
function onPays(v: string[]) { pays.value = v; syncUrl(); storeFiltersForBack() }
|
||||||
|
|
||||||
|
function onSelectPratique(id: number) {
|
||||||
|
selectedId.value = selectedId.value === id ? null : id
|
||||||
|
// Desktop : naviguer vers la fiche
|
||||||
|
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
|
||||||
|
storeFiltersForBack()
|
||||||
|
router.push(`/pratique/${id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectPratiqueMobile(id: number) {
|
||||||
|
selectedId.value = id
|
||||||
|
storeFiltersForBack()
|
||||||
|
router.push(`/pratique/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHoverPratique(id: number | null) {
|
||||||
|
if (id !== null) selectedId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasActiveFilters = computed(() =>
|
||||||
|
!!search.value || criteres.value.length > 0 || typesEntite.value.length > 0 || pays.value.length > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
search.value = ''
|
||||||
|
criteres.value = []
|
||||||
|
typesEntite.value = []
|
||||||
|
pays.value = []
|
||||||
|
router.replace({ query: undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCritere(id: number) {
|
||||||
|
if (criteres.value.includes(id)) {
|
||||||
|
onCriteres(criteres.value.filter(v => v !== id))
|
||||||
|
} else {
|
||||||
|
onCriteres([...criteres.value, id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleType(t: string) {
|
||||||
|
if (typesEntite.value.includes(t)) {
|
||||||
|
onTypesEntite(typesEntite.value.filter(v => v !== t))
|
||||||
|
} else {
|
||||||
|
onTypesEntite([...typesEntite.value, t])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync recherche depuis URL ?q=
|
||||||
|
watch(() => route.query.q, (v) => {
|
||||||
|
search.value = (v as string) ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Données ───────────────────────────────────────────────────────────────
|
||||||
|
const { data, pending, error: fetchError } = await useFetch<{ list: Pratique[]; source: string }>('/api/pratiques')
|
||||||
|
|
||||||
|
const pratiques = computed<Pratique[]>(() => data.value?.list ?? [])
|
||||||
|
|
||||||
|
// ── Filtrage côté client ──────────────────────────────────────────────────
|
||||||
|
const filtered = computed<Pratique[]>(() => {
|
||||||
|
let result = pratiques.value
|
||||||
|
|
||||||
|
if (search.value.trim()) {
|
||||||
|
const q = search.value.toLowerCase()
|
||||||
|
result = result.filter(
|
||||||
|
(o) =>
|
||||||
|
o.nom?.toLowerCase().includes(q) ||
|
||||||
|
o.ville?.toLowerCase().includes(q) ||
|
||||||
|
o.description?.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteres.value.length) {
|
||||||
|
result = result.filter((o) =>
|
||||||
|
criteres.value.some((cId) => o.criteres?.includes(cId))
|
||||||
|
)
|
||||||
|
// Tri par score pondéré : priorité au premier critère cliqué
|
||||||
|
const n = criteres.value.length
|
||||||
|
const score = (o: Pratique) =>
|
||||||
|
criteres.value.reduce((s, cId, i) => {
|
||||||
|
return s + (o.criteres?.includes(cId) ? (n - i) : 0)
|
||||||
|
}, 0)
|
||||||
|
result = [...result].sort((a, b) => score(b) - score(a))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typesEntite.value.length) {
|
||||||
|
result = result.filter((o) => o.type && typesEntite.value.includes(o.type))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pays.value.length) {
|
||||||
|
result = result.filter((o) => o.pays && pays.value.includes(o.pays))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// Séparation Europe / Outre-mer
|
||||||
|
const europeOrgs = computed<Pratique[]>(() =>
|
||||||
|
filtered.value.filter(o => !o.pays || (EUROPE_CODES as readonly string[]).includes(o.pays))
|
||||||
|
)
|
||||||
|
|
||||||
|
const outremerOrgs = computed<Pratique[]>(() =>
|
||||||
|
filtered.value.filter(o => o.pays && (OUTREMER_CODES as readonly string[]).includes(o.pays))
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Compteurs ─────────────────────────────────────────────────────────────
|
||||||
|
const critereCount = computed<Record<number, number>>(() => {
|
||||||
|
const counts: Record<number, number> = {}
|
||||||
|
CRITERES.forEach(c => { counts[c.id] = 0 })
|
||||||
|
pratiques.value.forEach(o => {
|
||||||
|
o.criteres?.forEach(cId => { counts[cId] = (counts[cId] ?? 0) + 1 })
|
||||||
|
})
|
||||||
|
return counts
|
||||||
|
})
|
||||||
|
|
||||||
|
const typeCount = computed<Record<string, number>>(() => {
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
TYPES_ENTITE.forEach(t => { counts[t] = 0 })
|
||||||
|
pratiques.value.forEach(o => {
|
||||||
|
if (o.type) counts[o.type] = (counts[o.type] ?? 0) + 1
|
||||||
|
})
|
||||||
|
return counts
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'AEP — Pratiques régénératives en Europe' })
|
||||||
|
</script>
|
||||||
833
pages/proposer-pratique.vue
Normal file
833
pages/proposer-pratique.vue
Normal file
@@ -0,0 +1,833 @@
|
|||||||
|
<template>
|
||||||
|
<div class="contribuer-page">
|
||||||
|
<div class="contribuer-inner">
|
||||||
|
<!-- Retour -->
|
||||||
|
<NuxtLink to="/pratiques-regeneratives" class="back-link">
|
||||||
|
← Retour à la carte
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<!-- En-tête -->
|
||||||
|
<div class="contribuer-header">
|
||||||
|
<h1>Proposer une pratique</h1>
|
||||||
|
<p class="contribuer-subtitle">
|
||||||
|
Tu connais une agence, un collectif ou un réseau qui incarne l'architecture régénérative ?
|
||||||
|
Soumets-le ici — Jules valide manuellement les nouvelles entrées.
|
||||||
|
</p>
|
||||||
|
<p class="contribuer-hint">
|
||||||
|
Si tu n'as pas le temps de tout remplir, laisse-nous juste le lien.
|
||||||
|
Mais une description de ta main, c'est toujours plus vivant.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message succès -->
|
||||||
|
<div v-if="success" class="success-block" role="status" aria-live="polite">
|
||||||
|
<div class="success-icon">✓</div>
|
||||||
|
<h2>Merci !</h2>
|
||||||
|
<p>Ta proposition est en attente de modération.</p>
|
||||||
|
<p class="success-detail">
|
||||||
|
Jules valide manuellement chaque entrée avant publication.
|
||||||
|
</p>
|
||||||
|
<button type="button" class="btn-secondary" @click="reset">
|
||||||
|
Proposer une autre pratique
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formulaire -->
|
||||||
|
<form v-else @submit.prevent="submit" class="contribuer-form" novalidate>
|
||||||
|
|
||||||
|
<!-- Nom -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.nom }">
|
||||||
|
<label for="nom">Nom de l'organisation <span class="required">*</span></label>
|
||||||
|
<input
|
||||||
|
id="nom"
|
||||||
|
v-model="form.nom"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ex : Lacaton & Vassal, Plateau Urbain..."
|
||||||
|
autocomplete="organization"
|
||||||
|
@blur="validateField('nom')"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URL -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.url }">
|
||||||
|
<label for="url">
|
||||||
|
Site web
|
||||||
|
<span class="label-hint">(optionnel — recommandé)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="url"
|
||||||
|
v-model="form.url"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://..."
|
||||||
|
@blur="validateField('url')"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.description_user }">
|
||||||
|
<label for="description_user">
|
||||||
|
Description courte <span class="required">*</span>
|
||||||
|
<span class="label-hint">(50 à 500 caractères)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="description_user"
|
||||||
|
v-model="form.description_user"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Décris cette pratique : approche, matériaux, posture, ce qui la rend régénérative..."
|
||||||
|
@blur="validateField('description_user')"
|
||||||
|
/>
|
||||||
|
<div class="field-meta">
|
||||||
|
<span v-if="errors.description_user" class="error-msg" role="alert">
|
||||||
|
{{ errors.description_user }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">
|
||||||
|
{{ form.description_user.length }}/500
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Critères régénératifs -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.criteres }">
|
||||||
|
<fieldset>
|
||||||
|
<legend>
|
||||||
|
Critères régénératifs <span class="required">*</span>
|
||||||
|
<span class="label-hint">(3 minimum, 8 maximum)</span>
|
||||||
|
</legend>
|
||||||
|
<div class="checkbox-grid">
|
||||||
|
<label
|
||||||
|
v-for="c in CRITERES"
|
||||||
|
:key="c.id"
|
||||||
|
class="checkbox-label"
|
||||||
|
:class="{
|
||||||
|
active: form.criteres.includes(c.id),
|
||||||
|
disabled: !form.criteres.includes(c.id) && form.criteres.length >= 8,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:value="c.id"
|
||||||
|
:checked="form.criteres.includes(c.id)"
|
||||||
|
:disabled="!form.criteres.includes(c.id) && form.criteres.length >= 8"
|
||||||
|
@change="toggleCritere(c.id)"
|
||||||
|
/>
|
||||||
|
{{ c.label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<span v-if="errors.criteres" class="error-msg" role="alert">{{ errors.criteres }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Type d'entité -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.type }">
|
||||||
|
<fieldset>
|
||||||
|
<legend>
|
||||||
|
Type d'entité <span class="required">*</span>
|
||||||
|
</legend>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label
|
||||||
|
v-for="t in TYPES_ENTITE"
|
||||||
|
:key="t"
|
||||||
|
class="radio-label"
|
||||||
|
:class="{ active: form.type === t }"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
:value="t"
|
||||||
|
v-model="form.type"
|
||||||
|
name="type"
|
||||||
|
@change="validateField('type')"
|
||||||
|
/>
|
||||||
|
{{ TYPES_ENTITE_LABELS[t] }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<span v-if="errors.type" class="error-msg" role="alert">{{ errors.type }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pays -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.pays }">
|
||||||
|
<label for="pays">
|
||||||
|
Pays <span class="required">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="pays"
|
||||||
|
v-model="form.pays"
|
||||||
|
@change="validateField('pays')"
|
||||||
|
>
|
||||||
|
<option value="" disabled>Sélectionne un pays...</option>
|
||||||
|
<optgroup label="Europe">
|
||||||
|
<option v-for="code in EUROPE_CODES" :key="code" :value="code">
|
||||||
|
{{ PAYS_LABELS[code] }}
|
||||||
|
</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="DOM-TOM">
|
||||||
|
<option v-for="code in OUTREMER_CODES" :key="code" :value="code">
|
||||||
|
{{ PAYS_LABELS[code] }}
|
||||||
|
</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Autre">
|
||||||
|
<option value="AUTRE">Autre pays...</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
<span v-if="errors.pays" class="error-msg" role="alert">{{ errors.pays }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pays autre (conditionnel) -->
|
||||||
|
<div v-if="form.pays === 'AUTRE'" class="field-group" :class="{ 'field-error': errors.pays_autre }">
|
||||||
|
<label for="pays_autre">Précise le pays</label>
|
||||||
|
<input
|
||||||
|
id="pays_autre"
|
||||||
|
v-model="form.pays_autre"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ex : Maroc, Brésil..."
|
||||||
|
maxlength="50"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.pays_autre" class="error-msg" role="alert">{{ errors.pays_autre }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ville -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.ville }">
|
||||||
|
<label for="ville">
|
||||||
|
Ville principale
|
||||||
|
<span class="label-hint">(optionnel)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ville"
|
||||||
|
v-model="form.ville"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ex : Paris, Bordeaux, Bruxelles..."
|
||||||
|
/>
|
||||||
|
<span v-if="errors.ville" class="error-msg" role="alert">{{ errors.ville }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.tags }">
|
||||||
|
<label for="tags">
|
||||||
|
Tags
|
||||||
|
<span class="label-hint">(optionnel — 3 à 6 mots-clés, séparés par des virgules)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="tags"
|
||||||
|
v-model="tagsInput"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ex : biosourcé, réhabilitation, circuit-court"
|
||||||
|
@blur="parseTags"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.tags" class="error-msg" role="alert">{{ errors.tags }}</span>
|
||||||
|
<div v-if="form.tags && form.tags.length" class="tags-preview">
|
||||||
|
<span v-for="tag in form.tags" :key="tag" class="tag-chip">{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
|
||||||
|
<label for="submitted_by_email">
|
||||||
|
Ton email
|
||||||
|
<span class="label-hint">(optionnel — pour le suivi)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="submitted_by_email"
|
||||||
|
v-model="form.submitted_by_email"
|
||||||
|
type="email"
|
||||||
|
placeholder="ton@email.fr"
|
||||||
|
autocomplete="email"
|
||||||
|
@blur="validateField('submitted_by_email')"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">
|
||||||
|
{{ errors.submitted_by_email }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Erreur globale -->
|
||||||
|
<div v-if="serverError" class="server-error" role="alert">
|
||||||
|
<strong>Erreur :</strong> {{ serverError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="form-actions">
|
||||||
|
<NuxtLink to="/pratiques-regeneratives" class="btn-secondary">Annuler</NuxtLink>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="submitting"
|
||||||
|
>
|
||||||
|
{{ submitting ? 'Envoi en cours...' : 'Proposer la pratique →' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="form-note">
|
||||||
|
Ta proposition sera examinée par Jules avant publication.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { CRITERES, TYPES_ENTITE, TYPES_ENTITE_LABELS, EUROPE_CODES, OUTREMER_CODES, PAYS_LABELS } from '~/types/pratique'
|
||||||
|
|
||||||
|
// ── Schéma Zod (côté client — miroir du serveur) ──────────────────────────────
|
||||||
|
|
||||||
|
const PratiqueSubmitSchema = z.object({
|
||||||
|
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
|
||||||
|
url: z.string().url('URL invalide (commencer par https://)').optional().or(z.literal('')),
|
||||||
|
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
|
||||||
|
criteres: z
|
||||||
|
.array(z.number().int().min(1).max(8))
|
||||||
|
.min(3, 'Sélectionne au moins 3 critères')
|
||||||
|
.max(8, 'Maximum 8 critères'),
|
||||||
|
pays: z.string().length(2, 'Sélectionne un pays').or(z.literal('AUTRE')),
|
||||||
|
pays_autre: z.string().max(50).optional(),
|
||||||
|
ville: z.string().max(100).optional(),
|
||||||
|
type: z.enum(TYPES_ENTITE, { errorMap: () => ({ message: 'Sélectionne un type d\'entité' }) }),
|
||||||
|
tags: z.array(z.string().max(30)).max(6).optional(),
|
||||||
|
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── État du formulaire ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
nom: '',
|
||||||
|
url: '',
|
||||||
|
description_user: '',
|
||||||
|
criteres: [] as number[],
|
||||||
|
pays: '' as string,
|
||||||
|
pays_autre: '',
|
||||||
|
ville: '',
|
||||||
|
type: '' as typeof TYPES_ENTITE[number] | '',
|
||||||
|
tags: [] as string[],
|
||||||
|
submitted_by_email: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const tagsInput = ref('')
|
||||||
|
const errors = reactive<Record<string, string>>({})
|
||||||
|
const submitting = ref(false)
|
||||||
|
const success = ref(false)
|
||||||
|
const serverError = ref('')
|
||||||
|
|
||||||
|
// ── Validation champ par champ ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function validateField(field: string) {
|
||||||
|
const partial = PratiqueSubmitSchema.partial()
|
||||||
|
const result = partial.safeParse({ [field]: (form as any)[field] })
|
||||||
|
if (!result.success) {
|
||||||
|
const fieldErrors = result.error.flatten().fieldErrors
|
||||||
|
errors[field] = fieldErrors[field]?.[0] ?? ''
|
||||||
|
} else {
|
||||||
|
delete errors[field]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAll(): boolean {
|
||||||
|
const result = PratiqueSubmitSchema.safeParse(form)
|
||||||
|
if (!result.success) {
|
||||||
|
const flat = result.error.flatten().fieldErrors
|
||||||
|
Object.assign(errors, Object.fromEntries(
|
||||||
|
Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])
|
||||||
|
))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
Object.keys(errors).forEach(k => delete errors[k])
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gestion critères ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function toggleCritere(id: number) {
|
||||||
|
const idx = form.criteres.indexOf(id)
|
||||||
|
if (idx >= 0) {
|
||||||
|
form.criteres.splice(idx, 1)
|
||||||
|
} else if (form.criteres.length < 8) {
|
||||||
|
form.criteres.push(id)
|
||||||
|
}
|
||||||
|
validateField('criteres')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gestion tags ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseTags() {
|
||||||
|
const raw = tagsInput.value
|
||||||
|
.split(',')
|
||||||
|
.map(t => t.trim().toLowerCase())
|
||||||
|
.filter(t => t.length > 0 && t.length <= 30)
|
||||||
|
.slice(0, 6)
|
||||||
|
form.tags = raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Soumission ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
serverError.value = ''
|
||||||
|
parseTags()
|
||||||
|
|
||||||
|
if (!validateAll()) {
|
||||||
|
await nextTick()
|
||||||
|
const firstError = document.querySelector('.field-error')
|
||||||
|
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch('/api/submit-pratique', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
nom: form.nom,
|
||||||
|
url: form.url || undefined,
|
||||||
|
description_user: form.description_user,
|
||||||
|
criteres: form.criteres,
|
||||||
|
pays: form.pays,
|
||||||
|
pays_autre: form.pays_autre || undefined,
|
||||||
|
ville: form.ville || undefined,
|
||||||
|
type: form.type,
|
||||||
|
tags: form.tags.length ? form.tags : undefined,
|
||||||
|
submitted_by_email: form.submitted_by_email || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
success.value = true
|
||||||
|
} catch (e: any) {
|
||||||
|
const status = e?.status ?? e?.statusCode
|
||||||
|
if (status === 429) {
|
||||||
|
serverError.value = 'Tu as déjà soumis 3 pratiques aujourd\'hui. Réessaie demain.'
|
||||||
|
} else if (status === 422 && e?.data) {
|
||||||
|
const fieldErrors = e.data
|
||||||
|
Object.entries(fieldErrors).forEach(([k, v]) => {
|
||||||
|
errors[k] = Array.isArray(v) ? v[0] : String(v)
|
||||||
|
})
|
||||||
|
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
|
||||||
|
} else {
|
||||||
|
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
Object.assign(form, {
|
||||||
|
nom: '', url: '', description_user: '', criteres: [],
|
||||||
|
pays: '', pays_autre: '', ville: '', type: '', tags: [], submitted_by_email: '',
|
||||||
|
})
|
||||||
|
tagsInput.value = ''
|
||||||
|
Object.keys(errors).forEach(k => delete errors[k])
|
||||||
|
success.value = false
|
||||||
|
serverError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Meta ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
useHead({ title: 'Proposer une pratique — AEP' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Layout ─────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.contribuer-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--nav-bg);
|
||||||
|
padding: 1.5rem 1rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contribuer-inner {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Retour ──────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--nav-primary-solid);
|
||||||
|
opacity: 0.7;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── En-tête ─────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.contribuer-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contribuer-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text);
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contribuer-subtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contribuer-hint {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
opacity: 0.75;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Succès ──────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.success-block {
|
||||||
|
background: var(--nav-surface);
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.15);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
background: rgba(26, 34, 56, 0.1);
|
||||||
|
color: var(--nav-text);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-block h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text);
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-block p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-detail {
|
||||||
|
font-size: 0.85rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Formulaire ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.contribuer-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Champ générique ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.field-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group label,
|
||||||
|
.field-group legend {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nav-text);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group fieldset {
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-hint {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group input[type="text"],
|
||||||
|
.field-group input[type="url"],
|
||||||
|
.field-group input[type="email"],
|
||||||
|
.field-group select,
|
||||||
|
.field-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--nav-text);
|
||||||
|
background: var(--nav-surface);
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group select {
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group input:focus,
|
||||||
|
.field-group select:focus,
|
||||||
|
.field-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
box-shadow: 0 0 0 2px rgba(245, 179, 66, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Erreur champ */
|
||||||
|
|
||||||
|
.field-error input,
|
||||||
|
.field-error select,
|
||||||
|
.field-error textarea {
|
||||||
|
border-color: #c0392b !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-warn {
|
||||||
|
color: #e67e22;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Radio (Type entité) ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.radio-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--nav-text);
|
||||||
|
background: var(--nav-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label input[type="radio"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label:hover {
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label.active {
|
||||||
|
background: var(--nav-primary);
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Checkboxes (Critères) ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.checkbox-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.checkbox-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--nav-text);
|
||||||
|
background: var(--nav-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input[type="checkbox"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label:hover:not(.disabled) {
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label.active {
|
||||||
|
background: var(--nav-primary);
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label.disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tags preview ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.tags-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.15);
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Erreur serveur ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.server-error {
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
background: #fdf0ee;
|
||||||
|
border: 1px solid #e74c3c;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Actions ──────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--nav-primary);
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.15s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: rgba(26, 34, 56, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
color: var(--nav-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-note {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.contribuer-page {
|
||||||
|
padding: 1rem 0.75rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1
public/data/pratiques-pending.json
Normal file
1
public/data/pratiques-pending.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
834
public/data/pratiques-regeneratives.json
Normal file
834
public/data/pratiques-regeneratives.json
Normal file
@@ -0,0 +1,834 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"nom": "Rotor",
|
||||||
|
"pays": "BE",
|
||||||
|
"ville": "Bruxelles",
|
||||||
|
"type": "cooperative",
|
||||||
|
"url": "https://rotordb.org",
|
||||||
|
"lat": 50.8503,
|
||||||
|
"lng": 4.3517,
|
||||||
|
"description": "Pionnier européen du réemploi. Filiale RotorDC opérationnelle depuis 2016, showroom + entrepôt à Vilvorde. Publications de référence sur l'économie circulaire du bâtiment, expositions internationales.",
|
||||||
|
"criteres": [1, 2, 5, 6, 8],
|
||||||
|
"score": 5,
|
||||||
|
"tags": ["réemploi", "recherche", "publications", "coopérative"],
|
||||||
|
"source": "seed Jules + scrape WBDM",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"nom": "Assemble Studio",
|
||||||
|
"pays": "UK",
|
||||||
|
"ville": "Londres",
|
||||||
|
"type": "collectif",
|
||||||
|
"url": "https://assemblestudio.co.uk",
|
||||||
|
"lat": 51.5074,
|
||||||
|
"lng": -0.1278,
|
||||||
|
"description": "Turner Prize 2015. Travaille à la frontière architecture/art/design. Granby Workshop, Yardhouse, Sugarhouse Studios. Pratique non-hiérarchique, autoconstruction, fabrication interne.",
|
||||||
|
"criteres": [1, 3, 4, 6, 8],
|
||||||
|
"score": 5,
|
||||||
|
"tags": ["autoconstruction", "art", "social", "collectif horizontal"],
|
||||||
|
"source": "seed Jules",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"nom": "Forensic Architecture",
|
||||||
|
"pays": "UK",
|
||||||
|
"ville": "Londres",
|
||||||
|
"type": "recherche",
|
||||||
|
"url": "https://forensic-architecture.org",
|
||||||
|
"lat": 51.4742,
|
||||||
|
"lng": -0.0176,
|
||||||
|
"description": "Investigation architecturale autour de violations des droits humains et environnementaux. Contre-expertise vidéo/spatiale open-source. Balise politique du courant régénératif, transmission ouverte.",
|
||||||
|
"criteres": [5, 8],
|
||||||
|
"score": 2,
|
||||||
|
"tags": ["enquête", "politique", "contre-expertise", "open-source"],
|
||||||
|
"source": "seed Jules",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"nom": "Encore Heureux",
|
||||||
|
"pays": "FR",
|
||||||
|
"ville": "Paris",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://encoreheureux.org",
|
||||||
|
"lat": 48.8566,
|
||||||
|
"lng": 2.3522,
|
||||||
|
"description": "Pavillon France Biennale Venise 2018 'Lieux Infinis'. Projets emblématiques sur le réemploi (Pavillon Circulaire, COP21). Manifeste, livres, posture publique forte.",
|
||||||
|
"criteres": [1, 3, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["réemploi", "manifeste", "Biennale", "publications"],
|
||||||
|
"source": "seed Jules",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"nom": "Construire (Patrick Bouchain)",
|
||||||
|
"pays": "FR",
|
||||||
|
"ville": "Paris",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://construire-architectes.com",
|
||||||
|
"lat": 48.8566,
|
||||||
|
"lng": 2.3522,
|
||||||
|
"description": "Maître d'œuvre du 'permis de faire'. Lieu Unique, Le Channel, friches culturelles. MOA habitante, pédagogie de chantier, transmission. Figure tutélaire du courant.",
|
||||||
|
"criteres": [3, 4, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["permis de faire", "MOA habitante", "friches", "transmission"],
|
||||||
|
"source": "seed Jules",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"nom": "Boidot+Robin",
|
||||||
|
"pays": "FR",
|
||||||
|
"ville": "Sézanne",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://julienboidot.fr",
|
||||||
|
"lat": 48.7253,
|
||||||
|
"lng": 3.7211,
|
||||||
|
"description": "Architecture rurale champenoise, terre crue, bois local, sobriété matérielle radicale. Maisons individuelles et équipements publics. Engagement territorial fort, déshérence rurale.",
|
||||||
|
"criteres": [1, 2, 3, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["terre crue", "rural", "low-tech", "filières courtes"],
|
||||||
|
"source": "seed Jules",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"nom": "Tepop",
|
||||||
|
"pays": "FR",
|
||||||
|
"ville": "Bordeaux",
|
||||||
|
"type": "cooperative",
|
||||||
|
"url": "https://tepop.fr",
|
||||||
|
"lat": 44.8378,
|
||||||
|
"lng": -0.5792,
|
||||||
|
"description": "Coopérative d'architecture et urbanisme. Démarches participatives, projets péri-urbains, modèle SCOP. Travaille sur la commande publique et habitat groupé.",
|
||||||
|
"criteres": [3, 4, 6, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["SCOP", "participatif", "périurbain", "coopérative"],
|
||||||
|
"source": "seed Jules",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"nom": "Architecture & Précarités",
|
||||||
|
"pays": "FR",
|
||||||
|
"ville": "Paris",
|
||||||
|
"type": "reseau",
|
||||||
|
"url": "https://architecture-precarites.fr",
|
||||||
|
"lat": 48.8566,
|
||||||
|
"lng": 2.3522,
|
||||||
|
"description": "Réseau d'architectes engagés sur les questions de précarité, mal-logement, hospitalité. Recherche-action, publications, plaidoyer. Plateforme lancée 2022 par ENSA Paris-Belleville.",
|
||||||
|
"criteres": [4, 5, 8],
|
||||||
|
"score": 3,
|
||||||
|
"tags": ["précarités", "recherche-action", "hospitalité"],
|
||||||
|
"source": "seed Jules",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"nom": "Agence ATM",
|
||||||
|
"pays": "FR",
|
||||||
|
"ville": "Bagnolet",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://agence-atm.com",
|
||||||
|
"lat": 48.8694,
|
||||||
|
"lng": 2.4200,
|
||||||
|
"description": "Architecture en terre, paille, bois. Chantiers-école, transmission. Projets en Île-de-France et province. Pratique low-tech assumée.",
|
||||||
|
"criteres": [1, 2, 3, 4],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["terre", "paille", "chantier-école", "low-tech"],
|
||||||
|
"source": "seed Jules",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"nom": "Frugalité Heureuse & Créative",
|
||||||
|
"pays": "FR",
|
||||||
|
"ville": "Paris",
|
||||||
|
"type": "mouvement",
|
||||||
|
"url": "https://frugalite.org",
|
||||||
|
"lat": 48.8566,
|
||||||
|
"lng": 2.3522,
|
||||||
|
"description": "Mouvement initié par Madec, Lefèvre, Rollet (2018). Manifeste signé par des milliers d'architectes. Expositions, publications, plaidoyer. Référence cardinale du courant frugal en France.",
|
||||||
|
"criteres": [1, 3, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["manifeste", "sobriété", "low-tech", "mouvement"],
|
||||||
|
"source": "seed Jules",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"nom": "Atelier Belenfant Daubas",
|
||||||
|
"pays": "FR",
|
||||||
|
"ville": "Vertou",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://atelierbelenfantdaubas.org",
|
||||||
|
"lat": 47.1667,
|
||||||
|
"lng": -1.4667,
|
||||||
|
"description": "Agence ligérienne. Bois local, paille, éco-construction sur équipements publics (écoles, salles). Pratique discrète mais référence du courant biosourcé Ouest.",
|
||||||
|
"criteres": [1, 2, 3],
|
||||||
|
"score": 3,
|
||||||
|
"tags": ["bois", "paille", "écoles", "Ouest"],
|
||||||
|
"source": "WebSearch BE",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"nom": "Réempro / Opalis",
|
||||||
|
"pays": "BE",
|
||||||
|
"ville": "Bruxelles",
|
||||||
|
"type": "plateforme",
|
||||||
|
"url": "https://opalis.eu",
|
||||||
|
"lat": 50.8503,
|
||||||
|
"lng": 4.3517,
|
||||||
|
"description": "Plateforme de référence pour le réemploi (revendeurs, fiches matériaux). Issue de la mouvance Rotor. Service complémentaire d'agence et outil documentaire ouvert.",
|
||||||
|
"criteres": [1, 2, 8],
|
||||||
|
"score": 3,
|
||||||
|
"tags": ["réemploi", "base de données", "open"],
|
||||||
|
"source": "WebSearch",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"nom": "BC Architects & Studies",
|
||||||
|
"pays": "BE",
|
||||||
|
"ville": "Bruxelles",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://bc-as.org",
|
||||||
|
"lat": 50.8503,
|
||||||
|
"lng": 4.3517,
|
||||||
|
"description": "Architecture en terre crue, BTC, transmission via BC Studies (formation). Projets en Belgique, France, Maroc, Burundi. Une des références européennes terre.",
|
||||||
|
"criteres": [1, 2, 3, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["terre crue", "BTC", "formation", "international"],
|
||||||
|
"source": "connaissance acteur",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 14,
|
||||||
|
"nom": "Lacol Arquitectura Cooperativa",
|
||||||
|
"pays": "ES",
|
||||||
|
"ville": "Barcelone",
|
||||||
|
"type": "cooperative",
|
||||||
|
"url": "https://lacol.coop",
|
||||||
|
"lat": 41.3851,
|
||||||
|
"lng": 2.1734,
|
||||||
|
"description": "Coopérative fondée 2009 (Sants). La Borda : plus grand bâtiment résidentiel bois d'Espagne à sa livraison. Modèle horizontal, transformation sociale via architecture, habitat coopératif.",
|
||||||
|
"criteres": [3, 4, 6, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["SCOP", "La Borda", "habitat coopératif", "Barcelone"],
|
||||||
|
"source": "WebSearch",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"nom": "Recetas Urbanas",
|
||||||
|
"pays": "ES",
|
||||||
|
"ville": "Séville",
|
||||||
|
"type": "collectif",
|
||||||
|
"url": "https://recetasurbanas.net",
|
||||||
|
"lat": 37.3891,
|
||||||
|
"lng": -5.9845,
|
||||||
|
"description": "'Recettes urbaines' : méthodologies open-source pour autoconstruire dans les interstices légaux. Hack urbain, droit comme matériau. Cirugeda figure cardinale architecture sociale espagnole.",
|
||||||
|
"criteres": [4, 5, 6, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["autoconstruction", "droit", "open-source", "hack"],
|
||||||
|
"source": "WebSearch",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 16,
|
||||||
|
"nom": "Die Baupiloten",
|
||||||
|
"pays": "DE",
|
||||||
|
"ville": "Berlin",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://baupiloten.com",
|
||||||
|
"lat": 52.5200,
|
||||||
|
"lng": 13.4050,
|
||||||
|
"description": "Agence pédagogique (Susanne Hofmann + TU Berlin). Étudiants impliqués dans projets réels (écoles, logement). Méthodologie participative avec usagers, notamment enfants.",
|
||||||
|
"criteres": [4, 8],
|
||||||
|
"score": 2,
|
||||||
|
"tags": ["participation", "écoles", "pédagogie", "université"],
|
||||||
|
"source": "WebSearch",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 17,
|
||||||
|
"nom": "Baubotanik",
|
||||||
|
"pays": "DE",
|
||||||
|
"ville": "Munich",
|
||||||
|
"type": "recherche",
|
||||||
|
"url": "https://baubotanik.org",
|
||||||
|
"lat": 48.1351,
|
||||||
|
"lng": 11.5820,
|
||||||
|
"description": "Architecture vivante : structures construites par interaction technique + croissance végétale. Recherche fondamentale + démonstrateurs. Champ unique en Europe sur l'intégration vivant/structure.",
|
||||||
|
"criteres": [1, 7, 8],
|
||||||
|
"score": 3,
|
||||||
|
"tags": ["architecture vivante", "végétal", "recherche"],
|
||||||
|
"source": "WebSearch",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 18,
|
||||||
|
"nom": "Superuse Studios",
|
||||||
|
"pays": "NL",
|
||||||
|
"ville": "Rotterdam",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://superuse-studios.com",
|
||||||
|
"lat": 51.9225,
|
||||||
|
"lng": 4.4792,
|
||||||
|
"description": "Pionnier néerlandais du réemploi industriel. Harvest Map (cartographie ressources). Projets emblématiques (Villa Welpeloo). Antériorité sur le sujet en Europe.",
|
||||||
|
"criteres": [1, 2, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["réemploi", "harvest map", "industriel", "NL"],
|
||||||
|
"source": "connaissance acteur",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 19,
|
||||||
|
"nom": "Atelier 56S",
|
||||||
|
"pays": "FR",
|
||||||
|
"ville": "Vannes",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://56s.fr",
|
||||||
|
"lat": 47.6559,
|
||||||
|
"lng": -2.7603,
|
||||||
|
"description": "Agence morbihannaise, bois et paille, équipements publics ruraux. Représentante du courant biosourcé Bretagne. Pratique de mise en œuvre soignée, ancrage local.",
|
||||||
|
"criteres": [1, 2, 3],
|
||||||
|
"score": 3,
|
||||||
|
"tags": ["bois", "paille", "Bretagne", "rural"],
|
||||||
|
"source": "connaissance acteur",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 20,
|
||||||
|
"nom": "Quatorze",
|
||||||
|
"pays": "FR",
|
||||||
|
"ville": "Paris",
|
||||||
|
"type": "asso",
|
||||||
|
"url": "https://quatorze.cc",
|
||||||
|
"lat": 48.8566,
|
||||||
|
"lng": 2.3522,
|
||||||
|
"description": "Association d'architecture. Projets autour des migrations, du logement d'urgence, des fab labs (Fab City). Posture militante, modèle associatif assumé.",
|
||||||
|
"criteres": [3, 4, 5, 6],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["asso", "migrations", "fab city", "militant"],
|
||||||
|
"source": "connaissance acteur",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 21,
|
||||||
|
"nom": "Yes We Camp",
|
||||||
|
"pays": "FR",
|
||||||
|
"ville": "Marseille",
|
||||||
|
"type": "collectif",
|
||||||
|
"url": "https://yeswecamp.org",
|
||||||
|
"lat": 43.2965,
|
||||||
|
"lng": 5.3698,
|
||||||
|
"description": "Tiers-lieux et occupation transitoire. Les Grands Voisins, Coco Velten. Modèle hybride architecture/programmation/animation. Ancrage friches.",
|
||||||
|
"criteres": [3, 4, 5, 6],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["tiers-lieux", "friches", "transitoire", "programmation"],
|
||||||
|
"source": "connaissance acteur",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 22,
|
||||||
|
"nom": "Bellastock",
|
||||||
|
"pays": "FR",
|
||||||
|
"ville": "Île-Saint-Denis",
|
||||||
|
"type": "cooperative",
|
||||||
|
"url": "https://bellastock.com",
|
||||||
|
"lat": 48.9408,
|
||||||
|
"lng": 2.3472,
|
||||||
|
"description": "Coopérative spécialisée réemploi. Festival Bellastock annuel (chantier expérimental étudiants). Recherche + maîtrise d'œuvre. Une des entrées historiques du réemploi en France.",
|
||||||
|
"criteres": [1, 2, 3, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["réemploi", "festival", "étudiants", "coopérative"],
|
||||||
|
"source": "connaissance acteur",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 23,
|
||||||
|
"nom": "Mario Cucinella Architects (TECLA)",
|
||||||
|
"pays": "IT",
|
||||||
|
"ville": "Bologne",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://mcarchitects.it",
|
||||||
|
"lat": 44.4949,
|
||||||
|
"lng": 11.3426,
|
||||||
|
"description": "MCA. Projet TECLA : maison imprimée 3D en terre locale (avec WASP). Posture bioclimatique forte, démonstrateurs. Référence italienne sur l'expérimentation matériaux+vivant.",
|
||||||
|
"criteres": [1, 3, 7, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["terre", "impression 3D", "bioclimatique", "IT"],
|
||||||
|
"source": "connaissance acteur",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 24,
|
||||||
|
"nom": "Ateliermob",
|
||||||
|
"pays": "PT",
|
||||||
|
"ville": "Lisbonne",
|
||||||
|
"type": "collectif",
|
||||||
|
"url": "https://ateliermob.com",
|
||||||
|
"lat": 38.7169,
|
||||||
|
"lng": -9.1399,
|
||||||
|
"description": "Collectif engagé 'Trabalhar com os 99%'. Autoconstruction, projets sociaux, Biennale Venise 2012. Architecture participative pour les quartiers informels et communautés défavorisées.",
|
||||||
|
"criteres": [3, 4, 5, 6, 8],
|
||||||
|
"score": 5,
|
||||||
|
"tags": ["autoconstruction", "social", "Biennale Venise 2012"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 25,
|
||||||
|
"nom": "Artéria",
|
||||||
|
"pays": "PT",
|
||||||
|
"ville": "Lisbonne",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://arteria.pt",
|
||||||
|
"lat": 38.7169,
|
||||||
|
"lng": -9.1399,
|
||||||
|
"description": "Atelier de réhabilitation urbaine. Edifício Manifesto, Mouraria. Réhabilitation sensible du patrimoine populaire lisbonnais, réemploi de matériaux, pratique frugale.",
|
||||||
|
"criteres": [1, 3, 4, 5],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["réhabilitation", "Edifício Manifesto", "Mouraria"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 26,
|
||||||
|
"nom": "oitoo",
|
||||||
|
"pays": "PT",
|
||||||
|
"ville": "Porto",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://oitoo.pt",
|
||||||
|
"lat": 41.1579,
|
||||||
|
"lng": -8.6291,
|
||||||
|
"description": "Réemploi, réactivation multi-sites. Pratique entre Porto et Lisbonne. Architecture de la transformation et du réemploi comme posture systématique.",
|
||||||
|
"criteres": [1, 3, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["réemploi", "réactivation", "multi-sites"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 27,
|
||||||
|
"nom": "Tegnestuen LOKAL",
|
||||||
|
"pays": "DK",
|
||||||
|
"ville": "Copenhague",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://tegnestuenlokal.dk",
|
||||||
|
"lat": 55.6761,
|
||||||
|
"lng": 12.5683,
|
||||||
|
"description": "Adaptive reuse, matériaux biogéniques, Manifest planétär. Posture frugale radicale, bois local, engagement écologique de fond dans toute la pratique.",
|
||||||
|
"criteres": [1, 2, 3, 7, 8],
|
||||||
|
"score": 5,
|
||||||
|
"tags": ["adaptive reuse", "biogenic", "Manifest planetær"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 28,
|
||||||
|
"nom": "Vandkunsten Architects",
|
||||||
|
"pays": "DK",
|
||||||
|
"ville": "Copenhague",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://vandkunsten.com",
|
||||||
|
"lat": 55.6761,
|
||||||
|
"lng": 12.5683,
|
||||||
|
"description": "Agence danoise historique, beauté + sobriété, posture engagée sur la longévité du bâti. Publications, transmission de savoir-faire architectural durable.",
|
||||||
|
"criteres": [1, 3, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["beauté+sobriété", "posture", "longévité"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 29,
|
||||||
|
"nom": "White Arkitekter",
|
||||||
|
"pays": "SE",
|
||||||
|
"ville": "Stockholm",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://whitearkitekter.com",
|
||||||
|
"lat": 59.3293,
|
||||||
|
"lng": 18.0686,
|
||||||
|
"description": "Grande agence suédoise engagée sur les modes de vie durables. Matériaux biosourcés à grande échelle, publications, transmission du savoir-faire nordique.",
|
||||||
|
"criteres": [1, 3, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["sustainable ways of life", "scale"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 30,
|
||||||
|
"nom": "ZRS Architekten Ingenieure",
|
||||||
|
"pays": "DE",
|
||||||
|
"ville": "Berlin",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://zrs.berlin",
|
||||||
|
"lat": 52.5200,
|
||||||
|
"lng": 13.4050,
|
||||||
|
"description": "Terre, bambou, construction biosourcée en Allemagne et en Afrique subsaharienne (NBL). Recherche appliquée couplée à la pratique. Référence germanophone sur les matériaux locaux et vernaculaires.",
|
||||||
|
"criteres": [1, 2, 3, 5, 8],
|
||||||
|
"score": 5,
|
||||||
|
"tags": ["terre", "bambou", "NBL", "recherche"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 31,
|
||||||
|
"nom": "Natural Building Lab (TU Berlin)",
|
||||||
|
"pays": "DE",
|
||||||
|
"ville": "Berlin",
|
||||||
|
"type": "recherche",
|
||||||
|
"url": "https://nbl.berlin",
|
||||||
|
"lat": 52.5200,
|
||||||
|
"lng": 13.4050,
|
||||||
|
"description": "Laboratoire universitaire low-tech à la TU Berlin. Transmission, expérimentation constructive avec matériaux naturels, lien recherche-pratique exemplaire.",
|
||||||
|
"criteres": [1, 3, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["low-tech", "université", "transmission"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 32,
|
||||||
|
"nom": "DGJ Architektur",
|
||||||
|
"pays": "DE",
|
||||||
|
"ville": "Frankfurt",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://dgj.eu",
|
||||||
|
"lat": 50.1109,
|
||||||
|
"lng": 8.6821,
|
||||||
|
"description": "Agence engagée sur le durable et le biosourcé en Allemagne. Pratique soignée, matériaux biosourcés intégrés aux projets résidentiels et publics.",
|
||||||
|
"criteres": [1, 3, 8],
|
||||||
|
"score": 3,
|
||||||
|
"tags": ["sustainable", "biosourcés"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 33,
|
||||||
|
"nom": "Baubüro in situ + Zirkular",
|
||||||
|
"pays": "CH",
|
||||||
|
"ville": "Bâle",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://insitu.ch",
|
||||||
|
"lat": 47.5596,
|
||||||
|
"lng": 7.5886,
|
||||||
|
"description": "Réemploi structurel de référence en Suisse. K118, Holcim Gold Award. Pratique circulaire systématique, publications, posture publique affirmée.",
|
||||||
|
"criteres": [1, 2, 5, 6, 8],
|
||||||
|
"score": 5,
|
||||||
|
"tags": ["K118", "réemploi structurel", "Holcim Gold"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 34,
|
||||||
|
"nom": "einszueins architektur",
|
||||||
|
"pays": "AT",
|
||||||
|
"ville": "Vienne",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://einszueins.at",
|
||||||
|
"lat": 48.2082,
|
||||||
|
"lng": 16.3738,
|
||||||
|
"description": "Wohnprojekt Wien, baugruppe, World Habitat Award. Habitat coopératif et participatif à Vienne. Modèle alternatif de co-construction avec futurs habitants.",
|
||||||
|
"criteres": [3, 4, 5, 6, 8],
|
||||||
|
"score": 5,
|
||||||
|
"tags": ["Wohnprojekt Wien", "baugruppe", "World Habitat Award"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 35,
|
||||||
|
"nom": "BudCud",
|
||||||
|
"pays": "PL",
|
||||||
|
"ville": "Cracovie",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://budcud.org",
|
||||||
|
"lat": 50.0647,
|
||||||
|
"lng": 19.9450,
|
||||||
|
"description": "Réemploi, mobilier urbain, pratique engagée à Cracovie. Projets d'espace public avec matériaux récupérés, participation des usagers.",
|
||||||
|
"criteres": [1, 3, 4, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["réemploi", "mobilier", "urbain"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 36,
|
||||||
|
"nom": "Centrala",
|
||||||
|
"pays": "PL",
|
||||||
|
"ville": "Varsovie",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://centrala.net.pl",
|
||||||
|
"lat": 52.2297,
|
||||||
|
"lng": 21.0122,
|
||||||
|
"description": "Recherche spatiale, posture critique. Agence-recherche varsovienne engagée sur les transformations urbaines post-industrielles.",
|
||||||
|
"criteres": [3, 5, 8],
|
||||||
|
"score": 3,
|
||||||
|
"tags": ["recherche spatiale", "posture"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 37,
|
||||||
|
"nom": "RiceHouse",
|
||||||
|
"pays": "IT",
|
||||||
|
"ville": "Biella",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://ricehouse.it",
|
||||||
|
"lat": 45.5629,
|
||||||
|
"lng": 8.0565,
|
||||||
|
"description": "Paille de riz comme matériau de construction. Filière biosourcée ultra-locale (Plaine du Pô). Recherche + démonstrations, lien producteurs-constructeurs.",
|
||||||
|
"criteres": [1, 2, 3, 7, 8],
|
||||||
|
"score": 5,
|
||||||
|
"tags": ["paille de riz", "biosourcé", "filière courte"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 38,
|
||||||
|
"nom": "TAMassociati",
|
||||||
|
"pays": "IT",
|
||||||
|
"ville": "Venise",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://tamassociati.org",
|
||||||
|
"lat": 45.4408,
|
||||||
|
"lng": 12.3155,
|
||||||
|
"description": "'Taking care' comme posture. Architecture humanitaire et sociale en Italie et à l'international. Projets de santé et d'éducation dans des contextes fragiles.",
|
||||||
|
"criteres": [3, 4, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["taking care", "humanitaire", "social"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 39,
|
||||||
|
"nom": "Diverserighestudio",
|
||||||
|
"pays": "IT",
|
||||||
|
"ville": "Bologne",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://diverserighestudio.it",
|
||||||
|
"lat": 44.4949,
|
||||||
|
"lng": 11.3426,
|
||||||
|
"description": "Cohousing Porto15, recherche, participatif. Studio bolonais engagé sur l'habitat participatif et la recherche architecturale, modèle coopératif.",
|
||||||
|
"criteres": [3, 4, 5, 6, 8],
|
||||||
|
"score": 5,
|
||||||
|
"tags": ["cohousing Porto15", "recherche", "participatif"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 40,
|
||||||
|
"nom": "Material Cultures",
|
||||||
|
"pays": "UK",
|
||||||
|
"ville": "Londres",
|
||||||
|
"type": "asso",
|
||||||
|
"url": "https://materialcultures.org",
|
||||||
|
"lat": 51.5074,
|
||||||
|
"lng": -0.1278,
|
||||||
|
"description": "Biosourcés, chanvre (hempcrete), Harvest House. Association de recherche sur les cultures matérielles biosourcées et leur transformation en techniques constructives.",
|
||||||
|
"criteres": [1, 2, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["biosourcés", "hempcrete", "Harvest House"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 41,
|
||||||
|
"nom": "6a architects",
|
||||||
|
"pays": "UK",
|
||||||
|
"ville": "Londres",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://6a.co.uk",
|
||||||
|
"lat": 51.5074,
|
||||||
|
"lng": -0.1278,
|
||||||
|
"description": "Strates, retrofit, transmission. Agence londonienne engagée sur la transformation du bâti existant, lecture des strates temporelles et sobriété des interventions.",
|
||||||
|
"criteres": [1, 3, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["strates", "retrofit", "transmission"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"nom": "We Made That",
|
||||||
|
"pays": "UK",
|
||||||
|
"ville": "Londres",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://wemadethat.co.uk",
|
||||||
|
"lat": 51.5074,
|
||||||
|
"lng": -0.1278,
|
||||||
|
"description": "Public realm, co-conception, civic. Agence engagée sur les espaces publics et l'aménagement participatif en contexte urbain dense.",
|
||||||
|
"criteres": [4, 5, 6, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["public realm", "co-conception", "civic"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 43,
|
||||||
|
"nom": "Public Practice",
|
||||||
|
"pays": "UK",
|
||||||
|
"ville": "Londres",
|
||||||
|
"type": "asso",
|
||||||
|
"url": "https://publicpractice.org.uk",
|
||||||
|
"lat": 51.5074,
|
||||||
|
"lng": -0.1278,
|
||||||
|
"description": "Architectes placés en mairies pour œuvrer dans l'intérêt public. Modèle innovant d'insertion professionnelle au service de la commande publique locale.",
|
||||||
|
"criteres": [4, 5, 6, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["architectes en mairies", "intérêt public"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 44,
|
||||||
|
"nom": "AgwA",
|
||||||
|
"pays": "BE",
|
||||||
|
"ville": "Bruxelles",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://agwa.be",
|
||||||
|
"lat": 50.8503,
|
||||||
|
"lng": 4.3517,
|
||||||
|
"description": "Transformation, EUmies 2026, Charleroi. Agence bruxelloise spécialisée en transformation du bâti existant, posture critique et réemploi intégré.",
|
||||||
|
"criteres": [1, 5, 8],
|
||||||
|
"score": 3,
|
||||||
|
"tags": ["transformation", "EUmies 2026", "Charleroi"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 45,
|
||||||
|
"nom": "Carton123 architecten",
|
||||||
|
"pays": "BE",
|
||||||
|
"ville": "Bruxelles",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://carton123.be",
|
||||||
|
"lat": 50.8503,
|
||||||
|
"lng": 4.3517,
|
||||||
|
"description": "Réemploi, école, collaboration. Agence bruxelloise engagée sur les projets scolaires avec une forte dimension réemploi et collaboration avec les usagers.",
|
||||||
|
"criteres": [1, 3, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["réemploi", "school", "collaboration"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 46,
|
||||||
|
"nom": "KEBATI",
|
||||||
|
"pays": "MQ",
|
||||||
|
"ville": "Fort-de-France",
|
||||||
|
"type": "asso",
|
||||||
|
"url": "https://www.kebati.com",
|
||||||
|
"lat": 14.6161,
|
||||||
|
"lng": -61.0588,
|
||||||
|
"description": "Association citoyenne martiniquaise dédiée au bâtiment durable en milieu tropical humide. Centre de ressources reconnu nationalement (22 centres ADEME). 236 visites de chantiers, groupes de travail biosourcés, newsletter 800 abonnés.",
|
||||||
|
"criteres": [1, 2, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["biosourcés", "tropical", "transmission", "Martinique", "centre de ressources"],
|
||||||
|
"source": "P1-RECAP scrape kebati.com",
|
||||||
|
"passe": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 47,
|
||||||
|
"nom": "AQUAA",
|
||||||
|
"pays": "GF",
|
||||||
|
"ville": "Cayenne",
|
||||||
|
"type": "asso",
|
||||||
|
"url": "https://www.aquaa.fr",
|
||||||
|
"lat": 4.9333,
|
||||||
|
"lng": -52.3333,
|
||||||
|
"description": "Association pour la qualité de la construction en Amazonie et aux Antilles. Guides techniques d'architecture bioclimatique guyanaise, éco-matériaux locaux, urbanisme équatorial. Membre fondateur du RBD Intertropical.",
|
||||||
|
"criteres": [2, 3, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["bioclimatique", "amazonien", "éco-matériaux", "Guyane"],
|
||||||
|
"source": "P1-RECAP — scrape échoué (ECONNREFUSED) — à compléter manuellement",
|
||||||
|
"passe": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 48,
|
||||||
|
"nom": "Karibati",
|
||||||
|
"pays": "FR",
|
||||||
|
"ville": "Paris",
|
||||||
|
"type": "reseau",
|
||||||
|
"url": "http://www.karibati.fr",
|
||||||
|
"lat": 48.8566,
|
||||||
|
"lng": 2.3522,
|
||||||
|
"description": "Expert national du bâtiment biosourcé et géosourcé avec 15 ans d'expérience. Développement et structuration de filières biosourcées, accompagnement fabricants, formations, outils méthodologiques (aKacia, Label Produit Biosourcé).",
|
||||||
|
"criteres": [1, 2, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["biosourcés", "géosourcés", "filières", "formation", "national"],
|
||||||
|
"source": "P1-RECAP scrape karibati.fr",
|
||||||
|
"passe": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 49,
|
||||||
|
"nom": "Réseau Bâtiment Durable Intertropical (RBD)",
|
||||||
|
"pays": "GP",
|
||||||
|
"ville": "Pointe-à-Pitre",
|
||||||
|
"type": "reseau",
|
||||||
|
"url": "https://www.synergile.fr/departements/rbd/",
|
||||||
|
"lat": 16.2410,
|
||||||
|
"lng": -61.5337,
|
||||||
|
"description": "Réseau intertropical de 4 structures (AQUAA Guyane, KEBATI Martinique, Envirobat Réunion, RBD Guadeloupe). Réponse concertée aux enjeux du bâtiment durable tropical. Pôle d'innovation Synergîles, financement ADEME.",
|
||||||
|
"criteres": [1, 2, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["réseau", "intertropical", "DOM-TOM", "ADEME"],
|
||||||
|
"source": "P1-RECAP scrape synergile.fr/rbd",
|
||||||
|
"passe": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 50,
|
||||||
|
"nom": "Caribois",
|
||||||
|
"pays": "GP",
|
||||||
|
"ville": "Baie-Mahault",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://www.caribois.com",
|
||||||
|
"lat": 16.2410,
|
||||||
|
"lng": -61.5337,
|
||||||
|
"description": "Constructeur de maisons individuelles en bois, béton ou mixte depuis 20 ans en Guadeloupe. Fabrication 100% locale, matériaux biosourcés (bois sélectionné), architecture créole. Accompagnement complet du financement à la livraison.",
|
||||||
|
"criteres": [1, 2],
|
||||||
|
"score": 2,
|
||||||
|
"tags": ["bois", "créole", "local", "Guadeloupe"],
|
||||||
|
"source": "P1-RECAP scrape caribois.com — constructeur commercial (pas collectif praticien au sens strict)",
|
||||||
|
"passe": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 51,
|
||||||
|
"nom": "Vegetal(e)",
|
||||||
|
"pays": "GP",
|
||||||
|
"ville": "Pointe-à-Pitre",
|
||||||
|
"type": "plateforme",
|
||||||
|
"url": "http://www.vegetal-e.com/fr/guadeloupe_384.html",
|
||||||
|
"lat": 16.2410,
|
||||||
|
"lng": -61.5337,
|
||||||
|
"description": "Portail d'information et base de données sur les éco-matériaux et solutions constructives biosourcés en Guadeloupe. Agrégateur de ressources régionales (chanvre, cellulose, bois, bambou, paille). Newsletter hebdomadaire sur l'innovation construction.",
|
||||||
|
"criteres": [1, 5, 8],
|
||||||
|
"score": 3,
|
||||||
|
"tags": ["biosourcés", "portail", "Guadeloupe", "éco-matériaux"],
|
||||||
|
"source": "P1-RECAP scrape vegetal-e.com — portail info, pas une agence praticien",
|
||||||
|
"passe": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 52,
|
||||||
|
"nom": "Envirobat Réunion",
|
||||||
|
"pays": "RE",
|
||||||
|
"ville": "Saint-Denis",
|
||||||
|
"type": "reseau",
|
||||||
|
"url": "https://www.envirobat.re",
|
||||||
|
"lat": -20.8789,
|
||||||
|
"lng": 55.4481,
|
||||||
|
"description": "Centre de ressources bâtiment durable de La Réunion. Membre fondateur du RBD Intertropical. Équivalent insulaire de KEBATI pour l'océan Indien, transmission et formation des professionnels du bâtiment réunionnais.",
|
||||||
|
"criteres": [2, 5, 8],
|
||||||
|
"score": 3,
|
||||||
|
"tags": ["bâtiment durable", "Réunion", "centre de ressources", "intertropical"],
|
||||||
|
"source": "P1-RECAP — scrape échoué (ECONNREFUSED) — à compléter manuellement",
|
||||||
|
"passe": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
const AuthSchema = z.object({
|
|
||||||
password: z.string().min(1).max(100),
|
|
||||||
})
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
const body = await readBody(event)
|
|
||||||
const parsed = AuthSchema.safeParse(body)
|
|
||||||
|
|
||||||
if (!parsed.success) {
|
|
||||||
throw createError({ statusCode: 422, statusMessage: 'Mot de passe invalide' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = useRuntimeConfig()
|
|
||||||
const expected = config.codevPassword || 'merci'
|
|
||||||
|
|
||||||
const isAdmin = parsed.data.password.trim().toLowerCase() === (config.codevAdminPassword || 'admin2026').trim().toLowerCase()
|
|
||||||
const isUser = parsed.data.password.trim().toLowerCase() === expected.trim().toLowerCase()
|
|
||||||
|
|
||||||
if (!isAdmin && !isUser) {
|
|
||||||
throw createError({ statusCode: 401, statusMessage: 'Mauvais mot de passe' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cookie session (user + admin)
|
|
||||||
setCookie(event, 'codev_session', 'ok', {
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'lax',
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
maxAge: 60 * 60 * 24, // 24h
|
|
||||||
path: '/',
|
|
||||||
})
|
|
||||||
|
|
||||||
// Cookie admin si mot de passe admin
|
|
||||||
if (isAdmin) {
|
|
||||||
setCookie(event, 'codev_admin', 'ok', {
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'lax',
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
maxAge: 60 * 60 * 24, // 24h
|
|
||||||
path: '/',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return { status: 200, ok: true, admin: isAdmin }
|
|
||||||
})
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import type { CodevFiche } from '~/types/codev'
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event): Promise<{ list: CodevFiche[] }> => {
|
|
||||||
const config = useRuntimeConfig()
|
|
||||||
const tableId = config.codevTableId
|
|
||||||
|
|
||||||
if (!tableId) {
|
|
||||||
throw createError({ statusCode: 500, message: 'codevTableId non configuré' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `${config.nocodbUrl}/api/v2/tables/${tableId}/records?sort=created_at&limit=200`
|
|
||||||
|
|
||||||
const data: any = await $fetch(url, {
|
|
||||||
headers: { 'xc-token': config.nocodbToken },
|
|
||||||
}).catch(() => ({ list: [] }))
|
|
||||||
|
|
||||||
// Mapper chaque record NocoDB vers CodevFiche
|
|
||||||
const list: CodevFiche[] = (data?.list ?? []).map((r: any) => ({
|
|
||||||
id: r.Id ?? r.id,
|
|
||||||
nom: r.nom || '',
|
|
||||||
besoin: r.besoin || '',
|
|
||||||
offre: r.offre || '',
|
|
||||||
hashtags: (r.hashtags || '')
|
|
||||||
.split(',')
|
|
||||||
.map((h: string) => h.trim().toLowerCase().replace(/^#/, ''))
|
|
||||||
.filter(Boolean),
|
|
||||||
created_at: r.created_at || r.CreatedAt || new Date().toISOString(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
return { list }
|
|
||||||
})
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
const FicheSchema = z.object({
|
|
||||||
nom: z.string().min(2).max(50).trim(),
|
|
||||||
besoin: z.string().min(5).max(300).trim(),
|
|
||||||
offre: z.string().min(5).max(300).trim(),
|
|
||||||
hashtags: z.array(z.string().max(30)).max(3).default([]),
|
|
||||||
})
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
const body = await readBody(event)
|
|
||||||
const parsed = FicheSchema.safeParse(body)
|
|
||||||
|
|
||||||
if (!parsed.success) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 422,
|
|
||||||
statusMessage: 'Validation échouée',
|
|
||||||
data: parsed.error.flatten().fieldErrors,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = useRuntimeConfig()
|
|
||||||
const tableId = config.codevTableId
|
|
||||||
const baseId = config.codevBaseId || 'pipilvsi7dibo80'
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
nom: parsed.data.nom,
|
|
||||||
besoin: parsed.data.besoin,
|
|
||||||
offre: parsed.data.offre,
|
|
||||||
hashtags: parsed.data.hashtags
|
|
||||||
.map((h) => h.trim().toLowerCase().replace(/^#/, ''))
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 3)
|
|
||||||
.join(','),
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// NocoDB v1 endpoint pour INSERT (cf. submit/index.post.ts pour le pattern)
|
|
||||||
const insertUrl = `${config.nocodbUrl}/api/v1/db/data/noco/${baseId}/${tableId}`
|
|
||||||
|
|
||||||
let inserted: any
|
|
||||||
try {
|
|
||||||
inserted = await $fetch(insertUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'xc-token': config.nocodbToken,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error('[codev/fiches.post] NocoDB insert error:', e?.message ?? e)
|
|
||||||
throw createError({
|
|
||||||
statusCode: 502,
|
|
||||||
statusMessage: 'Erreur serveur, réessaie',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 201,
|
|
||||||
id: inserted?.Id ?? inserted?.id ?? null,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
export default defineEventHandler(async (event) => {
|
|
||||||
// Vérif cookie admin
|
|
||||||
const adminCookie = getCookie(event, 'codev_admin')
|
|
||||||
if (adminCookie !== 'ok') {
|
|
||||||
throw createError({ statusCode: 403, statusMessage: 'Accès refusé' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = useRuntimeConfig()
|
|
||||||
const tableId = config.codevTableId
|
|
||||||
const id = getRouterParam(event, 'id')
|
|
||||||
|
|
||||||
if (!tableId || !id) {
|
|
||||||
throw createError({ statusCode: 400, message: 'Parametre manquant' })
|
|
||||||
}
|
|
||||||
|
|
||||||
await $fetch(`${config.nocodbUrl}/api/v2/tables/${tableId}/records`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'xc-token': config.nocodbToken, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ Id: Number(id) }),
|
|
||||||
}).catch(() => {
|
|
||||||
throw createError({ statusCode: 502, statusMessage: 'Erreur suppression' })
|
|
||||||
})
|
|
||||||
|
|
||||||
return { status: 200, ok: true }
|
|
||||||
})
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import type { CodevFiche } from '~/types/codev'
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event): Promise<CodevFiche> => {
|
|
||||||
const config = useRuntimeConfig()
|
|
||||||
const tableId = config.codevTableId
|
|
||||||
const baseId = config.codevBaseId || 'pipilvsi7dibo80'
|
|
||||||
const id = getRouterParam(event, 'id')
|
|
||||||
|
|
||||||
if (!tableId || !id) {
|
|
||||||
throw createError({ statusCode: 400, message: 'Parametre manquant' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `${config.nocodbUrl}/api/v1/db/data/noco/${baseId}/${tableId}/${id}`
|
|
||||||
|
|
||||||
const r: any = await $fetch(url, {
|
|
||||||
headers: { 'xc-token': config.nocodbToken },
|
|
||||||
}).catch(() => null)
|
|
||||||
|
|
||||||
if (!r) {
|
|
||||||
throw createError({ statusCode: 404, message: 'Fiche introuvable' })
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: r.Id ?? r.id,
|
|
||||||
nom: r.nom || '',
|
|
||||||
besoin: r.besoin || '',
|
|
||||||
offre: r.offre || '',
|
|
||||||
hashtags: (r.hashtags || '')
|
|
||||||
.split(',')
|
|
||||||
.map((h: string) => h.trim().toLowerCase().replace(/^#/, ''))
|
|
||||||
.filter(Boolean),
|
|
||||||
created_at: r.created_at || r.CreatedAt || '',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
const PatchSchema = z.object({
|
|
||||||
nom: z.string().min(2).max(50).trim(),
|
|
||||||
besoin: z.string().min(5).max(300).trim(),
|
|
||||||
offre: z.string().min(5).max(300).trim(),
|
|
||||||
hashtags: z.array(z.string().max(30)).max(3).default([]),
|
|
||||||
})
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
const config = useRuntimeConfig()
|
|
||||||
const tableId = config.codevTableId
|
|
||||||
const baseId = config.codevBaseId || 'pipilvsi7dibo80'
|
|
||||||
const id = getRouterParam(event, 'id')
|
|
||||||
const body = await readBody(event)
|
|
||||||
|
|
||||||
if (!tableId || !id) {
|
|
||||||
throw createError({ statusCode: 400, message: 'Parametre manquant' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = PatchSchema.safeParse(body)
|
|
||||||
if (!parsed.success) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 422,
|
|
||||||
statusMessage: 'Validation echouee',
|
|
||||||
data: parsed.error.flatten().fieldErrors,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
nom: parsed.data.nom,
|
|
||||||
besoin: parsed.data.besoin,
|
|
||||||
offre: parsed.data.offre,
|
|
||||||
hashtags: parsed.data.hashtags
|
|
||||||
.map((h) => h.trim().toLowerCase().replace(/^#/, ''))
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 3)
|
|
||||||
.join(','),
|
|
||||||
}
|
|
||||||
|
|
||||||
// NocoDB v1 PATCH par Id
|
|
||||||
const url = `${config.nocodbUrl}/api/v1/db/data/noco/${baseId}/${tableId}/${id}`
|
|
||||||
|
|
||||||
try {
|
|
||||||
await $fetch(url, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {
|
|
||||||
'xc-token': config.nocodbToken,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error('[codev/fiches.patch] NocoDB patch error:', e?.message ?? e)
|
|
||||||
throw createError({ statusCode: 502, statusMessage: 'Erreur serveur' })
|
|
||||||
}
|
|
||||||
|
|
||||||
return { status: 200, ok: true }
|
|
||||||
})
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export default defineEventHandler((event) => {
|
|
||||||
const admin = getCookie(event, 'codev_admin') === 'ok'
|
|
||||||
const session = getCookie(event, 'codev_session') === 'ok'
|
|
||||||
return { admin, session }
|
|
||||||
})
|
|
||||||
20
server/api/pratiques.get.ts
Normal file
20
server/api/pratiques.get.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
import type { Pratique } from '~/types/pratique'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/pratiques
|
||||||
|
* Lit public/data/pratiques-regeneratives.json
|
||||||
|
* Retourne { list: Pratique[], source: 'static' }
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (_event) => {
|
||||||
|
try {
|
||||||
|
const jsonPath = resolve(process.cwd(), 'public/data/pratiques-regeneratives.json')
|
||||||
|
const raw = readFileSync(jsonPath, 'utf-8')
|
||||||
|
const list: Pratique[] = JSON.parse(raw)
|
||||||
|
return { list, source: 'static' }
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[PRATIQUES API] Erreur lecture JSON:', err)
|
||||||
|
throw createError({ statusCode: 503, message: 'Données pratiques-regeneratives indisponibles' })
|
||||||
|
}
|
||||||
|
})
|
||||||
117
server/api/submit-pratique.post.ts
Normal file
117
server/api/submit-pratique.post.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// Modération : Jules consulte public/data/pratiques-pending.json,
|
||||||
|
// déplace les entrées validées dans public/data/pratiques-regeneratives.json,
|
||||||
|
// supprime de pending. À automatiser en V2 (UI admin).
|
||||||
|
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
// ── Schéma Zod (miroir du client) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const PratiqueSubmitSchema = z.object({
|
||||||
|
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
|
||||||
|
url: z
|
||||||
|
.string()
|
||||||
|
.url('URL invalide (commencer par https://)')
|
||||||
|
.optional()
|
||||||
|
.or(z.literal(''))
|
||||||
|
.transform((v) => v || undefined),
|
||||||
|
description_user: z
|
||||||
|
.string()
|
||||||
|
.min(50, 'Minimum 50 caractères')
|
||||||
|
.max(500, 'Maximum 500 caractères')
|
||||||
|
.trim(),
|
||||||
|
criteres: z
|
||||||
|
.array(z.number().int().min(1).max(8))
|
||||||
|
.min(3, 'Sélectionne au moins 3 critères')
|
||||||
|
.max(8, 'Maximum 8 critères'),
|
||||||
|
pays: z.string().length(2, 'Code pays invalide').or(z.literal('AUTRE')),
|
||||||
|
pays_autre: z.string().max(50).optional(),
|
||||||
|
ville: z.string().max(100).optional().transform((v) => v?.trim() || undefined),
|
||||||
|
type: z.enum([
|
||||||
|
'agence', 'cooperative', 'collectif', 'reseau', 'asso',
|
||||||
|
'recherche', 'mouvement', 'plateforme', 'inconnu',
|
||||||
|
], { errorMap: () => ({ message: 'Type d\'entité invalide' }) }),
|
||||||
|
tags: z.array(z.string().max(30)).max(6).optional(),
|
||||||
|
submitted_by_email: z
|
||||||
|
.string()
|
||||||
|
.email('Email invalide')
|
||||||
|
.optional()
|
||||||
|
.or(z.literal(''))
|
||||||
|
.transform((v) => v || undefined),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type PratiqueSubmitInput = z.infer<typeof PratiqueSubmitSchema>
|
||||||
|
|
||||||
|
// ── Chemin du fichier pending ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getPendingPath(): string {
|
||||||
|
return resolve(process.cwd(), 'public/data/pratiques-pending.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPending(): PratiqueSubmitInput[] {
|
||||||
|
const path = getPendingPath()
|
||||||
|
try {
|
||||||
|
if (!existsSync(path)) return []
|
||||||
|
return JSON.parse(readFileSync(path, 'utf-8'))
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePending(entries: unknown[]) {
|
||||||
|
writeFileSync(getPendingPath(), JSON.stringify(entries, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handler principal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
// 1. Récupérer l'IP (proxy-aware)
|
||||||
|
const ip =
|
||||||
|
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
|
||||||
|
event.node.req.socket?.remoteAddress ||
|
||||||
|
'0.0.0.0'
|
||||||
|
|
||||||
|
// 2. Rate limit JSON : 3 soumissions / IP / jour
|
||||||
|
const allowed = checkRateLimitJson(ip, 'submit-pratique', 3)
|
||||||
|
if (!allowed) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 429,
|
||||||
|
statusMessage: 'Trop de soumissions. Réessaie demain.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Lire et valider le body
|
||||||
|
const body = await readBody(event)
|
||||||
|
const parsed = PratiqueSubmitSchema.safeParse(body)
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 422,
|
||||||
|
statusMessage: 'Validation échouée',
|
||||||
|
data: parsed.error.flatten().fieldErrors,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parsed.data
|
||||||
|
|
||||||
|
// 4. Construire l'entrée pending
|
||||||
|
const timestamp = Date.now()
|
||||||
|
const entry = {
|
||||||
|
...data,
|
||||||
|
id: timestamp,
|
||||||
|
submitted_at: new Date().toISOString(),
|
||||||
|
moderation_status: 'pending' as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Append à pratiques-pending.json
|
||||||
|
const pending = readPending()
|
||||||
|
pending.push(entry)
|
||||||
|
writePending(pending)
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
trackingId: timestamp,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
// Middleware server Nuxt — protection des routes /codev/fiche et /codev/carto
|
|
||||||
// Laisse passer /codev (lock screen), /codev/demo et toutes les routes /api/*
|
|
||||||
|
|
||||||
export default defineEventHandler((event) => {
|
|
||||||
const url = getRequestURL(event)
|
|
||||||
const path = url.pathname
|
|
||||||
|
|
||||||
// Seulement les routes sous /codev/
|
|
||||||
if (!path.startsWith('/codev/')) return
|
|
||||||
|
|
||||||
// Routes publiques : /codev/demo et /codev/qr (et sous-routes éventuelles)
|
|
||||||
if (path === '/codev/demo' || path.startsWith('/codev/demo/')) return
|
|
||||||
if (path === '/codev/qr' || path.startsWith('/codev/qr/')) return
|
|
||||||
|
|
||||||
// Vérification cookie
|
|
||||||
const session = getCookie(event, 'codev_session')
|
|
||||||
if (session === 'ok') return
|
|
||||||
|
|
||||||
// Non authentifié -> redirect vers /codev (lock screen)
|
|
||||||
return sendRedirect(event, '/codev', 302)
|
|
||||||
})
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
export interface CodevFiche {
|
|
||||||
id: number
|
|
||||||
nom: string
|
|
||||||
besoin: string
|
|
||||||
offre: string
|
|
||||||
hashtags: string[] // parsé depuis CSV NocoDB
|
|
||||||
created_at: string // ISO
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodevMatch {
|
|
||||||
fromId: number
|
|
||||||
toId: number
|
|
||||||
score: number // 0-1
|
|
||||||
mode: 'solution' | 'alliance' | 'surprise'
|
|
||||||
// solution : fromId.besoin matche toId.offre (orienté)
|
|
||||||
// alliance : symétrique sur besoin
|
|
||||||
// surprise : symétrique sur offre
|
|
||||||
}
|
|
||||||
69
types/pratique.ts
Normal file
69
types/pratique.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Interface canonique Pratique — AEP Pratiques Régénératives
|
||||||
|
* Source unique : types/pratique.ts
|
||||||
|
* Importée dans pages/pratiques-regeneratives.vue, pages/pratique/[id].vue
|
||||||
|
*/
|
||||||
|
export interface Pratique {
|
||||||
|
id: number
|
||||||
|
nom: string
|
||||||
|
pays: string // ISO-2 — Europe (FR/BE/...) ou DOM-TOM (GP/MQ/GF/RE/YT/PF/NC/...)
|
||||||
|
ville: string
|
||||||
|
type: 'agence' | 'cooperative' | 'collectif' | 'reseau' | 'asso' | 'recherche' | 'mouvement' | 'plateforme' | 'inconnu'
|
||||||
|
url: string
|
||||||
|
lat: number | null
|
||||||
|
lng: number | null
|
||||||
|
description: string
|
||||||
|
criteres: number[] // 1-8
|
||||||
|
score: number
|
||||||
|
tags: string[]
|
||||||
|
source: string
|
||||||
|
passe: 1 | 2 | 3
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CRITERES = [
|
||||||
|
{ id: 1, label: 'Matériaux' },
|
||||||
|
{ id: 2, label: 'Filières' },
|
||||||
|
{ id: 3, label: 'Posture' },
|
||||||
|
{ id: 4, label: 'Process' },
|
||||||
|
{ id: 5, label: 'Politique' },
|
||||||
|
{ id: 6, label: 'Modèle éco' },
|
||||||
|
{ id: 7, label: 'Vivant' },
|
||||||
|
{ id: 8, label: 'Transmission' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const TYPES_ENTITE = [
|
||||||
|
'agence',
|
||||||
|
'cooperative',
|
||||||
|
'collectif',
|
||||||
|
'reseau',
|
||||||
|
'asso',
|
||||||
|
'recherche',
|
||||||
|
'mouvement',
|
||||||
|
'plateforme',
|
||||||
|
'inconnu',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const TYPES_ENTITE_LABELS: Record<string, string> = {
|
||||||
|
agence: 'Agence',
|
||||||
|
cooperative: 'Coopérative',
|
||||||
|
collectif: 'Collectif',
|
||||||
|
reseau: 'Réseau',
|
||||||
|
asso: 'Association',
|
||||||
|
recherche: 'Recherche',
|
||||||
|
mouvement: 'Mouvement',
|
||||||
|
plateforme: 'Plateforme',
|
||||||
|
inconnu: 'Autre',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EUROPE_CODES = ['FR', 'BE', 'UK', 'DE', 'ES', 'NL', 'CH', 'IT', 'PT', 'SE', 'DK', 'FI', 'NO', 'PL', 'CZ', 'AT'] as const
|
||||||
|
export const OUTREMER_CODES = ['GP', 'MQ', 'GF', 'RE', 'YT', 'PF', 'NC', 'BL', 'MF', 'PM', 'WF'] as const
|
||||||
|
|
||||||
|
export const PAYS_LABELS: Record<string, string> = {
|
||||||
|
FR: 'France', BE: 'Belgique', UK: 'Royaume-Uni', DE: 'Allemagne',
|
||||||
|
ES: 'Espagne', NL: 'Pays-Bas', CH: 'Suisse', IT: 'Italie',
|
||||||
|
PT: 'Portugal', SE: 'Suède', DK: 'Danemark', FI: 'Finlande',
|
||||||
|
NO: 'Norvège', PL: 'Pologne', CZ: 'Tchéquie', AT: 'Autriche',
|
||||||
|
GP: 'Guadeloupe', MQ: 'Martinique', GF: 'Guyane', RE: 'La Réunion',
|
||||||
|
YT: 'Mayotte', PF: 'Polynésie française', NC: 'Nouvelle-Calédonie',
|
||||||
|
BL: 'Saint-Barthélemy', MF: 'Saint-Martin', PM: 'Saint-Pierre-et-Miquelon', WF: 'Wallis-et-Futuna',
|
||||||
|
}
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import type { CodevFiche, CodevMatch } from '~/types/codev'
|
|
||||||
|
|
||||||
const STOP_WORDS_FR = new Set([
|
|
||||||
'le', 'la', 'les', 'un', 'une', 'des', 'de', 'du', 'au', 'aux',
|
|
||||||
'et', 'ou', 'mais', 'donc', 'car', 'ni', 'or',
|
|
||||||
'a', 'en', 'pour', 'par', 'sur', 'avec', 'sans', 'dans', 'sous',
|
|
||||||
'je', 'tu', 'il', 'elle', 'on', 'nous', 'vous', 'ils', 'elles',
|
|
||||||
'mon', 'ma', 'mes', 'ton', 'ta', 'tes', 'son', 'sa', 'ses',
|
|
||||||
'notre', 'nos', 'votre', 'vos', 'leur', 'leurs',
|
|
||||||
'ce', 'cet', 'cette', 'ces', 'qui', 'que', 'quoi', 'dont',
|
|
||||||
'est', 'sont', 'etre', 'ai', 'as', 'avoir',
|
|
||||||
'pas', 'plus', 'moins', 'tres', 'aussi', 'bien', 'tout', 'tous',
|
|
||||||
'me', 'te', 'se', 'lui', 'leur', 'y',
|
|
||||||
])
|
|
||||||
|
|
||||||
function tokenize(text: string): Set<string> {
|
|
||||||
if (!text) return new Set()
|
|
||||||
const tokens = text
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[.,;:!?()'"\-/]/g, ' ')
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter((t) => t.length >= 3 && !STOP_WORDS_FR.has(t))
|
|
||||||
return new Set(tokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
function jaccard(a: Set<string>, b: Set<string>): number {
|
|
||||||
if (a.size === 0 || b.size === 0) return 0
|
|
||||||
let inter = 0
|
|
||||||
for (const x of a) if (b.has(x)) inter++
|
|
||||||
const union = a.size + b.size - inter
|
|
||||||
return union === 0 ? 0 : inter / union
|
|
||||||
}
|
|
||||||
|
|
||||||
function score(textA: string, hashtagsA: string[], textB: string, hashtagsB: string[]): number {
|
|
||||||
const tagsA = new Set(hashtagsA.map((h) => h.toLowerCase()))
|
|
||||||
const tagsB = new Set(hashtagsB.map((h) => h.toLowerCase()))
|
|
||||||
|
|
||||||
if (tagsA.size > 0 && tagsB.size > 0) {
|
|
||||||
return jaccard(tagsA, tagsB)
|
|
||||||
}
|
|
||||||
return jaccard(tokenize(textA), tokenize(textB))
|
|
||||||
}
|
|
||||||
|
|
||||||
// scoreDirect tokenise TOUJOURS les textes, ignore les hashtags
|
|
||||||
// Utilise pour matchSolution : besoin vs offre doivent etre compares par leur contenu reel
|
|
||||||
function scoreDirect(textA: string, textB: string): number {
|
|
||||||
return jaccard(tokenize(textA), tokenize(textB))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function matchSolution(fiches: CodevFiche[], threshold = 0.18): CodevMatch[] {
|
|
||||||
const matches: CodevMatch[] = []
|
|
||||||
for (const a of fiches) {
|
|
||||||
for (const b of fiches) {
|
|
||||||
if (a.id === b.id) continue
|
|
||||||
// Solution : on compare le TEXTE besoin de A avec le TEXTE offre de B
|
|
||||||
// On ignore les hashtags pour differencier besoin et offre
|
|
||||||
const s = scoreDirect(a.besoin, b.offre)
|
|
||||||
if (s >= threshold) {
|
|
||||||
matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'solution' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return matches
|
|
||||||
}
|
|
||||||
|
|
||||||
export function matchAlliance(fiches: CodevFiche[], threshold = 0.25): CodevMatch[] {
|
|
||||||
const matches: CodevMatch[] = []
|
|
||||||
for (let i = 0; i < fiches.length; i++) {
|
|
||||||
for (let j = i + 1; j < fiches.length; j++) {
|
|
||||||
const a = fiches[i], b = fiches[j]
|
|
||||||
// Alliance : besoins similaires — on compare hashtags si presents, sinon textes
|
|
||||||
const s = score(a.besoin, a.hashtags, b.besoin, b.hashtags)
|
|
||||||
if (s >= threshold) {
|
|
||||||
matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'alliance' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return matches
|
|
||||||
}
|
|
||||||
|
|
||||||
export function matchSurprise(fiches: CodevFiche[], threshold = 0.25): CodevMatch[] {
|
|
||||||
const matches: CodevMatch[] = []
|
|
||||||
for (let i = 0; i < fiches.length; i++) {
|
|
||||||
for (let j = i + 1; j < fiches.length; j++) {
|
|
||||||
const a = fiches[i], b = fiches[j]
|
|
||||||
// Surprise : offres similaires
|
|
||||||
const s = score(a.offre, a.hashtags, b.offre, b.hashtags)
|
|
||||||
if (s >= threshold) {
|
|
||||||
matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'surprise' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return matches
|
|
||||||
}
|
|
||||||
|
|
||||||
export function computeMatches(
|
|
||||||
fiches: CodevFiche[],
|
|
||||||
mode: 'solution' | 'alliance' | 'surprise',
|
|
||||||
threshold?: number,
|
|
||||||
): CodevMatch[] {
|
|
||||||
switch (mode) {
|
|
||||||
case 'solution': return matchSolution(fiches, threshold)
|
|
||||||
case 'alliance': return matchAlliance(fiches, threshold)
|
|
||||||
case 'surprise': return matchSurprise(fiches, threshold)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user