6 Commits

Author SHA1 Message Date
Jules Neny
ad9e7db43c feat(aep-v2): restore V2 cascade composants récupérés depuis vault history
- Récupérés depuis commit vault b700612^ (état pré-chirurgie git)
- FicheFamilleModal.vue (284L) — PV2-5g
- FicheModalV2.vue (341L) + NavMapV2.vue (243L) — PV2-5
- HashtagFilter.vue (97L) + IntentionBanner.vue (76L) — PV2-5
- GraphView.vue (860L) — PV2-5b+5e+5f+5g complet
- ChatbotPlaceholder.vue (423L) — version chatbot-v2
- pages/index.vue (517L) — carte unifiée 3 onglets
- types/structure-v2.ts, assets/css/v2-bifurcation.css
- server/api/chatbot-v2.post.ts, server/utils/vectorSearch.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 17:23:36 +02:00
Jules Neny
825b0ddeb2 feat(codev): M5 phase 1 - mode demo factice + build local OK 2026-05-06 16:11:34 +02:00
Jules Neny
d345d7f6f9 feat(codev): M4 - matching 3 modes + boutons UI + animation force
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:07:20 +02:00
Jules Neny
3347b3f859 feat(codev): M3 - CodevGraph D3 force-directed + page carto affichage
- Install d3@^7.9.0 (absent du projet, requis pour force simulation)
- components/codev/CodevGraph.vue : simulation forceLink/forceManyBody/forceCenter/forceCollide, drag D3, pastilles offre (vert) + besoin (orange), tooltip SVG natif, ResizeObserver, watch matches/mode pret pour M4, placeholder si 0 fiches
- pages/codev/carto.vue : useFetch /api/codev/fiches, mount CodevGraph, refs matches+mode vides (M4 les remplira)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:03:28 +02:00
Jules Neny
9c4f4b8e87 feat(codev): M2 - lock screen + fiche form + middleware auth
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 15:59:26 +02:00
Jules Neny
5103942698 feat(codev): M1 - NocoDB table schema + 3 endpoints API + runtimeConfig 2026-05-06 15:56:19 +02:00
53 changed files with 4194 additions and 4492 deletions

View File

@@ -11,48 +11,6 @@ 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

View File

@@ -1,45 +0,0 @@
# 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`

View File

@@ -1,76 +0,0 @@
# 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

View File

@@ -1,59 +0,0 @@
# 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 ?**

View File

@@ -1,61 +0,0 @@
# 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.

View File

@@ -1,172 +0,0 @@
# 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`).

28
app.vue
View File

@@ -34,13 +34,6 @@
> >
É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"
@@ -107,9 +100,9 @@
> >
Signaler Signaler
</NuxtLink> </NuxtLink>
<!-- Proposer une ressource (cible contextuelle selon la carte active) --> <!-- Proposer une ressource -->
<NuxtLink <NuxtLink
:to="proposeTarget" to="/contribuer"
class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 hidden sm:inline-flex items-center gap-1" class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 hidden sm:inline-flex items-center gap-1"
style="background: var(--nav-accent); color: var(--nav-text);" style="background: var(--nav-accent); color: var(--nav-text);"
> >
@@ -136,9 +129,9 @@
</svg> </svg>
</button> </button>
<!-- Mobile : contribuer icône (cible contextuelle) --> <!-- Mobile : contribuer icône -->
<NuxtLink <NuxtLink
:to="proposeTarget" to="/contribuer"
class="sm:hidden p-2 rounded-lg" class="sm:hidden p-2 rounded-lg"
style="background: var(--nav-accent); color: var(--nav-text);" style="background: var(--nav-accent); color: var(--nav-text);"
title="Contribuer une fiche" title="Contribuer une fiche"
@@ -172,7 +165,6 @@
@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="/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="/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>
<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>
@@ -184,7 +176,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 === '/' || route.path === '/pratiques-regeneratives') ? 'overflow-hidden' : 'overflow-y-auto'"> <div class="flex-1" :class="route.path === '/' ? 'overflow-hidden' : 'overflow-y-auto'">
<NuxtPage /> <NuxtPage />
</div> </div>
@@ -253,16 +245,6 @@ function clearHeaderSearch() {
function goRandom() { function goRandom() {
router.push({ path: '/', query: { random: '1' } }) router.push({ path: '/', query: { random: '1' } })
} }
// ── Cible contextuelle du bouton Proposer ────────────────────────────────
// Sur l'onglet pratiques regeneratives, route vers /proposer-pratique.
// Sur l'onglet ecosysteme AEP (et toute autre route), route vers /contribuer.
const proposeTarget = computed(() => {
if (route.path.startsWith('/pratiques-regeneratives') || route.path.startsWith('/pratique/')) {
return '/proposer-pratique'
}
return '/contribuer'
})
</script> </script>
<style> <style>

View File

@@ -0,0 +1,23 @@
/* Palette familles V2 - variables locales, ne pas toucher --nav-* */
:root {
--bifurc-color-f1: #a85d3e; /* Réemploi & filières - terracotta */
--bifurc-color-f2: #c4a472; /* Frugalité & low-tech - terre crue */
--bifurc-color-f3: #d4a017; /* Architecture sociale - ocre */
--bifurc-color-f4: #5a7a4a; /* Collectifs & AMO - vert mousse */
--bifurc-color-f5: #3d6a8c; /* Urbanisme transition - bleu profond */
--bifurc-badge-f6: #6b3fa0; /* Recherche politique - violet */
--bifurc-badge-cr: #2d8a6b; /* Centre ressources - vert foncé */
--bifurc-badge-mm: #c44a2f; /* Mouvement manifeste - rouge brique */
--bifurc-badge-cp: #1a3a6b; /* Contre-pouvoir - bleu nuit */
--bifurc-banner-bg: #faf8f5;
--bifurc-banner-border: #e0d8cc;
--bifurc-banner-text: #2c2416;
}
.bifurc-pin-f1 { background: var(--bifurc-color-f1); }
.bifurc-pin-f2 { background: var(--bifurc-color-f2); }
.bifurc-pin-f3 { background: var(--bifurc-color-f3); }
.bifurc-pin-f4 { background: var(--bifurc-color-f4); }
.bifurc-pin-f5 { background: var(--bifurc-color-f5); }

View File

@@ -461,12 +461,10 @@ const jaugePct = computed(() => {
} }
/* ── FAB mobile soutenir ─────────────────────────────────────────────────── */ /* ── FAB mobile soutenir ─────────────────────────────────────────────────── */
/* Stack vertical avec le FAB Chatbot a droite (evite l'overlap avec les chips
sidebar mobile sur viewport intermediaire ~880px - bug E2E M3) */
.fab-soutenir { .fab-soutenir {
position: fixed; position: fixed;
bottom: 84px; /* au-dessus du FAB chatbot a bottom-6 (24px) + 48px de hauteur + 12px gap */ bottom: 68px; /* au-dessus du FAB chatbot à 24px du bas + 48px de hauteur */
right: 16px; left: 16px;
z-index: 1000; z-index: 1000;
width: 44px; width: 44px;
height: 44px; height: 44px;

View File

@@ -204,7 +204,7 @@ async function sendMessage() {
const res = await $fetch<{ const res = await $fetch<{
reponse_texte: string reponse_texte: string
fiches_recommandees: { id: number | string; nom: string; explication: string }[] fiches_recommandees: { id: number | string; nom: string; explication: string }[]
}>('/api/chatbot-v2', { }>('/api/chatbot', {
method: 'POST', method: 'POST',
body: { question }, body: { question },
}) })

View File

@@ -61,7 +61,7 @@
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/> <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> </svg>
</div> </div>
<span class="font-bold text-sm" style="color: var(--nav-text);">{{ title }}</span> <span class="font-bold text-sm" style="color: var(--nav-text);">Chatbot</span>
</div> </div>
</div> </div>
@@ -69,20 +69,18 @@
<div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3"> <div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
<!-- Message onboarding (avant la première question) --> <!-- Message onboarding (avant la première question) -->
<div v-if="messages.length === 0" class="onboarding-bubble"> <div v-if="messages.length === 0" class="onboarding-bubble">
<slot name="onboarding"> <p>Ce chatbot fonctionne sur un serveur européen souverain
<p>Ce chatbot fonctionne sur un serveur européen souverain
(Mistral FR, zéro rétention), conçu sobre en énergie.</p> (Mistral FR, zéro rétention), conçu sobre en énergie.</p>
<p>Pour m'aider à te répondre efficacement, <p>Pour m'aider à te répondre efficacement,
formule ta requête ainsi :</p> formule ta requête ainsi :</p>
<ul> <ul>
<li>• Besoin : [ce que tu cherches]</li> <li>• Besoin : [ce que tu cherches]</li>
<li>• Thématique : [juridique / technique / économique / ...]</li> <li>• Thématique : [juridique / technique / économique / ...]</li>
<li>• Lieu : [région ou ville]</li> <li>• Lieu : [région ou ville]</li>
</ul> </ul>
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon <p class="example">Exemple : "Je suis salarié d'agence, litige avec mon
employeur, besoin conseil juridique droit du travail, employeur, besoin conseil juridique droit du travail,
Île-de-France."</p> Île-de-France."</p>
</slot>
</div> </div>
<!-- Messages --> <!-- Messages -->
@@ -102,7 +100,7 @@ employeur, besoin conseil juridique droit du travail,
<a <a
v-for="fiche in msg.fiches" v-for="fiche in msg.fiches"
:key="fiche.id" :key="fiche.id"
:href="`${ficheBasePath}/${fiche.id}`" :href="`/fiche/${fiche.id}`"
class="fiche-card" class="fiche-card"
> >
<span class="fiche-nom">{{ fiche.nom }}</span> <span class="fiche-nom">{{ fiche.nom }}</span>
@@ -178,16 +176,9 @@ interface ChatMessage {
fiches?: FicheReco[] fiches?: FicheReco[]
} }
const props = withDefaults(defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
endpoint?: string }>()
title?: string
ficheBasePath?: string
}>(), {
endpoint: '/api/chatbot',
title: 'Chatbot',
ficheBasePath: '/fiche',
})
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: boolean] 'update:modelValue': [value: boolean]
@@ -234,7 +225,7 @@ async function sendMessage() {
const res = await $fetch<{ const res = await $fetch<{
reponse_texte: string reponse_texte: string
fiches_recommandees: { id: number | string; nom: string; explication: string }[] fiches_recommandees: { id: number | string; nom: string; explication: string }[]
}>(props.endpoint, { }>('/api/chatbot', {
method: 'POST', method: 'POST',
body: { question }, body: { question },
}) })

View File

@@ -1,41 +0,0 @@
<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>

View File

@@ -0,0 +1,284 @@
<template>
<Teleport to="body">
<!-- Backdrop -->
<Transition name="backdrop">
<div
v-if="modelValue && familleId != null"
class="fixed inset-0 z-[1400]"
style="background: rgba(26,34,56,0.55);"
@click="close"
aria-hidden="true"
/>
</Transition>
<!-- Modal -->
<Transition name="modal">
<div
v-if="modelValue && familleId != null"
class="fixed z-[1401] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col"
style="
width: min(720px, 94vw);
max-height: 88vh;
background: var(--nav-bg);
border-radius: 16px;
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
overflow: hidden;
"
role="dialog"
aria-modal="true"
:aria-label="familleLabel"
@keydown.esc="close"
>
<!-- Header : background couleur famille -->
<div
class="flex items-center justify-between px-5 py-4 shrink-0"
:style="`background: ${familleColor}; color: white;`"
>
<div class="flex items-center gap-3">
<div
class="w-3 h-3 rounded-full shrink-0"
style="background: white;"
/>
<h2 class="text-lg font-bold" style="color: white;">{{ familleLabel }}</h2>
</div>
<button
@click="close"
class="w-8 h-8 rounded-lg flex items-center justify-center transition-colors"
style="background: rgba(255,255,255,0.18); color: white;"
aria-label="Fermer"
>
<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"/>
</svg>
</button>
</div>
<!-- Body scrollable -->
<div class="flex-1 overflow-y-auto px-5 py-5">
<!-- Description longue editoriale -->
<p
class="text-sm leading-relaxed mb-5"
style="color: var(--nav-text); white-space: pre-wrap;"
>{{ familleDescription }}</p>
<!-- Separateur -->
<div style="height: 1px; background: var(--nav-bg-alt); margin-bottom: 16px;" />
<!-- Mode fusion : Principal+Secondaire melanges (peu de secondaires) -->
<template v-if="!showSplit">
<h3
class="text-xs font-bold uppercase tracking-wide mb-3"
style="color: var(--nav-text-muted);"
>{{ allStructures.length }} structure{{ allStructures.length > 1 ? 's' : '' }}</h3>
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px;">
<li
v-for="s in allStructures"
:key="s.id"
@click="selectStructure(s.id)"
class="structure-row"
style="
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 6px;
cursor: pointer; transition: background 0.1s;
"
>
<span
style="width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;"
:style="`background: ${FAMILLE_COLORS[s.famille_principale] ?? '#888'};`"
:title="`Famille principale : ${FAMILLE_LABELS[s.famille_principale] ?? ''}`"
/>
<span class="text-sm font-medium" style="color: var(--nav-text);">{{ s.nom }}</span>
<span class="text-xs" style="color: var(--nav-text-muted);">{{ s.ville }}</span>
</li>
</ul>
</template>
<!-- Mode split : Principal / Secondaire separes -->
<template v-else>
<div v-if="principalStructures.length" class="mb-5">
<h3
class="text-xs font-bold uppercase tracking-wide mb-3"
style="color: var(--nav-text-muted);"
>Principal ({{ principalStructures.length }})</h3>
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px;">
<li
v-for="s in principalStructures"
:key="s.id"
@click="selectStructure(s.id)"
class="structure-row"
style="
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 6px;
cursor: pointer; transition: background 0.1s;
"
>
<span
style="width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;"
:style="`background: ${FAMILLE_COLORS[s.famille_principale] ?? '#888'};`"
/>
<span class="text-sm font-medium" style="color: var(--nav-text);">{{ s.nom }}</span>
<span class="text-xs" style="color: var(--nav-text-muted);">{{ s.ville }}</span>
</li>
</ul>
</div>
<div v-if="secondaireStructures.length">
<h3
class="text-xs font-bold uppercase tracking-wide mb-3"
style="color: var(--nav-text-muted);"
>Secondaire ({{ secondaireStructures.length }})</h3>
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px;">
<li
v-for="s in secondaireStructures"
:key="s.id"
@click="selectStructure(s.id)"
class="structure-row"
style="
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 6px;
cursor: pointer; transition: background 0.1s;
"
>
<span
style="width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;"
:style="`background: ${FAMILLE_COLORS[s.famille_principale] ?? '#888'};`"
:title="`Famille principale : ${FAMILLE_LABELS[s.famille_principale] ?? ''}`"
/>
<span class="text-sm font-medium" style="color: var(--nav-text);">{{ s.nom }}</span>
<span class="text-xs" style="color: var(--nav-text-muted);">{{ s.ville }}</span>
</li>
</ul>
</div>
</template>
</div>
<!-- Footer -->
<div
class="px-5 py-3 shrink-0 text-xs"
style="border-top: 1px solid var(--nav-bg-alt); color: var(--nav-text-muted); background: var(--nav-surface);"
>
Click sur une structure pour ouvrir sa fiche
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { ReseauxBifurcationData, StructureV2 } from '~/types/structure-v2'
const FAMILLE_COLORS: Record<number, string> = {
1: '#a85d3e',
2: '#c4a472',
3: '#d4a017',
4: '#5a7a4a',
5: '#3d6a8c',
6: '#6b3fa0',
}
const FAMILLE_LABELS: Record<number, string> = {
1: 'Reemploi & filieres',
2: 'Frugalite & low-tech',
3: 'Architecture sociale',
4: 'Collectifs & AMO',
5: 'Urbanisme de transition',
6: 'Recherche-action',
}
const FAMILLE_DESCRIPTIONS: Record<number, string> = {
1: "Structures dont le geste premier est de travailler avec la matiere existante : deconstruction selective, plateformes de redistribution, filieres biosourcees et geosourcees.",
2: "Pratiques qui partent du principe qu'on peut faire mieux avec moins. Renovation profonde, materiaux locaux, sobriete choisie.",
3: "Structures dont le terrain premier est le mal-logement, la precarite, l'hospitalite. Architecture comme reponse a l'urgence sociale.",
4: "Structures qui accompagnent les projets collectifs : cooperatives d'habitat, ecovillages, accompagnement vers l'autogestion ou la renovation.",
5: "Demarches a l'echelle du territoire : villes en transition, PLU alternatifs, coalitions territoriales.",
6: "Recherche-action et production de contre-savoirs (Forensic Architecture, Rural Studio, PEROU, Centrala). Badge transversal aux familles.",
}
const props = defineProps<{
modelValue: boolean
familleId: number | null
data: ReseauxBifurcationData | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'select-structure': [id: string]
}>()
function close() {
emit('update:modelValue', false)
}
function selectStructure(id: string) {
// Fermer d'abord pour eviter superposition de modales
emit('update:modelValue', false)
emit('select-structure', id)
}
// Fermeture Esc globale
onMounted(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.modelValue) close()
}
window.addEventListener('keydown', handler)
onUnmounted(() => window.removeEventListener('keydown', handler))
})
const familleColor = computed(() =>
FAMILLE_COLORS[props.familleId ?? 0] ?? '#888'
)
const familleLabel = computed(() =>
FAMILLE_LABELS[props.familleId ?? 0] ?? ''
)
const familleDescription = computed(() =>
FAMILLE_DESCRIPTIONS[props.familleId ?? 0] ?? ''
)
const principalStructures = computed<StructureV2[]>(() => {
if (!props.data || props.familleId == null) return []
return props.data.structures
.filter(s => s.famille_principale === props.familleId)
.sort((a, b) => a.nom.localeCompare(b.nom, 'fr'))
})
const secondaireStructures = computed<StructureV2[]>(() => {
if (!props.data || props.familleId == null) return []
return props.data.structures
.filter(s =>
s.famille_principale !== props.familleId
&& (s.familles_secondaires ?? []).includes(props.familleId as number)
)
.sort((a, b) => a.nom.localeCompare(b.nom, 'fr'))
})
const allStructures = computed<StructureV2[]>(() => {
return [...principalStructures.value, ...secondaireStructures.value]
})
// Heuristique : si > 3 secondaires, separer en sections distinctes
const showSplit = computed(() => secondaireStructures.value.length > 3)
</script>
<style scoped>
.structure-row:hover {
background: var(--nav-bg-alt);
}
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
.modal-enter-active, .modal-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.modal-enter-from, .modal-leave-to {
opacity: 0;
transform: translate(-50%, -52%);
}
@media (prefers-reduced-motion: reduce) {
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
.modal-enter-active, .modal-leave-active { transition: none; }
}
</style>

341
components/FicheModalV2.vue Normal file
View File

@@ -0,0 +1,341 @@
<template>
<Teleport to="body">
<!-- Backdrop -->
<Transition name="backdrop">
<div
v-if="modelValue && structureId != null"
class="fixed inset-0 z-[1500]"
style="background: rgba(26,34,56,0.55);"
@click="close"
aria-hidden="true"
/>
</Transition>
<!-- Modal -->
<Transition name="modal">
<div
v-if="modelValue && structureId != null && structure"
class="fixed z-[1501] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col"
style="
width: min(780px, 94vw);
max-height: 90vh;
background: var(--nav-bg);
border-radius: 16px;
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
overflow: hidden;
"
role="dialog"
aria-modal="true"
:aria-label="structure?.nom ?? 'Fiche structure'"
@keydown.esc="close"
>
<!-- Header modal -->
<div
class="flex items-center justify-between px-5 py-3 shrink-0"
:style="`border-bottom: 3px solid ${familleColor}; background: var(--nav-surface);`"
>
<div class="flex items-center gap-3">
<!-- Pastille famille -->
<div
class="w-3 h-3 rounded-full shrink-0"
:style="`background: ${familleColor};`"
/>
<span class="text-sm font-semibold" style="color: var(--nav-text-muted);">
{{ familleLabel }}
</span>
<!-- Badges -->
<div class="flex gap-1.5">
<span
v-if="structure.badges.centre_ressources"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: #2d8a6b22; color: #2d8a6b;"
>Centre ressources</span>
<span
v-if="structure.badges.mouvement_manifeste"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: #c44a2f22; color: #c44a2f;"
>Manifeste</span>
<span
v-if="structure.badges.contre_pouvoir_spatial"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: #1a3a6b22; color: #1a3a6b;"
>Contre-pouvoir</span>
<span
v-if="structure.badges.f6_recherche_politique"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: #6b3fa022; color: #6b3fa0;"
>Recherche pol.</span>
</div>
</div>
<div class="flex items-center gap-2">
<a
v-if="structure.url"
:href="structure.url"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-opacity hover:opacity-70"
style="background: var(--nav-bg-alt); color: var(--nav-text);"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
Site web
</a>
<button
@click="close"
class="w-8 h-8 rounded-lg flex items-center justify-center transition-colors"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
aria-label="Fermer"
>
<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"/>
</svg>
</button>
</div>
</div>
<!-- Contenu scrollable -->
<div class="flex-1 overflow-y-auto px-5 py-5">
<!-- Nom + meta -->
<div class="mb-4">
<h2 class="text-xl font-bold mb-1" style="color: var(--nav-text);">{{ structure.nom }}</h2>
<div class="flex flex-wrap gap-2 text-sm" style="color: var(--nav-text-muted);">
<span>{{ structure.type_principal }}</span>
<span>·</span>
<span>{{ structure.ville }}, {{ structure.pays }}</span>
</div>
</div>
<!-- Hashtags -->
<div v-if="structure.hashtags.length" class="flex flex-wrap gap-1.5 mb-4">
<span
v-for="tag in structure.hashtags"
:key="tag"
class="px-2 py-0.5 rounded-full text-xs"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ tag }}</span>
</div>
<!-- Description courte -->
<p class="text-sm leading-relaxed mb-4" style="color: var(--nav-text);">{{ structure.description_courte }}</p>
<!-- Description longue (expandable) -->
<div v-if="structure.description_longue" class="mb-4">
<div
class="text-sm leading-relaxed"
style="color: var(--nav-text); white-space: pre-wrap;"
:style="showFullDesc ? '' : 'max-height: 120px; overflow: hidden;'"
>{{ structure.description_longue }}</div>
<button
@click="showFullDesc = !showFullDesc"
class="mt-2 text-xs underline"
style="color: var(--nav-text-muted);"
>{{ showFullDesc ? 'Réduire' : 'Lire la suite…' }}</button>
</div>
<!-- Pensées rattachées -->
<div v-if="structure.pensees && structure.pensees.length" class="mb-4">
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Pensées rattachées</h3>
<div class="flex flex-wrap gap-1.5">
<span
v-for="pensee in structure.pensees"
:key="pensee.id"
class="px-2 py-0.5 rounded text-xs"
:style="pensee.confiance === 'ia_suggested'
? 'background: var(--nav-bg-alt); color: var(--nav-text-muted); border: 1px dashed var(--nav-bg-alt);'
: 'background: var(--nav-bg-alt); color: var(--nav-text);'"
:title="pensee.confiance === 'ia_suggested' ? 'IA suggéré' : ''"
>
{{ pensee.label }}<span v-if="pensee.confiance === 'ia_suggested'" class="ml-1 opacity-60">~</span>
</span>
</div>
</div>
<!-- Projets emblématiques -->
<div v-if="projetsStructure.length" class="mb-4">
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Projets emblématiques</h3>
<div class="space-y-2">
<div
v-for="projet in projetsStructure.slice(0, 5)"
:key="projet.id"
class="rounded-lg p-3"
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);"
>
<div class="flex items-start justify-between gap-2">
<span class="font-medium text-sm" style="color: var(--nav-text);">{{ projet.nom }}</span>
<span v-if="projet.annee" class="text-xs shrink-0" style="color: var(--nav-text-muted);">{{ projet.annee }}</span>
</div>
<div v-if="projet.lieu" class="text-xs mt-0.5" style="color: var(--nav-text-muted);">{{ projet.lieu }}</div>
<p class="text-xs mt-1 leading-relaxed" style="color: var(--nav-text-muted);">{{ projet.description.slice(0, 120) }}{{ projet.description.length > 120 ? '…' : '' }}</p>
<a
v-if="projet.url"
:href="projet.url"
target="_blank"
rel="noopener noreferrer"
class="text-xs mt-1 inline-block"
style="color: var(--nav-text-muted); text-decoration: underline;"
>En savoir plus </a>
</div>
</div>
</div>
<!-- Structures voisines (graphe) -->
<div v-if="structuresVoisines.length" class="mb-4">
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Structures liées</h3>
<div class="flex flex-wrap gap-2">
<button
v-for="voisine in structuresVoisines.slice(0, 6)"
:key="voisine.id"
class="px-2 py-1 rounded text-xs transition-colors hover:opacity-70"
style="background: var(--nav-bg-alt); color: var(--nav-text); border: 1px solid transparent;"
@click="emit('update:structureId', voisine.id)"
>{{ voisine.nom }}</button>
</div>
</div>
<!-- Sources -->
<div v-if="structure.sources && structure.sources.length" class="mb-4">
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Sources</h3>
<div class="space-y-1">
<div v-for="(source, i) in structure.sources" :key="i" class="flex items-center gap-2">
<span
class="px-1.5 py-0.5 rounded text-xs shrink-0"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ source.type }}</span>
<a
v-if="source.url"
:href="source.url"
target="_blank"
rel="noopener noreferrer"
class="text-xs underline truncate"
style="color: var(--nav-text);"
>{{ source.titre }}</a>
<span v-else class="text-xs" style="color: var(--nav-text);">{{ source.titre }}</span>
</div>
</div>
</div>
<!-- CTAs -->
<div class="flex gap-3 pt-2" style="border-top: 1px solid var(--nav-bg-alt);">
<a
href="/contribuer"
class="text-xs underline"
style="color: var(--nav-text-muted);"
>Signaler une erreur</a>
<a
href="/contribuer"
class="text-xs underline"
style="color: var(--nav-text-muted);"
>Réclamer cette fiche</a>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { ReseauxBifurcationData, StructureV2, ProjetEmblematique } from '~/types/structure-v2'
const FAMILLE_COLORS: Record<number, string> = {
1: '#a85d3e',
2: '#c4a472',
3: '#d4a017',
4: '#5a7a4a',
5: '#3d6a8c',
}
const FAMILLE_LABELS: Record<number, string> = {
1: 'Réemploi & filières',
2: 'Frugalité & low-tech',
3: 'Architecture sociale',
4: 'Collectifs & AMO',
5: 'Urbanisme de transition',
}
const props = defineProps<{
modelValue: boolean
structureId: string | null
data: ReseauxBifurcationData | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'update:structureId': [id: string]
}>()
const showFullDesc = ref(false)
function close() {
emit('update:modelValue', false)
showFullDesc.value = false
}
// Fermeture Esc globale
onMounted(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.modelValue) close()
}
window.addEventListener('keydown', handler)
onUnmounted(() => window.removeEventListener('keydown', handler))
})
// Remettre showFullDesc a false a chaque changement de fiche
watch(() => props.structureId, () => {
showFullDesc.value = false
})
const structure = computed<StructureV2 | null>(() => {
if (!props.data || !props.structureId) return null
return props.data.structures.find(s => s.id === props.structureId) ?? null
})
const familleColor = computed(() =>
FAMILLE_COLORS[structure.value?.famille_principale ?? 1] ?? '#888'
)
const familleLabel = computed(() =>
FAMILLE_LABELS[structure.value?.famille_principale ?? 1] ?? ''
)
const projetsStructure = computed<ProjetEmblematique[]>(() => {
if (!props.data || !props.structureId) return []
return props.data.projets?.filter(p => p.structure_parent === props.structureId) ?? []
})
const structuresVoisines = computed<StructureV2[]>(() => {
if (!props.data || !props.structureId) return []
const edges = props.data.graphe?.edges ?? []
const voisineIds = edges
.filter(e => e.source === props.structureId || e.target === props.structureId)
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
.map(e => e.source === props.structureId ? e.target : e.source)
.slice(0, 8)
return voisineIds
.map(id => props.data!.structures.find(s => s.id === id))
.filter(Boolean) as StructureV2[]
})
</script>
<style scoped>
/* Backdrop */
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
/* Modal */
.modal-enter-active, .modal-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.modal-enter-from, .modal-leave-to {
opacity: 0;
transform: translate(-50%, -52%);
}
@media (prefers-reduced-motion: reduce) {
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
.modal-enter-active, .modal-leave-active { transition: none; }
}
</style>

View File

@@ -10,7 +10,7 @@
}" }"
></svg> ></svg>
<!-- Sidebar hashtags droite (repliable) --> <!-- Sidebar droite (repliable) - 3 sections : AFFICHER / HASHTAGS / MODE D'EMPLOI -->
<aside <aside
:style="{ :style="{
position: 'absolute', top: '0', right: '0', bottom: '0', position: 'absolute', top: '0', right: '0', bottom: '0',
@@ -46,46 +46,92 @@
" "
>HASHTAGS ({{ activeHashtags.length }}/{{ props.allHashtags.length }})</div> >HASHTAGS ({{ activeHashtags.length }}/{{ props.allHashtags.length }})</div>
<!-- Mode deplie : header + liste groupee --> <!-- Mode deplie : 3 sections empilees -->
<template v-if="sidebarOpen"> <template v-if="sidebarOpen">
<div style="padding: 8px 12px; border-bottom: 1px solid var(--nav-bg-alt); flex-shrink: 0;"> <div style="flex: 1; overflow-y: auto; display: flex; flex-direction: column;">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 8px;">
<span style="font-size: 0.72rem; font-weight: 700; color: var(--nav-text); text-transform: uppercase; letter-spacing: 0.08em;">Hashtags</span>
<span style="font-size: 0.68rem; color: var(--nav-text-muted);">{{ activeHashtags.length }} actif{{ activeHashtags.length > 1 ? 's' : '' }}</span>
</div>
<button
v-if="activeHashtags.length"
@click="activeHashtags = []"
style="margin-top: 4px; font-size: 0.68rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0;"
>Tout effacer</button>
</div>
<div style="flex: 1; overflow-y: auto; padding: 6px 10px 10px;"> <!-- SECTION 1 : AFFICHER (toggles familles / pratiques) -->
<div <div style="padding: 10px 12px; flex-shrink: 0;">
v-for="group in hashtagsByFamille" <div style="font-size: 0.72rem; font-weight: 700; color: var(--nav-text); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px;">Afficher</div>
:key="group.famille" <label
style="margin-bottom: 10px;"
>
<div
:style="{ :style="{
fontSize: '0.65rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px',
color: group.color, textTransform: 'uppercase', padding: '7px 10px', marginBottom: '4px',
letterSpacing: '0.06em', marginBottom: '4px', borderRadius: '6px', cursor: 'pointer',
paddingLeft: '2px', fontSize: '0.82rem', fontWeight: 600,
background: showFamilles ? 'var(--nav-bg-alt)' : 'transparent',
color: showFamilles ? 'var(--nav-text)' : 'var(--nav-text-muted)',
transition: 'all 0.12s',
}" }"
>{{ group.label }}</div> >
<div style="display: flex; flex-wrap: wrap; gap: 3px;"> <input type="checkbox" v-model="showFamilles" style="cursor: pointer; width: 14px; height: 14px;" />
<span <span>Familles</span>
v-for="tag in group.tags" </label>
:key="tag" <label
style="padding: 2px 7px; border-radius: 9999px; font-size: 0.66rem; cursor: pointer; transition: all 0.12s;" :style="{
:style="activeHashtags.includes(tag) display: 'flex', alignItems: 'center', gap: '8px',
? `background: ${group.color}; color: white; font-weight: 600;` padding: '7px 10px',
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'" borderRadius: '6px', cursor: 'pointer',
@click="toggleHashtag(tag)" fontSize: '0.82rem', fontWeight: 600,
>{{ tag }}</span> background: showPratiques ? 'var(--nav-bg-alt)' : 'transparent',
color: showPratiques ? 'var(--nav-text)' : 'var(--nav-text-muted)',
transition: 'all 0.12s',
}"
>
<input type="checkbox" v-model="showPratiques" style="cursor: pointer; width: 14px; height: 14px;" />
<span>Pratiques</span>
</label>
</div>
<!-- SECTION 2 : HASHTAGS (chips groupees) -->
<div style="border-top: 1px solid var(--nav-bg-alt); margin-top: 0; padding: 10px 12px 8px; flex-shrink: 0;">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px;">
<span style="font-size: 0.72rem; font-weight: 700; color: var(--nav-text); text-transform: uppercase; letter-spacing: 0.08em;">Hashtags</span>
<span style="font-size: 0.68rem; color: var(--nav-text-muted);">{{ activeHashtags.length }} actif{{ activeHashtags.length > 1 ? 's' : '' }}</span>
</div>
<button
v-if="activeHashtags.length"
@click="activeHashtags = []"
style="margin-bottom: 6px; font-size: 0.68rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0;"
>Tout effacer</button>
</div>
<div style="flex: 1; overflow-y: auto; padding: 0 10px 10px;">
<div
v-for="group in hashtagsByFamille"
:key="group.famille"
style="margin-bottom: 10px;"
>
<div
:style="{
fontSize: '0.65rem', fontWeight: 700,
color: group.color, textTransform: 'uppercase',
letterSpacing: '0.06em', marginBottom: '4px',
paddingLeft: '2px',
}"
>{{ group.label }}</div>
<div style="display: flex; flex-wrap: wrap; gap: 3px;">
<span
v-for="tag in group.tags"
:key="tag"
style="padding: 2px 7px; border-radius: 9999px; font-size: 0.66rem; cursor: pointer; transition: all 0.12s;"
:style="activeHashtags.includes(tag)
? `background: ${group.color}; color: white; font-weight: 600;`
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggleHashtag(tag)"
>{{ tag }}</span>
</div>
</div> </div>
</div> </div>
<!-- SECTION 3 : MODE D'EMPLOI -->
<div style="border-top: 1px solid var(--nav-bg-alt); padding: 10px 12px; flex-shrink: 0;">
<div style="font-size: 0.72rem; font-weight: 700; color: var(--nav-text); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 6px;">Mode d'emploi</div>
<div style="font-size: 0.7rem; color: var(--nav-text-muted); line-height: 1.5;">
La carte croise des familles editoriales avec des pratiques (hashtags). Coche les couches a afficher, filtre par hashtag, clique sur un noeud pour en savoir plus.
</div>
</div>
</div> </div>
</template> </template>
</aside> </aside>
@@ -98,6 +144,123 @@
color: var(--nav-text); max-width: 220px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); color: var(--nav-text); max-width: 220px; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
opacity: 0; transition: opacity 0.15s; z-index: 100; opacity: 0; transition: opacity 0.15s; z-index: 100;
"></div> "></div>
<!-- Popover unifie (famille OU hashtag) -->
<div
v-if="popover.open"
:style="{
position: 'absolute',
left: popover.x + 'px',
top: popover.y + 'px',
background: 'var(--nav-surface)',
border: '1px solid var(--nav-bg-alt)',
borderRadius: '8px',
padding: '12px 14px',
maxWidth: '280px',
boxShadow: '0 6px 18px rgba(0,0,0,0.18)',
zIndex: 50,
}"
@click.stop
>
<button
@click="closePopover"
style="
position: absolute; top: 4px; right: 6px;
background: none; border: none; cursor: pointer;
font-size: 1rem; color: var(--nav-text-muted); padding: 2px 6px;
line-height: 1;
"
title="Fermer"
>x</button>
<div
:style="{
fontWeight: 700, fontSize: '0.92rem',
color: popover.color, marginBottom: '6px',
paddingRight: '14px',
}"
>{{ popover.title }}</div>
<!-- Body famille : description + compteur + 6 structures + bouton "Voir toutes" -->
<div v-if="popover.kind === 'famille'">
<div style="font-size: 0.78rem; line-height: 1.45; color: var(--nav-text); margin-bottom: 10px;">
{{ popover.body }}
</div>
<div style="font-size: 0.72rem; color: var(--nav-text-muted); margin-bottom: 6px;">
{{ popover.structures.length }} structure{{ popover.structures.length > 1 ? 's' : '' }} dans cette famille
</div>
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 3px;">
<li
v-for="s in popover.structures.slice(0, 6)"
:key="s.id"
@click="selectStructureFromPopover(s.id)"
style="
font-size: 0.78rem; color: var(--nav-text);
padding: 4px 6px; border-radius: 4px;
cursor: pointer; transition: background 0.1s;
display: flex; align-items: center; gap: 6px;
"
@mouseenter="(e: any) => e.currentTarget.style.background = 'var(--nav-bg-alt)'"
@mouseleave="(e: any) => e.currentTarget.style.background = 'transparent'"
>
<span
style="width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;"
:style="`background: ${popover.color};`"
/>
<span>{{ s.nom }}</span>
</li>
</ul>
<button
v-if="popover.familleId != null"
@click="openFicheFamilleFromPopover"
style="
margin-top: 10px; width: 100%;
padding: 7px 10px; border-radius: 6px;
background: var(--nav-bg-alt); border: none;
font-size: 0.75rem; font-weight: 600; cursor: pointer;
color: var(--nav-text); transition: opacity 0.12s;
text-align: left;
"
@mouseenter="(e: any) => e.currentTarget.style.opacity = '0.7'"
@mouseleave="(e: any) => e.currentTarget.style.opacity = '1'"
>Voir toutes les {{ popover.structures.length }} pratiques -&gt;</button>
</div>
<!-- Body hashtag : ligne generique + compteur + liste structures cliquables -->
<div v-else-if="popover.kind === 'hashtag'">
<div
style="
font-size: 0.72rem; color: var(--nav-text-muted);
font-style: italic; margin-bottom: 8px; line-height: 1.4;
"
>Pratique transversale - portee par {{ popover.structures.length }} structure{{ popover.structures.length > 1 ? 's' : '' }} de {{ popover.famillesCount }} famille{{ popover.famillesCount > 1 ? 's' : '' }}</div>
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 3px;">
<li
v-for="s in popover.structures.slice(0, 6)"
:key="s.id"
@click="selectStructureFromPopover(s.id)"
style="
font-size: 0.78rem; color: var(--nav-text);
padding: 4px 6px; border-radius: 4px;
cursor: pointer; transition: background 0.1s;
"
@mouseenter="(e: any) => e.currentTarget.style.background = 'var(--nav-bg-alt)'"
@mouseleave="(e: any) => e.currentTarget.style.background = 'transparent'"
>{{ s.nom }}</li>
</ul>
<div
v-if="popover.structures.length > 6"
style="font-size: 0.7rem; color: var(--nav-text-muted); margin-top: 6px; padding-left: 6px;"
>+ {{ popover.structures.length - 6 }} autre{{ popover.structures.length - 6 > 1 ? 's' : '' }}</div>
</div>
</div>
<!-- Fiche famille modale -->
<FicheFamilleModal
v-model="ficheFamilleOpen"
:famille-id="ficheFamilleId"
:data="props.data"
@select-structure="(id) => emit('select-structure', id)"
/>
</div> </div>
</template> </template>
@@ -121,17 +284,68 @@ const tooltipRef = ref<HTMLElement | null>(null)
const activeHashtags = ref<string[]>([]) const activeHashtags = ref<string[]>([])
const sidebarOpen = ref(true) const sidebarOpen = ref(true)
// Layers superposables (remplace viewMode exclusif PV2-5e)
const showFamilles = ref(true)
const showPratiques = ref(false)
function toggleHashtag(tag: string) { function toggleHashtag(tag: string) {
activeHashtags.value = activeHashtags.value.includes(tag) activeHashtags.value = activeHashtags.value.includes(tag)
? activeHashtags.value.filter(t => t !== tag) ? activeHashtags.value.filter(t => t !== tag)
: [...activeHashtags.value, tag] : [...activeHashtags.value, tag]
} }
// Popover unifie (famille | hashtag)
type PopoverState = {
open: boolean
kind: 'famille' | 'hashtag' | null
x: number
y: number
title: string
body: string
color: string
structures: { id: string; nom: string }[]
familleId: number | null
famillesCount: number
}
const popover = ref<PopoverState>({
open: false,
kind: null,
x: 0,
y: 0,
title: '',
body: '',
color: '#000',
structures: [],
familleId: null,
famillesCount: 0,
})
// Fiche famille modale
const ficheFamilleOpen = ref(false)
const ficheFamilleId = ref<number | null>(null)
function closePopover() {
popover.value.open = false
popover.value.kind = null
}
function selectStructureFromPopover(id: string) {
closePopover()
emit('select-structure', id)
}
function openFicheFamilleFromPopover() {
if (popover.value.familleId == null) return
ficheFamilleId.value = popover.value.familleId
ficheFamilleOpen.value = true
closePopover()
}
// Mapping hashtag -> famille majoritaire // Mapping hashtag -> famille majoritaire
// En cas d'egalite : prendre la famille la plus petite (visibilite minoritaires) // En cas d'egalite : prendre la famille la plus petite (visibilite minoritaires)
const hashtagsByFamille = computed(() => { const tagToFamille = computed<Record<string, number>>(() => {
if (!props.data) return [] if (!props.data) return {}
// 1. Pour chaque hashtag, compter les structures par famille
const counts: Record<string, Record<number, number>> = {} const counts: Record<string, Record<number, number>> = {}
props.data.structures.forEach(s => { props.data.structures.forEach(s => {
s.hashtags.forEach(tag => { s.hashtags.forEach(tag => {
@@ -139,32 +353,33 @@ const hashtagsByFamille = computed(() => {
counts[tag][s.famille_principale] = (counts[tag][s.famille_principale] ?? 0) + 1 counts[tag][s.famille_principale] = (counts[tag][s.famille_principale] ?? 0) + 1
}) })
}) })
// 2. Pour chaque hashtag, trouver la famille majoritaire (egalite -> + petite famille)
// Pour preferer la famille la moins peuplee globalement, calculer la taille de chaque famille.
const familleSize: Record<number, number> = {} const familleSize: Record<number, number> = {}
props.data.structures.forEach(s => { props.data.structures.forEach(s => {
familleSize[s.famille_principale] = (familleSize[s.famille_principale] ?? 0) + 1 familleSize[s.famille_principale] = (familleSize[s.famille_principale] ?? 0) + 1
}) })
const tagToFamille: Record<string, number> = {} const out: Record<string, number> = {}
for (const tag in counts) { for (const tag in counts) {
const entries = Object.entries(counts[tag]) const entries = Object.entries(counts[tag])
entries.sort((a, b) => { entries.sort((a, b) => {
const diff = (b[1] as number) - (a[1] as number) const diff = (b[1] as number) - (a[1] as number)
if (diff !== 0) return diff if (diff !== 0) return diff
// egalite : famille avec moins de structures gagne
return (familleSize[Number(a[0])] ?? 0) - (familleSize[Number(b[0])] ?? 0) return (familleSize[Number(a[0])] ?? 0) - (familleSize[Number(b[0])] ?? 0)
}) })
tagToFamille[tag] = Number(entries[0][0]) out[tag] = Number(entries[0][0])
} }
// 3. Grouper les hashtags par famille return out
})
const hashtagsByFamille = computed(() => {
if (!props.data) return []
const map = tagToFamille.value
const groups: Record<number, string[]> = {} const groups: Record<number, string[]> = {}
props.allHashtags.forEach(tag => { props.allHashtags.forEach(tag => {
const fam = tagToFamille[tag] const fam = map[tag]
if (fam == null) return if (fam == null) return
if (!groups[fam]) groups[fam] = [] if (!groups[fam]) groups[fam] = []
groups[fam].push(tag) groups[fam].push(tag)
}) })
// 4. Sortie ordonnee selon ID de famille
return [1, 2, 3, 4, 5, 6] return [1, 2, 3, 4, 5, 6]
.filter(famId => groups[famId]?.length) .filter(famId => groups[famId]?.length)
.map(famId => ({ .map(famId => ({
@@ -175,6 +390,14 @@ const hashtagsByFamille = computed(() => {
})) }))
}) })
// Structures portant un hashtag donne (pour popover)
function structuresForHashtag(tag: string): { id: string; nom: string }[] {
if (!props.data) return []
return props.data.structures
.filter(s => s.hashtags.includes(tag))
.map(s => ({ id: s.id, nom: s.nom }))
}
// IDs de structures correspondant aux hashtags actifs // IDs de structures correspondant aux hashtags actifs
const filteredStructureIds = computed(() => { const filteredStructureIds = computed(() => {
if (!props.data || !activeHashtags.value.length) return null if (!props.data || !activeHashtags.value.length) return null
@@ -204,6 +427,15 @@ const FAMILLE_LABELS: Record<number, string> = {
6: 'Recherche', 6: 'Recherche',
} }
const FAMILLE_DESCRIPTIONS: Record<number, string> = {
1: "Structures dont le geste premier est de travailler avec la matiere existante : deconstruction selective, plateformes de redistribution, filieres biosourcees et geosourcees.",
2: "Pratiques qui partent du principe qu'on peut faire mieux avec moins. Renovation profonde, materiaux locaux, sobriete choisie.",
3: "Structures dont le terrain premier est le mal-logement, la precarite, l'hospitalite. Architecture comme reponse a l'urgence sociale.",
4: "Structures qui accompagnent les projets collectifs : cooperatives d'habitat, ecovillages, accompagnement vers l'autogestion ou la renovation.",
5: "Demarches a l'echelle du territoire : villes en transition, PLU alternatifs, coalitions territoriales.",
6: "Recherche-action et production de contre-savoirs (Forensic Architecture, Rural Studio, PEROU, Centrala). Badge transversal aux familles.",
}
let simulation: any = null let simulation: any = null
let d3NodeSelection: any = null let d3NodeSelection: any = null
let d3LinkSelection: any = null let d3LinkSelection: any = null
@@ -219,74 +451,43 @@ async function initGraph() {
// Nettoyer // Nettoyer
d3.select(svgEl).selectAll('*').remove() d3.select(svgEl).selectAll('*').remove()
closePopover()
const svg = d3.select(svgEl) const svg = d3.select(svgEl)
.attr('viewBox', `0 0 ${width} ${height}`) .attr('viewBox', `0 0 ${width} ${height}`)
// Click sur le SVG vide -> fermer popover
svg.on('click', (event: any) => {
if (event.target === svgEl) closePopover()
})
// Groupe principal avec zoom // Groupe principal avec zoom
const g = svg.append('g') const g = svg.append('g')
const zoomBehavior = d3.zoom<SVGElement, unknown>() const zoomBehavior = d3.zoom<SVGElement, unknown>()
.scaleExtent([0.2, 4]) .scaleExtent([0.2, 4])
.on('zoom', (event) => g.attr('transform', event.transform)) .on('zoom', (event) => {
g.attr('transform', event.transform)
closePopover()
})
svg.call(zoomBehavior as any) svg.call(zoomBehavior as any)
// Noeuds familles (centres fixes en etoile) const { allNodes, links } = buildNodesLinks(width, height)
const familyNodes = [1, 2, 3, 4, 5, 6].map(id => ({
id: `family-${id}`,
type: 'family',
familleId: id,
label: FAMILLE_LABELS[id],
color: FAMILLE_COLORS[id],
r: 32,
x: width / 2 + Math.cos((id - 1) * Math.PI * 2 / 6) * 180,
y: height / 2 + Math.sin((id - 1) * Math.PI * 2 / 6) * 180,
fx: width / 2 + Math.cos((id - 1) * Math.PI * 2 / 6) * 180,
fy: height / 2 + Math.sin((id - 1) * Math.PI * 2 / 6) * 180,
}))
// Noeuds structures
const structureNodes = props.data.structures.map(s => ({
id: s.id,
type: 'structure',
label: s.nom,
famille: s.famille_principale,
familles_secondaires: s.familles_secondaires ?? [],
hashtags: s.hashtags,
color: FAMILLE_COLORS[s.famille_principale] ?? '#888',
r: 8,
x: undefined as number | undefined,
y: undefined as number | undefined,
fx: undefined as number | null | undefined,
fy: undefined as number | null | undefined,
}))
const allNodes: any[] = [...familyNodes, ...structureNodes]
// Liens structures -> familles
const links: any[] = []
structureNodes.forEach(s => {
links.push({
source: s.id,
target: `family-${s.famille}`,
type: 'primary',
strength: 0.55,
})
s.familles_secondaires.forEach((f: number) => {
links.push({
source: s.id,
target: `family-${f}`,
type: 'secondary',
strength: 0.45,
})
})
})
// Simulation force-directed // Simulation force-directed
if (simulation) simulation.stop() if (simulation) simulation.stop()
// Adapter la charge selon le nombre de noeuds (mode "tout coche" = plus de repulsion)
const heavyMode = showPratiques.value && allNodes.length > 150
simulation = d3.forceSimulation(allNodes) simulation = d3.forceSimulation(allNodes)
.force('link', d3.forceLink(links).id((d: any) => d.id).distance((d: any) => d.type === 'primary' ? 80 : 120).strength((d: any) => d.strength ?? 0.5)) .force('link', d3.forceLink(links).id((d: any) => d.id)
.force('charge', d3.forceManyBody().strength(-120)) .distance((d: any) => {
if (d.type === 'practice') return 90
return d.type === 'primary' ? 80 : 120
})
.strength((d: any) => d.strength ?? 0.5))
.force('charge', d3.forceManyBody().strength(heavyMode ? -80 : -120))
.force('center', d3.forceCenter(width / 2, height / 2)) .force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius((d: any) => d.r + 4)) .force('collision', d3.forceCollide().radius((d: any) => d.r + 4))
@@ -294,7 +495,10 @@ async function initGraph() {
d3LinkSelection = g.append('g').selectAll('line') d3LinkSelection = g.append('g').selectAll('line')
.data(links) .data(links)
.join('line') .join('line')
.attr('stroke', (d: any) => d.type === 'primary' ? 'rgba(150,150,150,0.45)' : 'rgba(150,150,150,0.35)') .attr('stroke', (d: any) => {
if (d.type === 'practice') return 'rgba(150,150,150,0.25)'
return d.type === 'primary' ? 'rgba(150,150,150,0.45)' : 'rgba(150,150,150,0.35)'
})
.attr('stroke-width', 1.5) .attr('stroke-width', 1.5)
.attr('stroke-dasharray', null) .attr('stroke-dasharray', null)
@@ -302,13 +506,19 @@ async function initGraph() {
d3NodeSelection = g.append('g').selectAll('g') d3NodeSelection = g.append('g').selectAll('g')
.data(allNodes) .data(allNodes)
.join('g') .join('g')
.style('cursor', (d: any) => d.type === 'structure' ? 'pointer' : 'default') .style('cursor', (d: any) => {
if (d.type === 'structure') return 'pointer'
if (d.type === 'family') return 'pointer'
if (d.type === 'hashtag') return 'pointer'
return 'default'
})
.call( .call(
d3.drag<any, any>() d3.drag<any, any>()
.on('start', (event: any, d: any) => { .on('start', (event: any, d: any) => {
if (!event.active) simulation.alphaTarget(0.3).restart() if (!event.active) simulation.alphaTarget(0.3).restart()
d.fx = d.x d.fx = d.x
d.fy = d.y d.fy = d.y
closePopover()
}) })
.on('drag', (event: any, d: any) => { .on('drag', (event: any, d: any) => {
d.fx = event.x d.fx = event.x
@@ -322,16 +532,35 @@ async function initGraph() {
} }
}) })
) )
.on('click', (_event: any, d: any) => { .on('click', (event: any, d: any) => {
if (d.type === 'structure') emit('select-structure', d.id) event.stopPropagation()
if (d.type === 'structure') {
emit('select-structure', d.id)
} else if (d.type === 'family') {
openFamillePopover(d, event, svgEl)
} else if (d.type === 'hashtag') {
openHashtagPopover(d, event, svgEl)
}
}) })
// Cercles // Cercles
d3NodeSelection.append('circle') d3NodeSelection.append('circle')
.attr('r', (d: any) => d.r) .attr('r', (d: any) => d.r)
.attr('fill', (d: any) => d.type === 'family' ? d.color : d.color + 'cc') .attr('fill', (d: any) => {
.attr('stroke', (d: any) => d.type === 'family' ? 'white' : d.color) if (d.type === 'family') return d.color
.attr('stroke-width', (d: any) => d.type === 'family' ? 3 : 1.5) if (d.type === 'hashtag') return d.fill
return d.color + 'cc'
})
.attr('stroke', (d: any) => {
if (d.type === 'family') return 'white'
if (d.type === 'hashtag') return d.stroke
return d.color
})
.attr('stroke-width', (d: any) => {
if (d.type === 'family') return 3
if (d.type === 'hashtag') return 2
return 1.5
})
// Labels familles // Labels familles
d3NodeSelection.filter((d: any) => d.type === 'family') d3NodeSelection.filter((d: any) => d.type === 'family')
@@ -344,6 +573,20 @@ async function initGraph() {
.attr('fill', 'white') .attr('fill', 'white')
.style('pointer-events', 'none') .style('pointer-events', 'none')
// Labels hashtags : texte noir sur fond clair, tronque a 12 caracteres
d3NodeSelection.filter((d: any) => d.type === 'hashtag')
.append('text')
.text((d: any) => {
const raw = d.label as string
return raw.length > 12 ? raw.slice(0, 12) + '...' : raw
})
.attr('text-anchor', 'middle')
.attr('dy', '0.35em')
.attr('font-size', '9px')
.attr('font-weight', '600')
.attr('fill', '#2a2a2a')
.style('pointer-events', 'none')
// Tooltip hover pour structures // Tooltip hover pour structures
d3NodeSelection.filter((d: any) => d.type === 'structure') d3NodeSelection.filter((d: any) => d.type === 'structure')
.on('mouseenter', (_event: any, d: any) => { .on('mouseenter', (_event: any, d: any) => {
@@ -376,6 +619,181 @@ async function initGraph() {
}) })
} }
function buildNodesLinks(width: number, height: number) {
const allNodes: any[] = []
const links: any[] = []
if (!props.data) return { allNodes, links }
const tagFamilleMap = tagToFamille.value
// Noeuds structures (toujours presents)
const structureNodes = props.data.structures.map(s => ({
id: s.id,
type: 'structure',
label: s.nom,
famille: s.famille_principale,
familles_secondaires: s.familles_secondaires ?? [],
hashtags: s.hashtags,
color: FAMILLE_COLORS[s.famille_principale] ?? '#888',
r: 8,
}))
allNodes.push(...structureNodes)
// Layer Familles : 6 noeuds famille fixes en etoile + liens primaires/secondaires
if (showFamilles.value) {
const familyNodes = [1, 2, 3, 4, 5, 6].map(id => ({
id: `family-${id}`,
type: 'family',
familleId: id,
label: FAMILLE_LABELS[id],
color: FAMILLE_COLORS[id],
r: 32,
x: width / 2 + Math.cos((id - 1) * Math.PI * 2 / 6) * 180,
y: height / 2 + Math.sin((id - 1) * Math.PI * 2 / 6) * 180,
fx: width / 2 + Math.cos((id - 1) * Math.PI * 2 / 6) * 180,
fy: height / 2 + Math.sin((id - 1) * Math.PI * 2 / 6) * 180,
}))
allNodes.push(...familyNodes)
structureNodes.forEach(s => {
links.push({
source: s.id,
target: `family-${s.famille}`,
type: 'primary',
strength: 0.55,
})
;(s.familles_secondaires as number[]).forEach((f: number) => {
links.push({
source: s.id,
target: `family-${f}`,
type: 'secondary',
strength: 0.45,
})
})
})
}
// Layer Pratiques : noeuds hashtag + liens structure -> hashtag
if (showPratiques.value) {
const uniqueTags = new Set<string>()
props.data.structures.forEach(s => s.hashtags.forEach(t => uniqueTags.add(t)))
const tagsArr = Array.from(uniqueTags).sort()
// Si seul layer Pratiques actif : disposition radiale comme reference
// Si superpose avec Familles : laisser la simulation placer
const radius = Math.min(width, height) * 0.32
const hashtagNodes = tagsArr.map((tag, i) => {
const famId = tagFamilleMap[tag]
const strokeColor = famId != null ? FAMILLE_COLORS[famId] : '#888'
const node: any = {
id: `hashtag-${tag}`,
type: 'hashtag',
label: tag.startsWith('#') ? tag.slice(1) : tag,
tag,
fill: 'var(--nav-bg-alt)',
stroke: strokeColor,
color: strokeColor,
r: 22,
}
if (!showFamilles.value) {
const angle = (i / tagsArr.length) * Math.PI * 2
node.x = width / 2 + Math.cos(angle) * radius
node.y = height / 2 + Math.sin(angle) * radius
}
return node
})
allNodes.push(...hashtagNodes)
structureNodes.forEach(s => {
s.hashtags.forEach(tag => {
if (uniqueTags.has(tag)) {
links.push({
source: s.id,
target: `hashtag-${tag}`,
type: 'practice',
strength: 0.3,
})
}
})
})
}
return { allNodes, links }
}
function clampPopoverPosition(rect: DOMRect, evtX: number, evtY: number, w = 280, h = 180) {
const margin = 12
let x = evtX - rect.left + 14
let y = evtY - rect.top + 10
if (x + w > rect.width - margin) {
x = Math.max(margin, rect.width - w - margin)
}
if (y + h > rect.height - margin) {
y = Math.max(margin, rect.height - h - margin)
}
return { x, y }
}
function structuresForFamille(famId: number): { id: string; nom: string }[] {
if (!props.data) return []
return props.data.structures
.filter(s =>
s.famille_principale === famId
|| (s.familles_secondaires ?? []).includes(famId)
)
.sort((a, b) => a.nom.localeCompare(b.nom, 'fr'))
.map(s => ({ id: s.id, nom: s.nom }))
}
function openFamillePopover(d: any, event: any, svgEl: SVGElement) {
const rect = (svgEl as HTMLElement).getBoundingClientRect()
const famId = d.familleId as number
const desc = FAMILLE_DESCRIPTIONS[famId] ?? ''
const structures = structuresForFamille(famId)
const { x, y } = clampPopoverPosition(rect, event.clientX, event.clientY, 280, 280)
popover.value = {
open: true,
kind: 'famille',
x,
y,
title: FAMILLE_LABELS[famId] ?? '',
body: desc,
color: FAMILLE_COLORS[famId] ?? '#000',
structures,
familleId: famId,
famillesCount: 0,
}
}
function openHashtagPopover(d: any, event: any, svgEl: SVGElement) {
const rect = (svgEl as HTMLElement).getBoundingClientRect()
const tag = d.tag as string
const structures = structuresForHashtag(tag)
const famId = tagToFamille.value[tag]
const color = famId != null ? FAMILLE_COLORS[famId] : '#444'
// Compter les familles distinctes parmi les porteuses (famille_principale)
const famSet = new Set<number>()
if (props.data) {
props.data.structures
.filter(s => s.hashtags.includes(tag))
.forEach(s => famSet.add(s.famille_principale))
}
const { x, y } = clampPopoverPosition(rect, event.clientX, event.clientY, 280, 220)
popover.value = {
open: true,
kind: 'hashtag',
x,
y,
title: tag.startsWith('#') ? tag : '#' + tag,
body: '',
color,
structures,
familleId: null,
famillesCount: famSet.size,
}
}
function applyHashtagFilter() { function applyHashtagFilter() {
if (!d3NodeSelection || !d3LinkSelection) return if (!d3NodeSelection || !d3LinkSelection) return
if (filteredStructureIds.value) { if (filteredStructureIds.value) {
@@ -384,7 +802,8 @@ function applyHashtagFilter() {
.attr('opacity', (d: any) => ids.has(d.id) ? 1 : 0.1) .attr('opacity', (d: any) => ids.has(d.id) ? 1 : 0.1)
d3LinkSelection.attr('opacity', (d: any) => { d3LinkSelection.attr('opacity', (d: any) => {
const srcId = typeof d.source === 'object' ? d.source.id : d.source const srcId = typeof d.source === 'object' ? d.source.id : d.source
return ids.has(srcId) ? 1 : 0.05 const tgtId = typeof d.target === 'object' ? d.target.id : d.target
return ids.has(srcId) || ids.has(tgtId) ? 1 : 0.05
}) })
} else { } else {
d3NodeSelection.select('circle').attr('opacity', 1) d3NodeSelection.select('circle').attr('opacity', 1)
@@ -392,16 +811,14 @@ function applyHashtagFilter() {
} }
} }
// Déclencher quand l'onglet devient visible // Declencher quand l'onglet devient visible
// Double rAF : nextTick met à jour le vdom, les 2 frames garantissent que
// le browser a calculé le layout et que clientWidth/clientHeight != 0
watch(() => props.active, (val) => { watch(() => props.active, (val) => {
if (val && import.meta.client && props.data) { if (val && import.meta.client && props.data) {
requestAnimationFrame(() => requestAnimationFrame(() => initGraph())) requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
} }
}) })
// Relancer si les données arrivent après l'activation // Relancer si les donnees arrivent apres l'activation
watch(() => props.data, (val) => { watch(() => props.data, (val) => {
if (val && props.active && import.meta.client) { if (val && props.active && import.meta.client) {
requestAnimationFrame(() => requestAnimationFrame(() => initGraph())) requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
@@ -414,6 +831,14 @@ watch(activeHashtags, () => {
if (simulation) simulation.alpha(0.01).restart() if (simulation) simulation.alpha(0.01).restart()
}, { deep: true }) }, { deep: true })
// Watchers layers : rebuild simulation
watch([showFamilles, showPratiques], () => {
closePopover()
if (import.meta.client && props.data && props.active) {
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
}
})
// Toggle sidebar : largeur SVG change -> reinit graphe apres transition CSS // Toggle sidebar : largeur SVG change -> reinit graphe apres transition CSS
watch(sidebarOpen, () => { watch(sidebarOpen, () => {
if (!import.meta.client || !props.active || !props.data) return if (!import.meta.client || !props.active || !props.data) return

View File

@@ -0,0 +1,97 @@
<template>
<div class="hashtag-filter" style="padding: 8px 12px; background: var(--nav-surface);">
<!-- Filtres famille -->
<div style="margin-bottom: 6px;">
<span class="filter-label">FAMILLES</span>
<div class="chips-row">
<span
v-for="fam in FAMILLES"
:key="fam.id"
class="chip"
:style="selectedFamille === fam.id
? `background: ${fam.color}; color: white; font-weight: 600; border: 2px solid ${fam.color};`
: `background: var(--nav-bg-alt); color: ${fam.color}; border: 2px solid ${fam.color}; font-weight: 600;`"
@click="toggleFamille(fam.id)"
>{{ fam.shortLabel }}</span>
</div>
</div>
<!-- Filtres hashtags avec toggle -->
<div>
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
<span class="filter-label">HASHTAGS</span>
<button
@click="hashtagsVisible = !hashtagsVisible"
style="font-size: 0.7rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0;"
>{{ hashtagsVisible ? 'Replier' : 'Afficher (' + props.allHashtags.length + ')' }}</button>
</div>
<div v-if="hashtagsVisible" class="chips-row">
<span
v-for="tag in props.allHashtags"
:key="tag"
class="chip chip-small"
:style="selectedHashtags.includes(tag)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggleHashtag(tag)"
>{{ tag }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const FAMILLES = [
{ id: 1, shortLabel: 'Réemploi', color: '#a85d3e' },
{ id: 2, shortLabel: 'Frugalité', color: '#c4a472' },
{ id: 3, shortLabel: 'Social', color: '#d4a017' },
{ id: 4, shortLabel: 'Collectifs', color: '#5a7a4a' },
{ id: 5, shortLabel: 'Urbanisme', color: '#3d6a8c' },
{ id: 6, shortLabel: 'Recherche', color: '#6b3fa0' },
]
const props = defineProps<{
allHashtags: string[]
selectedHashtags: string[]
selectedFamille: number | null
}>()
const emit = defineEmits<{
'update:selectedHashtags': [v: string[]]
'update:selectedFamille': [v: number | null]
}>()
const hashtagsVisible = ref(false)
function toggleFamille(id: number) {
emit('update:selectedFamille', props.selectedFamille === id ? null : id)
}
function toggleHashtag(tag: string) {
const current = props.selectedHashtags
emit('update:selectedHashtags',
current.includes(tag) ? current.filter(t => t !== tag) : [...current, tag]
)
}
</script>
<style scoped>
.filter-label {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--nav-text-muted);
display: block;
margin-bottom: 4px;
}
.chips-row { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }
.chip {
cursor: pointer;
padding: 3px 10px;
border-radius: 9999px;
font-size: 0.75rem;
transition: all 0.15s;
user-select: none;
}
.chip-small { font-size: 0.7rem; padding: 2px 8px; }
</style>

View File

@@ -0,0 +1,76 @@
<template>
<Teleport to="body">
<Transition name="fade">
<div
v-if="visible"
class="intention-overlay"
style="
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(20, 18, 14, 0.85);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
"
@click.self="dismiss"
>
<div style="
max-width: 540px;
width: 100%;
background: #faf8f5;
border-radius: 8px;
padding: 32px;
color: #2c2416;
">
<p style="font-size: 1rem; line-height: 1.7; margin: 0 0 12px 0;">
Cette carte recense les réseaux, collectifs, agences et projets des
pensées écologiques deviennent des pratiques d'architecture et d'habiter.
</p>
<p style="font-size: 0.875rem; line-height: 1.6; opacity: 0.75; margin: 0 0 24px 0;">
Elle ne prétend pas à l'exhaustivité. Elle est un geste politique :
rendre visible ce qui se transforme, comment, par qui, où.
5 familles et des hashtags vous permettent d'explorer.
</p>
<button
@click="dismiss"
style="
background: #2c2416;
color: #faf8f5;
border: none;
border-radius: 4px;
padding: 10px 24px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
width: 100%;
"
>Explorer la carte</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
const visible = ref(false)
onMounted(() => {
if (typeof localStorage !== 'undefined' && !localStorage.getItem('aep_intention_seen')) {
visible.value = true
}
})
function dismiss() {
visible.value = false
if (typeof localStorage !== 'undefined') {
localStorage.setItem('aep_intention_seen', '1')
}
}
</script>
<style scoped>
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>

View File

@@ -128,8 +128,6 @@ async function initMap() {
updateMarkers(L) updateMarkers(L)
} }
let initialFitDone = false
function updateMarkers(L?: any) { function updateMarkers(L?: any) {
if (!mapInstance || !clusterGroup) return if (!mapInstance || !clusterGroup) return
const leaflet = L || (window as any).L const leaflet = L || (window as any).L
@@ -170,25 +168,6 @@ function updateMarkers(L?: any) {
markers.set(org.Id, marker) markers.set(org.Id, marker)
clusterGroup.addLayer(marker) clusterGroup.addLayer(marker)
}) })
// Bug E2E L3 : recadrer la carte sur les resultats filtres
if (orgsWithCoords.length > 0 && initialFitDone) {
try {
const bounds = leaflet.latLngBounds(
orgsWithCoords.map((o: any) => [o.latitude!, o.longitude!])
)
if (orgsWithCoords.length <= 15) {
mapInstance.fitBounds(bounds, {
padding: [40, 40],
maxZoom: 10,
animate: true,
})
}
} catch (e) {
console.warn('[NavMap] fitBounds echoue:', e)
}
}
initialFitDone = true
} }
// Réagir aux changements de filtres (liste d'orgs) // Réagir aux changements de filtres (liste d'orgs)

View File

@@ -6,40 +6,42 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Map, Marker, DivIcon } from 'leaflet' import type { Map, Marker, DivIcon } from 'leaflet'
import type { StructureV2 } from '~/types/structure-v2'
interface Pratique { // Couleurs par famille (synchronisées avec v2-bifurcation.css)
id: number const FAMILLE_COLORS: Record<number, string> = {
nom: string 1: '#a85d3e',
lat?: number | null 2: '#c4a472',
lng?: number | null 3: '#d4a017',
pays?: string 4: '#5a7a4a',
ville?: string 5: '#3d6a8c',
type?: string
score?: number
} }
const props = defineProps<{ const props = defineProps<{
orgs: Pratique[] structures: StructureV2[]
selectedId?: number | null selectedId?: string | null
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
'select-org': [id: number] 'select-structure': [id: string]
}>() }>()
const mapContainer = ref<HTMLElement | null>(null) const mapContainer = ref<HTMLElement | null>(null)
let mapInstance: Map | null = null let mapInstance: Map | null = null
let clusterGroup: any = null let clusterGroup: any = null
const markers = new Map<number, Marker>() const markers = new Map<string, Marker>()
let tileLayerInstance: any = null let tileLayerInstance: any = null
function createPinIcon(score: number, isSelected = false): DivIcon { function getFamilleColor(famille: number): string {
return FAMILLE_COLORS[famille] ?? '#888888'
}
function createPinIcon(famille: number, isSelected = false): DivIcon {
const L = (window as any).L const L = (window as any).L
// Couleur selon score (1-5) : du pale au vif const bg = getFamilleColor(famille)
const bg = score >= 4 ? '#f5b342' : score >= 3 ? 'rgba(26,34,56,0.75)' : 'rgba(26,34,56,0.5)' const size = isSelected ? 20 : 14
const border = isSelected ? '#f5b342' : '#ffffff' const border = isSelected ? 'white' : 'rgba(255,255,255,0.7)'
const size = isSelected ? 18 : 14 const shadow = isSelected ? `0 0 0 4px ${bg}55` : 'none'
const shadow = isSelected ? '0 0 0 4px rgba(245,179,66,0.5)' : 'none'
return L.divIcon({ return L.divIcon({
className: '', className: '',
@@ -69,19 +71,18 @@ async function initMap() {
// @ts-ignore // @ts-ignore
await import('leaflet.markercluster/dist/MarkerCluster.Default.css') await import('leaflet.markercluster/dist/MarkerCluster.Default.css')
// Installer L globalement AVANT le plugin
;(window as any).L = L ;(window as any).L = L
// @ts-ignore // @ts-ignore
await import('leaflet.markercluster') await import('leaflet.markercluster')
const MarkerClusterGroup = L.MarkerClusterGroup const MarkerClusterGroup = L.MarkerClusterGroup
mapInstance = L.map(mapContainer.value, { mapInstance = L.map(mapContainer.value, {
center: [50.0, 10.0], center: [46.6, 2.3],
zoom: 4, zoom: 5,
zoomControl: true, zoomControl: true,
attributionControl: true, attributionControl: true,
maxBounds: [[30.0, -15.0], [72.0, 40.0]], minZoom: 2,
maxBoundsViscosity: 0.8,
minZoom: 3,
maxZoom: 18, maxZoom: 18,
}) })
@@ -97,7 +98,7 @@ async function initMap() {
tileLayerInstance.addTo(mapInstance!) tileLayerInstance.addTo(mapInstance!)
clusterGroup = new MarkerClusterGroup({ clusterGroup = new MarkerClusterGroup({
disableClusteringAtZoom: 12, disableClusteringAtZoom: 14,
maxClusterRadius: 50, maxClusterRadius: 50,
showCoverageOnHover: false, showCoverageOnHover: false,
iconCreateFunction: (cluster: any) => { iconCreateFunction: (cluster: any) => {
@@ -123,12 +124,6 @@ async function initMap() {
updateMarkers(L) updateMarkers(L)
} }
// Vue initiale (centre Europe + zoom 4) - sauvegardee pour reset
const INITIAL_CENTER: [number, number] = [50.0, 10.0]
const INITIAL_ZOOM = 4
let initialFitDone = false
function updateMarkers(L?: any) { function updateMarkers(L?: any) {
if (!mapInstance || !clusterGroup) return if (!mapInstance || !clusterGroup) return
const leaflet = L || (window as any).L const leaflet = L || (window as any).L
@@ -137,60 +132,53 @@ function updateMarkers(L?: any) {
clusterGroup.clearLayers() clusterGroup.clearLayers()
markers.clear() markers.clear()
const orgsWithCoords = props.orgs.filter( const structuresWithCoords = props.structures.filter(
(o) => o.lat != null && o.lng != null (s) => s.latitude != null && s.longitude != null
) )
orgsWithCoords.forEach((org) => { structuresWithCoords.forEach((structure) => {
const isSelected = org.id === props.selectedId const isSelected = structure.id === props.selectedId
const icon = createPinIcon(org.score ?? 1, isSelected) const icon = createPinIcon(structure.famille_principale, isSelected)
const marker = leaflet.marker([org.lat!, org.lng!], { icon }) const marker = leaflet.marker([structure.latitude!, structure.longitude!], { icon })
const hashtagsHtml = structure.hashtags.slice(0, 2)
.map(h => `<span style="font-size:10px;color:var(--nav-text-muted);">${h}</span>`)
.join(' ')
marker.bindPopup(` marker.bindPopup(`
<div style="font-family: var(--nav-font); min-width: 180px; padding: 4px 0;"> <div style="font-family: var(--nav-font); min-width: 190px; padding: 4px 0;">
<div style="font-weight: 700; color: var(--nav-text); margin-bottom: 4px;">${org.nom}</div> <div style="font-weight: 700; color: var(--nav-text); margin-bottom: 2px; font-size: 0.9rem;">${structure.nom}</div>
${org.pays ? `<div style="font-size: 11px; color: var(--nav-text-muted);">${org.pays}${org.ville ? ' · ' + org.ville : ''}</div>` : ''} <div style="font-size: 11px; color: var(--nav-text-muted); margin-bottom: 4px;">${structure.type_principal} · ${structure.ville}, ${structure.pays}</div>
${org.type ? `<div style="font-size: 11px; color: var(--nav-text-muted); margin-top: 2px;">${org.type}</div>` : ''} ${hashtagsHtml ? `<div style="margin-bottom: 6px;">${hashtagsHtml}</div>` : ''}
<a href="/pratique/${org.id}" style=" <div style="font-size: 11px; color: var(--nav-text); line-height: 1.4; margin-bottom: 8px;">${structure.description_courte.slice(0, 100)}</div>
display: inline-block; margin-top: 8px; font-size: 12px; <button onclick="document.dispatchEvent(new CustomEvent('nav-v2-select', {detail:'${structure.id}'}))" style="
color: var(--nav-primary-solid); text-decoration: underline; font-size: 12px;
">Voir la fiche </a> color: var(--nav-primary-solid);
text-decoration: underline;
background: none;
border: none;
cursor: pointer;
padding: 0;
font-family: var(--nav-font);
">Voir la fiche </button>
</div> </div>
`, { maxWidth: 240 }) `, { maxWidth: 260 })
marker.on('click', () => emit('select-org', org.id)) marker.on('click', () => emit('select-structure', structure.id))
markers.set(org.id, marker) markers.set(structure.id, marker)
clusterGroup.addLayer(marker) clusterGroup.addLayer(marker)
}) })
}
// Bug E2E L3 : recadrer la carte sur les resultats filtres // Ecouter l'event custom depuis les popups Leaflet
// Conditions : 1+ resultat, et au moins 1 marker hors viewport actuel. function onNavV2Select(e: CustomEvent) {
// On evite de recadrer au tout premier rendu (laisser la vue initiale). emit('select-structure', e.detail)
if (orgsWithCoords.length > 0 && initialFitDone) {
try {
const bounds = leaflet.latLngBounds(
orgsWithCoords.map((o) => [o.lat!, o.lng!])
)
// On recadre uniquement si la liste filtree est restreinte
// (evite un recadrage permanent quand toutes les fiches sont la).
if (orgsWithCoords.length <= 15) {
mapInstance.fitBounds(bounds, {
padding: [40, 40],
maxZoom: 10,
animate: true,
})
}
} catch (e) {
console.warn('[EuropeMap] fitBounds echoue:', e)
}
}
initialFitDone = true
} }
watch( watch(
() => props.orgs, () => props.structures,
() => updateMarkers(), () => updateMarkers(),
{ deep: false } { deep: false }
) )
@@ -204,16 +192,16 @@ watch(
if (oldId != null) { if (oldId != null) {
const oldMarker = markers.get(oldId) const oldMarker = markers.get(oldId)
const oldOrg = props.orgs.find(o => o.id === oldId) const oldStructure = props.structures.find(s => s.id === oldId)
if (oldMarker && oldOrg) { if (oldMarker && oldStructure) {
oldMarker.setIcon(createPinIcon(oldOrg.score ?? 1, false)) oldMarker.setIcon(createPinIcon(oldStructure.famille_principale, false))
} }
} }
if (newId != null) { if (newId != null) {
const newMarker = markers.get(newId) const newMarker = markers.get(newId)
const newOrg = props.orgs.find(o => o.id === newId) const newStructure = props.structures.find(s => s.id === newId)
if (newMarker && newOrg) { if (newMarker && newStructure) {
newMarker.setIcon(createPinIcon(newOrg.score ?? 1, true)) newMarker.setIcon(createPinIcon(newStructure.famille_principale, true))
const latLng = newMarker.getLatLng() const latLng = newMarker.getLatLng()
mapInstance.panTo(latLng, { animate: true }) mapInstance.panTo(latLng, { animate: true })
} }
@@ -235,6 +223,7 @@ let themeObserver: MutationObserver | null = null
onMounted(() => { onMounted(() => {
initMap() initMap()
document.addEventListener('nav-v2-select', onNavV2Select as EventListener)
themeObserver = new MutationObserver(() => { themeObserver = new MutationObserver(() => {
const dark = document.documentElement.classList.contains('dark') const dark = document.documentElement.classList.contains('dark')
@@ -244,6 +233,7 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('nav-v2-select', onNavV2Select as EventListener)
themeObserver?.disconnect() themeObserver?.disconnect()
if (mapInstance) { if (mapInstance) {
mapInstance.remove() mapInstance.remove()

View File

@@ -136,18 +136,6 @@
</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="/contribuer"
class="sidebar-cta-link"
>
+ Proposer une fiche
</NuxtLink>
</div>
</aside> </aside>
</template> </template>
@@ -266,24 +254,4 @@ function orgFonctions(org: Org): string[] {
color: var(--nav-text); color: var(--nav-text);
background: var(--nav-bg-alt); 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> </style>

View File

@@ -1,276 +0,0 @@
<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>

View File

@@ -1,60 +0,0 @@
<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>

View File

@@ -1,274 +0,0 @@
<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>

View File

@@ -1,41 +0,0 @@
<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>

View File

@@ -0,0 +1,385 @@
<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 &rarr;</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'
}>(), {
matches: () => [],
mode: 'none',
})
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
const linkSel = gLinks
.selectAll<SVGLineElement, SimLink>('line')
.data(currentLinks, (d: SimLink) => {
const s = d.source as SimNode
const t = d.target as SimNode
return `${s.id}-${t.id}-${d.mode}`
})
linkSel.exit().remove()
linkSel.enter()
.append('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)
}
// ── 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, orange)
nodeGroups.append('circle')
.attr('r', 6)
.attr('cx', r * 0.65)
.attr('cy', r * 0.65)
.attr('fill', '#f97316')
.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}`)
// 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))
.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() {
if (!gLinks || !gNodes) return
gLinks.selectAll<SVGLineElement, SimLink>('line')
.attr('x1', d => (d.source as SimNode).x ?? 0)
.attr('y1', d => (d.source as SimNode).y ?? 0)
.attr('x2', d => (d.target as SimNode).x ?? 0)
.attr('y2', d => (d.target as SimNode).y ?? 0)
gNodes.selectAll<SVGGElement, SimNode>('g.node')
.attr('transform', d => `translate(${d.x ?? 0},${d.y ?? 0})`)
}
// ── Watch matches/mode (hook pour M4) ─────────────────────────────────────
watch(() => [props.matches, props.mode] as const, () => {
if (!simulation) return
rebuildLinks()
simulation.force('link', d3.forceLink<SimNode, SimLink>(currentLinks)
.id(d => d.id)
.distance(120)
.strength(0.3))
simulation.alpha(0.5).restart()
}, { deep: true })
// ── 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>

View File

@@ -14,6 +14,9 @@ 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)
}, },
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client // Leaflet ne fonctionne pas en SSR — forcer le rendu côté client

459
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"@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",
@@ -5312,6 +5313,416 @@
"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",
@@ -5425,6 +5836,15 @@
"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",
@@ -6480,6 +6900,18 @@
"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",
@@ -6555,6 +6987,15 @@
"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",
@@ -9480,6 +9921,12 @@
"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",
@@ -9595,6 +10042,12 @@
"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",
@@ -9633,6 +10086,12 @@
"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",

View File

@@ -13,6 +13,7 @@
"@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",

270
pages/codev/carto.vue Normal file
View File

@@ -0,0 +1,270 @@
<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>
</header>
<ClientOnly>
<CodevGraph
:fiches="fiches"
:matches="matches"
:mode="mode"
@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 - 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
:class="{ active: mode === 'surprise' }"
style="--mode-color: #3b82f6"
@click="setMode('surprise')"
type="button"
>
Surprise
<span class="hint">offres partagees</span>
</button>
<button
v-if="mode !== 'none'"
class="reset"
@click="setMode('none')"
type="button"
>
Effacer
</button>
</div>
</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 } = 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 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) {
navigateTo(`/codev/fiche?id=${id}`)
}
</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;
}
}
/* ── Mobile ── */
@media (max-width: 600px) {
.codev-carto {
padding: 1rem 0.75rem 1.5rem;
}
.carto-header h1 {
font-size: 1.25rem;
}
}
</style>

367
pages/codev/demo.vue Normal file
View File

@@ -0,0 +1,367 @@
<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>
<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>
<!-- 3 boutons matching identiques a carto.vue -->
<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
:class="{ active: mode === 'surprise' }"
style="--mode-color: #3b82f6"
@click="setMode('surprise')"
type="button"
>
Surprise
<span class="hint">offres partagees</span>
</button>
<button
v-if="mode !== 'none'"
class="reset"
@click="setMode('none')"
type="button"
>
Effacer
</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { CodevFiche, CodevMatch } from '~/types/codev'
import { computeMatches } from '~/utils/codev/matching'
// 10 fiches factices - hashtags alignes pour demontrer les 3 modes :
//
// Solution : Lea(besoin coaching) -> Maya(offre coaching)
// Sami(besoin formation+vente) -> Ines(offre vente+formation)
// Tom(besoin tiers-lieu) -> Zoe(offre facilitation+tiers-lieu)
//
// Alliance : Lea + Maya (hashtag coaching commun dans besoins)
// Sami + Kenji (hashtag formation+vente dans besoins)
// Tom + Zoe (hashtag tiers-lieu dans besoins)
//
// Surprise : Lea + Zoe (hashtag facilitation dans offres)
// Tom + Roman (hashtag archi dans offres)
const FICHES_DEMO: CodevFiche[] = [
{
id: 1,
nom: 'Lea',
besoin: 'Structurer mon offre de coaching pour la lancer en septembre',
offre: 'Animation de groupes, facilitation de cercles de parole',
hashtags: ['coaching', 'facilitation'],
created_at: '2026-05-08T10:00:00Z',
},
{
id: 2,
nom: 'Sami',
besoin: 'Comprendre comment vendre une formation en ligne',
offre: 'Developpement web, sites Astro et Nuxt',
hashtags: ['formation', 'vente'],
created_at: '2026-05-08T10:01:00Z',
},
{
id: 3,
nom: 'Ines',
besoin: 'Aide pour la facilitation de mes ateliers ecriture',
offre: 'Vente de formations en ligne, marketing direct',
hashtags: ['vente', 'formation'],
created_at: '2026-05-08T10:02:00Z',
},
{
id: 4,
nom: 'Tom',
besoin: 'Trouver un associe pour un projet de tiers-lieu',
offre: 'Architecture eco-responsable, conception bioclimatique',
hashtags: ['tiers-lieu', 'archi'],
created_at: '2026-05-08T10:03:00Z',
},
{
id: 5,
nom: 'Maya',
besoin: 'Structurer mon offre de coaching freelance',
offre: 'Coaching de carriere, accompagnement transition pro',
hashtags: ['coaching', 'carriere'],
created_at: '2026-05-08T10:04:00Z',
},
{
id: 6,
nom: 'Kenji',
besoin: 'Apprendre a vendre mes formations sans me sentir vendeur',
offre: 'Photographie, direction artistique de projets editoriaux',
hashtags: ['formation', 'vente'],
created_at: '2026-05-08T10:05:00Z',
},
{
id: 7,
nom: 'Zoe',
besoin: 'Trouver des associes pour mon projet de tiers-lieu rural',
offre: 'Animation et facilitation de collectifs, intelligence collective',
hashtags: ['tiers-lieu', 'facilitation'],
created_at: '2026-05-08T10:06:00Z',
},
{
id: 8,
nom: 'Nael',
besoin: 'Construire un site web pour ma formation',
offre: 'Strategie marketing, lancement de produits digitaux',
hashtags: ['web', 'strategie'],
created_at: '2026-05-08T10:07:00Z',
},
{
id: 9,
nom: 'Eva',
besoin: 'Lancer mon offre de coaching avec une page de vente',
offre: 'Ecriture longue forme, articles essais et tribunes',
hashtags: ['coaching', 'ecriture'],
created_at: '2026-05-08T10:08:00Z',
},
{
id: 10,
nom: 'Roman',
besoin: 'Ameliorer mes articles de blog sur la renovation',
offre: 'Architecture, plans techniques pour renovation energetique',
hashtags: ['archi', 'reno'],
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)
}
}
</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>

367
pages/codev/fiche.vue Normal file
View File

@@ -0,0 +1,367 @@
<template>
<div class="fiche-page">
<div class="fiche-inner">
<!-- En-tête -->
<div class="fiche-header">
<h1>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">
{{ loading ? 'Envoi en cours...' : 'Ajouter ma fiche' }}
</button>
</form>
</div>
</div>
</template>
<script setup lang="ts">
const form = ref({ nom: '', besoin: '', offre: '', hashtagsRaw: '' })
const error = ref('')
const loading = ref(false)
const activeTip = ref<'besoin' | 'offre' | null>(null)
useHead({ title: 'Ma fiche — Co-développement' })
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)
await $fetch('/api/codev/fiches', {
method: 'POST',
body: {
nom: form.value.nom,
besoin: form.value.besoin,
offre: form.value.offre,
hashtags,
},
})
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 ── */
.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;
}
/* ── Responsive ── */
@media (max-width: 480px) {
.fiche-page {
padding: 1.25rem 1rem 3rem;
}
}
</style>

217
pages/codev/index.vue Normal file
View File

@@ -0,0 +1,217 @@
<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 &rarr;</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>

View File

@@ -72,21 +72,6 @@ const { data: org, pending, error } = await useFetch<Org>(`/api/fiche/${orgId}`,
key: `fiche-${orgId}`, key: `fiche-${orgId}`,
}) })
// ── Fallback Pratiques regeneratives (bug E2E L1) ─────────────────────
// Si /api/fiche/:id echoue, on regarde si l'id correspond a une pratique
// regenerative et on redirige automatiquement vers /pratique/:id.
if (error.value) {
try {
const pratiquesRes = await $fetch<{ list: { id: number }[] }>('/api/pratiques')
const numericId = Number(orgId)
if (!isNaN(numericId) && pratiquesRes.list?.some((p) => p.id === numericId)) {
await navigateTo(`/pratique/${numericId}`, { replace: true })
}
} catch {
// pas de fallback dispo, on garde l'erreur
}
}
// ── Commentaires — tick de rafraîchissement ─────────────────────────── // ── Commentaires — tick de rafraîchissement ───────────────────────────
const commentRefreshTick = ref(0) const commentRefreshTick = ref(0)

View File

@@ -1,170 +0,0 @@
<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>

View File

@@ -1,564 +0,0 @@
<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 : Onglets Europe / Outre-mer + carte pleine hauteur -->
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
<!-- Onglets Europe / Outre-mer (desktop) -->
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<button
type="button"
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'europe'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'europe'"
>
Europe
<span class="ml-1 text-xs opacity-70">({{ europeOrgs.length }})</span>
</button>
<button
type="button"
class="flex-1 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
<span class="ml-1 text-xs opacity-70">({{ outremerOrgs.length }})</span>
</button>
</div>
<!-- Carte Europe (pleine hauteur) -->
<div v-show="desktopMapView === 'europe'" 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>
<ChatbotPlaceholder
endpoint="/api/chatbot-pratiques"
title="Chatbot Pratiques régé"
placeholder="Pose une question sur les pratiques régénératives…"
ficheBasePath="/pratique"
@highlightOrgs="onHighlightOrgs"
>
<template #onboarding>
<p>Ce chatbot interroge la base des pratiques régénératives
(Mistral FR, serveur européen souverain, zéro rétention).</p>
<p>Pour t'aider à trouver les pratiques pertinentes,
formule ta requête ainsi :</p>
<ul>
<li>• Besoin : [matériaux biosourcés / réemploi / posture politique...]</li>
<li>• Type : [agence / coopérative / collectif / réseau...]</li>
<li>• Lieu : [pays ou région]</li>
</ul>
<p class="example">Exemple : "Je cherche une coopérative qui travaille
le réemploi de matériaux en Belgique."</p>
</template>
</ChatbotPlaceholder>
</div>
<!-- Carte Outre-mer (pleine hauteur, scroll) -->
<div v-show="desktopMapView === 'outremer'" class="flex-1 overflow-y-auto" style="background: var(--nav-bg);">
<ClientOnly>
<OutremerMapPratiques
:orgs="outremerOrgs"
:selectedId="selectedId"
@select-org="onSelectPratique"
/>
<template #fallback>
<div
class="flex items-center justify-center h-48 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">
<button
v-for="c in CRITERES"
:key="c.id"
type="button"
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);'"
:aria-pressed="criteres.includes(c.id)"
@click="toggleCritere(c.id)"
>{{ c.label }}</button>
</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">
<button
v-for="t in TYPES_ENTITE"
:key="t"
type="button"
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);'"
:aria-pressed="typesEntite.includes(t)"
@click="toggleType(t)"
>{{ TYPES_ENTITE_LABELS[t] ?? t }}</button>
</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) -->
<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"
endpoint="/api/chatbot-pratiques"
title="Chatbot Pratiques régé"
ficheBasePath="/pratique"
@update:modelValue="chatbotOpen = $event"
@highlightOrgs="onHighlightOrgs"
>
<template #onboarding>
<p>Ce chatbot interroge la base des pratiques régénératives
(Mistral FR, serveur européen souverain, zéro rétention).</p>
<p>Pour t'aider à trouver les pratiques pertinentes,
formule ta requête ainsi :</p>
<ul>
<li>• Besoin : [matériaux biosourcés / réemploi / posture politique...]</li>
<li>• Type : [agence / coopérative / collectif / réseau...]</li>
<li>• Lieu : [pays ou région]</li>
</ul>
<p class="example">Exemple : "Je cherche une coopérative qui travaille
le réemploi de matériaux en Belgique."</p>
</template>
</ChatbotSheet>
</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')
const desktopMapView = ref<'europe' | 'outremer'>('europe')
const chatbotOpen = ref(false)
// Surlignage temporaire (5 sec) suite a une reponse chatbot
let highlightTimer: ReturnType<typeof setTimeout> | null = null
const prevSelectedId = ref<number | null>(null)
function onHighlightOrgs(ids: (number | string)[]) {
if (!ids.length) return
const firstId = typeof ids[0] === 'string' ? parseInt(ids[0], 10) : ids[0]
if (isNaN(firstId)) return
prevSelectedId.value = selectedId.value
selectedId.value = firstId
if (highlightTimer) clearTimeout(highlightTimer)
highlightTimer = setTimeout(() => {
selectedId.value = prevSelectedId.value
prevSelectedId.value = null
highlightTimer = null
}, 5000)
}
// 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 = ''
mobileSearch.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>

View File

@@ -1,833 +0,0 @@
<template>
<div class="contribuer-page">
<div class="contribuer-inner">
<!-- Retour -->
<NuxtLink to="/pratiques-regeneratives" class="back-link">
Retour aux pratiques régénératives
</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>

View File

@@ -1 +0,0 @@
[]

View File

@@ -1,834 +0,0 @@
[
{
"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
}
]

View File

@@ -1,127 +0,0 @@
// scripts/vectorize-v2.js
// Usage : MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js
// Génère : server/data/embeddings-v2.json
//
// SETUP AVANT DEPLOY :
// cd nav-carte && MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js
// Coût estimé : ~0.10 EUR pour 120 fiches
//
// Prérequis : Node >= 18 (fetch natif disponible)
const fs = require('fs')
const path = require('path')
const MISTRAL_API_KEY = process.env.MISTRAL_API_KEY
if (!MISTRAL_API_KEY) {
console.error('Erreur : MISTRAL_API_KEY manquante')
console.error('Usage : MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js')
process.exit(1)
}
const dataPath = path.join(process.cwd(), 'public', 'data', 'reseaux-bifurcation.json')
const outPath = path.join(process.cwd(), 'server', 'data', 'embeddings-v2.json')
// Créer server/data/ si absent
const outDir = path.dirname(outPath)
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true })
const rawData = fs.readFileSync(dataPath, 'utf-8')
const data = JSON.parse(rawData)
const structures = data.structures
if (!Array.isArray(structures) || structures.length === 0) {
console.error('Erreur : aucune structure trouvée dans reseaux-bifurcation.json')
process.exit(1)
}
async function embedBatch(texts) {
const res = await fetch('https://api.mistral.ai/v1/embeddings', {
method: 'POST',
headers: {
'Authorization': `Bearer ${MISTRAL_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'mistral-embed',
input: texts
})
})
if (!res.ok) {
const err = await res.text()
throw new Error(`Mistral API error ${res.status}: ${err}`)
}
const json = await res.json()
return json.data.map(d => d.embedding)
}
function buildText(s) {
const parts = [
s.nom,
s.description_courte ?? '',
(s.description_longue ?? '').slice(0, 800),
(s.hashtags ?? []).join(' '),
(s.sources ?? []).map(src => src.titre).join(' '),
(s.pensees ?? []).map(p => p.label).join(' ')
]
return parts.filter(Boolean).join('\n\n')
}
async function main() {
const embeddings = []
const BATCH_SIZE = 8 // Mistral embed : rate limit prudent
console.log(`Vectorisation de ${structures.length} structures (modele : mistral-embed)...`)
console.log(`Sortie : ${outPath}`)
console.log()
for (let i = 0; i < structures.length; i += BATCH_SIZE) {
const batch = structures.slice(i, i + BATCH_SIZE)
const texts = batch.map(buildText)
try {
const batchEmbeddings = await embedBatch(texts)
batch.forEach((s, j) => {
embeddings.push({
fiche_id: s.id,
nom: s.nom,
famille: s.famille_principale,
hashtags: s.hashtags ?? [],
embedding: batchEmbeddings[j],
text_preview: texts[j].slice(0, 300)
})
})
const batchNum = Math.floor(i / BATCH_SIZE) + 1
const totalBatches = Math.ceil(structures.length / BATCH_SIZE)
console.log(` Batch ${batchNum}/${totalBatches} OK (${batch.length} fiches)`)
// Pause rate limit entre batches
await new Promise(r => setTimeout(r, 200))
} catch (err) {
console.error(` Erreur batch ${i}-${i + BATCH_SIZE}:`, err.message)
process.exit(1)
}
}
const output = {
meta: {
total: embeddings.length,
model: 'mistral-embed',
date: new Date().toISOString(),
source: 'reseaux-bifurcation.json'
},
embeddings
}
fs.writeFileSync(outPath, JSON.stringify(output, null, 2), 'utf-8')
const sizeKb = Math.round(fs.statSync(outPath).size / 1024)
console.log()
console.log(`Done : ${embeddings.length} embeddings -> ${outPath}`)
console.log(`Taille : ${sizeKb} KB`)
console.log()
console.log('Prochaine etape : deployer le fichier sur le VPS avec les autres assets.')
}
main().catch(err => {
console.error('Erreur fatale :', err)
process.exit(1)
})

View File

@@ -0,0 +1,39 @@
/**
* GET /api/admin/rag-info
*
* Retourne le statut du système RAG (v1 + v2) pour la page /admin/rag-status
*/
import { existsSync, readFileSync } from 'fs'
import { resolve } from 'path'
export default defineEventHandler(async (_event) => {
// Statut V2 : compter les embeddings
let v2Count = 0
let v2Date: string | null = null
let v2Model: string | null = null
try {
// Chercher depuis process.cwd() (racine du projet Nuxt)
const embPath = resolve(process.cwd(), 'server', 'data', 'embeddings-v2.json')
if (existsSync(embPath)) {
const data = JSON.parse(readFileSync(embPath, 'utf-8'))
v2Count = data.embeddings?.length ?? 0
v2Date = data.meta?.date ?? null
v2Model = data.meta?.model ?? null
}
} catch (e: any) {
console.warn('[rag-info] Erreur lecture embeddings-v2.json :', e?.message ?? e)
}
return {
v2_embeddings_count: v2Count,
v2_ready: v2Count > 0,
v2_model: v2Model ?? 'mistral-embed',
v2_generated_date: v2Date ?? null,
v1_enabled: process.env.RAG_V1_ENABLED !== 'false',
v1_deprecation_date: process.env.RAG_V1_DEPRECATION_DATE ?? 'non défini',
model_chat: 'mistral-small-latest',
setup_command: 'MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js'
}
})

View File

@@ -1,304 +0,0 @@
/**
* POST /api/chatbot-pratiques
*
* Chatbot semantique sur la base des pratiques regeneratives (JSON statique).
* Adapte du endpoint /api/chatbot (ecosysteme AEP NocoDB).
*
* Flow :
* 1. Rate limit : 10 req/IP/jour (JSON fichier, SHA-256)
* 2. Circuit breaker : budget 20 EUR/mois partage avec /api/chatbot
* 3. Lit public/data/pratiques-regeneratives.json (52 fiches V1)
* 4. Score keyword puis top 20 fiches en contexte
* 5. Appel Mistral Small avec prompt systeme adapte aux pratiques
* 6. Parse JSON -> { reponse_texte, fiches_recommandees }
* 7. Log stats_usage
*
* Reponse 200 : { reponse_texte, fiches_recommandees: [{ id, nom, explication }] }
* Reponse 429 : rate limit depasse
* Reponse 503 : budget IA epuise
*/
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
import { CRITERES, TYPES_ENTITE_LABELS, PAYS_LABELS } from '~/types/pratique'
import type { Pratique } from '~/types/pratique'
interface FicheReco {
id: number
nom: string
explication: string
}
interface MistralResponse {
reponse_texte: string
fiches_recommandees: FicheReco[]
}
// Prompt systeme dedie aux pratiques regeneratives.
// Difference avec /api/chatbot : on parle de pratiques, criteres rege (8 axes),
// types d'entites (agence, cooperative, collectif...), perimetre Europe + DOM-TOM.
const SYSTEM_PROMPT = `Tu es un assistant engage au service de la transition ecologique des pratiques architecturales. Tu accedes a la base AEP - Pratiques regeneratives, qui referencee les acteurs concrets de l'architecture regenerative en Europe et dans les DOM-TOM (agences, cooperatives, collectifs, reseaux, associations, plateformes, recherche).
CRITERES DE REGENERATION (8 axes utilises pour decrire chaque pratique) :
1. Materiaux (biosources, geosources, reemploi)
2. Filieres (locales, courtes, paysannes)
3. Posture (ethique, engagement politique, refus)
4. Process (collaboratif, participatif, lent)
5. Politique (lobbying, plaidoyer, contre-expertise)
6. Modele economique (cooperatif, low-tech, soutenable)
7. Vivant (biodiversite, sols, eau)
8. Transmission (formation, partage, pedagogie)
REGLES ABSOLUES :
1. Tu ne peux recommander QUE des pratiques presentes dans le contexte ci-dessous.
2. Ne jamais inventer une pratique absente du contexte.
3. Cite chaque pratique recommandee par son nom exact et son identifiant id.
4. Si le contexte ne contient aucune pratique pertinente, dis-le honnetement.
5. Reponses concises (200 mots max). Si l'usager demande explicitement plus de detail, tu peux developper.
6. Retourne UNIQUEMENT un objet JSON valide, sans texte avant ou apres.
7. Si la question est hors du champ architecture / ecologie / regeneration / territoire, recadre poliment.
FORMAT DE SORTIE :
{
"reponse_texte": "Ta reponse en prose (max 200 mots)",
"fiches_recommandees": [
{ "id": 12, "explication": "Pourquoi cette pratique repond a la question (1-2 phrases max)" }
]
}
CONTEXTE - Pratiques regeneratives disponibles :
{{FICHES_JSON}}`
function scorePratique(p: Pratique, keywords: string[]): number {
if (keywords.length === 0) return 1
const critereLabels = (p.criteres ?? [])
.map((cId) => CRITERES.find((c) => c.id === cId)?.label ?? '')
.join(' ')
const haystack = [
p.nom,
p.description,
p.ville,
p.type ? (TYPES_ENTITE_LABELS[p.type] ?? p.type) : '',
p.pays ? (PAYS_LABELS[p.pays] ?? p.pays) : '',
critereLabels,
(p.tags ?? []).join(' '),
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return keywords.reduce((score, kw) => score + (haystack.includes(kw) ? 1 : 0), 0)
}
function extractKeywords(question: string): string[] {
return question
.toLowerCase()
.replace(/[^\w\sàâäéèêëîïôùûüç-]/g, ' ')
.split(/\s+/)
.filter((w) => w.length >= 3)
.slice(0, 10)
}
function loadPratiques(): Pratique[] {
try {
const jsonPath = resolve(process.cwd(), 'public/data/pratiques-regeneratives.json')
const raw = readFileSync(jsonPath, 'utf-8')
return JSON.parse(raw) as Pratique[]
} catch (e) {
console.error('[chatbot-pratiques] Erreur lecture JSON:', (e as Error).message)
return []
}
}
async function logUsage(params: {
nocodbUrl: string
nocodbToken: string
statsTableId: string
tokensIn: number
tokensOut: number
coutEur: number
}) {
const { nocodbUrl, nocodbToken, statsTableId, tokensIn, tokensOut, coutEur } = params
const logUrl = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
try {
await $fetch(logUrl, {
method: 'POST',
headers: { 'xc-token': nocodbToken, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'mistral-small-latest',
endpoint: 'chatbot-pratiques',
tokens_in: tokensIn,
tokens_out: tokensOut,
cout_eur: coutEur,
timestamp: new Date().toISOString(),
orga_id: null,
}),
})
} catch (e) {
console.warn('[chatbot-pratiques] Log stats_usage echoue (non bloquant):', (e as Error).message)
}
}
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
// 1. 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 : 10 req/IP/jour (compteur dedie chatbot-pratiques)
const allowed = checkRateLimitJson(ip, 'chatbot-pratiques', 10)
if (!allowed) {
throw createError({
statusCode: 429,
statusMessage: 'Limite de 10 questions par jour atteinte.',
})
}
// 3. Lire le body
const body = await readBody(event)
const question: string = (body?.question ?? '').trim()
if (!question || question.length < 3) {
throw createError({
statusCode: 400,
statusMessage: 'Question trop courte.',
})
}
// 4. Circuit breaker budget partage
const statsTableId = process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc'
const budget = await checkBudget({
nocodbUrl: config.nocodbUrl as string,
nocodbToken: config.nocodbToken as string,
statsTableId,
})
if (budget.blocked) {
throw createError({
statusCode: 503,
statusMessage: 'Budget IA mensuel epuise - reouverture le 1er du mois prochain.',
})
}
// 5. Charger pratiques + scoring
const allPratiques = loadPratiques()
if (allPratiques.length === 0) {
throw createError({
statusCode: 503,
statusMessage: 'Donnees pratiques indisponibles.',
})
}
const keywords = extractKeywords(question)
const scored = allPratiques
.map((p) => ({ pratique: p, score: scorePratique(p, keywords) }))
.sort((a, b) => b.score - a.score)
.slice(0, 20)
.map((x) => x.pratique)
const fichesContext = scored.map((p) => ({
id: p.id,
nom: p.nom,
type: p.type ? (TYPES_ENTITE_LABELS[p.type] ?? p.type) : '',
pays: p.pays ? (PAYS_LABELS[p.pays] ?? p.pays) : '',
ville: p.ville ?? '',
criteres: (p.criteres ?? [])
.map((cId) => CRITERES.find((c) => c.id === cId)?.label ?? '')
.filter(Boolean),
description: (p.description ?? '').slice(0, 250),
tags: (p.tags ?? []).slice(0, 5),
}))
const systemPrompt = SYSTEM_PROMPT.replace(
'{{FICHES_JSON}}',
JSON.stringify(fichesContext, null, 0),
)
// 6. Appel Mistral Small
const mistralApiKey = config.mistralApiKey as string
if (!mistralApiKey) {
throw createError({
statusCode: 500,
statusMessage: 'Cle API Mistral manquante.',
})
}
let mistralRaw: string
let tokensIn = 0
let tokensOut = 0
try {
const mistralRes = await $fetch<{
choices: { message: { content: string } }[]
usage?: { prompt_tokens: number; completion_tokens: number }
}>('https://api.mistral.ai/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${mistralApiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'mistral-small-latest',
temperature: 0.3,
max_tokens: 600,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: question },
],
}),
})
mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}'
tokensIn = mistralRes.usage?.prompt_tokens ?? 0
tokensOut = mistralRes.usage?.completion_tokens ?? 0
} catch (e: any) {
console.error('[chatbot-pratiques] Erreur Mistral Small:', e?.message ?? e)
throw createError({
statusCode: 502,
statusMessage: 'Erreur appel IA - reessaie dans quelques instants.',
})
}
// 7. Parse JSON
let parsed: MistralResponse
try {
const raw = JSON.parse(mistralRaw)
parsed = {
reponse_texte: raw.reponse_texte ?? "Je n'ai pas pu analyser ta demande.",
fiches_recommandees: (raw.fiches_recommandees ?? []).map((f: any) => {
const p = scored.find((x) => x.id === f.id)
return {
id: f.id,
nom: p?.nom ?? f.nom ?? `Fiche #${f.id}`,
explication: f.explication ?? '',
}
}),
}
} catch {
parsed = {
reponse_texte: "Je n'ai pas pu analyser ta demande correctement.",
fiches_recommandees: [],
}
}
// 8. Log usage (non bloquant)
const coutEur = calcCoutMistralSmall(tokensIn, tokensOut)
logUsage({
nocodbUrl: config.nocodbUrl as string,
nocodbToken: config.nocodbToken as string,
statsTableId,
tokensIn,
tokensOut,
coutEur,
})
return parsed
})

View File

@@ -0,0 +1,194 @@
/**
* POST /api/chatbot-v2
*
* Chatbot V2 - Embedding-based search sur structures bifurcation
* Coexiste avec /api/chatbot (keyword NocoDB) pendant la transition.
*
* SETUP AVANT DEPLOY :
* cd nav-carte && MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js
* Coût estimé : ~0.10 EUR pour 120 fiches
*
* Flow :
* 1. Rate limit (réutilise checkRateLimitJson, 10 req/IP/jour)
* 2. Embed la query via Mistral Embed (mistral-embed)
* 3. Top-5 cosine similarity sur embeddings-v2.json
* 4. Si embeddings absents : réponse graceful (v2_ready: false)
* 5. Construit contexte RAG depuis les fiches candidates
* 6. Génère réponse Mistral Small (json_object)
* 7. Retourne { reponse_texte, fiches_recommandees, sources, v2_ready }
*
* Variables d'env :
* MISTRAL_API_KEY - Clé Mistral (partagée avec chatbot v1)
* RAG_V1_ENABLED - true/false (défaut: true) - coexistence pendant transition
* RAG_V1_DEPRECATION_DATE - Date prévue deprecation v1 (ex: 2026-05-18)
*/
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { loadEmbeddingsV2, topKSearch } from '~/server/utils/vectorSearch'
// ── System prompt V2 ───────────────────────────────────────────────────────────
const SYSTEM_PROMPT_V2 = `Tu es un assistant pour la carte des réseaux de bifurcation en architecture (projet AEP).
Tu réponds aux questions sur les structures, les pratiques, les pensées écologiques.
Règles :
- Cite chaque structure par son nom exact et son fiche_id
- Indique la famille (1-5) entre parenthèses après chaque nom
- Reste sobre et descriptif - pas militant agressif
- Tirets longs interdits : utilise des - ou des ;
- Max 200 mots par réponse
- Si hors-scope (pas archi/habiter/écologie), redirige poliment vers la carte
- Retourne UNIQUEMENT un JSON valide, sans texte avant ou après
Familles :
1 - Réemploi et filières
2 - Frugalité et low-tech
3 - Architecture sociale et précarités
4 - Collectifs, écolieux et AMO
5 - Urbanisme de transition et territoires
FORMAT DE SORTIE :
{
"reponse_texte": "Ta réponse en prose (max 200 mots)",
"fiches_recommandees": [
{ "fiche_id": "f1-rotor", "nom": "Rotor", "explication": "1-2 phrases pourquoi cette fiche" }
]
}
CONTEXTE - Structures disponibles :
{{CONTEXTE_RAG}}`
// ── Handler ────────────────────────────────────────────────────────────────────
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
// 1. Rate limit
const ip =
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
event.node.req.socket?.remoteAddress ||
'0.0.0.0'
const allowed = checkRateLimitJson(ip, 'chatbot-v2', 10)
if (!allowed) {
throw createError({
statusCode: 429,
statusMessage: 'Limite de 10 questions par jour atteinte.'
})
}
// 2. Validation body
const body = await readBody(event)
const question: string = (body?.question ?? '').trim()
if (!question || question.length < 3) {
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
}
const mistralApiKey = config.mistralApiKey as string
if (!mistralApiKey) {
throw createError({ statusCode: 500, statusMessage: 'Clé API Mistral manquante.' })
}
// 3. Charger embeddings V2 (lazy, cachés en mémoire)
const embeddingsV2 = loadEmbeddingsV2()
// Graceful fallback si le script vectorize-v2.js n'a pas encore été lancé
if (embeddingsV2.length === 0) {
return {
reponse_texte: "La base vectorielle V2 est en cours de préparation. Merci d'utiliser le chatbot classique en attendant.",
fiches_recommandees: [],
sources: [],
v2_ready: false
}
}
// 4. Embed la query via Mistral Embed
let queryEmbedding: number[]
try {
const embedRes = await $fetch<{ data: { embedding: number[] }[] }>(
'https://api.mistral.ai/v1/embeddings',
{
method: 'POST',
headers: {
Authorization: `Bearer ${mistralApiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'mistral-embed',
inputs: [question]
})
}
)
queryEmbedding = embedRes.data[0].embedding
} catch (e: any) {
console.error('[chatbot-v2] Erreur embedding Mistral :', e?.message ?? e)
throw createError({ statusCode: 502, statusMessage: 'Erreur embedding Mistral.' })
}
// 5. Top-5 cosine similarity
const v2Results = topKSearch(embeddingsV2, queryEmbedding, 5)
// 6. Contexte RAG
const candidatesContext = v2Results.map(r => ({
fiche_id: r.fiche_id,
nom: r.nom,
famille: r.famille,
hashtags: r.hashtags,
score: r.score,
preview: r.text_preview
}))
const contextStr = candidatesContext
.map(c => `[${c.fiche_id}] ${c.nom} (famille ${c.famille}, score: ${c.score.toFixed(2)})\n${c.preview}`)
.join('\n\n---\n\n')
const systemPrompt = SYSTEM_PROMPT_V2.replace('{{CONTEXTE_RAG}}', contextStr)
// 7. Mistral Small - génération réponse
let mistralRaw: string
try {
const mistralRes = await $fetch<{
choices: { message: { content: string } }[]
}>('https://api.mistral.ai/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${mistralApiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'mistral-small-latest',
temperature: 0.3,
max_tokens: 600,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: question }
]
})
})
mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}'
} catch (e: any) {
console.error('[chatbot-v2] Erreur Mistral Small :', e?.message ?? e)
throw createError({ statusCode: 502, statusMessage: 'Erreur appel Mistral Small.' })
}
// 8. Parse JSON
let parsed: { reponse_texte: string; fiches_recommandees: any[] }
try {
parsed = JSON.parse(mistralRaw)
if (!parsed.reponse_texte) throw new Error('reponse_texte absent')
} catch {
parsed = {
reponse_texte: "Impossible d'analyser la réponse.",
fiches_recommandees: []
}
}
return {
reponse_texte: parsed.reponse_texte,
fiches_recommandees: parsed.fiches_recommandees ?? [],
sources: candidatesContext,
v2_ready: true
}
})

View File

@@ -0,0 +1,31 @@
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'
if (parsed.data.password.trim().toLowerCase() !== expected.trim().toLowerCase()) {
throw createError({ statusCode: 401, statusMessage: 'Mauvais mot de passe' })
}
setCookie(event, 'codev_session', 'ok', {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24, // 24h
path: '/',
})
return { status: 200, ok: true }
})

View File

@@ -0,0 +1,31 @@
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 }
})

View File

@@ -0,0 +1,63 @@
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,
}
})

View File

@@ -1,20 +0,0 @@
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' })
}
})

View File

@@ -1,117 +0,0 @@
// 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,
}
})

View File

@@ -0,0 +1,20 @@
// 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 sous-routes éventuelles)
if (path === '/codev/demo' || path.startsWith('/codev/demo/')) 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)
})

View File

@@ -0,0 +1,96 @@
/**
* Recherche vectorielle sur les embeddings V2
* Cosine similarity + top-K
*
* Utilisé par : server/api/chatbot-v2.post.ts
* Données : server/data/embeddings-v2.json (généré par scripts/vectorize-v2.js)
*/
import { readFileSync, existsSync } from 'fs'
import { fileURLToPath } from 'url'
import { resolve, dirname } from 'path'
// ── Types ──────────────────────────────────────────────────────────────────────
export interface EmbeddingEntry {
fiche_id: string
nom: string
famille: number
hashtags: string[]
embedding: number[]
text_preview: string
}
export interface SearchResult {
fiche_id: string
nom: string
famille: number
hashtags: string[]
score: number
text_preview: string
}
// ── Cosine similarity ──────────────────────────────────────────────────────────
export function cosineSimilarity(a: number[], b: number[]): number {
if (a.length !== b.length) return 0
let dot = 0, normA = 0, normB = 0
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
}
const denom = Math.sqrt(normA) * Math.sqrt(normB)
return denom === 0 ? 0 : dot / denom
}
// ── Top-K search ───────────────────────────────────────────────────────────────
export function topKSearch(
embeddings: EmbeddingEntry[],
queryEmbedding: number[],
k: number = 5
): SearchResult[] {
return embeddings
.map(e => ({
fiche_id: e.fiche_id,
nom: e.nom,
famille: e.famille,
hashtags: e.hashtags,
score: cosineSimilarity(e.embedding, queryEmbedding),
text_preview: e.text_preview
}))
.sort((a, b) => b.score - a.score)
.slice(0, k)
}
// ── Chargement lazy des embeddings (cache module-level) ────────────────────────
let _embeddingsV2: EmbeddingEntry[] | null = null
export function loadEmbeddingsV2(): EmbeddingEntry[] {
if (_embeddingsV2 !== null) return _embeddingsV2
try {
// Résolution du chemin depuis server/utils/ vers server/data/
const currentDir = dirname(fileURLToPath(import.meta.url))
const embPath = resolve(currentDir, '..', 'data', 'embeddings-v2.json')
if (!existsSync(embPath)) {
console.warn('[vectorSearch] embeddings-v2.json absent - V2 vector search désactivé')
console.warn('[vectorSearch] Lancer : MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js')
_embeddingsV2 = []
return []
}
const raw = readFileSync(embPath, 'utf-8')
const data = JSON.parse(raw)
_embeddingsV2 = data.embeddings ?? []
console.log(`[vectorSearch] ${_embeddingsV2!.length} embeddings V2 chargés (${data.meta?.model ?? 'unknown'})`)
return _embeddingsV2!
} catch (e: any) {
console.warn('[vectorSearch] Erreur chargement embeddings-v2.json :', e?.message ?? e)
_embeddingsV2 = []
return []
}
}

18
types/codev.ts Normal file
View File

@@ -0,0 +1,18 @@
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
}

View File

@@ -1,69 +0,0 @@
/**
* 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',
}

91
types/structure-v2.ts Normal file
View File

@@ -0,0 +1,91 @@
/**
* Types V2 - Carte des réseaux de bifurcation
* Source : public/data/reseaux-bifurcation.json
*/
export interface StructureV2 {
id: string
nom: string
url: string
pays: string
ville: string
famille_principale: 1 | 2 | 3 | 4 | 5
familles_secondaires?: number[]
hashtags: string[]
type_principal: string
badges: {
centre_ressources: boolean
mouvement_manifeste: boolean
contre_pouvoir_spatial: boolean
f6_recherche_politique: boolean
}
description_courte: string
description_longue: string
pensees: { id: string; label: string; confiance: string }[]
sources: { type: string; titre: string; url: string }[]
already_in_v1: boolean
eligible_v2: boolean
// Geocoords (ajoutés par géocodage - peut être null)
latitude?: number | null
longitude?: number | null
}
export interface ReseauxBifurcationData {
version: string
meta: {
total_structures: number
total_projets_emblematiques: number
total_edges_graphe: number
familles: { id: number; label: string; color: string }[]
hashtags_officiels: string[]
}
structures: StructureV2[]
projets: ProjetEmblematique[]
graphe: { edges: GrapheEdge[] }
}
export interface ProjetEmblematique {
id: string
nom: string
structure_parent: string
annee?: number
lieu?: string
geocoords?: { lat: number; lng: number } | null
description: string
url?: string | null
tags: string[]
}
export interface GrapheEdge {
source: string
target: string
types: string[]
score: number
evidence: string
}
// Mapping StructureV2 vers le format attendu par NavMap (interface Org)
// NavMap attend { Id, latitude, longitude, nom, ... }
export function structureToMapOrg(s: StructureV2, index: number): {
Id: number
nom: string
latitude?: number | null
longitude?: number | null
prioritaire?: boolean
famille_principale?: number
hashtags?: string[]
type_principal?: string
description_courte?: string
} {
return {
Id: index,
nom: s.nom,
latitude: s.latitude,
longitude: s.longitude,
prioritaire: s.badges?.centre_ressources || s.badges?.mouvement_manifeste || s.badges?.contre_pouvoir_spatial,
famille_principale: s.famille_principale,
hashtags: s.hashtags,
type_principal: s.type_principal,
description_courte: s.description_courte,
}
}

97
utils/codev/matching.ts Normal file
View File

@@ -0,0 +1,97 @@
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))
}
const THRESHOLD = 0.15
export function matchSolution(fiches: CodevFiche[]): CodevMatch[] {
const matches: CodevMatch[] = []
for (const a of fiches) {
for (const b of fiches) {
if (a.id === b.id) continue
const s = score(a.besoin, a.hashtags, b.offre, b.hashtags)
if (s >= THRESHOLD) {
matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'solution' })
}
}
}
return matches
}
export function matchAlliance(fiches: CodevFiche[]): 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]
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[]): 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]
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',
): CodevMatch[] {
switch (mode) {
case 'solution': return matchSolution(fiches)
case 'alliance': return matchAlliance(fiches)
case 'surprise': return matchSurprise(fiches)
}
}