Compare commits
40 Commits
0378f2bd72
...
feat/aep-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22a7a42206 | ||
|
|
b2fbbcb198 | ||
|
|
110fd58ec2 | ||
|
|
3917717eb1 | ||
|
|
55d7e4de55 | ||
|
|
88716657a2 | ||
|
|
86b95fa18e | ||
|
|
3a07d368f0 | ||
|
|
20d228fde7 | ||
|
|
fd33debf06 | ||
|
|
40b406bd41 | ||
|
|
46f57ae5fe | ||
|
|
b36587cb08 | ||
|
|
89608d894c | ||
|
|
fdd9d02859 | ||
|
|
a1c47002d5 | ||
|
|
c14a1ee01f | ||
|
|
1b1e373bea | ||
|
|
c6295ea228 | ||
|
|
cd2d225e91 | ||
|
|
11732a6a4b | ||
|
|
538c9a1214 | ||
|
|
8d673482b6 | ||
|
|
586742d90e | ||
|
|
668ae5caff | ||
|
|
f5732bf336 | ||
|
|
5967a5af57 | ||
|
|
419071b4c5 | ||
|
|
f7b01c33e6 | ||
|
|
a72928343f | ||
|
|
f9a0875727 | ||
|
|
ec9178be08 | ||
|
|
6525afd5f5 | ||
|
|
4d7e8bede9 | ||
|
|
70b9b1aa3c | ||
|
|
cc93571d94 | ||
|
|
2b34d05585 | ||
|
|
19ff17e236 | ||
|
|
eb1bcf6080 | ||
|
|
9eb66ac10c |
105
JOURNAL-V2.md
105
JOURNAL-V2.md
@@ -1,5 +1,6 @@
|
||||
---
|
||||
type: journal
|
||||
note_J8: Phase 8.G LIVREE 2026-05-14 - voir PILOTE-RAG-PE.md pour details
|
||||
project: NAV V2
|
||||
created: 2026-04-14
|
||||
status: actif
|
||||
@@ -11,6 +12,56 @@ Journal technique de la V2. Décisions, anomalies, points bloquants, TODOs.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-08 — Fix mobile + chatbot prod (cause racine résolue)
|
||||
|
||||
**Commits :** session loggée sur main (pushé sur gitea)
|
||||
**Pattern :** pilote direct, 2 batches successifs, ~3h, 11 fichiers
|
||||
|
||||
### Cause racine bug "chatbot Carte 1 == Carte 2"
|
||||
|
||||
`/api/chatbot-reseaux` était **404 en prod** (jamais déployé) — explique pourquoi 5 cycles de fix précédents (ChatbotReseaux.vue prop endpoint, useRoute fallback, useMarkdown direct, etc.) n'ont rien donné : le code source était correct depuis le début. Le rebuild + redeploy de cette session résout le bug.
|
||||
|
||||
**Verif :** `curl -s -X POST https://aep.trans-former.fr/api/chatbot-reseaux` → 200 + réponse distincte de `/api/chatbot`.
|
||||
|
||||
### Batch 1 — fixes mobile principaux
|
||||
|
||||
- Hamburger app.vue : ajout Jobs + Manifeste + Soutenir, ré-ordonnancement (Manifeste dans 2e groupe avec À propos/Soutenir/Signaler)
|
||||
- BandeauBas.vue : FAB cœur jaune mobile retiré (Soutenir migré dans hamburger via lien Liberapay direct)
|
||||
- agences.vue mobile : 3e onglet "Graphe" ajouté + masquage MobileSheet en mode graphe (canvas fullscreen)
|
||||
- a-propos.vue : section 1 "Mission" retirée (devient pop-up Carte 1) + `overflow-x: hidden` sur `.apropos-page` + retrait `white-space: nowrap` problématique sur `.badge-detail`
|
||||
- pages/manifeste.vue : nouvelle page (texte version `manifeste-page-carto-V1.md`, sans le diagramme ASCII pour V1 web)
|
||||
- components/MissionPopup.vue : nouveau composant générique (props `title`, `ctaLabel`, `storageKey`, slot pour contenu, `:slotted()` pour styles)
|
||||
- index.vue : intégration MissionPopup + bouton (i) `position:fixed` bottom-left + auto-show 1ère visite via `localStorage.aep_mission_seen`
|
||||
- trouver-du-taf.vue : toggle "Filtres [N] [chevron]" mobile-only (`@media max-width: 767px`) avec `taff-filters-collapsible` max-height transition
|
||||
- FicheModal.vue + FicheModalV2.vue : sur mobile `top: 76px` + `max-height: calc(100dvh - 92px)` au lieu de `top: 50% translate(-50%, -50%)` + `max-height: 90vh` qui mordait sur le header
|
||||
|
||||
### Batch 2 — pop-up Carte 2, logo, intro Jobs, labels graphe
|
||||
|
||||
- agences.vue : pop-up Réseaux AEP avec MissionPopup (storageKey `aep_reseaux_seen`, ctaLabel "Explorer les 120 réseaux") + bouton (i) flottant
|
||||
- app.vue logo header : badge AEP + 2 spans `logo-line-1` ("Architecture") / `logo-line-2` ("d'Écologie Politique") avec font-size responsive (0.7rem mobile → 0.85rem ≥1024)
|
||||
- trouver-du-taf.vue : `<details class="taff-pedago" open>` avec 3 blocs (deux onglets, trois étiquettes, cinq axes) + onglet "Plateformes B2C" → "Pour archi indépendants"
|
||||
- GraphView.vue : `d3NodeSelection.filter(type==='structure').append('text')` avec class `graph-struct-label`, `dy: -(d.r + 5)`, font-size 9.5px, halo via `paint-order: stroke; stroke: var(--nav-bg)` (style global non-scoped pour piercer D3)
|
||||
|
||||
### Bug d'opération à retenir
|
||||
|
||||
Lors du 1er déploiement batch 2, `bash deploy.sh` semblait OK (HTTP 200) mais le HTML en prod ne contenait pas les modifs. **Cause** : Dropbox sync a effacé `.output/` entre `npm run build` et le tar SCP — le tar a uploadé un `.output` quasi-vide. Solution : 2e cycle clean (`Remove-Item .nuxt/dist + .output`) + rebuild + redeploy avec `yes y |` (skip confirm interactif `.env diff`).
|
||||
|
||||
**Réflexe à intégrer** : après build, vérifier `grep -o "<un-fragment-de-modif>" .output/public/_nuxt/*.js | head` AVANT le deploy. Si 0 match → ne pas deploy, rebuild.
|
||||
|
||||
### Bug de communication à retenir
|
||||
|
||||
Jules a signalé "le logo n'a pas marché", "B2C pas renommé", "hamburger pas modifié" alors que le HTML en prod contenait bien les modifs (vérifié curl avec `?nc=$(date +%s)`). **Cause** : cache navigateur / service worker Nuxt. Réflexe à mettre en place pour /done de toute session web : si Jules dit "ça n'apparaît pas", vérifier curl en bypass cache AVANT de chercher un bug. Si match curl → demander hard refresh (Ctrl+Shift+R).
|
||||
|
||||
### Reste à faire (batch 3)
|
||||
|
||||
Voir `0 INBOX/PROMPTS/cascade-megaboum/REPRISE-aep-carto-fix-batch3.md` :
|
||||
- Bouton "+" → sélecteur 3 cartes (Entraide/Réseaux/Jobs)
|
||||
- Pop-up explication 5 axes Jobs (paragraphe par axe)
|
||||
- Pop-up Carte 1 visibilité (option à clarifier avec Jules)
|
||||
- GraphView Carte 1 (centres = hashtags, couche échelle activable) — gros chantier session dédiée
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
@@ -1003,3 +1054,57 @@ Incohérence entre `deploy.sh` (rsync vers `/opt/nav-carte/`) et `nav-carte.serv
|
||||
- **Caching API organisations** — actuellement re-fetch NocoDB à chaque render
|
||||
- **Full-text search côté client** — Fuse.js sur descriptions enrichies
|
||||
- **Mode offline / PWA** — manifest + service worker pour usage terrain
|
||||
|
||||
---
|
||||
|
||||
## Décisions structurantes (mémoire profonde)
|
||||
|
||||
> Archive des décisions structurantes du projet nav-carte (AEP V1/V2/Codev/Carte 3), déchargée hebdo depuis coordo-agent-dev. Tri chronologique inverse (plus récent en haut). Copies verbatim, pas de reformulation.
|
||||
|
||||
### 2026-W19 (décharge 2026-05-13)
|
||||
|
||||
- [W19 — 2026-05-08 19:21] **AEP nav-carte fix mobile batch1+2 LIVE + cause racine chatbot Carte1=Carte2 résolue** (~3h pilote direct, 2 batches successifs, 11 fichiers). **Cause racine identifiée** après 5 cycles de fix précédents infructueux : `/api/chatbot-reseaux` était **404 en prod** (jamais déployé). Le code source nav-carte était correct depuis le début. Rebuild + redeploy = bug résolu (vérifié curl POST sur les 2 endpoints, réponses distinctes). **Batch 1** : hamburger refondu (Jobs/Manifeste/Soutenir), FAB cœur jaune retiré, 3e onglet Graphe mobile sur agences, /a-propos refondue + scroll latéral fixé, page /manifeste créée (texte version page-carto-V1), MissionPopup générique avec slot/props (storageKey, title, ctaLabel) auto-show 1ère visite, filtres Jobs mobile repliables (toggle "Filtres [N]"), FicheModal/V2 décalage `top:76px` mobile pour ne plus mordre header. **Batch 2** : pop-up Carte 2 Réseaux AEP, logo header `Architecture d'Écologie Politique` en clair sur 2 lignes (logo-line-1/2 responsive), onglet "Plateformes B2C" → "Pour archi indépendants", intro pédagogique repliable Jobs (2 onglets / 3 tags / 5 axes éthiques), labels noms structures sur GraphView (D3 append text + halo via `paint-order: stroke`). **Pattern d'opération critique découvert** : Dropbox sync efface .output entre `npm run build` et `tar | ssh` du deploy.sh — 1er deploy batch 2 a uploadé un .output quasi-vide sans erreur visible (HTTP 200 trompeur). Réflexe : `grep "fragment-modif" .output/public/_nuxt/*.js | head` AVANT deploy. **Pattern de communication** : Jules a signalé "modifs pas faites" alors que HTML prod contenait bien les modifs (vérifié curl) — cache navigateur / service worker. Réflexe : si "ça apparaît pas", curl bypass cache AVANT chercher bug, puis demander hard refresh. Git : commit propre + push gitea/main (`yes y | bash deploy.sh` pour skip confirm interactif `.env diff`). Prompt batch 3 dans `0 INBOX/PROMPTS/cascade-megaboum/REPRISE-aep-carto-fix-batch3.md`.
|
||||
|
||||
- [W19 — 2026-05-07 17:18] **AEP Carte 3 "Trouver du taf" — T2 scoring 5 axes + T3/T4 LIVE** : 24 plateformes scorées (7✅/14⚠️/3❌), plateformes-taff.json prod, page /trouver-du-taf complète (filtres, grille 1 col, modal fiche, chatbot FAB Guide IA). Endpoints /api/chatbot-taff (import JSON statique + Mistral Small) + /api/chatbot-reseaux (keyword search 120 structures). ChatbotReseaux.vue créé (standalone Carte 2). useMarkdown.ts inline styles CSS-free. Onglet Jobs nav desktop. Bug dev : cache .nuxt corrompu par agent concurrent → bat rmdir+délai 12s. Chatbot séparation + markdown à vérifier en PROD (pas dev). Menu hamburger mobile Jobs manquant. Branche main.
|
||||
|
||||
- [W19 — 2026-05-07 01:11] **Codev MVP LIVRÉ en prod** — cascade M1→M5 complète (5 agents Sonnet), merge feat/codev-mvp→main, push Gitea. App entraide /codev live avec lock screen, formulaire, graphe D3 force-directed, annuaire table sticky, 2 modes matching (Solution tokenize direct + Alliance Jaccard), mode admin (DELETE fiche), QR code public, panel mobile bottom sheet. Décision build : TOUJOURS depuis C:\tmp\nav-build (Dropbox corrompt cache Vite = 500 prod). Algo fix critique : Solution compare textes besoin↔offre directement (ignore hashtags), évite que les 3 modes donnent le même graphe.
|
||||
|
||||
- [W19 — 2026-05-06 17:11] **MP-TAFF T1b scraping compléments DONE** — BrowserMCP utilisé pour débloquer Trustpilot (7 pages) + instao.fr + francemarches.com. `T1-output-plateformes.json` finalisé : 25 plateformes, 7 avec feedback Trustpilot. Signaux forts T2 : Archionline 2.4/5 (spam + permis PLU non conformes), hemea = courtier déguisé en MOE, TMA = meilleur ratio pros. Prochaine étape : **MP-TAFF T2 scoring 5 axes éthiques**.
|
||||
|
||||
- [W19 — 2026-05-06 13:30] **Cascade dispatchable Codev MVP livrée — 9 fichiers prêts à dispatcher en session Opus dédiée** : Cadrage `/orchestrateur` app entraide / co-développement Jules pour facilitation IRL jeudi 8/05 ~10 amis. 5 intersections tranchées : URL `/codev` (sous-route aep.trans-former.fr pas nouveau domaine), naming "Co-développement"/"Entraide entre pairs", mot de passe partagé `merci`, persistance NocoDB nouvelle table `codev_fiches` (carto vit hebdo, pas effacée), démo route publique `/codev/demo` (10 prénoms factices pour pitch univ). **Démêlage trinôme** (problème mathématique flaggé par Jules) : matching pair-only en MVP, trinômes émergent visuellement du D3 force-directed (triangles dans le graphe), pas de logique trinôme explicite codée — économie combinatoire majeure. 3 modes : Solution (besoin→offre asymétrique avec flèche), Alliance (besoin↔besoin symétrique), Surprise (offre↔offre symétrique). **Algo matching MVP simple** sans IA : Jaccard sur hashtags si présents, sinon Jaccard sur tokens FR (stop-words filtrés) seuil 0.15 — V2 embeddings si scaling. **Réutilisation pattern GraphView.vue** (~700 lignes existant) en simplifié (~200-300 lignes pour CodevGraph) — pas de greenfield. Cascade dans `codev-build/` (même pattern que `aep-communaute-build/`) : INDEX (table+décisions+statut) + META-PROMPT-OPUS (preflight+dispatch séquentiel+1 checkpoint deploy+format récap) + M1 (NocoDB table API + 3 endpoints + runtimeConfig) + M2 (lock+fiche+middleware auth skip /codev+/codev/demo) + M3 (CodevGraph D3 + page carto affichage seul) + M4 (matching 3 modes + boutons + animation force.alpha(0.5).restart()) + M5 split phase 1 (démo+build local+stop) + phase 2 (deploy prod après checkpoint Jules) + FEEDBACK-PASSES (10 risques pré-dispatch corrigés) + PHRASE-LANCEMENT (one-shot pour session Opus). **Patches en cours de session** : M1 fait création table NocoDB en autonomie via API (token NocoDB `e9rU...` déjà dans nav-carte/.env, endpoint `POST /api/v2/meta/bases/{baseId}/tables`), M5 phase 2 sync .env VPS automatiquement (ssh append + restart aep), règle d'or "couper M4/M5 si timing serré" retirée du META car Jules a confirmé scope OK. **1 seul checkpoint Jules** : entre M5 phase 1 (build local 200) et M5 phase 2 (deploy prod). Pattern méga-dispatch consécutif #5 (avant : Simulateur V2 30/04, Méga-cascade AEP V2 30/04, MP-TAFF cascade-megaboum 06/05, AEP V2 graphe PV2-5 micro-itérations 06/05). À dispatcher en session Opus dédiée (jauge propre, ~1h30 cascade attendue + ~5 min action manuelle Jules NocoDB).
|
||||
|
||||
- [W19 — 2026-05-06 21:30→01:45] **AEP V2 graphe interactif PV2-5b/e/f/g + chatbot v2 vivant + decouverte 2 repos imbriqués** : 4 commits vault `feat/aep-v2-cartobifurcation` (062337a sidebar+chatbot intégré, 2adffdf toggle Familles/Pratiques+popover famille, 5d7556a carte unifiée layers superposables+popover hashtag+lisibilité, e1ae1b9 popovers enrichis+FicheFamilleModal). 4 micro-itérations dispatch agent Sonnet en séquentiel, pilote commit lui-même chaque fois (pattern anti-hallucination établi après agent 1 a inventé hash 755d1ef). **Décisions design** : skip PV2-5c bicolores (8/120 structures = effet marginal), toggle PV2-5e exclusif fusionné en layers superposables PV2-5f (intuition Jules : séparation artificielle), Pratiques default OFF perf-friendly (174 noeuds + 640 liens si tout coché), FicheFamilleModal composant dédié réutilisable, skip définitions hashtags (pas de contenu source) → ligne générique "portée par N structures de M familles". **Découverte 2 repos git imbriqués** (vault parent ATIS-IPCJRA branche v2 + sous-repo nav-carte/ branche v1.1 distincte) → expliquait les "hallucinations" branche des agents. Notée dans `ATIS-Dev.md` section "Pièges connus" + réflexe pilote `git rev-parse --show-toplevel` au démarrage. **Chatbot v2** : endpoint v1→v2 dans ChatbotPlaceholder.vue (commit sous-repo 5878c56), vectorize-v2.js renommé en .cjs (incompat ESM type=module), payload Mistral fixed `inputs`→`input`, 120 embeddings générés (3.5MB embeddings-v2.json gitignored), patch vectorSearch.ts process.cwd() au lieu d'import.meta.url (bug Nitro bundle). **Rotation clé Mistral** : nouvelle clé `PXsPUhk...` notée _System/API-credentials.md, appliquée local .env + VPS /opt/aep/.env (backup .env.bak.before-rotation-20260506) + restart aep + smoke test prod chatbot v1 OK. **Doc features graphe** créée : `aep-communaute-build/PV2-5-FEATURES-GRAPHE.md` (briefing agent qui découvre en 30 sec). **Backlog différé** : PV2-5d sous-noeuds projets emblématiques (perf-critique 480 noeuds), définitions hashtags (session écriture éditoriale Jules), décision repo imbriqués (intersection stratégique demain). **Test live chatbot v2 bloqué** par lock Dropbox sur .nuxt/dev → prompt cloture demain `PV2-5h-test-chatbot-v2-local.md`.
|
||||
|
||||
- [W19 — 2026-05-06 02:30] **MP-TAFF V2 cadré + prompt scraping autonome séparé + rename atis-humain** : Brainstorm divergent Jules pour Carte 3 AEP "Trouver du taf en archi". 5 axes scoring éthique AEP validés (Rémunération / Transparence / Pratiques pro / Écologie / Matching) avec définitions + critères + échelles ✅⚠️❌ — c'est le différenciant central vs annuaires neutres. Décisions verrouillées : freelance only V1 (70% archis indé = cible la plus en galère), IA applique scoring (critères validés une fois = pas de validation fiche par fiche), onglet `aep.trans-former.fr` (pas sous-domaine), branche parallèle `feat/aep-taff-v1` (pas attendre merge V2), SEO reporté V2 (skill `/seo-page-aep` à créer). MP-TAFF V2 ~430 lignes avec 2 tours auto-feedback (table décision tag global, format desc IA 5 sections, §risque juridique nominatif, calibrage chatbot 3 questions, préflight conflit branche V2 + i18n). **MP-TAFF-T1-scraping-autonome.md créé** (~270 lignes) — sortable sur PC séparé pour parallélisation pendant qu'ATIS Dev bosse T0/T2/T3+. Pattern routing scraping documenté : Jina d'abord → crawl4ai SPA → BrowserMCP RGPD/auth → manuel flag. Forums commu intégrés : Team.Archi, Reddit r/Architecture FR, presse pro (Le Moniteur, AMC, D'A). Output JSON structuré consommable par T2. 🔒1 simplifié = récap scope synthétique (10 min Jules). Backlog cascade : MP-MENTOR (carte 4 entraide), MP-CROSS (n8n + GitHub OS) restent prêts. **Rename `tara` → `atis-humain`** : skill renommée, routing patché dans `atis-archi.md` ligne 390 et `ATIS-agents-specialises.md` ligne 29. Anciennes refs à Tara la personne (Mediathèque, done.md, ton-jules.md) inchangées.
|
||||
|
||||
- [W19 — 2026-05-05] **AEP V2 PV2-4+5+8 DONE + vue graphique D3** : PV2-4 (887 edges, 480 projets, reseaux-bifurcation.json 847KB). PV2-5 UI (NavMapV2, HashtagFilter, IntentionBanner, FicheModalV2, palette 5 familles, geocodage 118/120, GraphView.vue D3 force-directed). PV2-8 RAG (chatbot-v2.post.ts + vectorize-v2.js). Fixes UI : onglets outremer desktop, sidebar scroll, chips colorees, hashtags repliables, F6 filtre, intention overlay localStorage. EDITO-V2.md cree. 13 commits feat/aep-v2-cartobifurcation. 🔒 PV2-5 checkpoint visuel Jules en attente.
|
||||
|
||||
### 2026-W18 (décharge 2026-05-13)
|
||||
|
||||
- [W18 — 2026-05-03] **AEP V2 PV2-2ter DONE** : 10 emails récupérés. Volet A F2 (amaco/LTE), F4 (toitsdechoix/HPO/HabiterAuvergne/EmmanuelleDucos). Confirmed not public : F3 (LALCA/Sentiers/AOA/METALAB), F4 (unitoit/atelier15/a-tipic/HPF/atcoop). Blocages : rfcp.fr GravityView JS, a-tipic HTTP 400. Volet B F6 : 7 flags + 4 emails (Forensic/Centrala/NBL/Assemble) + 1 new fiche Collectif Etc (contact@collectifetc.com). Seed 122 fiches. Commit `7ce8e12`.
|
||||
|
||||
- [W18 — 2026-05-03] **AEP V2 PV2-2 F1 DONE** : 26 fiches réemploi & filières (14 V1 + 12 nouvelles). Nouvelles : Cycle Up (contact@cycle-up.fr), Backacia (form), Mobius (contact@mobius-corp.com), AD VITAM MATERIAL (reemploi@embuild.be), Cirkla (c/o insitu), CANCAN (contact@collectifcancan.fr), HArquitectes (harquitectes@harquitectes.com), isla (press@isla-architects.com), jdviv BE (EUmies 2026 co-winner), SalvoWEB, B+L Architectes, REFAIR/BDR. 6/12 emails high. Hashtag nouveau proposé : #amo-reemploi (AMO/diagnostic PEMD spécialisés). BrowserMCP off toute la session → Jina only (Reuse Foundation non scrapée, jdviv URL à confirmer). Commit `656cc2d`. **PV2-2 5/5 familles DONE.** → Reste PV2-3+PV2-4.
|
||||
|
||||
- [W18 — 2026-05-03] **AEP V2 PV2-2 F2 DONE** : 36 fiches frugalité & low-tech (22 V1 + 14 nouvelles). Nouvelles : Lacaton&Vassal (Pritzker 2021), Kéré Architecture (mail@kerearchitecture.com), Anna Heringer, CRATerre (secretariat@craterre.org), Les Grands Ateliers, AsTerre (secretariat@asterre.org), RFCP, EnvirobatBDM, NUNC, LAPS, Dorodango, BEES, amàco (contact@amaco.org), Lehm Ton Erde. 4/14 emails high conf. Sources productrices : AsTerre annuaire (19 agences archi identifiées), Pritzker (2 nouvelles), frugalite.org réseau. Blocages : RFCP annuaire JS, lehmtonerde.at 422, OFF laureats non scraped, BrowserMCP instable (3 décos). Commits `8808a35`+`301c3be`. → Reste F1 Réemploi.
|
||||
|
||||
- [W18 — 2026-05-03] **AEP V2 PV2-2 F3 DONE** : 22 fiches architecture sociale & précarités (11 V1 + 11 nouvelles). Nouvelles : PEROU, Plateau Urbain (SCIC), Bellastock, ASF France, Rural Studio, Forensic Architecture, Collectif Parenthèse, WoMa, Fab City Grand Paris, CivicWise. 6/11 emails high conf (PEROU·Bellastock·Rural Studio·Parenthèse·WoMa·CivicWise). Sources : Quatorze /partenaires-new (meilleur hub), YWC lieux (Grands Voisins + Coco Velten), A&P filtres. 4 multi-famille (Bellastock F3+F5, Parenthèse F3+F4, WoMa F3+F4, YWC F3+F4+F5). Commit `d2028f5`. → Reste F1 + F2.
|
||||
|
||||
- [W18 — 2026-05-02 23:23] **AEP V2 PV2-2 F4 DONE** : 20 nouvelles fiches collectifs/écolieux/AMO via Jina (BrowserMCP déco → pivot Jina). 11/20 emails high conf. Structures-clés : RAHP, HPF, Habicoop, Hab-Fab SCIC, Regain SCIC, Coopérative Oasis, Mietshäuser Syndikat. 9 contacts partiels (tel/form) à compléter BrowserMCP. Commit `f54afe3`.
|
||||
|
||||
- [W18 — 2026-05-02 19:51] **AEP V2 PV2-2 F5 DONE** : 15 fiches urbanisme transition via BrowserMCP. 7 emails high (CLER/TEPOS/Coloco/Bas Smets/EP/FNAU/Atelier Georges). Commit `56c93eb`.
|
||||
|
||||
- [W18 — 2026-05-02 18:17] **C3 smoke test + PV2-1 scrape DONE** : C3 = 2 bugs (P0 algo-config.json 404, P1 redirect 301 manquant) + rapport `C3-RAPPORT.md`. PV2-1 = 5/5 emails trouvés via BrowserMCP (Opalis/Frugalité/Quatorze/Tepop/Transition France), commit 6df5b84. Stack BrowserMCP validé pour batch PV2-2. Patcher P0+P1 avant merge master simulateur.
|
||||
|
||||
- [W18 — 2026-04-30 12:00] **AEP Cascade V2 Phase A2+A3 figées + PILOTE-V2 doc pilote vivant** : Session "AEP COMMU V3 suite" /atis-archi puis /atis-dev. SPEC-V2-FEEDBACK-DEV.md livré (faisabilité pipeline 3 passes, email cascade 5 niveaux estim 65-80%, reclaim JWT HS256 30j single-use, grain JSON suffisant si desc_longue 600+ + 3 sources, branches 2 dédiées, pre-flights standardisés, fix BOM). PILOTE-V2.md créé comme **source de vérité vivante** à la racine `aep-communaute-build/` (Jules pilote depuis ce fichier ; tout Opus suivant le lit en premier). 13 prompts PV2-X dans `0 INBOX/PROMPTS/aep-v2-cartobifurcation/` (README + PV2-0 preflight + PV2-1 scrape test 5 fiches + PV2-2 5 agents recherche par famille en parallèle (idée Jules : recoupement multi-famille = signal politique transversalité) + PV2-2bis recoupement + PV2-3 passe2 analyse + PV2-4 passe3 croisements + PV2-5 refonte UI + PV2-6 reclaim + PV2-7 badges statut + PV2-8 RAG coexistence v1+v2 + PV2-9 bandeau regards d'ailleurs + PV2-10 E2E build + PV2-11 batch emails + tri DOM-TOM). NEXT-SESSION-PROMPT-V3.md créé pour relais. **Amendements Jules sur SPEC-V2** : famille 1 "Réemploi & matière" → "Réemploi & filières", AMO ajouté famille 4 (Tepop/Hab-Fab/Habicoop), famille 5 Urbanisme transition gardée fermement (cibler scrape agressif), centres ressources DOM-TOM → carte ressources existante (sauf Caribois praticien direct), pas de cap fiches sur agents recherche, stratégie snowball depuis nodes établis (Frugalité, Opalis, A&P) + crawl collaborateurs/influences/prix/distinctions. Sessions batch nocturnes dimensionnées Claude Max 5h Opus. PV2-0 partiel exécuté : branche `feat/aep-v2-cartobifurcation` créée depuis origin/main (tracking unset = anti-push main accidentel) + BOM UTF-8 retiré de `nav-carte/deploy.sh`. PV2-0 effectif (checkout + structure `nav-carte/V2-cascade/` + hook pre-commit no-BOM + sources-par-famille.md) à faire prochaine session après commit/stash des 830 fichiers pending sur `feat/aep-website-v1.1`.
|
||||
|
||||
- [W18 — 2026-04-30] **Cascade MEGABOUM opérationnelle — 4 MP rédigés + cockpit lisible** : `0 INBOX/PROMPTS/cascade-megaboum/` créé avec `00-COCKPIT-CASCADE.md` (index lisible en 3 min par tout Opus dispatché, format différent de la mégaspec — celle-ci reste lecture profonde optionnelle). 4 méga-prompts prêts à dispatcher : **MP-TAFF** (app trouver du taf B2C/B2B/appels publics, ~5h, étend `aep-communaute-build/`), **MP-MENTOR** (app mentorat-entraide M7-M, ~5h, post-TAFF), **MP-CROSS** (pipeline cross-posting n8n LinkedIn+Substack+Listmonk+@aep.politique + GitHub OS publish skills/lightrag/vps-kit, ~5h, parallèle), **MP-DESIGN** (création agent atis-design, prompt court ~1h via /create-agent + scrape Prisme.one). Brief archive `MP10-manifeste-aep-INFO-BRIEF.md` (chantier déjà lancé par Jules). Chaque MP démarre par CHECKPOINT 0 réflexion faisabilité (l'Opus lit, propose, attend OK Jules avant dispatch). Backlog explicite : page-cerveau Astro from scratch, méga-RAG FRACAS vague 1, atis-philosophe, frontend-slides, rename atis-humain (P1 30 min), Insta @julesneny n8n (Q3). LightRAG VPS DOWN **déclassé** : pas bloqueur P0 semaine si on ne fait pas méga-RAG, devient bloqueur quand on attaquera RAG. Cadence : Jules pilote au fil des jours, 1-2 MP/jour, 2 clusters max simultanés.
|
||||
|
||||
- [W18 — 2026-04-30 02:46] **Méga-cascade V2 AEP Phase A1 figée + 3 agents background livrés** : Session Opus orchestrateur "AEP commu V2". 9 intersections tranchées par Jules en un message (5 familles fusion F4+F5, UX vignette + template carte 1, scope FR+Europe francophone + capture incidente régénératifs hors scope, articulation pensées<->structures reportée V2, passe profonde GO, email champ soft, charte reportée, filtre échelle drop, A3 absorbé A1). SPEC-V2.md figée. 3 agents Sonnet dispatchés en parallèle background : VOIE 2 V1.1 nav-carte (4 commits `feat/aep-v1.1-nav-carte` basée sur feat/aep-pratiques-regeneratives car main pré-V1 ; PA1 DOM-TOM pattern desktop 2 onglets, PA3 bouton Proposer contextuel, PA5 chatbot pratiques régé Mistral, 6/6 bugs E2E M1-M3+L1-L3 corrigés, build Nuxt OK), VOIE 3 website (1 commit `feat/aep-website-v1.1` e95f693 ; PB1 hamburger 4 entrées + stubs /manifeste /ressources /signaler ; **/!\ livré sur renovation-energetique.trans-former.fr - website pro, pas aep.trans-former.fr - à clarifier prochaine session**), PASSE PROFONDE (52 pratiques régé + 99 ressources institutionnelles analysés ; 5 familles confirmées avec garde-fous F5 ; 46 hashtags ; 7 cas limites + 4 hors-grille type "mouvement-manifeste" potentiel ; **226 acteurs candidats enrichissement carte ressources : P1=56 fiches urgentes dont 30 CAUE manquants top dpts + 4 CAUE DOM-TOM + 9 MA régionales + 2 CROA DOM ; constat critique : 0 DOM-TOM + 6/92 CAUE dans carte ressources actuelle**). 5 questions stratégiques remontées pour Phase A2 (Q-PP1 5 vs 6 familles, Q-PP2 type mouvement-manifeste, Q-PP3 F5 différée passe 2, Q-PP4 double-référencement KEBATI/AQUAA/RBD/Envirobat, Q-V3 site cible hamburger). NEXT-SESSION-PROMPT.md pré-écrit pour reprise propre Phase A2 /atis-dev. Pattern méga-dispatch consécutif #4. Tokens : 130k orchestrateur + ~451k délégués sous-agents.
|
||||
|
||||
- [W18 — 2026-04-30 01:42] **Méga-cascade V2 AEP cadrée** : `MEGA-V2.md` master orchestration 3 voies créé. VOIE 1 = V2 conceptuelle (refonte carte écosystème AEP en carte des réseaux de bifurcation, 5-6 familles éditoriales, reclaim email magique, pipeline IA cascade 3 passes, ~75-100 fiches). VOIE 2 = V1.1 nav-carte (items 1+2+4+5+8 ; item 3 absorbé par VOIE 1). VOIE 3 = website astro-site (hamburger + manifeste + ressources). Décisions Jules : `/atis-archi` pilote la spec V2 conceptuelle Phase A1, `/atis-dev` en relai Phase A2. Apports critiques : champ email obligatoire dans le scrape (sans email = pas de reclaim), passe profonde sur fiches existantes (~52+80) pour faire remonter hashtags sous-familles, item 4 (filtre échelle) à questionner, A3 absorbable dans A1, page Manifeste à ajouter au hamburger website. Phrase d'amorce + effort `high` recommandé pour la session Opus dédiée demain. PHRASE-LANCEMENT-OPUS-V2.md marqué SUPERSEDED. 2 briefs INBOX (V2-BRIEF-AGENT-OPUS + V2-RECAP-PROJET) déplacés dans aep-communaute-build/.
|
||||
|
||||
- [W18 — 2026-04-29 11:48] **AEP scrape P1-P7 FAIT** : BrowserMCP (P1 architecture-precarites.fr : 200+ projets, 5 catégories) + Jina (P4 vegetal-e ✅, P6 colorado-architecture ✅, P7 karibati ✅, P3 caribois ✅). P2 archidev bot-protégé + P5 envirobat 422 → consultation manuelle. `scrape-browsermcp.json` créé (7 entrées). Email auteurs architecture-precarites.fr envoyé par Jules. INCLURE : vegetal-e (5/8), envirobat-RE (7/8), karibati (5/8). EXCLURE : caribois (2/8), colorado (3/8).
|
||||
|
||||
- [W18 — 2026-04-29] **AEP V1 E2E PASS** : 5/5 scénarios OK (3 mobile, 3 laptop). Branche `feat/aep-pratiques-regeneratives` prête à merger main. `E2E-RESULTS.md` créé. Bugs mineurs capturés : M1 chips a11y + M2 reset searchbox + M3 floating button + L1 redirection.
|
||||
|
||||
- [W18 — 2026-04-29 08:11] **AEP V1 LIVRÉE** : 52 fiches prod (`aep.trans-former.fr/pratiques-regeneratives`), 12 commits feat/. V1.1 mode divergent cadrée (8 items brain-dump Jules).
|
||||
|
||||
147
app.vue
147
app.vue
@@ -7,21 +7,16 @@
|
||||
style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);"
|
||||
>
|
||||
<!-- Logo -->
|
||||
<a href="/" class="flex items-center gap-2 hover:opacity-90 transition-opacity shrink-0 group relative" title="Architecture d'Écologie Politique">
|
||||
<a href="/" class="logo-link flex items-center gap-2 hover:opacity-90 transition-opacity shrink-0" title="Architecture d'Écologie Politique">
|
||||
<div
|
||||
class="h-7 px-2 rounded-lg flex items-center justify-center shrink-0"
|
||||
class="h-8 px-2 rounded-lg flex items-center justify-center shrink-0"
|
||||
style="background: var(--nav-primary-solid);"
|
||||
>
|
||||
<span class="font-bold text-xs tracking-tight" style="color: var(--nav-text-on-primary);">AEP</span>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-bold text-base tracking-tight leading-tight" style="color: var(--nav-text);">AEP</span>
|
||||
<span class="text-xs leading-tight hidden lg:inline" style="color: var(--nav-text-muted);">Architecture d'Écologie Politique</span>
|
||||
</div>
|
||||
<!-- Tooltip sm (quand le sous-titre lg est caché) -->
|
||||
<div class="absolute left-0 top-full mt-2 px-2 py-1 rounded text-xs whitespace-nowrap pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity lg:hidden z-50"
|
||||
style="background: var(--nav-primary-solid); color: var(--nav-text-on-primary);">
|
||||
Architecture d'Écologie Politique
|
||||
<div class="logo-text flex flex-col leading-tight">
|
||||
<span class="logo-line-1 font-bold tracking-tight" style="color: var(--nav-text);">Architecture</span>
|
||||
<span class="logo-line-2 font-bold tracking-tight" style="color: var(--nav-text);">d'Écologie Politique</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -56,12 +51,11 @@
|
||||
Codev
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/rag"
|
||||
to="/media"
|
||||
class="nav-tab"
|
||||
:class="{ 'nav-tab--active': route.path === '/rag' }"
|
||||
:class="{ 'nav-tab--active': route.path === '/media' }"
|
||||
>
|
||||
RAG
|
||||
<span class="nav-tab-badge">en construction</span>
|
||||
recherche-média
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
@@ -113,14 +107,52 @@
|
||||
>
|
||||
Signaler
|
||||
</NuxtLink>
|
||||
<!-- Proposer une ressource -->
|
||||
<NuxtLink
|
||||
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"
|
||||
<!-- Proposer — popover 3 choix -->
|
||||
<div class="hidden sm:block relative" ref="proposerAnchor" data-proposer-popover>
|
||||
<button
|
||||
@click="proposerOpen = !proposerOpen"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 inline-flex items-center gap-1"
|
||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||
aria-label="Proposer une contribution"
|
||||
>
|
||||
+ Proposer
|
||||
</button>
|
||||
<div
|
||||
v-if="proposerOpen"
|
||||
class="absolute right-0 top-full mt-1 rounded-lg shadow-lg min-w-[240px] py-1"
|
||||
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
|
||||
>
|
||||
<NuxtLink
|
||||
to="/contribuer"
|
||||
class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70"
|
||||
style="color: var(--nav-text);"
|
||||
@click="proposerOpen = false"
|
||||
>
|
||||
<span>Fiche Entraide <span style="color: var(--nav-text-muted); font-weight: 400; font-size: 0.7rem; display: block;">Carte 1 — Écosystème archi</span></span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</NuxtLink>
|
||||
<div style="height: 1px; background: var(--nav-bg-alt); margin: 2px 0;"></div>
|
||||
<NuxtLink
|
||||
to="/contribuer-reseau"
|
||||
class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70"
|
||||
style="color: var(--nav-text);"
|
||||
@click="proposerOpen = false"
|
||||
>
|
||||
<span>Réseau / collectif <span style="color: var(--nav-text-muted); font-weight: 400; font-size: 0.7rem; display: block;">Carte 2 — Réseaux AEP</span></span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</NuxtLink>
|
||||
<div style="height: 1px; background: var(--nav-bg-alt); margin: 2px 0;"></div>
|
||||
<NuxtLink
|
||||
to="/contribuer-job"
|
||||
class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70"
|
||||
style="color: var(--nav-text);"
|
||||
@click="proposerOpen = false"
|
||||
>
|
||||
<span>Plateforme jobs <span style="color: var(--nav-text-muted); font-weight: 400; font-size: 0.7rem; display: block;">Carte 3 — Jobs archi</span></span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toggle dark mode -->
|
||||
<button
|
||||
@@ -142,18 +174,40 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Mobile : contribuer icône -->
|
||||
<NuxtLink
|
||||
to="/contribuer"
|
||||
class="sm:hidden p-2 rounded-lg"
|
||||
<!-- Mobile : contribuer icône → popover -->
|
||||
<div class="sm:hidden relative" data-proposer-popover>
|
||||
<button
|
||||
@click="proposerOpen = !proposerOpen"
|
||||
class="p-2 rounded-lg"
|
||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||
title="Contribuer une fiche"
|
||||
title="Contribuer"
|
||||
aria-label="Contribuer"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
v-if="proposerOpen"
|
||||
class="absolute right-0 top-full mt-1 rounded-lg shadow-lg min-w-[220px] py-1"
|
||||
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
|
||||
>
|
||||
<NuxtLink to="/contribuer" class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" style="color: var(--nav-text);" @click="proposerOpen = false">
|
||||
<span>Fiche Entraide</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</NuxtLink>
|
||||
<div style="height: 1px; background: var(--nav-bg-alt); margin: 2px 0;"></div>
|
||||
<NuxtLink to="/contribuer-reseau" class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" style="color: var(--nav-text);" @click="proposerOpen = false">
|
||||
<span>Réseau / collectif</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</NuxtLink>
|
||||
<div style="height: 1px; background: var(--nav-bg-alt); margin: 2px 0;"></div>
|
||||
<NuxtLink to="/contribuer-job" class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" style="color: var(--nav-text);" @click="proposerOpen = false">
|
||||
<span>Plateforme jobs</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hamburger mobile (lg:hidden) — toujours en dernier à droite -->
|
||||
<div class="lg:hidden relative">
|
||||
@@ -178,11 +232,14 @@
|
||||
@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="/agences" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">Réseaux AEP</NuxtLink>
|
||||
<NuxtLink to="/rag" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">RAG</NuxtLink>
|
||||
<NuxtLink to="/agences" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/agences' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Réseaux AEP</NuxtLink>
|
||||
<NuxtLink to="/trouver-du-taf" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/trouver-du-taf' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Jobs</NuxtLink>
|
||||
<NuxtLink to="/media" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/media' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">recherche-média</NuxtLink>
|
||||
<NuxtLink to="/codev" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/codev') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Codev</NuxtLink>
|
||||
<div style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div>
|
||||
<NuxtLink to="/manifeste" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/manifeste' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text-muted);'">Manifeste</NuxtLink>
|
||||
<NuxtLink to="/a-propos" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">À propos</NuxtLink>
|
||||
<a href="https://liberapay.com/trans-former.fr/donate" target="_blank" rel="noopener noreferrer" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">Soutenir →</a>
|
||||
<NuxtLink to="/signaler" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">Signaler</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,6 +264,31 @@ const route = useRoute()
|
||||
const hamburgerOpen = ref(false)
|
||||
watch(() => route.path, () => { hamburgerOpen.value = false })
|
||||
|
||||
// ── Popover "+ Proposer" ─────────────────────────────────────────────────
|
||||
const proposerOpen = ref(false)
|
||||
const proposerAnchor = ref<HTMLElement | null>(null)
|
||||
|
||||
function onClickOutsideProposer(e: MouseEvent) {
|
||||
// Ferme si le clic est hors de tout élément portant data-proposer-popover
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('[data-proposer-popover]')) {
|
||||
proposerOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(proposerOpen, (open) => {
|
||||
if (open) {
|
||||
// Délai court pour ne pas attraper le clic d'ouverture lui-même
|
||||
setTimeout(() => document.addEventListener('click', onClickOutsideProposer, true), 10)
|
||||
} else {
|
||||
document.removeEventListener('click', onClickOutsideProposer, true)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onClickOutsideProposer, true)
|
||||
})
|
||||
|
||||
// ── Dark mode ─────────────────────────────────────────────────────────────
|
||||
const isDark = ref(false)
|
||||
|
||||
@@ -262,6 +344,21 @@ function goRandom() {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* ── Logo header (texte 2 lignes) ─────────────────────────────────────── */
|
||||
.logo-text {
|
||||
line-height: 1.05;
|
||||
}
|
||||
.logo-line-1, .logo-line-2 {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.logo-line-1, .logo-line-2 { font-size: 0.78rem; }
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.logo-line-1, .logo-line-2 { font-size: 0.85rem; }
|
||||
}
|
||||
|
||||
/* ── Onglets header desktop ───────────────────────────────────────────── */
|
||||
.nav-tab {
|
||||
position: relative;
|
||||
|
||||
@@ -108,3 +108,16 @@
|
||||
.dark .leaflet-popup-tip {
|
||||
background: var(--nav-surface);
|
||||
}
|
||||
|
||||
/* ── Rendu Markdown chatbot (useMarkdown composable) ────────────────────── */
|
||||
.md-content { font-size: inherit; line-height: 1.6; }
|
||||
.md-content p { margin: 0 0 0.5em; }
|
||||
.md-content p:last-child { margin-bottom: 0; }
|
||||
.md-content strong, .md-h1, .md-h2, .md-h3 { font-weight: 700; }
|
||||
.md-h2 { font-size: 0.9375em; display: block; margin-bottom: 0.25em; }
|
||||
.md-h3 { font-size: 0.875em; display: block; }
|
||||
.md-content em { font-style: italic; }
|
||||
.md-list { margin: 0.375em 0 0.375em 1em; padding: 0; list-style: disc; }
|
||||
.md-list li { margin-bottom: 0.2em; }
|
||||
.md-link { text-decoration: underline; opacity: 0.85; }
|
||||
.md-link:hover { opacity: 1; }
|
||||
|
||||
23
assets/css/v2-bifurcation.css
Normal file
23
assets/css/v2-bifurcation.css
Normal 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); }
|
||||
@@ -139,72 +139,7 @@
|
||||
|
||||
</footer>
|
||||
|
||||
<!-- ═══════════════════════════════════════ FAB MOBILE (< 1024px) ════════ -->
|
||||
<div v-else>
|
||||
<!-- FAB soutenir (à gauche du chatbot) -->
|
||||
<button
|
||||
class="fab-soutenir"
|
||||
type="button"
|
||||
@click="fabSheetOpen = true"
|
||||
aria-label="Soutenir le projet AEP"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Bottom sheet FAB -->
|
||||
<Teleport to="body">
|
||||
<Transition name="backdrop">
|
||||
<div
|
||||
v-if="fabSheetOpen"
|
||||
class="fixed inset-0 z-[1020]"
|
||||
style="background: rgba(26,34,56,0.5);"
|
||||
@click="fabSheetOpen = false"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Transition>
|
||||
<Transition name="sheet">
|
||||
<div
|
||||
v-if="fabSheetOpen"
|
||||
class="fab-sheet"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Soutenir AEP"
|
||||
>
|
||||
<!-- Poignée -->
|
||||
<div class="flex justify-center pt-3 pb-1">
|
||||
<div class="rounded-full" style="width: 36px; height: 4px; background: var(--nav-bg-alt);" />
|
||||
</div>
|
||||
<div class="px-5 pb-6">
|
||||
<h2 class="text-base font-bold mb-2" style="color: var(--nav-text);">Soutenir AEP</h2>
|
||||
<template v-if="stats">
|
||||
<p class="text-sm mb-1" style="color: var(--nav-text-muted);">
|
||||
Coût IA ce mois : <strong>{{ stats.cout_mois_eur.toFixed(2) }} €</strong>
|
||||
· Tokens : {{ stats.tokens_mois.toLocaleString('fr-FR') }}
|
||||
</p>
|
||||
<p class="text-sm mb-3" style="color: var(--nav-text-muted);">
|
||||
{{ stats.fiches_semaine }} fiche{{ stats.fiches_semaine !== 1 ? 's' : '' }} ajoutée{{ stats.fiches_semaine !== 1 ? 's' : '' }} cette semaine
|
||||
</p>
|
||||
</template>
|
||||
<p class="text-sm mb-4" style="color: var(--nav-text-muted); line-height: 1.5;">
|
||||
1 € = 30 fiches mises en ligne. AEP est libre, sans pub, financé par les dons.
|
||||
</p>
|
||||
<a
|
||||
href="https://liberapay.com/trans-former.fr/donate"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block w-full text-center py-3 rounded-xl font-semibold text-sm"
|
||||
style="background: var(--nav-primary); color: var(--nav-text-on-primary); text-decoration: none;"
|
||||
@click="fabSheetOpen = false"
|
||||
>
|
||||
Soutenir sur Liberapay →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
<!-- Mobile (< 1024px) : pas de FAB — Soutenir est dans le menu hamburger -->
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -221,7 +156,6 @@ interface Stats {
|
||||
const stats = ref<Stats | null>(null)
|
||||
const loading = ref(true)
|
||||
const modalOpen = ref(false)
|
||||
const fabSheetOpen = ref(false)
|
||||
const tooltipVisible = ref(false)
|
||||
|
||||
// Desktop — replié par défaut, déploie au hover, replie immédiatement à la sortie
|
||||
@@ -460,39 +394,6 @@ const jaugePct = computed(() => {
|
||||
border-top-color: var(--nav-primary-solid, #1a2238);
|
||||
}
|
||||
|
||||
/* ── FAB mobile soutenir ─────────────────────────────────────────────────── */
|
||||
.fab-soutenir {
|
||||
position: fixed;
|
||||
bottom: 68px; /* au-dessus du FAB chatbot à 24px du bas + 48px de hauteur */
|
||||
left: 16px;
|
||||
z-index: 1000;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--nav-accent);
|
||||
color: var(--nav-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.fab-soutenir:hover { opacity: 0.88; transform: translateY(-1px); }
|
||||
|
||||
/* ── Bottom sheet FAB ────────────────────────────────────────────────────── */
|
||||
.fab-sheet {
|
||||
position: fixed;
|
||||
inset-x: 0;
|
||||
bottom: 0;
|
||||
z-index: 1021;
|
||||
background: var(--nav-surface);
|
||||
border-radius: 16px 16px 0 0;
|
||||
box-shadow: 0 -4px 32px rgba(26,34,56,0.18);
|
||||
}
|
||||
|
||||
/* ── Modal ───────────────────────────────────────────────────────────────── */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
|
||||
331
components/CartePensees.vue
Normal file
331
components/CartePensees.vue
Normal file
@@ -0,0 +1,331 @@
|
||||
<template>
|
||||
<div style="width: 100%; height: 100%; position: relative; background: #f5f3f0;">
|
||||
<svg ref="svgRef" style="width: 100%; height: 100%;"></svg>
|
||||
<div ref="tooltipRef" style="
|
||||
position: absolute; pointer-events: none;
|
||||
background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);
|
||||
border-radius: 6px; padding: 8px 12px; font-size: 0.78rem;
|
||||
color: var(--nav-text); max-width: 240px; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
opacity: 0; transition: opacity 0.15s; z-index: 100;
|
||||
"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface EcoleData { id: string; label: string; description: string; color: string; x_hint: number; y_hint: number }
|
||||
interface LivreRag { slug: string; titre: string; annee: number; couches: string[] }
|
||||
interface AuteurData { id: string; nom: string; dates: string; ecoles: string[]; ecole_principale: string; ingere: boolean; livres_rag: LivreRag[]; theses_cles: string[]; bio_courte: string; bio_courte_provisoire?: string }
|
||||
interface PenseesData { ecoles: EcoleData[]; auteurs: AuteurData[] }
|
||||
|
||||
// Liens d'influence inter-ecoles (Phase 7 - matrice de filiation)
|
||||
const LINKS_INFLUENCE = [
|
||||
// filiations directes
|
||||
{ source: 'eco-anarchisme', target: 'technocritique', auteurs_passerelle: ['Bookchin', 'Illich'], type: 'filiation' },
|
||||
{ source: 'eco-anarchisme', target: 'decroissance', auteurs_passerelle: ['Latouche', 'Kropotkine'], type: 'filiation' },
|
||||
{ source: 'ecosocialisme', target: 'decroissance', auteurs_passerelle: ['Saito', 'Gorz'], type: 'filiation' },
|
||||
{ source: 'ecosocialisme', target: 'ecologies-decoloniales', auteurs_passerelle: ['Klein', 'Ferdinand'], type: 'filiation' },
|
||||
{ source: 'ecofeminismes', target: 'ecologies-decoloniales', auteurs_passerelle: ['Shiva', 'Ouassak'], type: 'filiation' },
|
||||
{ source: 'ecofeminismes', target: 'pensees-vivant', auteurs_passerelle: ['Haraway', 'Despret'], type: 'filiation' },
|
||||
{ source: 'technocritique', target: 'decroissance', auteurs_passerelle: ['Ellul', 'Latouche'], type: 'filiation' },
|
||||
{ source: 'decroissance', target: 'pensees-vivant', auteurs_passerelle: ['Servigne', 'Despret'], type: 'filiation' },
|
||||
{ source: 'pensees-vivant', target: 'ethiques-environnementales', auteurs_passerelle: ['Naess', 'Latour'], type: 'filiation' },
|
||||
{ source: 'ecosocialisme', target: 'eco-anarchisme', auteurs_passerelle: ['Gorz', 'Graeber'], type: 'filiation' },
|
||||
// liens de critique (toutes les ecoles progressistes vs cap-vert / ecofascismes)
|
||||
{ source: 'ecosocialisme', target: 'capitalisme-vert', auteurs_passerelle: ['Klein', 'Malm'], type: 'critique' },
|
||||
{ source: 'decroissance', target: 'capitalisme-vert', auteurs_passerelle: ['Latouche', 'Meadows'], type: 'critique' },
|
||||
{ source: 'eco-anarchisme', target: 'capitalisme-vert', auteurs_passerelle: ['Bookchin'], type: 'critique' },
|
||||
{ source: 'ethiques-environnementales', target: 'ecofascismes', auteurs_passerelle: ['Naess'], type: 'critique' },
|
||||
{ source: 'capitalisme-vert', target: 'ecofascismes', auteurs_passerelle: [], type: 'critique' },
|
||||
]
|
||||
|
||||
const props = defineProps<{ data: PenseesData | null; active?: boolean }>()
|
||||
const emit = defineEmits<{ 'select-auteur': [id: string]; 'select-ecole': [id: string] }>()
|
||||
|
||||
const svgRef = ref<SVGElement | null>(null)
|
||||
const tooltipRef = ref<HTMLElement | null>(null)
|
||||
let simulation: any = null
|
||||
let d3LinkSel: any = null
|
||||
let d3InfluenceSel: any = null
|
||||
let d3NodeSel: any = null
|
||||
let d3EdgeLabelSel: any = null
|
||||
|
||||
async function initGraph() {
|
||||
if (!svgRef.value || !props.data) return
|
||||
const d3 = await import('d3')
|
||||
|
||||
const svgEl = svgRef.value
|
||||
const W = svgEl.clientWidth || 900
|
||||
const H = svgEl.clientHeight || 600
|
||||
|
||||
d3.select(svgEl).selectAll('*').remove()
|
||||
const svg = d3.select(svgEl).attr('viewBox', `0 0 ${W} ${H}`)
|
||||
const g = svg.append('g')
|
||||
|
||||
svg.call(d3.zoom<SVGElement, unknown>().scaleExtent([0.3, 4]).on('zoom', (e) => g.attr('transform', e.transform)) as any)
|
||||
|
||||
const ecoleMap = new Map<string, EcoleData>(props.data.ecoles.map(e => [e.id, e]))
|
||||
|
||||
// Positions fixes des ecoles (base pour forces D3)
|
||||
const ecolePositions = new Map<string, { tx: number; ty: number }>()
|
||||
props.data.ecoles.forEach(e => {
|
||||
ecolePositions.set(e.id, { tx: W * e.x_hint, ty: H * e.y_hint })
|
||||
})
|
||||
|
||||
// ---- LIENS D'INFLUENCE INTER-ECOLES (couche 3) ----
|
||||
const gInfluence = g.append('g').attr('class', 'links-influence')
|
||||
|
||||
LINKS_INFLUENCE.forEach(link => {
|
||||
const src = ecolePositions.get(link.source)
|
||||
const tgt = ecolePositions.get(link.target)
|
||||
if (!src || !tgt) return
|
||||
|
||||
const isCritique = link.type === 'critique'
|
||||
const lineEl = gInfluence.append('line')
|
||||
.attr('class', 'influence-link')
|
||||
.attr('x1', src.tx).attr('y1', src.ty)
|
||||
.attr('x2', tgt.tx).attr('y2', tgt.ty)
|
||||
.attr('stroke', isCritique ? '#d99' : '#9aa')
|
||||
.attr('stroke-width', 1)
|
||||
.attr('stroke-dasharray', isCritique ? '4,3' : '6,4')
|
||||
.attr('stroke-opacity', isCritique ? 0.2 : 0.22)
|
||||
|
||||
if (link.auteurs_passerelle && link.auteurs_passerelle.length > 0) {
|
||||
lineEl
|
||||
.on('mouseenter', (e: any) => {
|
||||
if (!tooltipRef.value) return
|
||||
tooltipRef.value.innerHTML = `<strong>Influence</strong><br><span style="opacity:0.8;font-size:0.72rem;">Passerelles : ${link.auteurs_passerelle.join(', ')}</span>`
|
||||
tooltipRef.value.style.opacity = '1'
|
||||
})
|
||||
.on('mousemove', (e: any) => {
|
||||
if (!tooltipRef.value || !svgEl) return
|
||||
const rect = (svgEl as HTMLElement).getBoundingClientRect()
|
||||
tooltipRef.value.style.left = (e.clientX - rect.left + 14) + 'px'
|
||||
tooltipRef.value.style.top = (e.clientY - rect.top - 10) + 'px'
|
||||
})
|
||||
.on('mouseleave', () => { if (tooltipRef.value) tooltipRef.value.style.opacity = '0' })
|
||||
}
|
||||
})
|
||||
|
||||
// ---- SIMULATION D3 (auteurs) ----
|
||||
// Pre-positionner chaque auteur pres de son ecole + jitter aleatoire pour eviter le rush initial vers la droite
|
||||
const auteurNodes: any[] = props.data.auteurs.map(a => {
|
||||
const ecole = ecoleMap.get(a.ecole_principale)
|
||||
const jitter = () => (Math.random() - 0.5) * 80
|
||||
return {
|
||||
id: a.id, type: 'auteur', nom: a.nom, dates: a.dates,
|
||||
bio_courte: a.bio_courte,
|
||||
bio_provisoire: a.bio_courte_provisoire ?? '',
|
||||
ingere: a.ingere,
|
||||
ecole_principale: a.ecole_principale,
|
||||
color: ecole?.color ?? '#888', r: 11,
|
||||
x: W * (ecole?.x_hint ?? 0.5) + jitter(),
|
||||
y: H * (ecole?.y_hint ?? 0.5) + jitter(),
|
||||
}
|
||||
})
|
||||
|
||||
// Liens appartenance auteur -> ecole (vers centroid fixe)
|
||||
const links: any[] = []
|
||||
props.data.auteurs.forEach(a => {
|
||||
links.push({ source: a.id, target: a.ecole_principale, strength: 0.65, isSubcourant: false })
|
||||
a.ecoles.filter(e => e !== a.ecole_principale).forEach(e => {
|
||||
links.push({ source: a.id, target: e, strength: 0.25, isSubcourant: true })
|
||||
})
|
||||
})
|
||||
|
||||
// Nodes fictifs fixes pour les ecoles (cibles des liens appartenance)
|
||||
const ecoleFixedNodes: any[] = props.data.ecoles.map(e => ({
|
||||
id: e.id, type: 'ecole-fixed', ecoleId: e.id,
|
||||
x: W * e.x_hint, y: H * e.y_hint,
|
||||
fx: W * e.x_hint, fy: H * e.y_hint,
|
||||
}))
|
||||
|
||||
// Rayon proportionnel au nombre d'auteurs de l'ecole
|
||||
const ecoleAuteurCounts = new Map<string, number>()
|
||||
props.data.ecoles.forEach(e => ecoleAuteurCounts.set(e.id, 0))
|
||||
props.data.auteurs.forEach(a => ecoleAuteurCounts.set(a.ecole_principale, (ecoleAuteurCounts.get(a.ecole_principale) ?? 0) + 1))
|
||||
const ecoleRadius = (count: number) => Math.max(16, Math.min(36, 13 + count * 1.5))
|
||||
|
||||
const allNodes = [...ecoleFixedNodes, ...auteurNodes]
|
||||
|
||||
if (simulation) simulation.stop()
|
||||
// Phase 8.D : sim ajustee pour 171 auteurs (vs 28 v2.1, densite 6x)
|
||||
simulation = d3.forceSimulation(allNodes)
|
||||
.force('link', d3.forceLink(links).id((d: any) => d.id).distance(120).strength((d: any) => d.strength ?? 0.5))
|
||||
.force('charge', d3.forceManyBody().strength(-70))
|
||||
.force('center', d3.forceCenter(W / 2, H / 2).strength(0.02))
|
||||
.force('collision', d3.forceCollide().radius((d: any) => d.type === 'ecole-fixed' ? ecoleRadius(ecoleAuteurCounts.get(d.ecoleId) ?? 0) + 4 : 12))
|
||||
.force('forceX', d3.forceX<any>((d: any) => {
|
||||
if (d.type === 'auteur') {
|
||||
const pos = ecolePositions.get(d.ecole_principale)
|
||||
return pos ? pos.tx : W / 2
|
||||
}
|
||||
return W / 2
|
||||
}).strength(0.15))
|
||||
.force('forceY', d3.forceY<any>((d: any) => {
|
||||
if (d.type === 'auteur') {
|
||||
const pos = ecolePositions.get(d.ecole_principale)
|
||||
return pos ? pos.ty : H / 2
|
||||
}
|
||||
return H / 2
|
||||
}).strength(0.15))
|
||||
|
||||
// ---- NOEUDS ECOLES visibles (couche 3.5) ----
|
||||
// Cercles proportionnels au count d'auteurs, fixes aux centroids Bonpote, cliquables
|
||||
const gEcoles = g.append('g').attr('class', 'ecoles-nodes')
|
||||
ecoleFixedNodes.forEach(eNode => {
|
||||
const ecole = ecoleMap.get(eNode.ecoleId)
|
||||
if (!ecole) return
|
||||
const count = ecoleAuteurCounts.get(eNode.ecoleId) ?? 0
|
||||
const r = ecoleRadius(count)
|
||||
gEcoles.append('circle')
|
||||
.attr('cx', eNode.fx).attr('cy', eNode.fy).attr('r', r)
|
||||
.attr('fill', ecole.color).attr('fill-opacity', 0.82).attr('stroke', ecole.color).attr('stroke-width', 2)
|
||||
.attr('class', 'ecole-node').style('cursor', 'pointer')
|
||||
.on('mouseenter', (e: any) => {
|
||||
if (!tooltipRef.value) return
|
||||
tooltipRef.value.innerHTML = `<strong>${ecole.label}</strong> <span style="opacity:0.6;font-size:0.7rem;">${count} auteur${count > 1 ? 's' : ''}</span><br><span style="opacity:0.75;font-size:0.72rem;">${ecole.description}</span>`
|
||||
tooltipRef.value.style.opacity = '1'
|
||||
})
|
||||
.on('mousemove', (e: any) => {
|
||||
if (!tooltipRef.value || !svgEl) return
|
||||
const rect = (svgEl as HTMLElement).getBoundingClientRect()
|
||||
tooltipRef.value.style.left = (e.clientX - rect.left + 14) + 'px'
|
||||
tooltipRef.value.style.top = (e.clientY - rect.top - 10) + 'px'
|
||||
})
|
||||
.on('mouseleave', () => { if (tooltipRef.value) tooltipRef.value.style.opacity = '0' })
|
||||
.on('click', (e: any) => { e.stopPropagation(); emit('select-ecole', eNode.ecoleId) })
|
||||
})
|
||||
|
||||
// ---- LIENS APPARTENANCE (couche 4) ----
|
||||
const gLinks = g.append('g').attr('class', 'links-appartenance')
|
||||
d3LinkSel = gLinks.selectAll('line').data(links).join('line')
|
||||
.attr('stroke', 'rgba(150,150,150,0.28)').attr('stroke-width', 1.2)
|
||||
|
||||
// ---- EDGE LABELS - sous-courants (couche 4b) ----
|
||||
// Afficher label "decroissance" sur lien Servigne (sous-courant specifique - option C)
|
||||
const subcourantLinks = links.filter((l: any) => l.isSubcourant)
|
||||
d3EdgeLabelSel = gLinks.selectAll('text.pensees-edge-label')
|
||||
.data(subcourantLinks)
|
||||
.join('text')
|
||||
.attr('class', 'pensees-edge-label')
|
||||
|
||||
// ---- NODES AUTEURS (couche 5) ----
|
||||
const gAuteurs = g.append('g').attr('class', 'auteurs')
|
||||
d3NodeSel = gAuteurs.selectAll('g').data(auteurNodes).join('g')
|
||||
.style('cursor', (d: any) => d.ingere ? 'pointer' : 'default')
|
||||
.call(d3.drag<any, any>()
|
||||
.on('start', (e: any, d: any) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y })
|
||||
.on('drag', (e: any, d: any) => { d.fx = e.x; d.fy = e.y })
|
||||
.on('end', (e: any, d: any) => { if (!e.active) simulation.alphaTarget(0); d.fx = null; d.fy = null }))
|
||||
.on('click', (e: any, d: any) => {
|
||||
if (!d.ingere) return
|
||||
e.stopPropagation()
|
||||
emit('select-auteur', d.id)
|
||||
})
|
||||
|
||||
// Phase 8.D : grisage conditionnel auteurs non-ingeres (ingere:false)
|
||||
d3NodeSel.append('circle')
|
||||
.attr('r', (d: any) => d.r)
|
||||
.attr('fill', (d: any) => d.ingere ? (d.color + 'cc') : '#bbbbbb')
|
||||
.attr('stroke', (d: any) => d.ingere ? d.color : '#999999')
|
||||
.attr('stroke-width', 1.5)
|
||||
.attr('opacity', (d: any) => d.ingere ? 1 : 0.35)
|
||||
|
||||
// ---- LABELS AUTEURS (couche 6 - fix 7.1 : drop-shadow blanc) ----
|
||||
d3NodeSel.append('text')
|
||||
.attr('class', 'pensees-auteur-label')
|
||||
.text((d: any) => d.nom.split(' ').pop() ?? d.nom)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dy', (d: any) => -(d.r + 4))
|
||||
.style('pointer-events', 'none')
|
||||
.style('opacity', (d: any) => d.ingere ? 1 : 0.3)
|
||||
.style('fill', (d: any) => d.ingere ? '#1a1a1a' : '#777777')
|
||||
|
||||
d3NodeSel
|
||||
.on('mouseenter', (e: any, d: any) => {
|
||||
if (!tooltipRef.value) return
|
||||
let tooltipHtml = ''
|
||||
if (d.ingere) {
|
||||
const rawBio = d.bio_courte || ''
|
||||
const bio = rawBio.length > 90 ? rawBio.slice(0, 87) + '...' : rawBio
|
||||
tooltipHtml = `<strong>${d.nom}</strong> <span style="opacity:0.6;font-size:0.7rem;">${d.dates}</span><br><span style="opacity:0.75;font-size:0.72rem;">${bio || 'Dans le RAG ATIS.'}</span>`
|
||||
} else {
|
||||
tooltipHtml = `<strong>${d.nom}</strong> <span style="opacity:0.6;font-size:0.7rem;">${d.dates}</span><br><span style="opacity:0.65;font-size:0.72rem;font-style:italic;">Présent dans Bonpote, pas encore ingéré dans le RAG ATIS.</span>`
|
||||
}
|
||||
tooltipRef.value.innerHTML = tooltipHtml
|
||||
tooltipRef.value.style.opacity = '1'
|
||||
})
|
||||
.on('mousemove', (e: any) => {
|
||||
if (!tooltipRef.value || !svgEl) return
|
||||
const rect = (svgEl as HTMLElement).getBoundingClientRect()
|
||||
tooltipRef.value.style.left = (e.clientX - rect.left + 14) + 'px'
|
||||
tooltipRef.value.style.top = (e.clientY - rect.top - 10) + 'px'
|
||||
})
|
||||
.on('mouseleave', () => { if (tooltipRef.value) tooltipRef.value.style.opacity = '0' })
|
||||
|
||||
simulation.on('tick', () => {
|
||||
d3LinkSel
|
||||
.attr('x1', (d: any) => d.source.x).attr('y1', (d: any) => d.source.y)
|
||||
.attr('x2', (d: any) => d.target.x).attr('y2', (d: any) => d.target.y)
|
||||
|
||||
// Edge labels positions (milieu du lien)
|
||||
d3EdgeLabelSel
|
||||
.attr('x', (d: any) => (d.source.x + d.target.x) / 2)
|
||||
.attr('y', (d: any) => (d.source.y + d.target.y) / 2)
|
||||
.text((d: any) => {
|
||||
const targetId = typeof d.target === 'object' ? d.target.id : d.target
|
||||
return targetId
|
||||
})
|
||||
|
||||
d3NodeSel.attr('transform', (d: any) => `translate(${d.x},${d.y})`)
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => props.active, (val) => { if (val && import.meta.client && props.data) requestAnimationFrame(() => requestAnimationFrame(() => initGraph())) })
|
||||
watch(() => props.data, (val) => { if (val && props.active && import.meta.client) requestAnimationFrame(() => requestAnimationFrame(() => initGraph())) })
|
||||
onMounted(async () => { if (import.meta.client && props.data && props.active) { await nextTick(); initGraph() } })
|
||||
onUnmounted(() => { if (simulation) simulation.stop() })
|
||||
|
||||
function triggerResize() {
|
||||
if (simulation) {
|
||||
simulation.alpha(0.3).restart()
|
||||
} else if (import.meta.client && props.data && props.active) {
|
||||
initGraph()
|
||||
}
|
||||
}
|
||||
defineExpose({ triggerResize })
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* ---- Labels auteurs : fix 7.1 drop-shadow blanc pour lisibilite sur pastel ---- */
|
||||
.pensees-auteur-label {
|
||||
fill: #1a1a1a;
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
filter: drop-shadow(0 0 2.5px rgba(255,255,255,0.95));
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ---- Labels edge sous-courants (option C : seulement les liens secondaires) ---- */
|
||||
.pensees-edge-label {
|
||||
fill: #555;
|
||||
font-size: 8.5px;
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: middle;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---- Voronoi cellules : non-blurre Phase 8.F (revert Phase 8.D) ---- */
|
||||
/* Blur retire ; les cellules colorees Bonpote-aligned suffisent visuellement. */
|
||||
|
||||
.ecole-node {
|
||||
transition: opacity 0.15s, r 0.15s;
|
||||
}
|
||||
.ecole-node:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
</style>
|
||||
446
components/ChatbotPensees.vue
Normal file
446
components/ChatbotPensees.vue
Normal file
@@ -0,0 +1,446 @@
|
||||
<template>
|
||||
<!-- Mode overlay : bouton flottant bottom-right (legacy) -->
|
||||
<template v-if="!inline">
|
||||
<button v-if="!open" @click="open = true"
|
||||
class="fixed bottom-6 right-6 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
|
||||
style="height:48px;background:var(--nav-primary);color:var(--nav-text-on-primary);font-size:0.875rem;font-weight:600;"
|
||||
aria-label="Chatbot Pensees Ecologiques">
|
||||
<svg width="16" height="16" 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>Pensees ?</span>
|
||||
</button>
|
||||
|
||||
<Transition name="cpanel">
|
||||
<div v-if="open" class="fixed bottom-6 right-6 z-[1000] flex flex-col"
|
||||
style="width:min(360px,calc(100vw - 24px));max-height:60vh;background:var(--nav-surface);border-radius:14px;box-shadow:0 8px 32px rgba(26,34,56,0.22);overflow:hidden;border:1px solid var(--nav-bg-alt);"
|
||||
role="dialog" aria-modal="true" aria-label="RAG Pensees Ecologiques">
|
||||
|
||||
<!-- Header overlay -->
|
||||
<div class="flex items-center justify-between px-4 py-3 shrink-0" style="border-bottom:1px solid var(--nav-bg-alt);background:var(--nav-bg);">
|
||||
<div>
|
||||
<p class="text-sm font-bold" style="color:var(--nav-text);">RAG Pensees Ecologiques</p>
|
||||
</div>
|
||||
<button @click="open = false" class="flex items-center justify-center w-7 h-7 rounded-full hover:opacity-70"
|
||||
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);" aria-label="Fermer">
|
||||
<svg width="12" height="12" 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>
|
||||
</div>
|
||||
|
||||
<!-- Corpus toggle overlay -->
|
||||
<div class="shrink-0 px-3 pt-2 pb-1" style="background:var(--nav-bg);border-bottom:1px solid var(--nav-bg-alt);">
|
||||
<div class="flex gap-1" role="group" aria-label="Choisir le corpus">
|
||||
<button v-for="opt in corpusOptions" :key="opt.value" @click="setCorpus(opt.value)" :title="opt.tooltip"
|
||||
class="flex-1 px-2 py-1 rounded text-xs font-medium transition-colors"
|
||||
:style="corpus === opt.value ? 'background:var(--nav-primary);color:var(--nav-text-on-primary);' : 'background:var(--nav-bg-alt);color:var(--nav-text-muted);'"
|
||||
:aria-pressed="corpus === opt.value">{{ opt.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages overlay -->
|
||||
<div ref="msgElOverlay" class="flex-1 overflow-y-auto px-4 py-3 flex flex-col gap-3" style="min-height:0;">
|
||||
<div v-if="messages.length === 0" style="font-size:0.8rem;color:var(--nav-text-muted);line-height:1.5;">
|
||||
<template v-if="corpus === 'pensees'">Pose une question sur les pensees ecologiques...</template>
|
||||
<template v-else-if="corpus === 'projets'">Pose une question sur les projets d'architecture de Jules...</template>
|
||||
<template v-else>Pose une question sur les pensees ecologiques ancrees dans les projets archi de Jules.</template>
|
||||
</div>
|
||||
<template v-for="(msg, i) in messages" :key="i">
|
||||
<div v-if="msg.role === 'user'" class="self-end max-w-[85%] px-3 py-2 rounded-xl text-sm"
|
||||
style="background:var(--nav-primary);color:var(--nav-text-on-primary);font-weight:500;">{{ msg.content }}</div>
|
||||
<div v-else class="self-start max-w-full">
|
||||
<div class="px-3 py-2 rounded-xl text-sm leading-relaxed" style="background:var(--nav-bg-alt);color:var(--nav-text);"
|
||||
v-html="renderMd(stripSrc(msg.content))" />
|
||||
<div v-if="filteredSources(msg.content).length" class="mt-1.5">
|
||||
<button @click="toggled[i] = !toggled[i]" class="flex items-center gap-1 text-xs hover:opacity-70" style="color:var(--nav-text-muted);">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
|
||||
:style="`transform:rotate(${toggled[i] ? 90 : 0}deg);transition:transform 0.15s`"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
Sources ({{ filteredSources(msg.content).length }})
|
||||
</button>
|
||||
<div v-if="toggled[i]" class="mt-1 flex flex-col gap-1">
|
||||
<div v-for="(s, si) in filteredSources(msg.content)" :key="si" class="px-2 py-1 rounded text-xs"
|
||||
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);border-left:2px solid var(--nav-primary-solid);">
|
||||
<span style="font-weight:600;color:var(--nav-text);">[{{ si + 1 }}]</span> {{ s }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="loading" class="self-start px-3 py-2 rounded-xl" style="background:var(--nav-bg-alt);">
|
||||
<span class="dots"><span/><span style="animation-delay:150ms"/><span style="animation-delay:300ms"/></span>
|
||||
</div>
|
||||
<div v-if="err" class="text-xs px-3 py-2 rounded-xl" style="background:#fee;color:#c0392b;">{{ err }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Input overlay -->
|
||||
<div class="shrink-0 px-3 py-3" style="border-top:1px solid var(--nav-bg-alt);">
|
||||
<div class="flex items-center gap-2" style="position:relative;">
|
||||
<!-- Hashtag autocomplete dropdown (Slack/Discord pattern, au-dessus de l'input) -->
|
||||
<div v-if="hashtagDropdownOpen && hashtagSuggestions.length"
|
||||
class="hashtag-dropdown"
|
||||
style="position:absolute;bottom:100%;left:0;right:0;margin-bottom:6px;max-height:220px;overflow-y:auto;background:var(--nav-surface);border:1px solid var(--nav-bg-alt);border-radius:8px;box-shadow:0 -4px 12px rgba(0,0,0,0.12);z-index:50;">
|
||||
<div v-for="(auteur, idx) in hashtagSuggestions" :key="auteur.id"
|
||||
@mousedown.prevent="applyHashtagSuggestion(auteur)"
|
||||
@mouseenter="hashtagSelectedIndex = idx"
|
||||
class="px-3 py-2 cursor-pointer text-sm"
|
||||
:style="idx === hashtagSelectedIndex ? 'background:var(--nav-primary);color:var(--nav-text-on-primary);' : 'color:var(--nav-text);'">
|
||||
<span style="font-weight:600;">#{{ auteur.id }}</span>
|
||||
<span :style="idx === hashtagSelectedIndex ? 'opacity:0.85;margin-left:8px;font-size:0.78rem;' : 'opacity:0.65;margin-left:8px;font-size:0.78rem;color:var(--nav-text-muted);'">{{ auteur.nom }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<input ref="inputElOverlay" v-model="q" type="text" placeholder="Ta question, tape #auteur pour cibler" maxlength="500"
|
||||
class="flex-1 px-3 py-2 rounded-lg text-sm outline-none"
|
||||
style="background:var(--nav-bg);color:var(--nav-text);border:1px solid var(--nav-bg-alt);"
|
||||
@keydown="onInputKeydown" />
|
||||
<button @click="send" :disabled="loading || !q.trim()"
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg"
|
||||
:style="loading||!q.trim() ? 'background:var(--nav-bg-alt);opacity:0.5;cursor:not-allowed;' : 'background:var(--nav-primary);cursor:pointer;'"
|
||||
aria-label="Envoyer">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color:white;">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<!-- Mode inline : remplit 100% de son parent slot -->
|
||||
<div v-else
|
||||
class="flex flex-col w-full h-full"
|
||||
style="background:var(--nav-surface);overflow:hidden;"
|
||||
role="region" aria-label="RAG Pensees Ecologiques">
|
||||
|
||||
<!-- Header inline -->
|
||||
<div class="flex items-center justify-between px-4 py-2 shrink-0" style="border-bottom:1px solid var(--nav-bg-alt);background:var(--nav-bg);">
|
||||
<div>
|
||||
<p class="text-sm font-bold" style="color:var(--nav-text);">RAG Pensees Ecologiques</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Corpus toggle inline -->
|
||||
<div class="shrink-0 px-3 pt-2 pb-1" style="background:var(--nav-bg);border-bottom:1px solid var(--nav-bg-alt);">
|
||||
<div class="flex gap-1" role="group" aria-label="Choisir le corpus">
|
||||
<button v-for="opt in corpusOptions" :key="opt.value" @click="setCorpus(opt.value)" :title="opt.tooltip"
|
||||
class="flex-1 px-2 py-1 rounded text-xs font-medium transition-colors"
|
||||
:style="corpus === opt.value ? 'background:var(--nav-primary);color:var(--nav-text-on-primary);' : 'background:var(--nav-bg-alt);color:var(--nav-text-muted);'"
|
||||
:aria-pressed="corpus === opt.value">{{ opt.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages inline -->
|
||||
<div ref="msgElInline" class="flex-1 overflow-y-auto px-4 py-3 flex flex-col gap-3" style="min-height:0;">
|
||||
<div v-if="messages.length === 0" style="font-size:0.8rem;color:var(--nav-text-muted);line-height:1.5;">
|
||||
<template v-if="corpus === 'pensees'">Pose une question sur les pensees ecologiques : ecosocialisme, decroissance, ecofeminismes, technocritique, deep ecology...</template>
|
||||
<template v-else-if="corpus === 'projets'">Pose une question sur les projets d'architecture de Jules : Butte Pinson, strategie thermique, partis pris constructifs...</template>
|
||||
<template v-else>Pose une question sur les pensees ecologiques ancrees dans les projets archi de Jules (corpus croise, defaut).</template>
|
||||
</div>
|
||||
<template v-for="(msg, i) in messages" :key="i">
|
||||
<div v-if="msg.role === 'user'" class="self-end max-w-[85%] px-3 py-2 rounded-xl text-sm"
|
||||
style="background:var(--nav-primary);color:var(--nav-text-on-primary);font-weight:500;">{{ msg.content }}</div>
|
||||
<div v-else class="self-start max-w-full">
|
||||
<div class="px-3 py-2 rounded-xl text-sm leading-relaxed" style="background:var(--nav-bg-alt);color:var(--nav-text);"
|
||||
v-html="renderMd(stripSrc(msg.content))" />
|
||||
<div v-if="filteredSources(msg.content).length" class="mt-1.5">
|
||||
<button @click="toggled[i] = !toggled[i]" class="flex items-center gap-1 text-xs hover:opacity-70" style="color:var(--nav-text-muted);">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
|
||||
:style="`transform:rotate(${toggled[i] ? 90 : 0}deg);transition:transform 0.15s`"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
Sources ({{ filteredSources(msg.content).length }})
|
||||
</button>
|
||||
<div v-if="toggled[i]" class="mt-1 flex flex-col gap-1">
|
||||
<div v-for="(s, si) in filteredSources(msg.content)" :key="si" class="px-2 py-1 rounded text-xs"
|
||||
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);border-left:2px solid var(--nav-primary-solid);">
|
||||
<span style="font-weight:600;color:var(--nav-text);">[{{ si + 1 }}]</span> {{ s }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="loading" class="self-start px-3 py-2 rounded-xl" style="background:var(--nav-bg-alt);">
|
||||
<span class="dots"><span/><span style="animation-delay:150ms"/><span style="animation-delay:300ms"/></span>
|
||||
</div>
|
||||
<div v-if="err" class="text-xs px-3 py-2 rounded-xl" style="background:#fee;color:#c0392b;">{{ err }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Input inline -->
|
||||
<div class="shrink-0 px-3 py-3" style="border-top:1px solid var(--nav-bg-alt);">
|
||||
<div class="flex items-center gap-2" style="position:relative;">
|
||||
<!-- Hashtag autocomplete dropdown (Slack/Discord pattern, au-dessus de l'input) -->
|
||||
<div v-if="hashtagDropdownOpen && hashtagSuggestions.length"
|
||||
class="hashtag-dropdown"
|
||||
style="position:absolute;bottom:100%;left:0;right:0;margin-bottom:6px;max-height:220px;overflow-y:auto;background:var(--nav-surface);border:1px solid var(--nav-bg-alt);border-radius:8px;box-shadow:0 -4px 12px rgba(0,0,0,0.12);z-index:50;">
|
||||
<div v-for="(auteur, idx) in hashtagSuggestions" :key="auteur.id"
|
||||
@mousedown.prevent="applyHashtagSuggestion(auteur)"
|
||||
@mouseenter="hashtagSelectedIndex = idx"
|
||||
class="px-3 py-2 cursor-pointer text-sm"
|
||||
:style="idx === hashtagSelectedIndex ? 'background:var(--nav-primary);color:var(--nav-text-on-primary);' : 'color:var(--nav-text);'">
|
||||
<span style="font-weight:600;">#{{ auteur.id }}</span>
|
||||
<span :style="idx === hashtagSelectedIndex ? 'opacity:0.85;margin-left:8px;font-size:0.78rem;' : 'opacity:0.65;margin-left:8px;font-size:0.78rem;color:var(--nav-text-muted);'">{{ auteur.nom }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<input ref="inputElInline" v-model="q" type="text" placeholder="Ta question, tape #auteur pour cibler" maxlength="500"
|
||||
class="flex-1 px-3 py-2 rounded-lg text-sm outline-none"
|
||||
style="background:var(--nav-bg);color:var(--nav-text);border:1px solid var(--nav-bg-alt);"
|
||||
@keydown="onInputKeydown" />
|
||||
<button @click="send" :disabled="loading || !q.trim()"
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg"
|
||||
:style="loading||!q.trim() ? 'background:var(--nav-bg-alt);opacity:0.5;cursor:not-allowed;' : 'background:var(--nav-primary);cursor:pointer;'"
|
||||
aria-label="Envoyer">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color:white;">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Message { role: 'user' | 'assistant'; content: string }
|
||||
interface AuteurMini { id: string; nom: string }
|
||||
|
||||
type CorpusMode = 'pensees' | 'projets' | 'both'
|
||||
|
||||
const CORPUS_STORAGE_KEY = 'chatbot-pensees-corpus'
|
||||
|
||||
const PROJECT_SOURCE_PATTERNS = [/butte.?pinson/i, /butte_pinson/i]
|
||||
|
||||
function isProjectSource(s: string): boolean {
|
||||
return PROJECT_SOURCE_PATTERNS.some(p => p.test(s))
|
||||
}
|
||||
|
||||
const corpusOptions: { value: CorpusMode; label: string; tooltip: string }[] = [
|
||||
{ value: 'pensees', label: 'Pensees', tooltip: 'Corpus FRACAS uniquement (auteurs ecologie politique)' },
|
||||
{ value: 'projets', label: 'Projets', tooltip: 'Projets archi de Jules uniquement' },
|
||||
{ value: 'both', label: 'Croise*', tooltip: 'Projets ancres + pensees en eclairage (defaut)' },
|
||||
]
|
||||
|
||||
const props = defineProps<{
|
||||
auteurContext?: string | null
|
||||
inline?: boolean
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
const q = ref('')
|
||||
const messages = ref<Message[]>([])
|
||||
const loading = ref(false)
|
||||
const err = ref('')
|
||||
const toggled = ref<Record<number, boolean>>({})
|
||||
const msgElOverlay = ref<HTMLElement | null>(null)
|
||||
const msgElInline = ref<HTMLElement | null>(null)
|
||||
const inputElOverlay = ref<HTMLInputElement | null>(null)
|
||||
const inputElInline = ref<HTMLInputElement | null>(null)
|
||||
const corpusCount = 18
|
||||
|
||||
const corpus = ref<CorpusMode>('pensees')
|
||||
|
||||
// Phase 8.E : hashtag mentions
|
||||
const auteursIngeres = ref<AuteurMini[]>([])
|
||||
const hashtagSuggestions = ref<AuteurMini[]>([])
|
||||
const hashtagDropdownOpen = ref(false)
|
||||
const hashtagSelectedIndex = ref(0)
|
||||
|
||||
function getActiveInput(): HTMLInputElement | null {
|
||||
return props.inline ? inputElInline.value : inputElOverlay.value
|
||||
}
|
||||
|
||||
function detectHashtagAtCursor(input: string, cursorPos: number): { start: number; partial: string } | null {
|
||||
const before = input.slice(0, cursorPos)
|
||||
const m = before.match(/#([a-z0-9-]*)$/i)
|
||||
if (!m) return null
|
||||
return { start: m.index ?? 0, partial: (m[1] || '').toLowerCase() }
|
||||
}
|
||||
|
||||
function updateHashtagSuggestions() {
|
||||
const el = getActiveInput()
|
||||
const cursorPos = el?.selectionStart ?? q.value.length
|
||||
const detection = detectHashtagAtCursor(q.value, cursorPos)
|
||||
// Ouvrir dès que le # est présent (partial vide accepté pour afficher la liste)
|
||||
if (!detection) {
|
||||
hashtagDropdownOpen.value = false
|
||||
return
|
||||
}
|
||||
const partial = detection.partial
|
||||
const list = partial.length === 0
|
||||
? auteursIngeres.value.slice(0, 8)
|
||||
: auteursIngeres.value
|
||||
.filter(a => a.id.toLowerCase().includes(partial) || a.nom.toLowerCase().includes(partial))
|
||||
.slice(0, 8)
|
||||
hashtagSuggestions.value = list
|
||||
hashtagDropdownOpen.value = list.length > 0
|
||||
hashtagSelectedIndex.value = 0
|
||||
}
|
||||
|
||||
function applyHashtagSuggestion(auteur: AuteurMini) {
|
||||
const el = getActiveInput()
|
||||
const cursorPos = el?.selectionStart ?? q.value.length
|
||||
const detection = detectHashtagAtCursor(q.value, cursorPos)
|
||||
if (!detection) return
|
||||
const before = q.value.slice(0, detection.start)
|
||||
const after = q.value.slice(cursorPos)
|
||||
const insert = '#' + auteur.id + ' '
|
||||
q.value = before + insert + after
|
||||
hashtagDropdownOpen.value = false
|
||||
nextTick(() => {
|
||||
const focusEl = getActiveInput()
|
||||
if (!focusEl) return
|
||||
focusEl.focus()
|
||||
const newPos = before.length + insert.length
|
||||
focusEl.setSelectionRange(newPos, newPos)
|
||||
})
|
||||
}
|
||||
|
||||
function onInputKeydown(e: KeyboardEvent) {
|
||||
if (hashtagDropdownOpen.value && hashtagSuggestions.value.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
hashtagSelectedIndex.value = (hashtagSelectedIndex.value + 1) % hashtagSuggestions.value.length
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
hashtagSelectedIndex.value = (hashtagSelectedIndex.value - 1 + hashtagSuggestions.value.length) % hashtagSuggestions.value.length
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
applyHashtagSuggestion(hashtagSuggestions.value[hashtagSelectedIndex.value])
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
hashtagDropdownOpen.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
send()
|
||||
}
|
||||
}
|
||||
|
||||
watch(q, () => {
|
||||
updateHashtagSuggestions()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const saved = window.localStorage.getItem(CORPUS_STORAGE_KEY) as CorpusMode | null
|
||||
if (saved && ['pensees', 'projets', 'both'].includes(saved)) {
|
||||
corpus.value = saved
|
||||
}
|
||||
// Chargement liste auteurs ingérés pour autocomplete hashtag
|
||||
try {
|
||||
const data = await $fetch<any>('/data/auteurs-pensees.json')
|
||||
auteursIngeres.value = (data?.auteurs ?? [])
|
||||
.filter((a: any) => a.ingere === true)
|
||||
.map((a: any) => ({ id: String(a.id), nom: String(a.nom) }))
|
||||
} catch (e) {
|
||||
console.error('Erreur chargement auteurs-pensees.json pour hashtag', e)
|
||||
}
|
||||
})
|
||||
|
||||
function setCorpus(val: CorpusMode) {
|
||||
corpus.value = val
|
||||
window.localStorage.setItem(CORPUS_STORAGE_KEY, val)
|
||||
}
|
||||
|
||||
watch(open, (val) => {
|
||||
if (!val) return
|
||||
nextTick(() => inputElOverlay.value?.focus())
|
||||
if (props.auteurContext && messages.value.length === 0)
|
||||
q.value = `Quelles sont les theses centrales de ${props.auteurContext} ?`
|
||||
})
|
||||
watch(() => props.auteurContext, (ctx) => {
|
||||
if (!ctx) return
|
||||
if (!props.inline && !open.value) open.value = true
|
||||
if (messages.value.length === 0) q.value = `Quelles sont les theses centrales de ${ctx} ?`
|
||||
})
|
||||
|
||||
async function send() {
|
||||
const query = q.value.trim()
|
||||
if (!query || loading.value) return
|
||||
|
||||
// Extraire le premier hashtag matchant un auteur ingéré
|
||||
let auteurSlug: string | null = null
|
||||
const matches = [...query.matchAll(/#([a-z0-9-]+)/gi)]
|
||||
for (const m of matches) {
|
||||
const slug = m[1].toLowerCase()
|
||||
if (auteursIngeres.value.find(a => a.id === slug)) {
|
||||
auteurSlug = slug
|
||||
break
|
||||
}
|
||||
}
|
||||
// Premier hashtag non-matché (pour info utilisateur si jamais ne match aucun)
|
||||
let auteurSlugUnmatched: string | null = null
|
||||
if (!auteurSlug && matches.length > 0) {
|
||||
auteurSlugUnmatched = matches[0][1].toLowerCase()
|
||||
}
|
||||
|
||||
err.value = ''
|
||||
messages.value.push({ role: 'user', content: query })
|
||||
q.value = ''
|
||||
hashtagDropdownOpen.value = false
|
||||
loading.value = true
|
||||
await nextTick()
|
||||
scrollBottom()
|
||||
try {
|
||||
const res = await $fetch<any>('/api/chatbot-pensees', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
query,
|
||||
mode: 'hybrid',
|
||||
corpus: corpus.value,
|
||||
auteur_slug: auteurSlug ?? auteurSlugUnmatched,
|
||||
},
|
||||
})
|
||||
let responseText = res.response ?? ''
|
||||
if (res.auteur_unmatched) {
|
||||
responseText = `*(Aucun livre de #${res.auteur_unmatched} n'est ingéré dans le RAG. Je réponds depuis la carte entière.)*\n\n` + responseText
|
||||
}
|
||||
messages.value.push({ role: 'assistant', content: responseText })
|
||||
} catch (e: any) {
|
||||
const s = e?.response?.status ?? e?.statusCode
|
||||
err.value = s === 429 ? 'Limite atteinte.' : s === 503 ? 'RAG indisponible.' : 'Erreur, reessaie.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
scrollBottom()
|
||||
}
|
||||
}
|
||||
|
||||
function scrollBottom() {
|
||||
const el = props.inline ? msgElInline.value : msgElOverlay.value
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}
|
||||
|
||||
function renderMd(t: string) {
|
||||
return '<p>' + t.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/\*(.+?)\*/g, '<em>$1</em>').replace(/\n\n/g, '</p><p>').replace(/\n/g, '<br>') + '</p>'
|
||||
}
|
||||
function stripSrc(t: string) { return t.replace(/\n*(?:Sources?|References?)\s*:[\s\S]*$/i, '').trim() }
|
||||
|
||||
function parseSrc(t: string): string[] {
|
||||
const bloc = t.match(/\n*(?:Sources?|References?)\s*:\n?([\s\S]+?)$/i)
|
||||
if (bloc) return bloc[1].split('\n').map(l => l.replace(/^[-*\d.[\]]+\s*/, '').trim()).filter(l => l.length > 3)
|
||||
return [...new Set([...t.matchAll(/\[([^\]]{5,80})\]/g)].filter(m => m[1].includes(' - ')).map(m => m[1]))]
|
||||
}
|
||||
|
||||
function filteredSources(t: string): string[] {
|
||||
const all = parseSrc(t)
|
||||
if (corpus.value === 'both') return all
|
||||
if (corpus.value === 'projets') return all.filter(s => isProjectSource(s))
|
||||
return all.filter(s => !isProjectSource(s))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cpanel-enter-active { transition: opacity 0.2s, transform 0.22s cubic-bezier(0.34,1.56,0.64,1); }
|
||||
.cpanel-leave-active { transition: opacity 0.18s, transform 0.15s ease-in; }
|
||||
.cpanel-enter-from { opacity: 0; transform: translateY(12px) scale(0.95); }
|
||||
.cpanel-leave-to { opacity: 0; transform: translateY(8px) scale(0.97); }
|
||||
.dots span { display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--nav-text-muted);margin:0 2px;animation:bounce 1s infinite; }
|
||||
@keyframes bounce { 0%,80%,100%{transform:translateY(0)}40%{transform:translateY(-5px)} }
|
||||
</style>
|
||||
@@ -52,18 +52,10 @@
|
||||
<div class="chatbot-body-inner" ref="messagesContainer">
|
||||
<!-- Onboarding -->
|
||||
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||
<p>Ce chatbot fonctionne sur un serveur européen souverain
|
||||
(Mistral FR, zéro rétention), conçu sobre en énergie.</p>
|
||||
<p>Pour m'aider à te répondre efficacement,
|
||||
formule ta requête ainsi :</p>
|
||||
<ul>
|
||||
<li>• Besoin : [ce que tu cherches]</li>
|
||||
<li>• Thématique : [juridique / technique / économique / ...]</li>
|
||||
<li>• Lieu : [région ou ville]</li>
|
||||
</ul>
|
||||
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon
|
||||
employeur, besoin conseil juridique droit du travail,
|
||||
Île-de-France."</p>
|
||||
<p>Je connais les structures d'entraide pour architectes référencées sur cette carte — appui juridique, technique, économique, formation, santé mentale, gestion d'agence…</p>
|
||||
<p>Décris ta situation, je te propose les fiches les plus pertinentes.</p>
|
||||
<p class="example">Exemple : "Architecte salarié, litige avec mon employeur, besoin d'un appui juridique droit du travail, Île-de-France."</p>
|
||||
<p style="margin-top: 8px; font-size: 0.72rem; opacity: 0.6;">Propulsé par Mistral FR — serveur européen souverain, zéro rétention.</p>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
@@ -72,7 +64,7 @@ employeur, besoin conseil juridique droit du travail,
|
||||
<div v-else class="assistant-bubble">
|
||||
<p>{{ msg.content }}</p>
|
||||
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
|
||||
<p class="fiches-title">Fiches recommandées :</p>
|
||||
<p class="fiches-title">Fiches recommandees :</p>
|
||||
<a
|
||||
v-for="fiche in msg.fiches"
|
||||
:key="fiche.id"
|
||||
@@ -83,6 +75,21 @@ employeur, besoin conseil juridique droit du travail,
|
||||
<span v-if="fiche.explication" class="fiche-expl">{{ fiche.explication }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="msg.suggestedHashtags && msg.suggestedHashtags.length" style="margin-top: 8px;">
|
||||
<p style="font-size: 0.7rem; color: var(--nav-text-muted); margin-bottom: 4px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;">Filtrer par :</p>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 4px;">
|
||||
<span
|
||||
v-for="tag in msg.suggestedHashtags"
|
||||
:key="tag"
|
||||
style="
|
||||
padding: 2px 8px; border-radius: 9999px; font-size: 0.7rem; cursor: pointer;
|
||||
background: var(--nav-bg-alt); color: var(--nav-text); border: 1px solid var(--nav-bg-alt);
|
||||
transition: all 0.15s;
|
||||
"
|
||||
@click="emit('applyHashtag', tag)"
|
||||
>{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -132,10 +139,12 @@ interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
fiches?: FicheReco[]
|
||||
suggestedHashtags?: string[]
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
'highlightOrgs': [ids: (number | string)[]]
|
||||
'applyHashtag': [tag: string]
|
||||
}>()
|
||||
|
||||
const isExpanded = ref(false)
|
||||
@@ -145,6 +154,37 @@ const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const messagesContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
// Detection hashtags depuis la question posee
|
||||
const HASHTAG_KEYWORDS: Record<string, string[]> = {
|
||||
'#reemploi-structurel': ['reemploi', 'materiaux recuperes', 'deconstruction', 'reemploi structurel'],
|
||||
'#reemploi-second-oeuvre': ['revetement', 'second oeuvre', 'reemploi'],
|
||||
'#biosource-geosource': ['biosource', 'geosource', 'paille', 'terre', 'chanvre', 'lin', 'biosource'],
|
||||
'#low-tech-experimentation': ['low-tech', 'low tech', 'technique simple', 'autonomie', 'lowtech'],
|
||||
'#chantier-ecole': ['formation', 'chantier ecole', 'chantier-ecole', 'apprendre', 'auto-construction', 'autoconstruction'],
|
||||
'#sobriete-energetique': ['sobriete', 'energie', 'renovation energetique', 'isolation', 'chauffage', 'economie energie'],
|
||||
'#mal-logement-precarite': ['mal-logement', 'precarite', 'sans-abri', 'logement social', 'squat', 'mal logement'],
|
||||
'#tiers-lieux-friches': ['friche', 'tiers-lieu', 'tiers lieu', 'espace intermediaire', 'temporaire', 'reconversion'],
|
||||
'#accompagnement-cooperatif': ['cooperative', 'accompagnement', 'cooperation', 'collectif', 'mutualisation'],
|
||||
'#transition-energetique-territoriale': ['territoire', 'transition', 'energetique', 'local', 'region', 'transition energetique'],
|
||||
'#communs-fonciers': ['communs', 'foncier', 'anti-speculatif', 'community land trust', 'commun foncier'],
|
||||
'#hack-juridique': ['juridique', 'montage', 'structure legale', 'sci', 'cooperative', 'statut'],
|
||||
'#retrofit-strates': ['retrofit', 'renovation lourde', 'sur-isolation', 'rehaussement'],
|
||||
'#phytoconstruction': ['plantes', 'vegetal', 'arbre', 'construction vivante', 'phyto'],
|
||||
}
|
||||
|
||||
function detectHashtagsFromQuery(query: string): string[] {
|
||||
const q = query.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
const detected: string[] = []
|
||||
for (const [hashtag, keywords] of Object.entries(HASHTAG_KEYWORDS)) {
|
||||
if (keywords.some(kw => q.includes(kw))) {
|
||||
detected.push(hashtag)
|
||||
}
|
||||
}
|
||||
return detected.slice(0, 3)
|
||||
}
|
||||
|
||||
function toggleExpand() {
|
||||
isExpanded.value = !isExpanded.value
|
||||
}
|
||||
@@ -170,10 +210,12 @@ async function sendMessage() {
|
||||
body: { question },
|
||||
})
|
||||
|
||||
const suggestedHashtags = detectHashtagsFromQuery(question)
|
||||
const assistantMsg: ChatMessage = {
|
||||
role: 'assistant',
|
||||
content: res.reponse_texte,
|
||||
fiches: res.fiches_recommandees || [],
|
||||
suggestedHashtags: suggestedHashtags.length ? suggestedHashtags : undefined,
|
||||
}
|
||||
messages.value.push(assistantMsg)
|
||||
|
||||
|
||||
208
components/ChatbotReseaux.vue
Normal file
208
components/ChatbotReseaux.vue
Normal file
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<transition name="backdrop">
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="fixed inset-0 z-[1010]"
|
||||
style="background: rgba(26,34,56,0.5);"
|
||||
@click="emit('update:modelValue', false)"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</transition>
|
||||
|
||||
<transition name="sheet">
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="fixed inset-x-0 bottom-0 z-[1011] flex flex-col"
|
||||
style="background: var(--nav-surface); height: 100dvh; max-height: 100dvh; box-shadow: 0 -4px 32px rgba(26,34,56,0.18);"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Assistant Réseaux AEP"
|
||||
>
|
||||
<div class="flex justify-center pt-3 pb-1 shrink-0">
|
||||
<div class="rounded-full" style="width: 36px; height: 4px; background: var(--nav-bg-alt);" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between px-4 py-3 shrink-0 border-b" style="border-color: var(--nav-bg-alt);">
|
||||
<button
|
||||
@click="emit('update:modelValue', false)"
|
||||
class="flex items-center gap-2 text-sm font-medium transition-opacity hover:opacity-70"
|
||||
style="color: var(--nav-text-muted);"
|
||||
aria-label="Retour"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 18 9 12 15 6"/>
|
||||
</svg>
|
||||
Retour
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-7 h-7 rounded-full flex items-center justify-center shrink-0" style="background: var(--nav-primary);">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: var(--nav-text-on-primary);">
|
||||
<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>
|
||||
</div>
|
||||
<span class="font-bold text-sm" style="color: var(--nav-text);">Réseaux AEP</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
|
||||
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||
<p>Je connais les <strong>120 réseaux, collectifs et agences</strong> cartographiés dans AEP — ceux qui portent une vision écologique et politique de l'architecture.</p>
|
||||
<p>Décris ta situation : tu cherches un collectif, une agence inspirante, un partenaire sur un projet en Occitanie, en transition énergétique ?</p>
|
||||
</div>
|
||||
|
||||
<template v-for="(msg, i) in messages" :key="i">
|
||||
<div v-if="msg.role === 'user'" class="user-bubble">{{ msg.content }}</div>
|
||||
<div v-else class="assistant-bubble">
|
||||
<div v-html="renderMd(msg.content)" />
|
||||
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list" style="margin-top:12px;">
|
||||
<p class="fiches-title">Structures recommandées :</p>
|
||||
<a
|
||||
v-for="fiche in msg.fiches"
|
||||
:key="fiche.id"
|
||||
:href="`/agences#${fiche.id}`"
|
||||
class="fiche-card"
|
||||
>
|
||||
<span class="fiche-nom">{{ fiche.nom }}</span>
|
||||
<span v-if="fiche.explication" class="fiche-expl">{{ fiche.explication }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="loading" class="assistant-bubble loading-bubble">
|
||||
<span class="dot" /><span class="dot" /><span class="dot" />
|
||||
</div>
|
||||
<div v-if="errorMsg" class="error-bubble">{{ errorMsg }}</div>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 px-4 pt-3 border-t" style="border-color: var(--nav-bg-alt); padding-bottom: max(1rem, env(safe-area-inset-bottom));">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="inputText"
|
||||
type="text"
|
||||
:disabled="loading"
|
||||
placeholder="Décris ta situation…"
|
||||
class="flex-1 px-4 py-3 rounded-xl text-sm border"
|
||||
style="border-color: var(--nav-bg-alt); background: var(--nav-bg); color: var(--nav-text); font-family: var(--nav-font); font-size: 16px;"
|
||||
@keydown.enter.prevent="sendMessage"
|
||||
/>
|
||||
<button
|
||||
:disabled="loading || !inputText.trim()"
|
||||
class="w-11 h-11 rounded-xl flex items-center justify-center shrink-0 transition-opacity"
|
||||
style="background: var(--nav-primary);"
|
||||
:style="{ opacity: (loading || !inputText.trim()) ? 0.4 : 1 }"
|
||||
aria-label="Envoyer"
|
||||
@click="sendMessage"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color: var(--nav-text-on-primary);">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMarkdown } from '~/composables/useMarkdown'
|
||||
const { render: renderMd } = useMarkdown()
|
||||
|
||||
interface FicheReco { id: number | string; nom: string; explication?: string }
|
||||
interface ChatMessage { role: 'user' | 'assistant'; content: string; fiches?: FicheReco[] }
|
||||
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const inputText = ref('')
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const messagesContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (typeof document === 'undefined') return
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
document.documentElement.style.overflow = open ? 'hidden' : ''
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.body.style.overflow = ''
|
||||
document.documentElement.style.overflow = ''
|
||||
}
|
||||
})
|
||||
|
||||
async function sendMessage() {
|
||||
const question = inputText.value.trim()
|
||||
if (!question || loading.value) return
|
||||
inputText.value = ''
|
||||
errorMsg.value = ''
|
||||
messages.value.push({ role: 'user', content: question })
|
||||
loading.value = true
|
||||
await nextTick()
|
||||
if (messagesContainer.value) messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||
|
||||
try {
|
||||
const res = await $fetch<{ reponse_texte: string; fiches_recommandees: FicheReco[] }>(
|
||||
'/api/chatbot-reseaux',
|
||||
{ method: 'POST', body: { question } }
|
||||
)
|
||||
messages.value.push({ role: 'assistant', content: res.reponse_texte, fiches: res.fiches_recommandees || [] })
|
||||
} catch (e: any) {
|
||||
const s = e?.statusCode ?? e?.status
|
||||
errorMsg.value = s === 429
|
||||
? 'Limite de 20 questions par jour atteinte.'
|
||||
: 'Une erreur est survenue. Réessaie dans quelques instants.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
if (messagesContainer.value) messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
|
||||
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
|
||||
.sheet-enter-active, .sheet-leave-active { transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); }
|
||||
.sheet-enter-from, .sheet-leave-to { transform: translateY(100%); }
|
||||
|
||||
.onboarding-bubble {
|
||||
background: var(--nav-bg); border: 1px solid var(--nav-bg-alt);
|
||||
border-radius: 12px; padding: 16px;
|
||||
font-size: 0.85rem; line-height: 1.65; color: var(--nav-text-muted);
|
||||
}
|
||||
.onboarding-bubble p { margin-bottom: 10px; }
|
||||
.onboarding-bubble strong { font-weight: 700; color: var(--nav-text); }
|
||||
|
||||
.user-bubble {
|
||||
align-self: flex-end; max-width: 80%;
|
||||
background: var(--nav-primary); color: var(--nav-text-on-primary);
|
||||
border-radius: 16px 16px 4px 16px; padding: 10px 14px;
|
||||
font-size: 0.875rem; line-height: 1.5;
|
||||
}
|
||||
.assistant-bubble {
|
||||
align-self: flex-start; max-width: 90%;
|
||||
background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);
|
||||
border-radius: 16px 16px 16px 4px; padding: 12px 14px;
|
||||
font-size: 0.875rem; line-height: 1.6; color: var(--nav-text);
|
||||
}
|
||||
.loading-bubble { display: flex; gap: 5px; align-items: center; }
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--nav-text-muted); animation: blink 1.2s infinite; }
|
||||
.dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes blink { 0%,80%,100% { opacity: 0.3; } 40% { opacity: 1; } }
|
||||
|
||||
.error-bubble { align-self: flex-start; max-width: 90%; color: #a85d3e; font-size: 0.8rem; padding: 8px 12px; border-radius: 8px; background: rgba(168,93,62,0.08); }
|
||||
|
||||
.fiches-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.fiches-title { font-size: 0.75rem; font-weight: 600; color: var(--nav-text-muted); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 4px; }
|
||||
.fiche-card { display: block; background: var(--nav-bg); border: 1px solid var(--nav-bg-alt); border-radius: 8px; padding: 8px 12px; text-decoration: none; transition: background 0.15s; }
|
||||
.fiche-card:hover { background: var(--nav-bg-alt); }
|
||||
.fiche-nom { display: block; font-size: 0.875rem; font-weight: 600; color: var(--nav-text); }
|
||||
.fiche-expl { display: block; font-size: 0.8rem; color: var(--nav-text-muted); margin-top: 2px; }
|
||||
</style>
|
||||
@@ -69,18 +69,14 @@
|
||||
<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) -->
|
||||
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||
<p>Ce chatbot fonctionne sur un serveur européen souverain
|
||||
(Mistral FR, zéro rétention), conçu sobre en énergie.</p>
|
||||
<p>Pour m'aider à te répondre efficacement,
|
||||
formule ta requête ainsi :</p>
|
||||
<p>Ce chatbot fonctionne sur un serveur européen souverain (Mistral FR, zéro rétention), conçu sobre en énergie.</p>
|
||||
<p>Pour m'aider à te répondre efficacement, formule ta requête ainsi :</p>
|
||||
<ul>
|
||||
<li>• Besoin : [ce que tu cherches]</li>
|
||||
<li>• Thématique : [juridique / technique / économique / ...]</li>
|
||||
<li>• Lieu : [région ou ville]</li>
|
||||
</ul>
|
||||
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon
|
||||
employeur, besoin conseil juridique droit du travail,
|
||||
Île-de-France."</p>
|
||||
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon employeur, besoin conseil juridique droit du travail, Île-de-France."</p>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
@@ -92,7 +88,7 @@ employeur, besoin conseil juridique droit du travail,
|
||||
|
||||
<!-- Message assistant -->
|
||||
<div v-else class="assistant-bubble">
|
||||
<p>{{ msg.content }}</p>
|
||||
<div class="md-content" v-html="renderMd(msg.content)" />
|
||||
|
||||
<!-- Fiches recommandées -->
|
||||
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
|
||||
@@ -164,6 +160,9 @@ employeur, besoin conseil juridique droit du travail,
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMarkdown } from '~/composables/useMarkdown'
|
||||
const { render: renderMd } = useMarkdown()
|
||||
|
||||
interface FicheReco {
|
||||
id: number | string
|
||||
nom: string
|
||||
@@ -320,7 +319,17 @@ function scrollToBottom() {
|
||||
line-height: 1.6;
|
||||
color: var(--nav-text);
|
||||
}
|
||||
.assistant-bubble p { margin: 0; }
|
||||
.assistant-bubble > p { margin: 0; }
|
||||
|
||||
/* Markdown rendu via v-html — :deep() perce le scoped */
|
||||
:deep(.md-content) { font-size: inherit; line-height: 1.6; }
|
||||
:deep(.md-content p) { margin: 0 0 0.4em; }
|
||||
:deep(.md-content p:last-child) { margin-bottom: 0; }
|
||||
:deep(.md-content strong) { font-weight: 700; }
|
||||
:deep(.md-content em) { font-style: italic; }
|
||||
:deep(.md-content ul) { margin: 0.3em 0 0.3em 1.1em; list-style: disc; padding: 0; }
|
||||
:deep(.md-content li) { margin-bottom: 0.15em; }
|
||||
:deep(.md-content a) { text-decoration: underline; opacity: 0.8; }
|
||||
|
||||
/* Fiches recommandées */
|
||||
.fiches-list {
|
||||
|
||||
@@ -1,38 +1,16 @@
|
||||
<template>
|
||||
<div class="space-y-1.5">
|
||||
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Échelle</p>
|
||||
<!-- Inline sur 1 ligne — même pattern que FonctionFilter -->
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1.5">
|
||||
<label
|
||||
<div class="space-y-1">
|
||||
<p class="filter-label">ÉCHELLE</p>
|
||||
<div class="chips-row">
|
||||
<span
|
||||
v-for="option in ECHELLES"
|
||||
:key="option"
|
||||
class="flex items-center gap-1.5 cursor-pointer select-none transition-opacity"
|
||||
>
|
||||
<!-- Case carrée -->
|
||||
<span
|
||||
class="flex items-center justify-center shrink-0 transition-all"
|
||||
style="width: 18px; height: 18px; border: 1.5px solid; border-radius: 3px;"
|
||||
class="chip"
|
||||
:style="isSelected(option)
|
||||
? 'background: var(--nav-primary); border-color: var(--nav-primary); color: #ffffff;'
|
||||
: 'background: var(--nav-bg-alt); border-color: rgba(26,34,56,0.25); color: transparent;'"
|
||||
>
|
||||
<svg v-if="isSelected(option)" width="11" height="11" viewBox="0 0 12 12" fill="none">
|
||||
<polyline points="2,6 5,9 10,3" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
<!-- Label -->
|
||||
<span
|
||||
class="text-sm leading-tight"
|
||||
:style="isSelected(option) ? 'color: var(--nav-text); font-weight: 600;' : 'color: var(--nav-text);'"
|
||||
? '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(option)"
|
||||
>{{ option }}</span>
|
||||
<!-- Input réel (masqué) -->
|
||||
<input
|
||||
type="checkbox"
|
||||
class="sr-only"
|
||||
:checked="isSelected(option)"
|
||||
@change="toggle(option)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -61,3 +39,24 @@ function toggle(option: string) {
|
||||
}
|
||||
}
|
||||
</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;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
|
||||
98
components/FicheAuteur.vue
Normal file
98
components/FicheAuteur.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="backdrop">
|
||||
<div v-if="open && auteur" class="fixed inset-0 z-[1500]" style="background: rgba(26,34,56,0.55);" @click="emit('close')" aria-hidden="true" />
|
||||
</Transition>
|
||||
<Transition name="modal">
|
||||
<div v-if="open && auteur" class="fixed z-[1501] left-1/2 flex flex-col"
|
||||
style="top:50%;transform:translate(-50%,-50%);width:min(520px,94vw);max-height:85vh;background:var(--nav-bg);border-radius:14px;box-shadow:0 16px 64px rgba(26,34,56,0.28);overflow:hidden;"
|
||||
role="dialog" aria-modal="true">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between px-5 py-4 shrink-0"
|
||||
:style="`border-bottom: 3px solid ${ecoleColor}; background: var(--nav-surface);`">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-semibold" :style="`background:${ecoleColor}22;color:${ecoleColor};`">{{ ecoleLabel }}</span>
|
||||
<span v-for="eid in auteur.ecoles.filter(e => e !== auteur.ecole_principale)" :key="eid"
|
||||
class="px-2 py-0.5 rounded-full text-xs" :style="`background:${getEcoleColor(eid)}22;color:${getEcoleColor(eid)};`">{{ getEcoleLabel(eid) }}</span>
|
||||
</div>
|
||||
<h2 class="mt-2 font-bold text-lg leading-tight" style="color:var(--nav-text);">{{ auteur.nom }}</h2>
|
||||
<p class="text-sm" style="color:var(--nav-text-muted);">{{ auteur.dates }}</p>
|
||||
</div>
|
||||
<button @click="emit('close')" class="ml-3 shrink-0 flex items-center justify-center w-8 h-8 rounded-full hover:opacity-70"
|
||||
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">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Body -->
|
||||
<div class="flex-1 overflow-y-auto px-5 py-4 flex flex-col gap-4">
|
||||
<p class="text-sm leading-relaxed" style="color:var(--nav-text);">{{ auteur.bio_courte }}</p>
|
||||
<div v-if="auteur.theses_cles.length">
|
||||
<p class="text-xs font-bold uppercase tracking-widest mb-2" style="color:var(--nav-text-muted);">Theses cles</p>
|
||||
<ul class="flex flex-col gap-1.5">
|
||||
<li v-for="t in auteur.theses_cles" :key="t" class="flex items-start gap-2 text-sm" style="color:var(--nav-text);">
|
||||
<span class="mt-1.5 w-1.5 h-1.5 rounded-full shrink-0" :style="`background:${ecoleColor};`"></span>
|
||||
<span>{{ t }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="auteur.livres_rag.length">
|
||||
<p class="text-xs font-bold uppercase tracking-widest mb-2" style="color:var(--nav-text-muted);">Livres dans le RAG</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div v-for="l in auteur.livres_rag" :key="l.slug" class="flex items-start gap-3 p-3 rounded-lg" style="background:var(--nav-bg-alt);">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold leading-snug" style="color:var(--nav-text);">{{ l.titre }}</p>
|
||||
<p class="text-xs mt-0.5" style="color:var(--nav-text-muted);">{{ l.annee }}</p>
|
||||
</div>
|
||||
<div class="flex gap-1 shrink-0">
|
||||
<span v-for="c in l.couches" :key="c" class="px-1.5 py-0.5 rounded text-xs" style="background:var(--nav-surface);color:var(--nav-text-muted);">{{ c }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div class="shrink-0 px-5 py-3 border-t" style="border-color:var(--nav-bg-alt);">
|
||||
<button @click="emit('interroger-rag', auteurId!)" class="w-full py-2.5 rounded-lg text-sm font-semibold hover:opacity-80"
|
||||
:style="`background:${ecoleColor};color:white;`">
|
||||
Interroger le RAG sur {{ auteur.nom.split(' ').pop() }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface LivreRag { slug: string; titre: string; annee: number; couches: string[] }
|
||||
interface AuteurData { id: string; nom: string; dates: string; ecoles: string[]; ecole_principale: string; livres_rag: LivreRag[]; theses_cles: string[]; bio_courte: string }
|
||||
interface EcoleData { id: string; label: string; color: string }
|
||||
interface PenseesData { ecoles: EcoleData[]; auteurs: AuteurData[] }
|
||||
|
||||
const props = defineProps<{ open: boolean; auteurId: string | null; data: PenseesData | null }>()
|
||||
const emit = defineEmits<{ close: []; 'interroger-rag': [auteurId: string] }>()
|
||||
|
||||
const auteur = computed<AuteurData | null>(() => {
|
||||
if (!props.auteurId || !props.data) return null
|
||||
return props.data.auteurs.find(a => a.id === props.auteurId) ?? null
|
||||
})
|
||||
const ecoleColor = computed(() => props.data?.ecoles.find(e => e.id === auteur.value?.ecole_principale)?.color ?? '#888')
|
||||
const ecoleLabel = computed(() => props.data?.ecoles.find(e => e.id === auteur.value?.ecole_principale)?.label ?? '')
|
||||
function getEcoleColor(id: string) { return props.data?.ecoles.find(e => e.id === id)?.color ?? '#888' }
|
||||
function getEcoleLabel(id: string) { return props.data?.ecoles.find(e => e.id === id)?.label ?? id }
|
||||
|
||||
function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && props.open) emit('close') }
|
||||
onMounted(() => window.addEventListener('keydown', onKey))
|
||||
onUnmounted(() => window.removeEventListener('keydown', onKey))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.backdrop-enter-active,.backdrop-leave-active { transition: opacity 0.2s; }
|
||||
.backdrop-enter-from,.backdrop-leave-to { opacity: 0; }
|
||||
.modal-enter-active { transition: opacity 0.2s, transform 0.22s cubic-bezier(0.34,1.56,0.64,1); }
|
||||
.modal-leave-active { transition: opacity 0.18s, transform 0.18s ease-in; }
|
||||
.modal-enter-from { opacity: 0; transform: translate(-50%,-48%) scale(0.94); }
|
||||
.modal-leave-to { opacity: 0; transform: translate(-50%,-48%) scale(0.96); }
|
||||
</style>
|
||||
101
components/FicheEcole.vue
Normal file
101
components/FicheEcole.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="backdrop">
|
||||
<div v-if="open && ecole" class="fixed inset-0 z-[1500]" style="background: rgba(26,34,56,0.55);" @click="emit('close')" aria-hidden="true" />
|
||||
</Transition>
|
||||
<Transition name="modal">
|
||||
<div v-if="open && ecole" class="fixed z-[1501] left-1/2 flex flex-col"
|
||||
style="top:50%;transform:translate(-50%,-50%);width:min(540px,94vw);max-height:85vh;background:var(--nav-bg);border-radius:14px;box-shadow:0 16px 64px rgba(26,34,56,0.28);overflow:hidden;"
|
||||
role="dialog" aria-modal="true">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between px-5 py-4 shrink-0"
|
||||
:style="`border-bottom: 3px solid ${ecole.color}; background: var(--nav-surface);`">
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-semibold" :style="`background:${ecole.color}22;color:${ecole.color};`">Ecole de pensee</span>
|
||||
<h2 class="mt-2 font-bold text-lg leading-tight" style="color:var(--nav-text);">{{ ecole.label }}</h2>
|
||||
<p class="text-sm mt-1 leading-relaxed" style="color:var(--nav-text-muted);">{{ ecole.description }}</p>
|
||||
</div>
|
||||
<button @click="emit('close')" class="ml-3 shrink-0 flex items-center justify-center w-8 h-8 rounded-full hover:opacity-70"
|
||||
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">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Body -->
|
||||
<div class="flex-1 overflow-y-auto px-5 py-4 flex flex-col gap-4">
|
||||
<div v-if="auteursIngeres.length">
|
||||
<p class="text-xs font-bold uppercase tracking-widest mb-2" style="color:var(--nav-text-muted);">Dans le RAG ({{ auteursIngeres.length }})</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button v-for="a in auteursIngeres" :key="a.id"
|
||||
class="px-2.5 py-1 rounded-full text-xs font-medium hover:opacity-80 transition-opacity"
|
||||
:style="`background:${ecole.color}22;color:${ecole.color};border:1px solid ${ecole.color}44;cursor:pointer;`"
|
||||
@click="onSelectAuteur(a.id)">
|
||||
{{ a.nom }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="auteursNonIngeres.length">
|
||||
<p class="text-xs font-bold uppercase tracking-widest mb-2" style="color:var(--nav-text-muted);">Presents dans Bonpote, pas encore dans le RAG ({{ auteursNonIngeres.length }})</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span v-for="a in auteursNonIngeres" :key="a.id"
|
||||
class="px-2.5 py-1 rounded-full text-xs"
|
||||
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);">
|
||||
{{ a.nom }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div class="shrink-0 px-5 py-3 border-t" style="border-color:var(--nav-bg-alt);">
|
||||
<button @click="emit('interroger-ecole', ecoleId!)" class="w-full py-2.5 rounded-lg text-sm font-semibold hover:opacity-80"
|
||||
:style="`background:${ecole.color};color:white;`">
|
||||
Interroger le RAG sur {{ ecole.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface AuteurData { id: string; nom: string; ecoles: string[]; ecole_principale: string; ingere: boolean }
|
||||
interface EcoleData { id: string; label: string; description: string; color: string }
|
||||
interface PenseesData { ecoles: EcoleData[]; auteurs: AuteurData[] }
|
||||
|
||||
const props = defineProps<{ open: boolean; ecoleId: string | null; data: PenseesData | null }>()
|
||||
const emit = defineEmits<{ close: []; 'select-auteur': [auteurId: string]; 'interroger-ecole': [ecoleId: string] }>()
|
||||
|
||||
const ecole = computed<EcoleData | null>(() => {
|
||||
if (!props.ecoleId || !props.data) return null
|
||||
return props.data.ecoles.find(e => e.id === props.ecoleId) ?? null
|
||||
})
|
||||
|
||||
const auteursIngeres = computed(() => {
|
||||
if (!props.ecoleId || !props.data) return []
|
||||
return props.data.auteurs.filter(a => a.ecole_principale === props.ecoleId && (a as any).ingere)
|
||||
})
|
||||
|
||||
const auteursNonIngeres = computed(() => {
|
||||
if (!props.ecoleId || !props.data) return []
|
||||
return props.data.auteurs.filter(a => a.ecole_principale === props.ecoleId && !(a as any).ingere)
|
||||
})
|
||||
|
||||
function onSelectAuteur(id: string) {
|
||||
emit('close')
|
||||
emit('select-auteur', id)
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && props.open) emit('close') }
|
||||
onMounted(() => window.addEventListener('keydown', onKey))
|
||||
onUnmounted(() => window.removeEventListener('keydown', onKey))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.backdrop-enter-active,.backdrop-leave-active { transition: opacity 0.2s; }
|
||||
.backdrop-enter-from,.backdrop-leave-to { opacity: 0; }
|
||||
.modal-enter-active { transition: opacity 0.2s, transform 0.22s cubic-bezier(0.34,1.56,0.64,1); }
|
||||
.modal-leave-active { transition: opacity 0.18s, transform 0.18s ease-in; }
|
||||
.modal-enter-from { opacity: 0; transform: translate(-50%,-48%) scale(0.94); }
|
||||
.modal-leave-to { opacity: 0; transform: translate(-50%,-48%) scale(0.96); }
|
||||
</style>
|
||||
284
components/FicheFamilleModal.vue
Normal file
284
components/FicheFamilleModal.vue
Normal 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>
|
||||
@@ -15,10 +15,9 @@
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="modelValue && orgId != null"
|
||||
class="fixed z-[1501] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col"
|
||||
class="fiche-modal fixed z-[1501] left-1/2 -translate-x-1/2 flex flex-col"
|
||||
style="
|
||||
width: min(768px, 92vw);
|
||||
max-height: 90vh;
|
||||
background: var(--nav-bg);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
|
||||
@@ -144,6 +143,21 @@ function onCommentSubmitted() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Modal positionnement : centré desktop, descendu sous le header sur mobile */
|
||||
.fiche-modal {
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.fiche-modal {
|
||||
top: 76px;
|
||||
transform: translateX(-50%);
|
||||
max-height: calc(100dvh - 92px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Backdrop */
|
||||
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
|
||||
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
|
||||
@@ -156,6 +170,11 @@ function onCommentSubmitted() {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -52%);
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.modal-enter-from, .modal-leave-to {
|
||||
transform: translate(-50%, calc(-2% + 76px));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
|
||||
|
||||
360
components/FicheModalV2.vue
Normal file
360
components/FicheModalV2.vue
Normal file
@@ -0,0 +1,360 @@
|
||||
<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="fiche-modal-v2 fixed z-[1501] left-1/2 -translate-x-1/2 flex flex-col"
|
||||
style="
|
||||
width: min(780px, 94vw);
|
||||
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 positionnement : centré desktop, descendu sous le header sur mobile */
|
||||
.fiche-modal-v2 {
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.fiche-modal-v2 {
|
||||
top: 76px;
|
||||
transform: translateX(-50%);
|
||||
max-height: calc(100dvh - 92px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 (max-width: 1023px) {
|
||||
.modal-enter-from, .modal-leave-to {
|
||||
transform: translate(-50%, calc(-2% + 76px));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
|
||||
.modal-enter-active, .modal-leave-active { transition: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,35 +1,33 @@
|
||||
<template>
|
||||
<div class="space-y-1.5">
|
||||
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Fonction</p>
|
||||
<div class="space-y-1">
|
||||
<!-- Label + toggle collapse -->
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
|
||||
<p class="filter-label" style="margin-bottom: 0;">
|
||||
FONCTION
|
||||
<span v-if="modelValue.length" style="font-weight: 400; text-transform: none; letter-spacing: 0; font-size: 0.65rem; margin-left: 4px;">({{ modelValue.length }} active{{ modelValue.length > 1 ? 's' : '' }})</span>
|
||||
</p>
|
||||
<button
|
||||
@click="toggleCollapse"
|
||||
style="font-size: 0.7rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0; white-space: nowrap;"
|
||||
>{{ isOpen ? 'Replier' : 'Fonctions (' + FONCTIONS.length + ')' }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Chips (visible si ouvert ou si des fonctions sont actives) -->
|
||||
<div v-if="isOpen" class="chips-row">
|
||||
<span
|
||||
v-for="fn in FONCTIONS"
|
||||
:key="fn"
|
||||
@click="toggle(fn)"
|
||||
:aria-pressed="modelValue.includes(fn)"
|
||||
class="flex items-center gap-2.5 w-full rounded px-1 py-0.5 transition-all text-left hover:opacity-80"
|
||||
:style="modelValue.includes(fn) ? 'background: rgba(26,34,56,0.06);' : ''"
|
||||
>
|
||||
<!-- Case : affiche le rang de priorité si actif, sinon le nombre d'orgs -->
|
||||
<span
|
||||
class="flex items-center justify-center shrink-0 text-xs font-bold transition-all"
|
||||
style="width: 24px; height: 24px; border: 1.5px solid; border-radius: 4px;"
|
||||
class="chip"
|
||||
:style="modelValue.includes(fn)
|
||||
? 'background: var(--nav-primary); border-color: var(--nav-primary); color: var(--nav-text-on-primary);'
|
||||
: 'background: var(--nav-bg-alt); border-color: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||
>
|
||||
{{ modelValue.includes(fn) ? (modelValue.indexOf(fn) + 1) : (counts[fn] ?? 0) }}
|
||||
</span>
|
||||
<!-- Label -->
|
||||
<span
|
||||
class="text-sm leading-tight"
|
||||
:style="modelValue.includes(fn) ? 'color: var(--nav-text); font-weight: 600;' : 'color: var(--nav-text);'"
|
||||
? '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(fn)"
|
||||
>{{ fn }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Effacer (visible même replié si filtres actifs) -->
|
||||
<p v-if="modelValue.length" class="text-xs pt-0.5" style="color: var(--nav-text-muted);">
|
||||
{{ modelValue.length }} actif{{ modelValue.length > 1 ? 's' : '' }}
|
||||
<button @click="emit('update:modelValue', [])" class="ml-2 underline hover:opacity-70">Effacer</button>
|
||||
<button @click="emit('update:modelValue', [])" class="underline hover:opacity-70">Effacer</button>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -57,6 +55,25 @@ const emit = defineEmits<{
|
||||
'update:modelValue': [value: string[]]
|
||||
}>()
|
||||
|
||||
// Replié par défaut, ouvre automatiquement quand des filtres sont actifs
|
||||
const manuallyOpen = ref(false)
|
||||
|
||||
const isOpen = computed(() => {
|
||||
return manuallyOpen.value || props.modelValue.length > 0
|
||||
})
|
||||
|
||||
function toggleCollapse() {
|
||||
// Si des filtres actifs forcent l'ouverture, on doit gérer le cas « forcer fermer »
|
||||
if (isOpen.value) {
|
||||
manuallyOpen.value = false
|
||||
// Si des fonctions sont actives, le computed va les réouvrir — on les efface
|
||||
// Non : on laisse le choix à l'utilisateur. On toggle juste manuallyOpen.
|
||||
// Quand replié avec filtres actifs, l'indicateur "(N actives)" reste visible.
|
||||
} else {
|
||||
manuallyOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(fn: string) {
|
||||
if (props.modelValue.includes(fn)) {
|
||||
emit('update:modelValue', props.modelValue.filter(f => f !== fn))
|
||||
@@ -65,3 +82,23 @@ function toggle(fn: string) {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--nav-text-muted);
|
||||
display: block;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
|
||||
887
components/GraphView.vue
Normal file
887
components/GraphView.vue
Normal file
@@ -0,0 +1,887 @@
|
||||
<template>
|
||||
<div class="graph-view" style="width: 100%; height: 100%; position: relative; background: var(--nav-bg);">
|
||||
<!-- Canvas SVG pour D3 (zone centrale, marge droite pour sidebar) -->
|
||||
<svg
|
||||
ref="svgRef"
|
||||
:style="{
|
||||
width: sidebarOpen ? 'calc(100% - 200px)' : 'calc(100% - 40px)',
|
||||
height: '100%',
|
||||
transition: 'width 0.2s ease',
|
||||
}"
|
||||
></svg>
|
||||
|
||||
<!-- Sidebar droite (repliable) - 3 sections : AFFICHER / HASHTAGS / MODE D'EMPLOI -->
|
||||
<aside
|
||||
:style="{
|
||||
position: 'absolute', top: '0', right: '0', bottom: '0',
|
||||
width: sidebarOpen ? '200px' : '40px',
|
||||
background: 'var(--nav-surface)',
|
||||
borderLeft: '1px solid var(--nav-bg-alt)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
transition: 'width 0.2s ease',
|
||||
zIndex: 10,
|
||||
}"
|
||||
>
|
||||
<!-- Toggle (toujours visible) -->
|
||||
<button
|
||||
@click="sidebarOpen = !sidebarOpen"
|
||||
:title="sidebarOpen ? 'Replier la sidebar' : 'Deplier la sidebar'"
|
||||
style="
|
||||
width: 100%; height: 36px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--nav-bg-alt); border: none; cursor: pointer;
|
||||
color: var(--nav-text-muted); font-size: 0.78rem; font-weight: 700;
|
||||
border-bottom: 1px solid var(--nav-bg-alt);
|
||||
"
|
||||
>{{ sidebarOpen ? '>>' : '<<' }}</button>
|
||||
|
||||
<!-- Mode replie : label vertical -->
|
||||
<div
|
||||
v-if="!sidebarOpen"
|
||||
style="
|
||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
writing-mode: vertical-rl; transform: rotate(180deg);
|
||||
font-size: 0.7rem; font-weight: 700; color: var(--nav-text-muted);
|
||||
letter-spacing: 0.12em; text-transform: uppercase;
|
||||
"
|
||||
>HASHTAGS ({{ activeHashtags.length }}/{{ props.allHashtags.length }})</div>
|
||||
|
||||
<!-- Mode deplie : 3 sections empilees -->
|
||||
<template v-if="sidebarOpen">
|
||||
<div style="flex: 1; overflow-y: auto; display: flex; flex-direction: column;">
|
||||
|
||||
<!-- SECTION 1 : AFFICHER (toggles familles / pratiques) -->
|
||||
<div style="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: 8px;">Afficher</div>
|
||||
<label
|
||||
:style="{
|
||||
display: 'flex', alignItems: 'center', gap: '8px',
|
||||
padding: '7px 10px', marginBottom: '4px',
|
||||
borderRadius: '6px', cursor: 'pointer',
|
||||
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',
|
||||
}"
|
||||
>
|
||||
<input type="checkbox" v-model="showFamilles" style="cursor: pointer; width: 14px; height: 14px;" />
|
||||
<span>Familles</span>
|
||||
</label>
|
||||
<label
|
||||
:style="{
|
||||
display: 'flex', alignItems: 'center', gap: '8px',
|
||||
padding: '7px 10px',
|
||||
borderRadius: '6px', cursor: 'pointer',
|
||||
fontSize: '0.82rem', fontWeight: 600,
|
||||
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>
|
||||
|
||||
<!-- 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>
|
||||
</template>
|
||||
</aside>
|
||||
|
||||
<!-- Tooltip -->
|
||||
<div ref="tooltipRef" style="
|
||||
position: absolute; pointer-events: none;
|
||||
background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);
|
||||
border-radius: 6px; padding: 8px 12px; font-size: 0.78rem;
|
||||
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;
|
||||
"></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 -></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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ReseauxBifurcationData } from '~/types/structure-v2'
|
||||
|
||||
const props = defineProps<{
|
||||
data: ReseauxBifurcationData | null
|
||||
allHashtags: string[]
|
||||
active?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'select-structure': [id: string]
|
||||
}>()
|
||||
|
||||
const svgRef = ref<SVGElement | null>(null)
|
||||
const tooltipRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Hashtag filter
|
||||
const activeHashtags = ref<string[]>([])
|
||||
const sidebarOpen = ref(true)
|
||||
|
||||
// Layers superposables (remplace viewMode exclusif PV2-5e)
|
||||
const showFamilles = ref(true)
|
||||
const showPratiques = ref(false)
|
||||
|
||||
function toggleHashtag(tag: string) {
|
||||
activeHashtags.value = activeHashtags.value.includes(tag)
|
||||
? activeHashtags.value.filter(t => t !== 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
|
||||
// En cas d'egalite : prendre la famille la plus petite (visibilite minoritaires)
|
||||
const tagToFamille = computed<Record<string, number>>(() => {
|
||||
if (!props.data) return {}
|
||||
const counts: Record<string, Record<number, number>> = {}
|
||||
props.data.structures.forEach(s => {
|
||||
s.hashtags.forEach(tag => {
|
||||
if (!counts[tag]) counts[tag] = {}
|
||||
counts[tag][s.famille_principale] = (counts[tag][s.famille_principale] ?? 0) + 1
|
||||
})
|
||||
})
|
||||
const familleSize: Record<number, number> = {}
|
||||
props.data.structures.forEach(s => {
|
||||
familleSize[s.famille_principale] = (familleSize[s.famille_principale] ?? 0) + 1
|
||||
})
|
||||
const out: Record<string, number> = {}
|
||||
for (const tag in counts) {
|
||||
const entries = Object.entries(counts[tag])
|
||||
entries.sort((a, b) => {
|
||||
const diff = (b[1] as number) - (a[1] as number)
|
||||
if (diff !== 0) return diff
|
||||
return (familleSize[Number(a[0])] ?? 0) - (familleSize[Number(b[0])] ?? 0)
|
||||
})
|
||||
out[tag] = Number(entries[0][0])
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const hashtagsByFamille = computed(() => {
|
||||
if (!props.data) return []
|
||||
const map = tagToFamille.value
|
||||
const groups: Record<number, string[]> = {}
|
||||
props.allHashtags.forEach(tag => {
|
||||
const fam = map[tag]
|
||||
if (fam == null) return
|
||||
if (!groups[fam]) groups[fam] = []
|
||||
groups[fam].push(tag)
|
||||
})
|
||||
return [1, 2, 3, 4, 5, 6]
|
||||
.filter(famId => groups[famId]?.length)
|
||||
.map(famId => ({
|
||||
famille: famId,
|
||||
label: FAMILLE_LABELS[famId],
|
||||
color: FAMILLE_COLORS[famId],
|
||||
tags: groups[famId].sort(),
|
||||
}))
|
||||
})
|
||||
|
||||
// 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
|
||||
const filteredStructureIds = computed(() => {
|
||||
if (!props.data || !activeHashtags.value.length) return null
|
||||
const ids = new Set(
|
||||
props.data.structures
|
||||
.filter(s => activeHashtags.value.every(h => s.hashtags.includes(h)))
|
||||
.map(s => s.id)
|
||||
)
|
||||
return ids
|
||||
})
|
||||
|
||||
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',
|
||||
2: 'Frugalite',
|
||||
3: 'Social',
|
||||
4: 'Collectifs',
|
||||
5: 'Urbanisme',
|
||||
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 d3NodeSelection: any = null
|
||||
let d3LinkSelection: any = null
|
||||
|
||||
async function initGraph() {
|
||||
if (!svgRef.value || !props.data) return
|
||||
|
||||
const d3 = await import('d3')
|
||||
|
||||
const svgEl = svgRef.value
|
||||
const width = svgEl.clientWidth || 800
|
||||
const height = svgEl.clientHeight || 600
|
||||
|
||||
// Nettoyer
|
||||
d3.select(svgEl).selectAll('*').remove()
|
||||
closePopover()
|
||||
|
||||
const svg = d3.select(svgEl)
|
||||
.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
|
||||
const g = svg.append('g')
|
||||
const zoomBehavior = d3.zoom<SVGElement, unknown>()
|
||||
.scaleExtent([0.2, 4])
|
||||
.on('zoom', (event) => {
|
||||
g.attr('transform', event.transform)
|
||||
closePopover()
|
||||
})
|
||||
|
||||
svg.call(zoomBehavior as any)
|
||||
|
||||
const { allNodes, links } = buildNodesLinks(width, height)
|
||||
|
||||
// Simulation force-directed
|
||||
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)
|
||||
.force('link', d3.forceLink(links).id((d: any) => d.id)
|
||||
.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('collision', d3.forceCollide().radius((d: any) => d.r + 4))
|
||||
|
||||
// Rendu liens
|
||||
d3LinkSelection = g.append('g').selectAll('line')
|
||||
.data(links)
|
||||
.join('line')
|
||||
.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-dasharray', null)
|
||||
|
||||
// Rendu noeuds (groupes g)
|
||||
d3NodeSelection = g.append('g').selectAll('g')
|
||||
.data(allNodes)
|
||||
.join('g')
|
||||
.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(
|
||||
d3.drag<any, any>()
|
||||
.on('start', (event: any, d: any) => {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart()
|
||||
d.fx = d.x
|
||||
d.fy = d.y
|
||||
closePopover()
|
||||
})
|
||||
.on('drag', (event: any, d: any) => {
|
||||
d.fx = event.x
|
||||
d.fy = event.y
|
||||
})
|
||||
.on('end', (event: any, d: any) => {
|
||||
if (!event.active) simulation.alphaTarget(0)
|
||||
if (d.type !== 'family') {
|
||||
d.fx = null
|
||||
d.fy = null
|
||||
}
|
||||
})
|
||||
)
|
||||
.on('click', (event: any, d: any) => {
|
||||
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
|
||||
d3NodeSelection.append('circle')
|
||||
.attr('r', (d: any) => d.r)
|
||||
.attr('fill', (d: any) => {
|
||||
if (d.type === 'family') return d.color
|
||||
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
|
||||
d3NodeSelection.filter((d: any) => d.type === 'family')
|
||||
.append('text')
|
||||
.text((d: any) => d.label)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dy', '0.35em')
|
||||
.attr('font-size', '11px')
|
||||
.attr('font-weight', '700')
|
||||
.attr('fill', 'white')
|
||||
.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')
|
||||
|
||||
// Labels structures : nom au-dessus du cercle, halo pour lisibilite
|
||||
d3NodeSelection.filter((d: any) => d.type === 'structure')
|
||||
.append('text')
|
||||
.attr('class', 'graph-struct-label')
|
||||
.text((d: any) => {
|
||||
const raw = d.label as string
|
||||
return raw.length > 22 ? raw.slice(0, 20) + '…' : raw
|
||||
})
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dy', (d: any) => -(d.r + 5))
|
||||
.attr('font-size', '9.5px')
|
||||
.attr('font-weight', '500')
|
||||
.style('pointer-events', 'none')
|
||||
|
||||
// Tooltip hover pour structures
|
||||
d3NodeSelection.filter((d: any) => d.type === 'structure')
|
||||
.on('mouseenter', (_event: any, d: any) => {
|
||||
if (!tooltipRef.value) return
|
||||
tooltipRef.value.style.opacity = '1'
|
||||
tooltipRef.value.innerHTML = `<strong>${d.label}</strong><br><span style="opacity:0.6;font-size:0.7rem;">${FAMILLE_LABELS[d.famille] ?? ''}</span>`
|
||||
})
|
||||
.on('mousemove', (event: any) => {
|
||||
if (!tooltipRef.value || !svgEl) return
|
||||
const rect = (svgEl as HTMLElement).getBoundingClientRect()
|
||||
tooltipRef.value.style.left = (event.clientX - rect.left + 12) + 'px'
|
||||
tooltipRef.value.style.top = (event.clientY - rect.top - 10) + 'px'
|
||||
})
|
||||
.on('mouseleave', () => {
|
||||
if (tooltipRef.value) tooltipRef.value.style.opacity = '0'
|
||||
})
|
||||
|
||||
// Tick - mise a jour positions
|
||||
simulation.on('tick', () => {
|
||||
d3LinkSelection
|
||||
.attr('x1', (d: any) => d.source.x)
|
||||
.attr('y1', (d: any) => d.source.y)
|
||||
.attr('x2', (d: any) => d.target.x)
|
||||
.attr('y2', (d: any) => d.target.y)
|
||||
|
||||
d3NodeSelection.attr('transform', (d: any) => `translate(${d.x},${d.y})`)
|
||||
|
||||
// Surlignage selon hashtags actifs
|
||||
applyHashtagFilter()
|
||||
})
|
||||
}
|
||||
|
||||
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() {
|
||||
if (!d3NodeSelection || !d3LinkSelection) return
|
||||
if (filteredStructureIds.value) {
|
||||
const ids = filteredStructureIds.value
|
||||
d3NodeSelection.filter((d: any) => d.type === 'structure').select('circle')
|
||||
.attr('opacity', (d: any) => ids.has(d.id) ? 1 : 0.1)
|
||||
d3LinkSelection.attr('opacity', (d: any) => {
|
||||
const srcId = typeof d.source === 'object' ? d.source.id : d.source
|
||||
const tgtId = typeof d.target === 'object' ? d.target.id : d.target
|
||||
return ids.has(srcId) || ids.has(tgtId) ? 1 : 0.05
|
||||
})
|
||||
} else {
|
||||
d3NodeSelection.select('circle').attr('opacity', 1)
|
||||
d3LinkSelection.attr('opacity', 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Declencher quand l'onglet devient visible
|
||||
watch(() => props.active, (val) => {
|
||||
if (val && import.meta.client && props.data) {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
|
||||
}
|
||||
})
|
||||
|
||||
// Relancer si les donnees arrivent apres l'activation
|
||||
watch(() => props.data, (val) => {
|
||||
if (val && props.active && import.meta.client) {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
|
||||
}
|
||||
})
|
||||
|
||||
// Re-appliquer le filtre visuel sans rebuild complet
|
||||
watch(activeHashtags, () => {
|
||||
applyHashtagFilter()
|
||||
if (simulation) simulation.alpha(0.01).restart()
|
||||
}, { 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
|
||||
watch(sidebarOpen, () => {
|
||||
if (!import.meta.client || !props.active || !props.data) return
|
||||
setTimeout(() => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
|
||||
}, 220)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (import.meta.client && props.data && props.active) {
|
||||
await nextTick()
|
||||
initGraph()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (simulation) simulation.stop()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Labels des structures dans le graphe (D3 injecte les <text>, donc style global) */
|
||||
.graph-view .graph-struct-label {
|
||||
fill: var(--nav-text);
|
||||
opacity: 0.7;
|
||||
paint-order: stroke;
|
||||
stroke: var(--nav-bg);
|
||||
stroke-width: 3px;
|
||||
stroke-linejoin: round;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
97
components/HashtagFilter.vue
Normal file
97
components/HashtagFilter.vue
Normal 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>
|
||||
76
components/IntentionBanner.vue
Normal file
76
components/IntentionBanner.vue
Normal 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 où 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>
|
||||
181
components/MissionPopup.vue
Normal file
181
components/MissionPopup.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="backdrop">
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="fixed inset-0 z-[1500]"
|
||||
style="background: rgba(26,34,56,0.55);"
|
||||
@click="close"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="mission-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="mission-title"
|
||||
@keydown.esc="close"
|
||||
>
|
||||
<button
|
||||
class="mission-close"
|
||||
type="button"
|
||||
@click="close"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<svg width="16" height="16" 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 class="mission-body">
|
||||
<h2 id="mission-title" class="mission-title">{{ title }}</h2>
|
||||
<slot>
|
||||
<p class="mission-text">
|
||||
L'architecture est l'une des professions les plus complexes qui soit ; elle croise droit, technique, esthétique, économie, social, écologie — tout à la fois, tout simultanément, souvent sans filet. Paradoxalement, c'est aussi l'une des moins structurées sur le plan de l'entraide : peu de transmission horizontale, beaucoup d'isolement, une culture du chacun-pour-soi héritée d'une formation qui prépare à la compétition plus qu'à la coopération. On sort de l'école seul·e. On s'installe seul·e. On réinvente ce que d'autres ont déjà traversé.
|
||||
</p>
|
||||
<p class="mission-text">
|
||||
Cette carte est née de cette frustration — et de cette conviction : les ressources existent, les gens qui ont réussi à sortir la tête de l'eau aussi. L'enjeu, c'est de les documenter, de les rendre accessibles, de les ajuster en temps réel grâce aux retours de la communauté. Pas un catalogue figé ; un commun vivant, au service de ceux et celles qui cherchent à faire évoluer leur pratique vers quelque chose de plus épanouissant, mieux rémunéré, au service de la société — et qui prend soin de la santé, la nôtre et celle des gens pour qui nous construisons.
|
||||
</p>
|
||||
</slot>
|
||||
|
||||
<div class="mission-cta-wrap">
|
||||
<button class="btn-explorer" type="button" @click="close">{{ ctaLabel }}</button>
|
||||
<NuxtLink to="/manifeste" class="link-manifeste" @click="close">Lire le manifeste →</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: boolean
|
||||
title?: string
|
||||
ctaLabel?: string
|
||||
storageKey?: string
|
||||
}>(), {
|
||||
title: "L'écosystème d'entraide architecte",
|
||||
ctaLabel: 'Explorer la carte',
|
||||
storageKey: 'aep_mission_seen',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
if (typeof window !== 'undefined') {
|
||||
try { localStorage.setItem(props.storageKey, '1') } catch {}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mission-modal {
|
||||
position: fixed;
|
||||
z-index: 1501;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(560px, 92vw);
|
||||
max-height: calc(100dvh - 80px);
|
||||
background: var(--nav-bg);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mission-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--nav-bg-alt);
|
||||
color: var(--nav-text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
z-index: 1;
|
||||
}
|
||||
.mission-close:hover { background: var(--nav-surface); }
|
||||
|
||||
.mission-body {
|
||||
padding: 1.75rem 1.5rem 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mission-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--nav-text);
|
||||
margin: 0 0 1rem;
|
||||
line-height: 1.25;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.mission-text,
|
||||
:slotted(.mission-text) {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.65;
|
||||
color: var(--nav-text);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
:slotted(.mission-text strong) { font-weight: 700; }
|
||||
:slotted(.mission-text a) { color: var(--nav-primary-solid); text-decoration: underline; }
|
||||
|
||||
.mission-cta-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-explorer {
|
||||
padding: 0.65rem 1.25rem;
|
||||
background: var(--nav-primary);
|
||||
color: var(--nav-text-on-primary);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-explorer:hover { opacity: 0.88; }
|
||||
|
||||
.link-manifeste {
|
||||
font-size: 0.875rem;
|
||||
color: var(--nav-primary-solid);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.link-manifeste:hover { opacity: 0.75; }
|
||||
|
||||
.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%, -48%); }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.mission-body { padding: 1.5rem 1.1rem 1.25rem; }
|
||||
.mission-title { font-size: 1.1rem; }
|
||||
.mission-text { font-size: 0.9rem; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.modal-enter-active, .modal-leave-active { transition: none; }
|
||||
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
|
||||
}
|
||||
</style>
|
||||
243
components/NavMapV2.vue
Normal file
243
components/NavMapV2.vue
Normal file
@@ -0,0 +1,243 @@
|
||||
<template>
|
||||
<div class="relative w-full h-full">
|
||||
<div ref="mapContainer" class="w-full h-full rounded-none" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Map, Marker, DivIcon } from 'leaflet'
|
||||
import type { StructureV2 } from '~/types/structure-v2'
|
||||
|
||||
// Couleurs par famille (synchronisées avec v2-bifurcation.css)
|
||||
const FAMILLE_COLORS: Record<number, string> = {
|
||||
1: '#a85d3e',
|
||||
2: '#c4a472',
|
||||
3: '#d4a017',
|
||||
4: '#5a7a4a',
|
||||
5: '#3d6a8c',
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
structures: StructureV2[]
|
||||
selectedId?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'select-structure': [id: string]
|
||||
}>()
|
||||
|
||||
const mapContainer = ref<HTMLElement | null>(null)
|
||||
let mapInstance: Map | null = null
|
||||
let clusterGroup: any = null
|
||||
const markers = new Map<string, Marker>()
|
||||
let tileLayerInstance: any = null
|
||||
|
||||
function getFamilleColor(famille: number): string {
|
||||
return FAMILLE_COLORS[famille] ?? '#888888'
|
||||
}
|
||||
|
||||
function createPinIcon(famille: number, isSelected = false): DivIcon {
|
||||
const L = (window as any).L
|
||||
const bg = getFamilleColor(famille)
|
||||
const size = isSelected ? 20 : 14
|
||||
const border = isSelected ? 'white' : 'rgba(255,255,255,0.7)'
|
||||
const shadow = isSelected ? `0 0 0 4px ${bg}55` : 'none'
|
||||
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="
|
||||
width: ${size}px;
|
||||
height: ${size}px;
|
||||
border-radius: 50%;
|
||||
background: ${bg};
|
||||
border: 2px solid ${border};
|
||||
box-shadow: ${shadow};
|
||||
transition: all 0.2s;
|
||||
"></div>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
popupAnchor: [0, -(size / 2 + 4)],
|
||||
})
|
||||
}
|
||||
|
||||
async function initMap() {
|
||||
if (!mapContainer.value) return
|
||||
|
||||
const Lmod = await import('leaflet')
|
||||
const L: any = (Lmod as any).default || Lmod
|
||||
await import('leaflet/dist/leaflet.css')
|
||||
// @ts-ignore
|
||||
await import('leaflet.markercluster/dist/MarkerCluster.css')
|
||||
// @ts-ignore
|
||||
await import('leaflet.markercluster/dist/MarkerCluster.Default.css')
|
||||
|
||||
// Installer L globalement AVANT le plugin
|
||||
;(window as any).L = L
|
||||
// @ts-ignore
|
||||
await import('leaflet.markercluster')
|
||||
const MarkerClusterGroup = L.MarkerClusterGroup
|
||||
|
||||
mapInstance = L.map(mapContainer.value, {
|
||||
center: [46.6, 2.3],
|
||||
zoom: 5,
|
||||
zoomControl: true,
|
||||
attributionControl: true,
|
||||
minZoom: 2,
|
||||
maxZoom: 18,
|
||||
})
|
||||
|
||||
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
||||
const tileUrl = isDark
|
||||
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||
|
||||
tileLayerInstance = L.tileLayer(tileUrl, {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
||||
maxZoom: 19,
|
||||
})
|
||||
tileLayerInstance.addTo(mapInstance!)
|
||||
|
||||
clusterGroup = new MarkerClusterGroup({
|
||||
disableClusteringAtZoom: 14,
|
||||
maxClusterRadius: 50,
|
||||
showCoverageOnHover: false,
|
||||
iconCreateFunction: (cluster: any) => {
|
||||
const count = cluster.getChildCount()
|
||||
return L.divIcon({
|
||||
html: `<div style="
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: var(--nav-primary);
|
||||
color: var(--nav-text-on-primary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: 13px;
|
||||
border: 2px solid white;
|
||||
font-family: var(--nav-font);
|
||||
">${count}</div>`,
|
||||
className: '',
|
||||
iconSize: [36, 36],
|
||||
iconAnchor: [18, 18],
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
mapInstance.addLayer(clusterGroup)
|
||||
updateMarkers(L)
|
||||
}
|
||||
|
||||
function updateMarkers(L?: any) {
|
||||
if (!mapInstance || !clusterGroup) return
|
||||
const leaflet = L || (window as any).L
|
||||
if (!leaflet) return
|
||||
|
||||
clusterGroup.clearLayers()
|
||||
markers.clear()
|
||||
|
||||
const structuresWithCoords = props.structures.filter(
|
||||
(s) => s.latitude != null && s.longitude != null
|
||||
)
|
||||
|
||||
structuresWithCoords.forEach((structure) => {
|
||||
const isSelected = structure.id === props.selectedId
|
||||
const icon = createPinIcon(structure.famille_principale, isSelected)
|
||||
|
||||
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(`
|
||||
<div style="font-family: var(--nav-font); min-width: 190px; padding: 4px 0;">
|
||||
<div style="font-weight: 700; color: var(--nav-text); margin-bottom: 2px; font-size: 0.9rem;">${structure.nom}</div>
|
||||
<div style="font-size: 11px; color: var(--nav-text-muted); margin-bottom: 4px;">${structure.type_principal} · ${structure.ville}, ${structure.pays}</div>
|
||||
${hashtagsHtml ? `<div style="margin-bottom: 6px;">${hashtagsHtml}</div>` : ''}
|
||||
<div style="font-size: 11px; color: var(--nav-text); line-height: 1.4; margin-bottom: 8px;">${structure.description_courte.slice(0, 100)}…</div>
|
||||
<button onclick="document.dispatchEvent(new CustomEvent('nav-v2-select', {detail:'${structure.id}'}))" style="
|
||||
font-size: 12px;
|
||||
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>
|
||||
`, { maxWidth: 260 })
|
||||
|
||||
marker.on('click', () => emit('select-structure', structure.id))
|
||||
|
||||
markers.set(structure.id, marker)
|
||||
clusterGroup.addLayer(marker)
|
||||
})
|
||||
}
|
||||
|
||||
// Ecouter l'event custom depuis les popups Leaflet
|
||||
function onNavV2Select(e: CustomEvent) {
|
||||
emit('select-structure', e.detail)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.structures,
|
||||
() => updateMarkers(),
|
||||
{ deep: false }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.selectedId,
|
||||
(newId, oldId) => {
|
||||
if (!mapInstance) return
|
||||
const leaflet = (window as any).L
|
||||
if (!leaflet) return
|
||||
|
||||
if (oldId != null) {
|
||||
const oldMarker = markers.get(oldId)
|
||||
const oldStructure = props.structures.find(s => s.id === oldId)
|
||||
if (oldMarker && oldStructure) {
|
||||
oldMarker.setIcon(createPinIcon(oldStructure.famille_principale, false))
|
||||
}
|
||||
}
|
||||
if (newId != null) {
|
||||
const newMarker = markers.get(newId)
|
||||
const newStructure = props.structures.find(s => s.id === newId)
|
||||
if (newMarker && newStructure) {
|
||||
newMarker.setIcon(createPinIcon(newStructure.famille_principale, true))
|
||||
const latLng = newMarker.getLatLng()
|
||||
mapInstance.panTo(latLng, { animate: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function updateTileTheme(dark: boolean) {
|
||||
if (!mapInstance || !tileLayerInstance) return
|
||||
const L = (window as any).L
|
||||
if (!L) return
|
||||
const url = dark
|
||||
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||
tileLayerInstance.setUrl(url)
|
||||
}
|
||||
|
||||
let themeObserver: MutationObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
initMap()
|
||||
document.addEventListener('nav-v2-select', onNavV2Select as EventListener)
|
||||
|
||||
themeObserver = new MutationObserver(() => {
|
||||
const dark = document.documentElement.classList.contains('dark')
|
||||
updateTileTheme(dark)
|
||||
})
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('nav-v2-select', onNavV2Select as EventListener)
|
||||
themeObserver?.disconnect()
|
||||
if (mapInstance) {
|
||||
mapInstance.remove()
|
||||
mapInstance = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
36
composables/useMarkdown.ts
Normal file
36
composables/useMarkdown.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Convertit du Markdown Mistral en HTML avec inline styles.
|
||||
* Inline styles = zéro dépendance CSS, fonctionne dans tout contexte Vue (scoped, v-html, etc.)
|
||||
*/
|
||||
export function useMarkdown() {
|
||||
const S = {
|
||||
p: 'style="margin:0 0 0.45em;line-height:1.6;"',
|
||||
strong: 'style="font-weight:700;"',
|
||||
em: 'style="font-style:italic;"',
|
||||
h2: 'style="font-weight:700;display:block;margin-bottom:0.2em;"',
|
||||
h3: 'style="font-weight:700;display:block;font-size:0.95em;margin-bottom:0.15em;"',
|
||||
ul: 'style="margin:0.3em 0 0.3em 1.2em;padding:0;list-style:disc;"',
|
||||
li: 'style="margin-bottom:0.15em;"',
|
||||
a: 'style="text-decoration:underline;opacity:0.85;"',
|
||||
}
|
||||
|
||||
function render(text: string): string {
|
||||
if (!text) return ''
|
||||
let html = text
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/^### (.+)$/gm, `<strong ${S.h3}>$1</strong>`)
|
||||
.replace(/^## (.+)$/gm, `<strong ${S.h2}>$1</strong>`)
|
||||
.replace(/^# (.+)$/gm, `<strong ${S.h2}>$1</strong>`)
|
||||
.replace(/\*\*(.+?)\*\*/g, `<strong ${S.strong}>$1</strong>`)
|
||||
.replace(/\*(.+?)\*/g, `<em ${S.em}>$1</em>`)
|
||||
.replace(/^[-•]\s+(.+)$/gm, `<li ${S.li}>$1</li>`)
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, `<a href="$2" target="_blank" rel="noopener" ${S.a}>$1</a>`)
|
||||
|
||||
html = html.replace(/(<li[^>]*>.*<\/li>\n?)+/g, m => `<ul ${S.ul}>${m}</ul>`)
|
||||
html = html.replace(/\n{2,}/g, `</p><p ${S.p}>`)
|
||||
html = html.replace(/\n/g, '<br>')
|
||||
return `<p ${S.p}>${html}</p>`
|
||||
}
|
||||
|
||||
return { render }
|
||||
}
|
||||
@@ -30,9 +30,9 @@ LOCAL_ENV_CONTENT=$(cat "$LOCAL_ENV" 2>/dev/null || echo "")
|
||||
if [ "$LOCAL_ENV_CONTENT" != "$REMOTE_ENV_CONTENT" ]; then
|
||||
log "AVERTISSEMENT : .env.production local != .env VPS"
|
||||
log " --- Local ---"
|
||||
echo "$LOCAL_ENV_CONTENT" | sed 's/TOKEN=.*/TOKEN=***/' | sed 's/^/ /'
|
||||
echo "$LOCAL_ENV_CONTENT" | sed -E 's/(TOKEN|API_KEY|PASSWORD|SECRET)=.*$/\1=***/' | sed 's/^/ /'
|
||||
log " --- VPS ---"
|
||||
echo "$REMOTE_ENV_CONTENT" | sed 's/TOKEN=.*/TOKEN=***/' | sed 's/^/ /'
|
||||
echo "$REMOTE_ENV_CONTENT" | sed -E 's/(TOKEN|API_KEY|PASSWORD|SECRET)=.*$/\1=***/' | sed 's/^/ /'
|
||||
read -p "Continuer malgre la difference ? [y/N] " CONFIRM
|
||||
[ "$CONFIRM" = "y" ] || { log "Deploiement annule."; exit 1; }
|
||||
fi
|
||||
|
||||
@@ -16,6 +16,7 @@ export default defineNuxtConfig({
|
||||
commentTableId: process.env.COMMENT_TABLE_ID || process.env.AVIS_TABLE_ID,
|
||||
statsTableId: process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc',
|
||||
mistralApiKey: process.env.MISTRAL_API_KEY,
|
||||
nebiusApiKey: process.env.NEBIUS_API_KEY,
|
||||
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
|
||||
resendApiKey: process.env.RESEND_API_KEY,
|
||||
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
|
||||
@@ -23,6 +24,7 @@ export default defineNuxtConfig({
|
||||
codevPassword: 'merci', // NUXT_CODEV_PASSWORD - défaut "merci", overridable
|
||||
codevBaseId: '', // NUXT_CODEV_BASE_ID - base NocoDB (ex: pipilvsi7dibo80)
|
||||
codevAdminPassword: 'admin2026', // NUXT_CODEV_ADMIN_PASSWORD
|
||||
ragPeUrl: process.env.NUXT_RAG_PE_URL || 'http://localhost:9621',
|
||||
},
|
||||
|
||||
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client
|
||||
|
||||
@@ -8,16 +8,12 @@
|
||||
</NuxtLink>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
SECTION 1 - Mission AEP
|
||||
SECTION INTRO - À propos d'AEP
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<!-- TODO Jules : Écrire le pitch (~100 mots) - qui est AEP, pour qui, pourquoi, quelle promesse -->
|
||||
<section class="section-mission">
|
||||
<h1>Architecture d'Écologie Politique</h1>
|
||||
<h1>À propos d'AEP</h1>
|
||||
<p class="mission-text">
|
||||
L'architecture est l'une des professions les plus complexes qui soit ; elle croise droit, technique, esthétique, économie, social, écologie - tout à la fois, tout simultanément, souvent sans filet. Paradoxalement, c'est aussi l'une des moins structurées sur le plan de l'entraide : peu de transmission horizontale, beaucoup d'isolement, une culture du chacun-pour-soi héritée d'une formation qui prépare à la compétition plus qu'à la coopération. On sort de l'école seul.e. On s'installe seul.e. On réinvente ce que d'autres ont déjà traversé.
|
||||
</p>
|
||||
<p class="mission-text">
|
||||
Cette carte est née de cette frustration - et de cette conviction : les ressources existent, les gens qui ont réussi à sortir la tête de l'eau aussi. L'enjeu, c'est de les documenter, de les rendre accessibles, de les ajuster en temps réel grâce aux retours de la communauté. Pas un catalogue figé ; un commun vivant, au service de ceux et celles qui cherchent à faire évoluer leur pratique vers quelque chose de plus épanouissant, mieux rémunéré, au service de la société - et qui prend soin de la santé, la nôtre et celle des gens pour qui nous construisons.
|
||||
AEP — Architecture d'Écologie Politique — est un commun vivant : une infrastructure d'entraide, de ressources documentées et de cartographies au service d'une profession en mutation. Ce site rassemble trois cartes (entraide, réseaux engagés, plateformes de mise en relation), un manifeste, une transparence radicale sur l'IA et le financement, et une gouvernance partagée.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -209,11 +205,14 @@ useHead({ title: 'À propos - AEP' })
|
||||
min-height: 100vh;
|
||||
background: var(--nav-bg);
|
||||
padding: 1.5rem 1rem 5rem;
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.apropos-inner {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ── Retour ──────────────────────────────────────────────────────────────────── */
|
||||
@@ -322,13 +321,16 @@ useHead({ title: 'À propos - AEP' })
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--nav-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-detail {
|
||||
font-size: 0.775rem;
|
||||
color: var(--nav-text-muted);
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (min-width: 560px) {
|
||||
.badge-label { white-space: nowrap; }
|
||||
}
|
||||
|
||||
@media (max-width: 559px) {
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + sheet swipable ── -->
|
||||
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer/Graphique + sheet swipable ── -->
|
||||
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
||||
<button
|
||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||
@@ -212,6 +212,13 @@
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="mobileMapView = 'outremer'"
|
||||
>Outre-mer</button>
|
||||
<button
|
||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||
:style="mobileMapView === 'graphe'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="mobileMapView = 'graphe'"
|
||||
>Graphe</button>
|
||||
</div>
|
||||
|
||||
<div class="lg:hidden flex-1 relative overflow-hidden">
|
||||
@@ -248,8 +255,25 @@
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<!-- Bottom sheet swipable -->
|
||||
<!-- Vue graphique mobile -->
|
||||
<div v-show="mobileMapView === 'graphe'" class="absolute inset-0 overflow-hidden" style="background: var(--nav-bg);">
|
||||
<ClientOnly>
|
||||
<GraphView
|
||||
:data="bifurcationData"
|
||||
:allHashtags="allHashtags"
|
||||
:active="mobileMapView === 'graphe'"
|
||||
@select-structure="onSelectStructureMobile"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
|
||||
Chargement du graphe…
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<!-- Bottom sheet swipable (masqué en vue graphique pour ne pas occulter le canvas) -->
|
||||
<ClientOnly v-if="mobileMapView !== 'graphe'">
|
||||
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
||||
<!-- Bandeau intention mobile -->
|
||||
<div class="px-3 py-2" style="background: var(--bifurc-banner-bg, #faf8f5); border-bottom: 1px solid var(--bifurc-banner-border, #e0d8cc);">
|
||||
@@ -374,12 +398,46 @@
|
||||
</button>
|
||||
|
||||
<!-- ═══════════════════════════════════════ CHATBOT BOTTOM SHEET (mobile) -->
|
||||
<ChatbotSheet
|
||||
<ChatbotReseaux
|
||||
:modelValue="chatbotOpen"
|
||||
@update:modelValue="chatbotOpen = $event"
|
||||
@highlightOrgs="() => {}"
|
||||
/>
|
||||
|
||||
<!-- ═══════════════════════════════════════ CHATBOT PENSEES (desktop, tous onglets) -->
|
||||
<ClientOnly>
|
||||
<ChatbotPensees />
|
||||
</ClientOnly>
|
||||
|
||||
<!-- ═══════════════════════════════════════ POP-UP MISSION RÉSEAUX AEP -->
|
||||
<button
|
||||
class="reseaux-info-btn"
|
||||
type="button"
|
||||
@click="missionOpen = true"
|
||||
aria-label="À propos des réseaux AEP cartographiés"
|
||||
title="À propos de cette carte"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<MissionPopup
|
||||
:modelValue="missionOpen"
|
||||
@update:modelValue="missionOpen = $event"
|
||||
title="Réseaux AEP — l'architecture qui s'engage"
|
||||
ctaLabel="Explorer les 120 réseaux"
|
||||
storageKey="aep_reseaux_seen"
|
||||
>
|
||||
<p class="mission-text">
|
||||
Cette carte rassemble <strong>120 réseaux, collectifs et agences</strong> qui pratiquent une architecture engagée — écologique, politique, biorégionale. Ce ne sont pas seulement des agences « vertes » : ce sont celles et ceux qui assument des positions, refusent des projets, expérimentent des modèles de gouvernance, mettent leurs ressources et leurs savoirs en commun.
|
||||
</p>
|
||||
<p class="mission-text">
|
||||
Six familles structurent la cartographie : militants, agences engagées, collectifs de production, ressources communes, recherche, formations alternatives. Filtre par hashtag, ouvre la fiche d'une structure, navigue le graphe (3<sup>e</sup> onglet) pour voir les affinités. Si tu animes ou connais un réseau qui devrait y être : <NuxtLink to="/contribuer" @click.stop>propose-le</NuxtLink>.
|
||||
</p>
|
||||
</MissionPopup>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -406,8 +464,17 @@ const hoveredId = ref<string | null>(null)
|
||||
const ficheModalOpen = ref(false)
|
||||
const ficheModalId = ref<string | null>(null)
|
||||
const chatbotOpen = ref(false)
|
||||
const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
|
||||
const mobileMapView = ref<'metropole' | 'outremer' | 'graphe'>('metropole')
|
||||
const desktopMapView = ref<'metropole' | 'outremer' | 'graphe'>('metropole')
|
||||
const missionOpen = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
if (!localStorage.getItem('aep_reseaux_seen')) {
|
||||
missionOpen.value = true
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
|
||||
// Filtres
|
||||
const search = ref('')
|
||||
@@ -515,3 +582,29 @@ function onSelectStructureMobile(id: string) {
|
||||
|
||||
useHead({ title: "AEP - Réseaux de bifurcation architecturale" })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.reseaux-info-btn {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 16px;
|
||||
z-index: 1000;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--nav-surface);
|
||||
color: var(--nav-text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 12px rgba(26,34,56,0.18);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
}
|
||||
.reseaux-info-btn:hover { opacity: 0.85; transform: translateY(-1px); color: var(--nav-text); }
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.reseaux-info-btn { bottom: 16px; left: 340px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -266,6 +266,29 @@ function setMode(newMode: typeof mode.value) {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* ── Tabs ── */
|
||||
|
||||
.codev-tabs { display: flex; gap: 4px; background: #f3f4f6; border-radius: 10px; padding: 4px; }
|
||||
.codev-tabs button { flex: 1; padding: 8px 4px; border: none; border-radius: 7px; background: transparent; font-size: 0.875rem; font-weight: 500; cursor: pointer; color: #6b7280; transition: all 0.15s; }
|
||||
.codev-tabs button.active { background: white; color: #1a1a2e; font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
|
||||
/* ── Annuaire ── */
|
||||
|
||||
.annuaire-wrap { display: flex; flex-direction: column; gap: 8px; flex: 1; }
|
||||
.annuaire-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; border: 1px solid #e5e7eb; border-radius: 10px; }
|
||||
.annuaire-table { width: 100%; border-collapse: collapse; min-width: 480px; }
|
||||
.annuaire-table thead tr { background: #f9fafb; border-bottom: 2px solid #e5e7eb; }
|
||||
.annuaire-table th { padding: 10px 14px; text-align: left; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; white-space: nowrap; }
|
||||
.annuaire-table td { padding: 12px 14px; font-size: 0.875rem; color: #374151; vertical-align: top; border-bottom: 1px solid #f3f4f6; line-height: 1.5; }
|
||||
.annuaire-row { transition: background 0.12s; }
|
||||
.annuaire-row:hover { background: #f9fafb; }
|
||||
.annuaire-row:last-child td { border-bottom: none; }
|
||||
.col-nom { position: sticky; left: 0; z-index: 2; background: #ffffff; font-weight: 600; color: #1a1a2e !important; white-space: nowrap; min-width: 80px; border-right: 2px solid #e5e7eb; box-shadow: 2px 0 6px rgba(0,0,0,0.06); }
|
||||
.annuaire-row:hover .col-nom { background: #f9fafb; }
|
||||
thead tr .col-nom { background: #f9fafb; z-index: 3; }
|
||||
.col-besoin { min-width: 200px; max-width: 260px; }
|
||||
.col-offre { min-width: 200px; max-width: 260px; }
|
||||
|
||||
/* ── Bandeau mode actif ── */
|
||||
|
||||
.mode-banner {
|
||||
|
||||
@@ -201,10 +201,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtres FONCTION — chips flex-wrap -->
|
||||
<!-- Filtres FONCTION — chips flex-wrap + toggle collapse -->
|
||||
<div class="mt-2">
|
||||
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">FONCTION</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span class="text-xs font-bold uppercase tracking-wide" style="color: var(--nav-text-muted);">
|
||||
FONCTION
|
||||
<span v-if="fonctions.length" style="font-weight: 400; text-transform: none; letter-spacing: 0; font-size: 0.65rem; margin-left: 4px;">({{ fonctions.length }} active{{ fonctions.length > 1 ? 's' : '' }})</span>
|
||||
</span>
|
||||
<button
|
||||
@click="mobileFonctionsOpen = !mobileFonctionsOpen"
|
||||
style="font-size: 0.65rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0; white-space: nowrap;"
|
||||
>{{ mobileFonctionsOpen || fonctions.length ? (mobileFonctionsOpen ? 'Replier' : 'Afficher') : 'Fonctions (' + FONCTIONS.length + ')' }}</button>
|
||||
</div>
|
||||
<div v-if="mobileFonctionsOpen || fonctions.length" class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="fn in FONCTIONS"
|
||||
:key="fn"
|
||||
@@ -312,6 +321,26 @@
|
||||
@highlightOrgs="onHighlightOrgs"
|
||||
/>
|
||||
|
||||
<!-- ═══════════════════════════════════════ POP-UP MISSION ENTRAIDE -->
|
||||
<button
|
||||
class="mission-info-btn"
|
||||
type="button"
|
||||
@click="missionOpen = true"
|
||||
aria-label="À propos de cette carte d'entraide"
|
||||
title="À propos de cette carte"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<MissionPopup
|
||||
:modelValue="missionOpen"
|
||||
@update:modelValue="missionOpen = $event"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -344,6 +373,16 @@ const chatbotOpen = ref(false)
|
||||
const ficheModalOpen = ref(false)
|
||||
const ficheModalId = ref<number | null>(null)
|
||||
const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
|
||||
const missionOpen = ref(false)
|
||||
const mobileFonctionsOpen = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
if (!localStorage.getItem('aep_mission_seen')) {
|
||||
missionOpen.value = true
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
// Surlignage temporaire (5 sec) suite à une réponse chatbot
|
||||
// → sélectionne le premier ID recommandé sur la carte, puis remet à null
|
||||
let highlightTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -575,3 +614,29 @@ function fonctionsList(org: Org): string[] {
|
||||
|
||||
useHead({ title: 'AEP — Cartographie de l\'écologie politique architecturale' })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mission-info-btn {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 16px;
|
||||
z-index: 1000;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--nav-surface);
|
||||
color: var(--nav-text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 12px rgba(26,34,56,0.18);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
}
|
||||
.mission-info-btn:hover { opacity: 0.85; transform: translateY(-1px); color: var(--nav-text); }
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.mission-info-btn { bottom: 16px; left: 340px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
239
pages/manifeste.vue
Normal file
239
pages/manifeste.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<template>
|
||||
<div class="manifeste-page">
|
||||
<div class="manifeste-inner">
|
||||
|
||||
<NuxtLink to="/" class="back-link">← Retour à la carte</NuxtLink>
|
||||
|
||||
<h1 class="manifeste-title">Manifeste — Architecture d'Écologie Politique</h1>
|
||||
|
||||
<p class="lede">
|
||||
<em>Un quart des architectes vivent sous le seuil de pauvreté. La moitié de nos heures, non facturées. Nos cotisations, parmi les plus lourdes des professions réglementées. Et le secteur du bâtiment, à lui seul, pèse 34 % des émissions mondiales de gaz à effet de serre.</em>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Quelque chose s'est rompu — pas dans nos vies, dans les cadres qui les contiennent.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Notre profession ne traverse pas une simple crise. Elle reflète l'effondrement d'un monde qui confond performance et destruction, signature et silence, expertise et soumission.
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<h2>Ce que nous voyons.</h2>
|
||||
|
||||
<p>
|
||||
À l'échelle du métier, une profession structurellement sous l'eau, qui absorbe les tensions d'un système extractiviste — et porte la responsabilité quand d'autres captent la valeur.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
À l'échelle des corps, une culture qui rend l'exploitation désirable : métier-passion, modèle starchitecte, isolement libéral, moteur critique délégitimant. Nous tenons. Nous payons.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
À l'échelle du monde, l'effondrement écologique et social qui avance, pendant que notre voix s'efface du débat public. Notre silence le sert.
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<h2>Ce que nous refusons.</h2>
|
||||
|
||||
<p class="refus">
|
||||
Nous ne signerons plus pour des projets qui détruisent.<br />
|
||||
Nous n'isolerons plus celles et ceux qui doutent.<br />
|
||||
Nous ne porterons plus seul·es ce qui doit se penser, se faire — et se soigner — ensemble.
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<p class="pivot">
|
||||
<strong>Et pourtant, quelque chose tient.</strong>
|
||||
</p>
|
||||
|
||||
<p class="pivot-suite">
|
||||
Pas l'espoir naïf, ni la promesse héroïque. Quelque chose de plus humble : la fatigue commune reconnue, et l'envie qui revient de ne plus économiser sa vie.
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<h2>Ce que nous tentons.</h2>
|
||||
|
||||
<p>
|
||||
<em>Partager.</em> Nos parcours, nos doutes, nos bifurcations. Se former les un·es les autres. Se tendre la main. Documenter ce qui marche, ce qui rate. Le personnel devient politique quand il se met en commun.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<em>Construire.</em> L'infrastructure collective qui nous a manqué. Cartes d'entraide, communs documentés, gouvernance horizontale, financement transparent, infra souveraine. <strong>Architecture d'Écologie Politique</strong> : un commun vivant, ouvert, biorégional, ancré.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<em>Pratiquer une médecine du corps social.</em> Diagnostiquer les infrastructures qui défaillent — l'éducation, la justice, la sécurité, l'énergie, la santé, le logement, l'agriculture. Proposer des reconfigurations situées, territoire par territoire. Reprendre le pouvoir par la base. Écrire, lentement, un nouveau contrat social.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<em>Commencer par les marges.</em> Là où le corps social souffre le plus, là où il est le plus prêt à changer. Ne pas décider à la place — faire émerger. Transparence totale, sur le process et sur l'argent. Tendresse militante : la lucidité sans le mépris, l'engagement sans la dureté.
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<h2>Architectes, allié·es, habitant·es.</h2>
|
||||
|
||||
<p>
|
||||
Nous avons un travail à faire ensemble. Lentement, patiemment, par accumulation de petits gestes situés. Pas pour fuir — pour bifurquer.
|
||||
</p>
|
||||
|
||||
<p class="chute">
|
||||
<em>Nos métiers sont des médecines. Reprenons-en le pouls — à mains nues, ensemble.</em>
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<p class="cta-wrap">
|
||||
<a
|
||||
href="https://www.trans-former.fr/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn-blog"
|
||||
>
|
||||
En lire plus — blog AEP →
|
||||
</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({
|
||||
title: 'Manifeste — AEP',
|
||||
meta: [
|
||||
{ name: 'description', content: 'Manifeste d\'Architecture d\'Écologie Politique — un commun vivant pour bifurquer ensemble.' },
|
||||
],
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.manifeste-page {
|
||||
min-height: 100vh;
|
||||
background: var(--nav-bg);
|
||||
padding: 1.5rem 1rem 5rem;
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.manifeste-inner {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.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: 2rem;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.back-link:hover { opacity: 1; }
|
||||
|
||||
.manifeste-title {
|
||||
font-size: 1.65rem;
|
||||
font-weight: 700;
|
||||
color: var(--nav-text);
|
||||
margin: 0 0 1.5rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.lede {
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
color: var(--nav-text);
|
||||
margin: 0 0 1.25rem;
|
||||
border-left: 3px solid var(--nav-primary-solid);
|
||||
padding-left: 1rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.975rem;
|
||||
line-height: 1.75;
|
||||
color: var(--nav-text);
|
||||
margin: 0 0 1.1rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--nav-text);
|
||||
margin: 2rem 0 1rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--nav-bg-alt);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.refus {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.pivot {
|
||||
font-size: 1.15rem;
|
||||
text-align: center;
|
||||
margin: 2rem 0 1rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.pivot strong {
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.pivot-suite {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.chute {
|
||||
font-size: 1.05rem;
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
color: var(--nav-text);
|
||||
}
|
||||
|
||||
.cta-wrap {
|
||||
text-align: center;
|
||||
margin: 2rem 0 0;
|
||||
}
|
||||
|
||||
.btn-blog {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--nav-primary);
|
||||
color: var(--nav-text-on-primary);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.btn-blog:hover { opacity: 0.85; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.manifeste-page { padding: 1rem 0.85rem 4rem; }
|
||||
.manifeste-title { font-size: 1.4rem; }
|
||||
.lede { font-size: 0.95rem; padding-left: 0.85rem; }
|
||||
p { font-size: 0.95rem; }
|
||||
.pivot { font-size: 1.05rem; }
|
||||
}
|
||||
</style>
|
||||
590
pages/media.vue
Normal file
590
pages/media.vue
Normal file
@@ -0,0 +1,590 @@
|
||||
<template>
|
||||
<div class="media-page" style="background: var(--nav-bg);">
|
||||
|
||||
<!-- ZONE PRINCIPALE (pleine largeur, pas de sidebar) -->
|
||||
<main class="media-main">
|
||||
|
||||
<!-- Header onglet -->
|
||||
<div class="shrink-0 px-5 py-3"
|
||||
style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt); display:flex; align-items:center; justify-content:space-between; gap:12px;">
|
||||
<div>
|
||||
<h1 class="font-bold text-base" style="color: var(--nav-text);">ATIS Media</h1>
|
||||
<p class="text-xs mt-0.5" style="color: var(--nav-text-muted);">
|
||||
{{ corpusCount }} auteurs / {{ livresCount }} livres ingeres dans le RAG -
|
||||
<a href="https://bonpote.com/la-carte-des-pensees-ecologiques/"
|
||||
target="_blank" rel="noopener"
|
||||
style="color: var(--nav-primary, #3b6ea5); text-decoration: underline; text-underline-offset: 2px;">
|
||||
carte FRACAS Bonpote V2
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="ragInfoOpen = true"
|
||||
title="A propos du RAG FRACAS"
|
||||
style="width:26px;height:26px;border-radius:50%;border:1.5px solid var(--nav-text-muted);color:var(--nav-text-muted);font-size:0.72rem;font-weight:700;cursor:pointer;flex-shrink:0;background:var(--nav-bg-alt);display:flex;align-items:center;justify-content:center;"
|
||||
aria-label="A propos du RAG">
|
||||
i
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Conteneur split / plein ecran -->
|
||||
<div class="layout-container">
|
||||
|
||||
<!-- Slot carte D3 -->
|
||||
<div
|
||||
class="carte-slot"
|
||||
:class="[
|
||||
layoutMode === 'split' ? 'carte-split' : '',
|
||||
layoutMode === 'carte-full' ? 'carte-full' : '',
|
||||
layoutMode === 'chatbot-full' ? 'carte-hidden' : '',
|
||||
]"
|
||||
:style="layoutMode === 'split' ? { flexBasis: carteFlexBasis } : {}"
|
||||
>
|
||||
<ClientOnly>
|
||||
<CartePensees
|
||||
ref="cartePenseesRef"
|
||||
:data="penseesData"
|
||||
:active="true"
|
||||
@select-auteur="onSelectAuteur"
|
||||
@select-ecole="onSelectEcole"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div class="w-full h-full flex items-center justify-center" style="color: var(--nav-text-muted);">
|
||||
Chargement de la carte...
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<!-- Barre de toggle -->
|
||||
<div class="layout-toggle-bar shrink-0">
|
||||
<button
|
||||
@click="setLayoutMode('carte-full')"
|
||||
:class="{ active: layoutMode === 'carte-full' }"
|
||||
class="toggle-btn"
|
||||
title="Carte en plein ecran"
|
||||
>
|
||||
<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">
|
||||
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
|
||||
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
|
||||
</svg>
|
||||
Carte plein ecran
|
||||
</button>
|
||||
<button
|
||||
v-if="layoutMode !== 'split'"
|
||||
@click="setLayoutMode('split')"
|
||||
class="toggle-btn"
|
||||
title="Vue partagee"
|
||||
>
|
||||
<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">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="12" x2="21" y2="12"/>
|
||||
</svg>
|
||||
Vue partagee
|
||||
</button>
|
||||
<button
|
||||
@click="setLayoutMode('chatbot-full')"
|
||||
:class="{ active: layoutMode === 'chatbot-full' }"
|
||||
class="toggle-btn"
|
||||
title="Chatbot en plein ecran"
|
||||
>
|
||||
<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">
|
||||
<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>
|
||||
Chatbot plein ecran
|
||||
</button>
|
||||
<button
|
||||
@click="setLayoutMode('bonpote')"
|
||||
:class="{ active: layoutMode === 'bonpote' }"
|
||||
class="toggle-btn"
|
||||
title="A propos de la carte FRACAS Bonpote V2"
|
||||
style="margin-left: auto;"
|
||||
>
|
||||
<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">
|
||||
<circle cx="12" cy="12" r="10"/><polyline points="12 8 12 12 14 14"/>
|
||||
</svg>
|
||||
Bonpote V2
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Poignee draggable (visible uniquement en mode split, pas sur mobile) -->
|
||||
<div
|
||||
v-if="layoutMode === 'split'"
|
||||
class="split-handle"
|
||||
@mousedown.prevent="onHandleMousedown"
|
||||
title="Redimensionner"
|
||||
>
|
||||
<span class="split-handle-grip"></span>
|
||||
</div>
|
||||
|
||||
<!-- Slot chatbot inline -->
|
||||
<div
|
||||
class="chatbot-slot"
|
||||
:class="[
|
||||
layoutMode === 'split' ? 'chatbot-split' : '',
|
||||
layoutMode === 'chatbot-full' ? 'chatbot-full-mode' : '',
|
||||
layoutMode === 'carte-full' ? 'chatbot-hidden' : '',
|
||||
]"
|
||||
:style="layoutMode === 'split' ? { flexBasis: chatbotFlexBasis } : {}"
|
||||
>
|
||||
<ClientOnly>
|
||||
<ChatbotPensees :auteurContext="chatbotAuteur" :inline="true" />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<!-- Vue Bonpote V2 -->
|
||||
<div
|
||||
v-if="layoutMode === 'bonpote'"
|
||||
class="flex-1 overflow-y-auto px-6 py-8"
|
||||
style="max-width: 680px; margin: 0 auto;"
|
||||
>
|
||||
<div class="mb-6">
|
||||
<p class="text-xs font-bold uppercase tracking-widest mb-2" style="color: var(--nav-text-muted);">Reference editoriale</p>
|
||||
<h2 class="text-xl font-bold mb-3" style="color: var(--nav-text);">Carte FRACAS des pensees ecologiques</h2>
|
||||
<p class="text-sm leading-relaxed mb-4" style="color: var(--nav-text);">
|
||||
FRACAS (Familles, Racines et Arpentages des Courants et Alternatives Solidaires) est une carte des ecoles de pensee ecologique publiee par Bonpote en octobre 2024. Elle reference ~140 auteurs et autrices reparti-es en 10 ecoles de pensee, depuis l'ecosocialisme jusqu'a l'ethique environnementale.
|
||||
</p>
|
||||
<p class="text-sm leading-relaxed mb-6" style="color: var(--nav-text);">
|
||||
Le RAG ATIS est construit sur cette reference : chaque auteur ingere dans la bibliotheque correspond a une entree de la carte FRACAS. Les ecoles de pensee, les positions et les couleurs de notre carte sont transposees 1:1 depuis Bonpote V2.
|
||||
</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<a href="https://bonpote.com/la-carte-des-pensees-ecologiques/"
|
||||
target="_blank" rel="noopener"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:opacity-80 transition-opacity"
|
||||
style="background: var(--nav-primary, #3b6ea5); color: white; font-size: 0.875rem; font-weight: 600; text-decoration: none;">
|
||||
<svg width="16" height="16" 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>
|
||||
Lire l'article Bonpote + carte interactive
|
||||
</a>
|
||||
<a href="https://bonpote.com/wp-content/uploads/2024/10/FRACAS_BONPOTE_CARTE_VERSO_V2-OCT2024.pdf"
|
||||
target="_blank" rel="noopener"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:opacity-80 transition-opacity"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text); font-size: 0.875rem; font-weight: 500; text-decoration: none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
Telecharger le poster PDF (recto/verso)
|
||||
</a>
|
||||
<button
|
||||
@click="setLayoutMode('split')"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:opacity-80 transition-opacity text-left"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text); font-size: 0.875rem; font-weight: 500; border: none; cursor: pointer;">
|
||||
<svg width="16" height="16" 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>
|
||||
Interroger le RAG ATIS sur ces pensees
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-bold uppercase tracking-widest mb-3" style="color: var(--nav-text-muted);">Les 10 ecoles de pensee (FRACAS V2)</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div v-for="ecole in (penseesData?.ecoles ?? [])" :key="ecole.id"
|
||||
class="flex items-start gap-3 px-3 py-2 rounded-lg"
|
||||
style="background: var(--nav-bg-alt);">
|
||||
<span class="w-3 h-3 rounded-full shrink-0 mt-1" :style="`background:${ecole.color};`"></span>
|
||||
<div>
|
||||
<p class="text-sm font-semibold" style="color: var(--nav-text);">{{ ecole.label }}</p>
|
||||
<p class="text-xs mt-0.5 leading-relaxed" style="color: var(--nav-text-muted);">{{ ecole.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Fiche auteur modal -->
|
||||
<FicheAuteur
|
||||
:open="ficheOpen"
|
||||
:auteurId="ficheAuteurId"
|
||||
:data="penseesData"
|
||||
@close="ficheOpen = false"
|
||||
@interroger-rag="onInterrogerRag"
|
||||
/>
|
||||
|
||||
<!-- Fiche ecole modal -->
|
||||
<FicheEcole
|
||||
:open="ficheEcoleOpen"
|
||||
:ecoleId="ficheEcoleId"
|
||||
:data="penseesData"
|
||||
@close="ficheEcoleOpen = false"
|
||||
@select-auteur="onSelectAuteurFromEcole"
|
||||
@interroger-ecole="onInterrogerEcole"
|
||||
/>
|
||||
|
||||
<!-- Modal info RAG -->
|
||||
<Teleport to="body">
|
||||
<Transition name="backdrop">
|
||||
<div v-if="ragInfoOpen" class="fixed inset-0 z-[2000]" style="background:rgba(26,34,56,0.55);" @click="ragInfoOpen = false" aria-hidden="true" />
|
||||
</Transition>
|
||||
<Transition name="modal">
|
||||
<div v-if="ragInfoOpen" class="fixed z-[2001] left-1/2 flex flex-col"
|
||||
style="top:50%;transform:translate(-50%,-50%);width:min(580px,94vw);max-height:85vh;background:var(--nav-bg);border-radius:14px;box-shadow:0 16px 64px rgba(26,34,56,0.28);overflow:hidden;"
|
||||
role="dialog" aria-modal="true" aria-label="A propos du RAG FRACAS">
|
||||
<div class="flex items-center justify-between px-5 py-4 shrink-0"
|
||||
style="border-bottom:2px solid var(--nav-bg-alt);background:var(--nav-surface);">
|
||||
<h2 class="font-bold text-base" style="color:var(--nav-text);">FRACAS - Bibliotheque des pensees ecologiques</h2>
|
||||
<button @click="ragInfoOpen = false" class="ml-3 shrink-0 flex items-center justify-center w-8 h-8 rounded-full hover:opacity-70"
|
||||
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">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-5 py-4" style="color:var(--nav-text);font-size:0.875rem;line-height:1.6;">
|
||||
<p class="mb-3">Une bibliotheque parlante politisee - des pensees ecologiques de gauche, organisees pour aider a creer une pensee complexe et nuancee, critiquer le recit dominant et soutenir des alternatives concretes et des projets collectifs.</p>
|
||||
<p class="mb-4" style="color:var(--nav-text-muted);font-size:0.8rem;">Projet open source, ouvert a toutes et a tous - <a href="https://bonpote.com/la-carte-des-pensees-ecologiques/" target="_blank" rel="noopener" style="text-decoration:underline;">article + carte FRACAS Bonpote V2</a>.</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="p-3 rounded-lg" style="background:var(--nav-bg-alt);">
|
||||
<p class="font-semibold mb-1" style="font-size:0.8rem;color:var(--nav-text-muted);text-transform:uppercase;letter-spacing:0.05em;">Ce qu'est un RAG</p>
|
||||
<p>Les textes sont vectorises dans un espace de 662 dimensions - chaque livre devient un nuage de points semantiques. La proximite entre les points capture la proximite entre les idees, pas les mots.</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg" style="background:var(--nav-bg-alt);">
|
||||
<p class="font-semibold mb-1" style="font-size:0.8rem;color:var(--nav-text-muted);text-transform:uppercase;letter-spacing:0.05em;">Chunking intelligent</p>
|
||||
<p>Lors de l'ingestion, nous selectionnons les entites cles (concepts, auteurs, relations entre idees) plutot que de decouper mecaniquement les textes.</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg" style="background:var(--nav-bg-alt);">
|
||||
<p class="font-semibold mb-2" style="font-size:0.8rem;color:var(--nav-text-muted);text-transform:uppercase;letter-spacing:0.05em;">Trois couches d'analyse</p>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex gap-2"><span class="font-semibold" style="min-width:70px;">Fond</span><span>Les idees, les theses, les arguments - ce qu'on interroge directement.</span></div>
|
||||
<div class="flex gap-2"><span class="font-semibold" style="min-width:70px;">Forme</span><span>Les modeles narratifs, la rhetorique, la construction argumentative.</span></div>
|
||||
<div class="flex gap-2"><span class="font-semibold" style="min-width:70px;">Structure</span><span>L'architecture des livres - comment les auteurs construisent leur pensee.</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface EcoleData { id: string; label: string; description: string; color: string; x_hint: number; y_hint: number }
|
||||
interface LivreRag { slug: string; titre: string; annee: number; couches: string[] }
|
||||
interface AuteurData { id: string; nom: string; dates: string; ecoles: string[]; ecole_principale: string; livres_rag: LivreRag[]; theses_cles: string[]; bio_courte: string }
|
||||
interface PenseesData { meta: any; ecoles: EcoleData[]; auteurs: AuteurData[] }
|
||||
|
||||
type LayoutMode = 'split' | 'carte-full' | 'chatbot-full' | 'bonpote'
|
||||
|
||||
const STORAGE_KEY = 'media-layout-mode'
|
||||
const SPLIT_RATIO_KEY = 'media-split-ratio'
|
||||
const DEFAULT_SPLIT_RATIO = 0.66
|
||||
|
||||
const ficheOpen = ref(false)
|
||||
const ficheAuteurId = ref<string | null>(null)
|
||||
const ficheEcoleOpen = ref(false)
|
||||
const ficheEcoleId = ref<string | null>(null)
|
||||
const ragInfoOpen = ref(false)
|
||||
const chatbotAuteur = ref<string | null>(null)
|
||||
const penseesData = ref<PenseesData | null>(null)
|
||||
const layoutMode = ref<LayoutMode>('split')
|
||||
const cartePenseesRef = ref<{ triggerResize: () => void } | null>(null)
|
||||
|
||||
// Ratio de la carte vs chatbot en mode split (0.2 a 0.8)
|
||||
const splitRatio = ref(DEFAULT_SPLIT_RATIO)
|
||||
const carteFlexBasis = computed(() => `${splitRatio.value * 100}%`)
|
||||
const chatbotFlexBasis = computed(() => `${(1 - splitRatio.value) * 100}%`)
|
||||
|
||||
const corpusCount = computed(() => penseesData.value?.auteurs.filter((a: any) => a.ingere).length ?? 0)
|
||||
const livresCount = computed(() => {
|
||||
if (!penseesData.value) return 0
|
||||
const slugs = new Set<string>()
|
||||
penseesData.value.auteurs
|
||||
.filter((a: any) => a.ingere)
|
||||
.forEach((a: any) => (a.livres_rag ?? []).forEach((l: any) => slugs.add(l.slug)))
|
||||
return slugs.size
|
||||
})
|
||||
|
||||
// Logique poignee draggable
|
||||
let dragStartY = 0
|
||||
let dragStartRatio = DEFAULT_SPLIT_RATIO
|
||||
let containerHeight = 0
|
||||
|
||||
function onHandleMousedown(e: MouseEvent) {
|
||||
dragStartY = e.clientY
|
||||
dragStartRatio = splitRatio.value
|
||||
// Hauteur du layout-container (carte + handle + chatbot)
|
||||
const container = (e.target as HTMLElement)?.closest('.layout-container') as HTMLElement | null
|
||||
containerHeight = container ? container.clientHeight : window.innerHeight
|
||||
|
||||
window.addEventListener('mousemove', onHandleMousemove)
|
||||
window.addEventListener('mouseup', onHandleMouseup)
|
||||
}
|
||||
|
||||
function onHandleMousemove(e: MouseEvent) {
|
||||
const delta = e.clientY - dragStartY
|
||||
const newRatio = dragStartRatio + delta / containerHeight
|
||||
splitRatio.value = Math.min(0.80, Math.max(0.20, newRatio))
|
||||
}
|
||||
|
||||
function onHandleMouseup() {
|
||||
window.removeEventListener('mousemove', onHandleMousemove)
|
||||
window.removeEventListener('mouseup', onHandleMouseup)
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(SPLIT_RATIO_KEY, String(splitRatio.value))
|
||||
}
|
||||
// Notifier D3 du resize apres relachement
|
||||
cartePenseesRef.value?.triggerResize()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem(STORAGE_KEY) as LayoutMode | null
|
||||
if (saved && ['split', 'carte-full', 'chatbot-full', 'bonpote'].includes(saved)) {
|
||||
layoutMode.value = saved
|
||||
}
|
||||
const savedRatio = parseFloat(localStorage.getItem(SPLIT_RATIO_KEY) ?? '')
|
||||
if (!isNaN(savedRatio) && savedRatio >= 0.20 && savedRatio <= 0.80) {
|
||||
splitRatio.value = savedRatio
|
||||
}
|
||||
// Afficher le popup info RAG a la premiere visite
|
||||
if (!localStorage.getItem('rag-fracas-info-seen')) {
|
||||
ragInfoOpen.value = true
|
||||
localStorage.setItem('rag-fracas-info-seen', '1')
|
||||
}
|
||||
}
|
||||
try {
|
||||
penseesData.value = await $fetch<PenseesData>('/data/auteurs-pensees.json?v=4.2')
|
||||
} catch (e) {
|
||||
console.error('Erreur chargement auteurs-pensees.json', e)
|
||||
}
|
||||
})
|
||||
|
||||
// Persister + reset D3 apres transition
|
||||
function setLayoutMode(mode: LayoutMode) {
|
||||
layoutMode.value = mode
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_KEY, mode)
|
||||
}
|
||||
// Restart simulation D3 apres la fin de la transition CSS (300ms)
|
||||
if (mode !== 'chatbot-full') {
|
||||
setTimeout(() => {
|
||||
cartePenseesRef.value?.triggerResize()
|
||||
}, 350)
|
||||
}
|
||||
}
|
||||
|
||||
watch(layoutMode, (v) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_KEY, v)
|
||||
}
|
||||
})
|
||||
|
||||
function onSelectAuteur(id: string) {
|
||||
ficheAuteurId.value = id
|
||||
ficheOpen.value = true
|
||||
chatbotAuteur.value = null
|
||||
}
|
||||
|
||||
function onSelectEcole(id: string) {
|
||||
ficheEcoleId.value = id
|
||||
ficheEcoleOpen.value = true
|
||||
}
|
||||
|
||||
function onSelectAuteurFromEcole(auteurId: string) {
|
||||
ficheEcoleOpen.value = false
|
||||
onSelectAuteur(auteurId)
|
||||
}
|
||||
|
||||
function onInterrogerEcole(ecoleId: string) {
|
||||
ficheEcoleOpen.value = false
|
||||
const ecole = penseesData.value?.ecoles.find(e => e.id === ecoleId)
|
||||
chatbotAuteur.value = ecole?.label ?? null
|
||||
if (layoutMode.value === 'carte-full') setLayoutMode('split')
|
||||
}
|
||||
|
||||
function onInterrogerRag(auteurId: string) {
|
||||
ficheOpen.value = false
|
||||
const auteur = penseesData.value?.auteurs.find(a => a.id === auteurId)
|
||||
chatbotAuteur.value = auteur?.nom ?? null
|
||||
// Basculer en split pour que le chatbot soit visible
|
||||
if (layoutMode.value === 'carte-full') {
|
||||
setLayoutMode('split')
|
||||
}
|
||||
}
|
||||
|
||||
useHead({ title: 'AEP - Media - Carte FRACAS Bonpote' })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Page container : flex column, prend toute la hauteur viewport */
|
||||
.media-page {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.media-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Conteneur des slots carte + toggle + chatbot */
|
||||
.layout-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* --- Slot carte --- */
|
||||
.carte-slot {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.carte-split {
|
||||
flex: 0 0 66%;
|
||||
min-height: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.carte-full {
|
||||
flex: 1 1 100%;
|
||||
min-height: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.carte-hidden {
|
||||
flex: 0 0 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- Barre de toggle --- */
|
||||
.layout-toggle-bar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
background: var(--nav-bg);
|
||||
border-top: 1px solid rgba(180, 170, 160, 0.22);
|
||||
border-bottom: 1px solid rgba(180, 170, 160, 0.22);
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: var(--nav-bg-alt);
|
||||
color: var(--nav-text-muted);
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
background: var(--nav-surface);
|
||||
color: var(--nav-text);
|
||||
}
|
||||
|
||||
.toggle-btn.active {
|
||||
background: var(--nav-primary);
|
||||
color: var(--nav-text-on-primary);
|
||||
border-color: var(--nav-primary);
|
||||
}
|
||||
|
||||
/* --- Poignee draggable entre carte et chatbot --- */
|
||||
.split-handle {
|
||||
flex-shrink: 0;
|
||||
height: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: row-resize;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.split-handle:hover {
|
||||
background: rgba(180, 170, 160, 0.18);
|
||||
}
|
||||
|
||||
.split-handle-grip {
|
||||
display: block;
|
||||
width: 32px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(160, 150, 140, 0.55) 0px,
|
||||
rgba(160, 150, 140, 0.55) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
);
|
||||
}
|
||||
|
||||
/* Masquer la poignee sur mobile (ratio fixe) */
|
||||
@media (max-width: 767px) {
|
||||
.split-handle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Slot chatbot --- */
|
||||
.chatbot-slot {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: opacity 0.2s ease;
|
||||
border-top: 1px solid rgba(180, 170, 160, 0.28);
|
||||
}
|
||||
|
||||
.chatbot-split {
|
||||
flex: 0 0 34%;
|
||||
min-height: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chatbot-full-mode {
|
||||
flex: 1 1 100%;
|
||||
min-height: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chatbot-hidden {
|
||||
flex: 0 0 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- Transitions modal RAG info --- */
|
||||
.backdrop-enter-active,.backdrop-leave-active { transition: opacity 0.2s; }
|
||||
.backdrop-enter-from,.backdrop-leave-to { opacity: 0; }
|
||||
.modal-enter-active { transition: opacity 0.2s, transform 0.22s cubic-bezier(0.34,1.56,0.64,1); }
|
||||
.modal-leave-active { transition: opacity 0.18s, transform 0.18s ease-in; }
|
||||
.modal-enter-from { opacity: 0; transform: translate(-50%,-48%) scale(0.94); }
|
||||
.modal-leave-to { opacity: 0; transform: translate(-50%,-48%) scale(0.96); }
|
||||
|
||||
/* --- Responsive mobile (<768px) --- */
|
||||
/* Stack vertical : carte 60vh + chatbot 40vh en mode split */
|
||||
@media (max-width: 767px) {
|
||||
.carte-split {
|
||||
flex: 0 0 60vh;
|
||||
height: 60vh;
|
||||
}
|
||||
|
||||
.chatbot-split {
|
||||
flex: 0 0 calc(40vh - 38px);
|
||||
height: calc(40vh - 38px);
|
||||
}
|
||||
|
||||
.toggle-btn span,
|
||||
.toggle-btn {
|
||||
font-size: 0.7rem;
|
||||
padding: 3px 7px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,38 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center h-full gap-6" style="background: var(--nav-bg);">
|
||||
<div class="text-center max-w-md px-6">
|
||||
<div
|
||||
class="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-5"
|
||||
style="background: var(--nav-bg-alt);"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted);">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
||||
<line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold mb-3" style="color: var(--nav-text);">RAG — Retrieval Augmented Generation</h1>
|
||||
<p class="text-sm leading-relaxed mb-6" style="color: var(--nav-text-muted);">
|
||||
Une base de connaissances interrogeable par IA — textes, rapports, manifestes et ressources documentaires sur l'architecture d'écologie politique.
|
||||
</p>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest mb-6" style="color: var(--nav-text-muted); opacity: 0.6;">
|
||||
Bientôt disponible
|
||||
</p>
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold transition-all hover:opacity-80"
|
||||
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
|
||||
>
|
||||
<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="19" y1="12" x2="5" y2="12"/>
|
||||
<polyline points="12 19 5 12 12 5"/>
|
||||
</svg>
|
||||
Retour à l'écosystème
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'RAG — AEP (bientôt disponible)' })
|
||||
</script>
|
||||
@@ -6,10 +6,50 @@
|
||||
<div class="taff-header-inner">
|
||||
<h1 class="taff-title">Trouver du taf en archi</h1>
|
||||
<p class="taff-subtitle">
|
||||
Annuaire critique des plateformes de mise en relation archi ↔ particulier.
|
||||
Évaluées sur 5 axes éthiques — rémunération, transparence, pratiques pro, écologie, qualité du matching.
|
||||
Cible : archi freelance indépendant en France.
|
||||
Annuaire critique des plateformes de mise en relation et d'appels d'offres,
|
||||
pour les <strong>architectes indépendants</strong> — 70 % de la profession et sa part la plus précaire économiquement.
|
||||
</p>
|
||||
|
||||
<!-- Pédagogie : ce qu'on évalue -->
|
||||
<details class="taff-pedago" open>
|
||||
<summary class="taff-pedago-summary">
|
||||
<span>Comment on lit cette carte ?</span>
|
||||
<svg class="taff-pedago-chevron" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</summary>
|
||||
|
||||
<div class="taff-pedago-body">
|
||||
<div class="taff-pedago-block">
|
||||
<h3>Deux onglets, deux mondes</h3>
|
||||
<ul>
|
||||
<li><strong>Pour archi indépendants</strong> (B2C) : plateformes privées qui te mettent en relation avec des particuliers (Houzz, Architoo, etc.). Modèle économique = elles te facturent l'accès aux leads.</li>
|
||||
<li><strong>Appels d'offres publics</strong> : marchés publics (BOAMP, JOUE, plateformes territoriales). Procédure réglementée, gros volumes, accès aux MOE publics.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="taff-pedago-block">
|
||||
<h3>Trois étiquettes, trois niveaux de confiance</h3>
|
||||
<ul>
|
||||
<li><span class="taff-pedago-tag" style="background: rgba(90,122,74,0.12); color: #3d5534;">✅ Recommandé</span> validé par AEP — pratiques alignées avec une éthique architecturale (rémunération correcte, transparence, écologie, qualité du matching)</li>
|
||||
<li><span class="taff-pedago-tag" style="background: rgba(196,164,114,0.15); color: #7a5f2a;">⚠️ Sous réserve</span> utilisable mais avec vigilance — un ou plusieurs points faibles à connaître avant de t'inscrire</li>
|
||||
<li><span class="taff-pedago-tag" style="background: rgba(168,93,62,0.12); color: #7a3322;">❌ À éviter</span> pratiques contraires à l'intérêt des architectes (commissions abusives, dumping, appâtage, etc.)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="taff-pedago-block">
|
||||
<h3>Cinq axes d'évaluation</h3>
|
||||
<p class="taff-pedago-axes">
|
||||
<strong>Rémunération</strong> (commissions, leads payants) ·
|
||||
<strong>Transparence</strong> (CGV, modèle économique) ·
|
||||
<strong>Pratiques pro</strong> (respect du Code de déontologie) ·
|
||||
<strong>Écologie</strong> (incitation à la réno, matériaux) ·
|
||||
<strong>Qualité du matching</strong> (filtres, pertinence des leads).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="taff-stats">
|
||||
<span class="taff-stat" style="color: #3d5534;">
|
||||
<span class="taff-stat-dot" style="background: #5a7a4a;"></span>
|
||||
@@ -28,7 +68,7 @@
|
||||
</div>
|
||||
|
||||
<!-- ── Filtres ─────────────────────────────────────────────────── -->
|
||||
<div class="taff-filters-bar">
|
||||
<div class="taff-filters-bar" :class="{ 'taff-filters-bar--collapsed': !filtersExpanded }">
|
||||
<div class="taff-filters-inner">
|
||||
|
||||
<!-- Onglets B2C / AO publics -->
|
||||
@@ -39,7 +79,7 @@
|
||||
:class="{ 'taff-tab--active': activeTab === 'b2c' }"
|
||||
@click="activeTab = 'b2c'; resetFilters()"
|
||||
>
|
||||
Plateformes B2C
|
||||
Pour archi indépendants
|
||||
<span class="taff-tab-count">{{ b2cCount }}</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -51,8 +91,33 @@
|
||||
Appels d'offres publics
|
||||
<span class="taff-tab-count">{{ aoCount }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Toggle filtres mobile -->
|
||||
<button
|
||||
type="button"
|
||||
class="taff-filters-toggle"
|
||||
:aria-expanded="filtersExpanded"
|
||||
@click="filtersExpanded = !filtersExpanded"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<line x1="4" y1="6" x2="20" y2="6"/><line x1="7" y1="12" x2="17" y2="12"/><line x1="10" y1="18" x2="14" y2="18"/>
|
||||
</svg>
|
||||
<span>Filtres</span>
|
||||
<span v-if="activeFilterCount" class="taff-filters-toggle-badge">{{ activeFilterCount }}</span>
|
||||
<svg
|
||||
width="11" height="11" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2.5"
|
||||
stroke-linecap="round" stroke-linejoin="round"
|
||||
:style="{ transform: filtersExpanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.18s' }"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="taff-filters-collapsible">
|
||||
|
||||
<!-- Filtres tag global -->
|
||||
<div class="taff-filter-group">
|
||||
<button
|
||||
@@ -116,6 +181,8 @@
|
||||
@click="resetFilters"
|
||||
>Effacer</button>
|
||||
</div>
|
||||
|
||||
</div><!-- /.taff-filters-collapsible -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -135,6 +202,105 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Chatbot FAB ───────────────────────────────────────────── -->
|
||||
<Teleport to="body">
|
||||
<!-- Bouton flottant -->
|
||||
<button
|
||||
v-if="!chatOpen"
|
||||
class="taff-fab"
|
||||
@click="chatOpen = true"
|
||||
aria-label="Ouvrir le guide IA — Quel plateforme me convient ?"
|
||||
title="Guide IA — Je t'aide à choisir"
|
||||
>
|
||||
<svg width="20" height="20" 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 class="taff-fab-label">Guide IA</span>
|
||||
</button>
|
||||
|
||||
<!-- Panel chatbot -->
|
||||
<Transition name="taff-chat">
|
||||
<div v-if="chatOpen" class="taff-chat-panel" role="dialog" aria-modal="true" aria-label="Guide IA — Choisir sa plateforme">
|
||||
|
||||
<!-- Header panel -->
|
||||
<div class="taff-chat-header">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="taff-chat-avatar">
|
||||
<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-on-primary);">
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold" style="color: var(--nav-text);">Guide AEP</div>
|
||||
<div class="text-xs" style="color: var(--nav-text-muted);">Je t'aide à choisir ta plateforme</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="chatOpen = false" class="taff-chat-close" aria-label="Fermer">
|
||||
<svg width="14" height="14" 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>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="taff-chat-messages" ref="chatMessagesEl">
|
||||
<!-- Message d'accueil -->
|
||||
<div class="taff-msg taff-msg--bot">
|
||||
<p>Dis-moi ta situation : ton secteur, ta zone, ce qui te bloque. Je t'oriente parmi les {{ allPlateformes.length }} plateformes de l'annuaire.</p>
|
||||
<p class="text-xs mt-1.5 opacity-60">Ex : "Je suis en rénovation à Lyon, je cherche des leads sans commission."</p>
|
||||
</div>
|
||||
|
||||
<!-- Messages de la conversation -->
|
||||
<template v-for="(msg, i) in chatMessages" :key="i">
|
||||
<div :class="['taff-msg', msg.role === 'user' ? 'taff-msg--user' : 'taff-msg--bot']">
|
||||
<div v-if="msg.role === 'bot'" class="md-content" v-html="renderMd(msg.content)" />
|
||||
<span v-else>{{ msg.content }}</span>
|
||||
</div>
|
||||
<!-- Plateformes recommandées -->
|
||||
<div v-if="msg.role === 'bot' && msg.recommandations?.length" class="taff-chat-recos">
|
||||
<button
|
||||
v-for="r in msg.recommandations"
|
||||
:key="r.id"
|
||||
class="taff-reco-chip"
|
||||
@click="openModalById(r.id); chatOpen = false"
|
||||
>
|
||||
{{ r.nom }}
|
||||
<span class="taff-reco-arrow">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Loader -->
|
||||
<div v-if="chatLoading" class="taff-msg taff-msg--bot">
|
||||
<span class="taff-typing"><span/><span/><span/></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="taff-chat-input-wrap">
|
||||
<textarea
|
||||
v-model="chatInput"
|
||||
class="taff-chat-input"
|
||||
placeholder="Décris ta situation..."
|
||||
rows="2"
|
||||
@keydown.enter.exact.prevent="sendChat"
|
||||
/>
|
||||
<button
|
||||
class="taff-chat-send"
|
||||
:disabled="chatLoading || !chatInput.trim()"
|
||||
@click="sendChat"
|
||||
aria-label="Envoyer"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="taff-chat-footer-note">Propulsé par Mistral · 20 questions/jour</p>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- ── Note juridique ────────────────────────────────────────── -->
|
||||
<div class="taff-disclaimer">
|
||||
<p>
|
||||
@@ -157,8 +323,8 @@
|
||||
<Transition name="taff-modal">
|
||||
<div
|
||||
v-if="modalPlateforme"
|
||||
class="fixed z-[10001] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col"
|
||||
style="width: min(760px, 92vw); max-height: 90vh; background: var(--nav-bg); border-radius: 16px; box-shadow: 0 16px 64px rgba(26,34,56,0.28); overflow: hidden;"
|
||||
class="fixed z-[10001] left-1/2 -translate-x-1/2 flex flex-col"
|
||||
style="width: min(760px, 92vw); top: 72px; max-height: calc(100vh - 88px); 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="modalPlateforme.nom"
|
||||
@@ -306,6 +472,21 @@ const filterTag = ref('')
|
||||
const filterSecteur = ref('')
|
||||
const search = ref('')
|
||||
const hasFilters = computed(() => !!(filterTag.value || filterSecteur.value || search.value))
|
||||
const activeFilterCount = computed(() => {
|
||||
let n = 0
|
||||
if (filterTag.value) n++
|
||||
if (filterSecteur.value) n++
|
||||
if (search.value) n++
|
||||
return n
|
||||
})
|
||||
|
||||
// Filtres ouverts par défaut sur desktop, repliés sur mobile (init côté client)
|
||||
const filtersExpanded = ref(true)
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined' && window.innerWidth < 768) {
|
||||
filtersExpanded.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function resetFilters() {
|
||||
filterTag.value = ''
|
||||
@@ -389,6 +570,52 @@ function axeScoreText(score: string) {
|
||||
const modalPlateforme = ref<PlateformeTaff | null>(null)
|
||||
function openModal(p: PlateformeTaff) { modalPlateforme.value = p }
|
||||
function closeModal() { modalPlateforme.value = null }
|
||||
function openModalById(id: string) {
|
||||
const p = allPlateformes.value.find(p => p.id === id)
|
||||
if (p) modalPlateforme.value = p
|
||||
}
|
||||
|
||||
// Chatbot
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'bot'
|
||||
content: string
|
||||
recommandations?: { id: string; nom: string; raison: string }[]
|
||||
}
|
||||
|
||||
const { render: renderMd } = useMarkdown()
|
||||
|
||||
const chatOpen = ref(false)
|
||||
const chatInput = ref('')
|
||||
const chatLoading = ref(false)
|
||||
const chatMessages = ref<ChatMessage[]>([])
|
||||
const chatMessagesEl = ref<HTMLElement | null>(null)
|
||||
|
||||
async function sendChat() {
|
||||
const q = chatInput.value.trim()
|
||||
if (!q || chatLoading.value) return
|
||||
chatMessages.value.push({ role: 'user', content: q })
|
||||
chatInput.value = ''
|
||||
chatLoading.value = true
|
||||
await nextTick()
|
||||
chatMessagesEl.value?.scrollTo({ top: chatMessagesEl.value.scrollHeight, behavior: 'smooth' })
|
||||
try {
|
||||
const res = await $fetch<{ reponse_texte: string; plateformes_recommandees: { id: string; nom: string; raison: string }[] }>(
|
||||
'/api/chatbot-taff',
|
||||
{ method: 'POST', body: { question: q } }
|
||||
)
|
||||
chatMessages.value.push({
|
||||
role: 'bot',
|
||||
content: res.reponse_texte,
|
||||
recommandations: res.plateformes_recommandees ?? [],
|
||||
})
|
||||
} catch (e: any) {
|
||||
chatMessages.value.push({ role: 'bot', content: e?.data?.statusMessage ?? 'Erreur — réessaie dans un instant.' })
|
||||
} finally {
|
||||
chatLoading.value = false
|
||||
await nextTick()
|
||||
chatMessagesEl.value?.scrollTo({ top: chatMessagesEl.value.scrollHeight, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
const TAG_CONFIG: Record<string, { emoji: string; label: string; bg: string; text: string }> = {
|
||||
'recommande': { emoji: '✅', label: 'Recommandé AEP', bg: 'rgba(90,122,74,0.12)', text: '#3d5534' },
|
||||
@@ -420,16 +647,147 @@ const parsedDescription = computed(() => {
|
||||
<style scoped>
|
||||
.taff-page { max-width: 1280px; margin: 0 auto; padding-bottom: 3rem; }
|
||||
|
||||
.taff-header { padding: 2.5rem 1.5rem 1.5rem; border-bottom: 1px solid var(--nav-bg-alt); }
|
||||
.taff-header-inner { max-width: 680px; }
|
||||
.taff-header { padding: 2.5rem 1.5rem 1.5rem; border-bottom: 1px solid var(--nav-bg-alt); text-align: center; }
|
||||
.taff-header-inner { max-width: 680px; margin: 0 auto; }
|
||||
.taff-title { font-size: 1.875rem; font-weight: 800; color: var(--nav-text); margin-bottom: 0.5rem; letter-spacing: -0.02em; }
|
||||
.taff-subtitle { font-size: 0.9375rem; color: var(--nav-text-muted); line-height: 1.6; margin-bottom: 1rem; }
|
||||
.taff-stats { display: flex; gap: 1.25rem; flex-wrap: wrap; }
|
||||
.taff-subtitle { font-size: 0.9375rem; color: var(--nav-text-muted); line-height: 1.6; margin-bottom: 0.625rem; }
|
||||
.taff-cible { font-size: 0.875rem; color: var(--nav-text-muted); line-height: 1.55; margin-bottom: 1rem; font-style: italic; }
|
||||
.taff-cible strong { color: var(--nav-text); font-style: normal; }
|
||||
.taff-stats { display: flex; gap: 1.25rem; flex-wrap: wrap; justify-content: center; margin-top: 1rem; }
|
||||
.taff-stat { display: flex; align-items: center; gap: 0.375rem; font-size: 0.8125rem; font-weight: 600; }
|
||||
|
||||
/* Pédagogie repliable */
|
||||
.taff-pedago {
|
||||
background: var(--nav-bg);
|
||||
border: 1px solid var(--nav-bg-alt);
|
||||
border-radius: 12px;
|
||||
margin: 1rem 0 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.taff-pedago-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--nav-text);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.taff-pedago-summary::-webkit-details-marker { display: none; }
|
||||
.taff-pedago-chevron {
|
||||
color: var(--nav-text-muted);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.taff-pedago[open] .taff-pedago-chevron { transform: rotate(180deg); }
|
||||
.taff-pedago-body {
|
||||
padding: 0 1rem 1rem;
|
||||
border-top: 1px solid var(--nav-bg-alt);
|
||||
}
|
||||
.taff-pedago-block { margin-top: 0.875rem; }
|
||||
.taff-pedago-block h3 {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--nav-text-muted);
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.taff-pedago-block ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.taff-pedago-block li {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.55;
|
||||
color: var(--nav-text);
|
||||
}
|
||||
.taff-pedago-block li strong { font-weight: 700; }
|
||||
.taff-pedago-tag {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
.taff-pedago-axes {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.65;
|
||||
color: var(--nav-text);
|
||||
margin: 0;
|
||||
}
|
||||
.taff-pedago-axes strong { font-weight: 700; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.taff-pedago-body { padding: 0 0.85rem 0.85rem; }
|
||||
.taff-pedago-block li, .taff-pedago-axes { font-size: 0.82rem; }
|
||||
}
|
||||
.taff-stat-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
|
||||
.taff-filters-bar { position: sticky; top: 0; z-index: 100; background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt); padding: 0.75rem 1.5rem; box-shadow: 0 2px 8px rgba(26,34,56,0.06); }
|
||||
.taff-filters-inner { display: flex; align-items: center; gap: 0.625rem; flex-wrap: wrap; }
|
||||
.taff-filters-collapsible { display: contents; }
|
||||
|
||||
/* Toggle filtres : visible uniquement sur mobile */
|
||||
.taff-filters-toggle { display: none; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.taff-filters-bar { padding: 0.5rem 0.875rem; }
|
||||
.taff-filters-inner { gap: 0.4rem; }
|
||||
.taff-tabs { width: 100%; justify-content: space-between; }
|
||||
.taff-tabs .taff-tab { flex: 1; justify-content: center; }
|
||||
.taff-filters-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: var(--nav-bg);
|
||||
border: 1px solid var(--nav-bg-alt);
|
||||
border-left: none;
|
||||
color: var(--nav-text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.taff-filters-toggle-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9999px;
|
||||
background: var(--nav-primary-solid);
|
||||
color: var(--nav-text-on-primary);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.taff-filters-collapsible {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
overflow: hidden;
|
||||
max-height: 1000px;
|
||||
transition: max-height 0.3s ease, opacity 0.2s ease, margin-top 0.2s ease;
|
||||
margin-top: 0.4rem;
|
||||
opacity: 1;
|
||||
}
|
||||
.taff-filters-bar--collapsed .taff-filters-collapsible {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
margin-top: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.taff-tabs { display: flex; border-radius: 8px; overflow: hidden; border: 1px solid var(--nav-bg-alt); flex-shrink: 0; }
|
||||
.taff-tab { display: flex; align-items: center; gap: 0.375rem; padding: 0.375rem 0.875rem; font-size: 0.8125rem; font-weight: 500; color: var(--nav-text-muted); background: var(--nav-bg); border: none; cursor: pointer; transition: background 0.15s; }
|
||||
@@ -448,7 +806,7 @@ const parsedDescription = computed(() => {
|
||||
.taff-search-clear { color: var(--nav-text-muted); background: none; border: none; cursor: pointer; padding: 0; display: flex; }
|
||||
|
||||
.taff-grid-wrap { padding: 1.5rem; }
|
||||
.taff-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
|
||||
.taff-grid { display: flex; flex-direction: column; gap: 0.75rem; max-width: 720px; margin: 0 auto; }
|
||||
.taff-empty { text-align: center; padding: 3rem; }
|
||||
.taff-reset-btn { margin-top: 0.75rem; padding: 0.5rem 1.25rem; border-radius: 8px; background: var(--nav-bg-alt); color: var(--nav-text); font-size: 0.875rem; border: none; cursor: pointer; }
|
||||
.taff-reset-btn:hover { opacity: 0.7; }
|
||||
@@ -464,9 +822,195 @@ const parsedDescription = computed(() => {
|
||||
.modal-meta-key { font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 700; color: var(--nav-text-muted); }
|
||||
.modal-meta-val { font-size: 0.875rem; font-weight: 500; color: var(--nav-text); }
|
||||
|
||||
/* Transitions */
|
||||
/* Transitions modal */
|
||||
.taff-backdrop-enter-active, .taff-backdrop-leave-active { transition: opacity 0.2s; }
|
||||
.taff-backdrop-enter-from, .taff-backdrop-leave-to { opacity: 0; }
|
||||
.taff-modal-enter-active, .taff-modal-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
||||
.taff-modal-enter-from, .taff-modal-leave-to { opacity: 0; transform: translate(-50%, calc(-50% + 12px)); }
|
||||
.taff-modal-enter-from, .taff-modal-leave-to { opacity: 0; transform: translateX(-50%) translateY(-12px); }
|
||||
|
||||
/* ── Chatbot FAB ──────────────────────────────────────────────────── */
|
||||
.taff-fab {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 9998;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.125rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--nav-primary-solid);
|
||||
color: var(--nav-text-on-primary);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 20px rgba(26,34,56,0.3);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.taff-fab:hover { transform: translateY(-2px); box-shadow: 0 8px 28px rgba(26,34,56,0.35); }
|
||||
.taff-fab-label { white-space: nowrap; }
|
||||
|
||||
/* Panel chatbot */
|
||||
.taff-chat-panel {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 9999;
|
||||
width: min(380px, calc(100vw - 2rem));
|
||||
max-height: calc(100vh - 4rem);
|
||||
background: var(--nav-surface);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 40px rgba(26,34,56,0.25);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--nav-bg-alt);
|
||||
}
|
||||
|
||||
.taff-chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.875rem 1rem;
|
||||
border-bottom: 1px solid var(--nav-bg-alt);
|
||||
background: var(--nav-surface);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.taff-chat-avatar {
|
||||
width: 32px; height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--nav-primary-solid);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.taff-chat-close {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 8px;
|
||||
background: var(--nav-bg-alt);
|
||||
color: var(--nav-text-muted);
|
||||
border: none; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.taff-chat-close:hover { opacity: 0.7; }
|
||||
|
||||
.taff-chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.taff-msg {
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
max-width: 92%;
|
||||
}
|
||||
.taff-msg--bot {
|
||||
background: var(--nav-bg-alt);
|
||||
color: var(--nav-text);
|
||||
align-self: flex-start;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
.taff-msg--user {
|
||||
background: var(--nav-primary-solid);
|
||||
color: var(--nav-text-on-primary);
|
||||
align-self: flex-end;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.taff-chat-recos {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
align-self: flex-start;
|
||||
max-width: 92%;
|
||||
}
|
||||
.taff-reco-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.3rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
background: var(--nav-bg);
|
||||
color: var(--nav-text);
|
||||
border: 1px solid var(--nav-bg-alt);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.taff-reco-chip:hover { background: var(--nav-bg-alt); }
|
||||
.taff-reco-arrow { opacity: 0.5; }
|
||||
|
||||
/* Typing indicator */
|
||||
.taff-typing { display: inline-flex; gap: 4px; align-items: center; }
|
||||
.taff-typing span {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--nav-text-muted);
|
||||
animation: taff-bounce 1.2s infinite;
|
||||
}
|
||||
.taff-typing span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.taff-typing span:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes taff-bounce {
|
||||
0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||
40% { transform: translateY(-5px); opacity: 1; }
|
||||
}
|
||||
|
||||
.taff-chat-input-wrap {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--nav-bg-alt);
|
||||
background: var(--nav-surface);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.taff-chat-input {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
border: 1px solid var(--nav-bg-alt);
|
||||
border-radius: 10px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
background: var(--nav-bg);
|
||||
color: var(--nav-text);
|
||||
font-family: var(--nav-font);
|
||||
outline: none;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.taff-chat-input::placeholder { color: var(--nav-text-muted); }
|
||||
.taff-chat-input:focus { border-color: var(--nav-primary); }
|
||||
|
||||
.taff-chat-send {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 10px;
|
||||
background: var(--nav-primary-solid);
|
||||
color: var(--nav-text-on-primary);
|
||||
border: none; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.taff-chat-send:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.taff-chat-send:not(:disabled):hover { opacity: 0.85; }
|
||||
|
||||
.taff-chat-footer-note {
|
||||
text-align: center;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--nav-text-muted);
|
||||
padding: 0.375rem;
|
||||
background: var(--nav-surface);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Transition panel chatbot */
|
||||
.taff-chat-enter-active, .taff-chat-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
||||
.taff-chat-enter-from, .taff-chat-leave-to { opacity: 0; transform: translateY(12px) scale(0.97); }
|
||||
</style>
|
||||
|
||||
3765
public/data/auteurs-pensees.json
Normal file
3765
public/data/auteurs-pensees.json
Normal file
File diff suppressed because it is too large
Load Diff
21073
public/data/reseaux-bifurcation.json
Normal file
21073
public/data/reseaux-bifurcation.json
Normal file
File diff suppressed because it is too large
Load Diff
376
scripts/build_authors_v3.mjs
Normal file
376
scripts/build_authors_v3.mjs
Normal file
@@ -0,0 +1,376 @@
|
||||
// Build auteurs-pensees.json v3.0 — Phase 8.A
|
||||
// Sync corpus JSON unifié : Bonpote authors + LightRAG ingestion flags
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const JSON_PATH = 'C:\\Users\\jules\\Dropbox\\ATIS - IPCJRA\\1 PROJETS\\TECH - infra VPS, website pro, RAG\\nav-carte\\public\\data\\auteurs-pensees.json';
|
||||
|
||||
// === LightRAG slug prefixes (from /documents endpoint 2026-05-12) ===
|
||||
const LIGHTRAG_PREFIX_TO_AUTHOR_SLUG = {
|
||||
bookchin: 'murray-bookchin',
|
||||
brand: 'steward-brand',
|
||||
carson: 'rachel-carson',
|
||||
charbonneau: 'bernard-charbonneau',
|
||||
descola: 'philippe-descola',
|
||||
despret: 'vinciane-despret',
|
||||
eaubonne: 'francoise-deaubonne',
|
||||
ellul: 'jacques-ellul',
|
||||
federici: 'silvia-federici',
|
||||
ferdinand: 'malcolm-ferdinand',
|
||||
figueres: 'christiana-figueres',
|
||||
georgescu: 'nicholas-georgescu-roegen',
|
||||
gorz: 'andre-gorz',
|
||||
graeber: 'david-graeber',
|
||||
keith: 'david-keith',
|
||||
klein: 'naomi-klein',
|
||||
kropotkine: 'pierre-kropotkine',
|
||||
latouche: 'serge-latouche',
|
||||
latour: 'bruno-latour',
|
||||
lowy: 'michael-lowy',
|
||||
malm: 'andreas-malm',
|
||||
marx: 'karl-marx',
|
||||
meadows: 'donella-meadows',
|
||||
morizot: 'baptiste-morizot',
|
||||
naess: 'arne-naess',
|
||||
ouassak: 'fatima-ouassak',
|
||||
reclus: 'elisee-reclus',
|
||||
saito: 'kohei-saito',
|
||||
servigne: 'pablo-servigne',
|
||||
shiva: 'vandana-shiva',
|
||||
stengers: 'isabelle-stengers',
|
||||
vettese: 'troy-vettese',
|
||||
};
|
||||
|
||||
const INGESTED_AUTHOR_SLUGS = new Set(Object.values(LIGHTRAG_PREFIX_TO_AUTHOR_SLUG));
|
||||
|
||||
// === Bonpote authors (nom, dates, ecole_principale, ecoles_secondaires[]) ===
|
||||
const BONPOTE_AUTHORS = [
|
||||
// Éco-anarchisme
|
||||
['Pierre Kropotkine', '1842-1921', 'eco-anarchisme', []],
|
||||
['Élisée Reclus', '1830-1905', 'eco-anarchisme', []],
|
||||
['Murray Bookchin', '1921-2006', 'eco-anarchisme', []],
|
||||
['David Graeber', '1961-2020', 'eco-anarchisme', []],
|
||||
['James C. Scott', '1936-2024', 'eco-anarchisme', []],
|
||||
['Marshall Sahlins', '1930-2021', 'eco-anarchisme', []],
|
||||
['Pierre Clastres', '1934-1977', 'eco-anarchisme', []],
|
||||
['Cornélius Castoriadis', '1922-1997', 'eco-anarchisme', []],
|
||||
['David Harvey', '1935-', 'eco-anarchisme', ['ecosocialisme']],
|
||||
['Henri Lefebvre', '1901-1991', 'eco-anarchisme', ['ecosocialisme']],
|
||||
['Émile Gravelle', '1855-1920', 'eco-anarchisme', []],
|
||||
['Henri Zisly', '1872-1945', 'eco-anarchisme', []],
|
||||
['Edward Carpenter', '1844-1929', 'eco-anarchisme', []],
|
||||
['William Morris', '1834-1896', 'eco-anarchisme', []],
|
||||
['John Ruskin', '1819-1900', 'eco-anarchisme', []],
|
||||
['Kirkpatrick Sale', '1937-', 'eco-anarchisme', []],
|
||||
['Wendell Berry', '1934-', 'eco-anarchisme', []],
|
||||
['Kristin Ross', '1953-', 'eco-anarchisme', []],
|
||||
['Theodore Kaczynski', '1942-2023', 'eco-anarchisme', ['technocritique']],
|
||||
['Saint-Simon', '1760-1825', 'eco-anarchisme', []],
|
||||
['Auguste Comte', '1798-1857', 'eco-anarchisme', []],
|
||||
['Alberto Magnaghi', '1941-2023', 'eco-anarchisme', []],
|
||||
['Peter Berg', '1937-2011', 'eco-anarchisme', []],
|
||||
['Andreas Malm', '1977-', 'ecosocialisme', ['eco-anarchisme']],
|
||||
|
||||
// Écosocialisme
|
||||
['Karl Marx', '1818-1883', 'ecosocialisme', []],
|
||||
['Friedrich Engels', '1820-1895', 'ecosocialisme', []],
|
||||
['Rosa Luxemburg', '1871-1919', 'ecosocialisme', []],
|
||||
['Walter Benjamin', '1892-1940', 'ecosocialisme', []],
|
||||
['John Maynard Keynes', '1883-1946', 'ecosocialisme', []],
|
||||
['Pascal Lamy', '1947-', 'ecosocialisme', []],
|
||||
['Ann Pettifor', '1947-', 'ecosocialisme', []],
|
||||
['Holly Jean Buck', '', 'ecosocialisme', []],
|
||||
['Cédric Durand', '1975-', 'ecosocialisme', []],
|
||||
['Kim Stanley Robinson', '1952-', 'ecosocialisme', []],
|
||||
['André Gorz', '1923-2007', 'ecosocialisme', ['decroissance', 'technocritique']],
|
||||
['Kohei Saito', '1987-', 'ecosocialisme', ['decroissance']],
|
||||
['Razmig Keucheyan', '1975-', 'ecosocialisme', []],
|
||||
['Dominique Méda', '1962-', 'ecosocialisme', []],
|
||||
['Dominique Bourg', '1953-', 'ecosocialisme', []],
|
||||
['Troy Vettese', '', 'ecosocialisme', []],
|
||||
['Loïc Blondiaux', '1962-', 'ecosocialisme', []],
|
||||
['Drew Pendergrass', '', 'ecosocialisme', []],
|
||||
['Jason W. Moore', '', 'ecosocialisme', []],
|
||||
["James O'Connor", '1930-2017', 'ecosocialisme', []],
|
||||
['Herman Daly', '1938-2022', 'ecosocialisme', ['capitalisme-vert']],
|
||||
['John Bellamy Foster', '1953-', 'ecosocialisme', []],
|
||||
['Michael Löwy', '1938-', 'ecosocialisme', []],
|
||||
['Joel Kovel', '1936-2018', 'ecosocialisme', []],
|
||||
['Naomi Klein', '1970-', 'ecosocialisme', []],
|
||||
|
||||
// Technocritique
|
||||
['Jacques Ellul', '1912-1994', 'technocritique', []],
|
||||
['Bernard Charbonneau', '1910-1996', 'technocritique', []],
|
||||
['Lewis Mumford', '1895-1990', 'technocritique', []],
|
||||
['Alain Caillé', '1944-', 'technocritique', []],
|
||||
['Hans Jonas', '1903-1993', 'technocritique', ['ethiques-environnementales']],
|
||||
['Herbert Marcuse', '1898-1979', 'technocritique', []],
|
||||
['Günther Anders', '1902-1992', 'technocritique', []],
|
||||
['Pierre Fournier', '1937-1973', 'technocritique', []],
|
||||
['Alexandre Grothendieck', '1928-2014', 'technocritique', []],
|
||||
['Patrick Viveret', '1948-', 'technocritique', []],
|
||||
['Philippe Bihouix', '1971-', 'technocritique', []],
|
||||
['Jean Baudrillard', '1929-2007', 'technocritique', []],
|
||||
['Serge Latouche', '1940-', 'decroissance', ['technocritique']],
|
||||
['Ivan Illich', '1926-2002', 'technocritique', ['decroissance']],
|
||||
['Leopold Kohr', '1909-1994', 'technocritique', ['decroissance']],
|
||||
['Ernst Schumacher', '1911-1977', 'technocritique', ['decroissance']],
|
||||
['Nicholas Georgescu-Roegen', '1906-1994', 'decroissance', ['technocritique']],
|
||||
|
||||
// Écoféminismes
|
||||
["Françoise d'Eaubonne", '1920-2005', 'ecofeminismes', []],
|
||||
['Vandana Shiva', '1952-', 'ecofeminismes', ['ecologies-decoloniales']],
|
||||
['Starhawk', '1951-', 'ecofeminismes', []],
|
||||
['Ariel Salleh', '1944-', 'ecofeminismes', []],
|
||||
['Maria Mies', '1931-2023', 'ecofeminismes', []],
|
||||
['Carolyn Merchant', '1936-', 'ecofeminismes', []],
|
||||
['Silvia Federici', '1942-', 'ecofeminismes', []],
|
||||
['Val Plumwood', '1939-2008', 'ecofeminismes', []],
|
||||
['Susan Griffin', '1943-', 'ecofeminismes', []],
|
||||
['Veronika Bennholdt-Thomsen', '1944-', 'ecofeminismes', []],
|
||||
['Geneviève Pruvost', '1973-', 'ecofeminismes', []],
|
||||
['Donna Haraway', '1944-', 'ecofeminismes', ['pensees-vivant']],
|
||||
['Émilie Hache', '', 'ecofeminismes', []],
|
||||
['Joanna Macy', '1929-', 'ecofeminismes', ['ethiques-environnementales']],
|
||||
|
||||
// Capitalisme vert
|
||||
['Bill Gates', '1955-', 'capitalisme-vert', []],
|
||||
['Christiana Figueres', '1956-', 'capitalisme-vert', []],
|
||||
['Nicholas Stern', '1946-', 'capitalisme-vert', []],
|
||||
['Jeffrey Sachs', '1954-', 'capitalisme-vert', []],
|
||||
['Jared Diamond', '1937-', 'capitalisme-vert', ['decroissance']],
|
||||
['Jørgen Randers', '1945-', 'capitalisme-vert', ['decroissance']],
|
||||
['Donella Meadows', '1941-2001', 'decroissance', ['capitalisme-vert']],
|
||||
['Dennis Meadows', '1942-', 'decroissance', ['capitalisme-vert']],
|
||||
['Kate Raworth', '1970-', 'capitalisme-vert', []],
|
||||
['Al Gore', '1948-', 'capitalisme-vert', []],
|
||||
['Hal Harvey', '1960-', 'capitalisme-vert', []],
|
||||
['Laurence Tubiana', '1951-', 'capitalisme-vert', []],
|
||||
['Amory Lovins', '1947-', 'capitalisme-vert', []],
|
||||
['David Pearce', '1959-', 'capitalisme-vert', []],
|
||||
['Kerry Turner', '1948-', 'capitalisme-vert', []],
|
||||
['David Keith', '1963-', 'capitalisme-vert', []],
|
||||
['Ted Nordhaus', '1965-', 'capitalisme-vert', []],
|
||||
['Michael Shellenberger', '1971-', 'capitalisme-vert', []],
|
||||
['Pavan Sukhdev', '1960-', 'capitalisme-vert', []],
|
||||
['Janine Benyus', '1958-', 'capitalisme-vert', []],
|
||||
['Robert Costanza', '1950-', 'capitalisme-vert', []],
|
||||
['Peter Kareiva', '1951-', 'capitalisme-vert', []],
|
||||
['Michelle Marvier', '', 'capitalisme-vert', []],
|
||||
['Robert Lalasz', '1915-2003', 'capitalisme-vert', []],
|
||||
['Steward Brand', '1938-', 'capitalisme-vert', []],
|
||||
['Paul Crutzen', '1933-2021', 'capitalisme-vert', []],
|
||||
['Kenneth Boulding', '1910-1993', 'capitalisme-vert', []],
|
||||
['Eugene Odum', '1913-2002', 'capitalisme-vert', []],
|
||||
['Howard Odum', '1924-2002', 'capitalisme-vert', []],
|
||||
['Jean-Marc Jancovici', '1962-', 'capitalisme-vert', []],
|
||||
['Yves Cochet', '1946-', 'capitalisme-vert', ['decroissance']],
|
||||
['Pablo Servigne', '1978-', 'decroissance', ['capitalisme-vert']],
|
||||
['Gauthier Chapelle', '1968-', 'decroissance', ['capitalisme-vert']],
|
||||
|
||||
// Écologies décoloniales
|
||||
['Malcom Ferdinand', '1985-', 'ecologies-decoloniales', []],
|
||||
['Frantz Fanon', '1925-1961', 'ecologies-decoloniales', []],
|
||||
['Édouard Glissant', '1928-2011', 'ecologies-decoloniales', []],
|
||||
['Aimé Césaire', '1913-2008', 'ecologies-decoloniales', []],
|
||||
['Mohamad Amer Meziane', '', 'ecologies-decoloniales', []],
|
||||
['Chico Mendes', '1944-1988', 'ecologies-decoloniales', []],
|
||||
['Joan Martínez Alier', '1939-', 'ecologies-decoloniales', []],
|
||||
['Arturo Escobar', '1951-', 'ecologies-decoloniales', []],
|
||||
['Sous-commandant Marcos', '1957-', 'ecologies-decoloniales', []],
|
||||
['Alberto Acosta', '1948-', 'ecologies-decoloniales', []],
|
||||
['Jérôme Baschet', '1960-', 'ecologies-decoloniales', []],
|
||||
['Fatima Ouassak', '1976-', 'ecofeminismes', ['ecologies-decoloniales']],
|
||||
['William Acker', '1991-', 'ecologies-decoloniales', []],
|
||||
['Giorgos Kallis', '1972-', 'ecologies-decoloniales', ['decroissance']],
|
||||
['Bernard Lambert', '1931-1984', 'ecologies-decoloniales', []],
|
||||
|
||||
// Écofascismes
|
||||
['Alain de Benoist', '1943-', 'ecofascismes', []],
|
||||
['Paul Ralph Ehrlich', '1932-', 'ecofascismes', []],
|
||||
['Garrett Hardin', '1915-2003', 'ecofascismes', []],
|
||||
['Edward Osborne Wilson', '1929-2021', 'ecofascismes', []],
|
||||
['Thomas Malthus', '1803-1882', 'ecofascismes', []],
|
||||
['David Foreman', '1946-2022', 'ecofascismes', []],
|
||||
['Piero San Giorgio', '1971-', 'ecofascismes', []],
|
||||
|
||||
// Éthique environnementale
|
||||
['Arne Næss', '1912-2009', 'ethiques-environnementales', []],
|
||||
['Rachel Carson', '1907-1964', 'ethiques-environnementales', []],
|
||||
['Aldo Leopold', '1887-1948', 'ethiques-environnementales', []],
|
||||
['Imanishi Kinji', '1902-1992', 'ethiques-environnementales', []],
|
||||
['Paul Watson', '1950-', 'ethiques-environnementales', []],
|
||||
['John Muir', '1838-1914', 'ethiques-environnementales', []],
|
||||
['Edward Abbey', '1927-1989', 'ethiques-environnementales', []],
|
||||
['John Baird Callicott', '1941-', 'ethiques-environnementales', []],
|
||||
['Bill Mollison', '1928-2016', 'ethiques-environnementales', []],
|
||||
['David Holmgren', '1955-', 'ethiques-environnementales', []],
|
||||
['Peter Singer', '1946-', 'ethiques-environnementales', []],
|
||||
['Pierre Rabhi', '1938-2021', 'ethiques-environnementales', []],
|
||||
['Rob Hopkins', '1968-', 'ethiques-environnementales', []],
|
||||
['Cyril Dion', '1978-', 'ethiques-environnementales', []],
|
||||
['Gandhi', '1869-1948', 'ethiques-environnementales', []],
|
||||
['Gifford Pinchot', '1865-1946', 'ethiques-environnementales', []],
|
||||
['Lanza del Vasto', '1901-1981', 'ethiques-environnementales', []],
|
||||
['Jorge Mario Bergoglio', '1936-', 'ethiques-environnementales', []],
|
||||
['Gary Snyder', '1930-', 'ethiques-environnementales', []],
|
||||
['Henry David Thoreau', '1817-1862', 'ethiques-environnementales', []],
|
||||
['Ralph Waldo Emerson', '1803-1882', 'ethiques-environnementales', []],
|
||||
['José Bové', '1953-', 'ethiques-environnementales', []],
|
||||
['Glenn Albrecht', '1953-', 'ethiques-environnementales', []],
|
||||
|
||||
// Pensées du vivant
|
||||
['Bruno Latour', '1947-2022', 'pensees-vivant', []],
|
||||
['Isabelle Stengers', '1949-', 'pensees-vivant', []],
|
||||
['Vinciane Despret', '1959-', 'pensees-vivant', []],
|
||||
['Baptiste Morizot', '1983-', 'pensees-vivant', []],
|
||||
['Philippe Descola', '1949-', 'pensees-vivant', []],
|
||||
['Eduardo Viveiros de Castro', '1951-', 'pensees-vivant', []],
|
||||
['Anna Tsing', '1952-', 'pensees-vivant', []],
|
||||
['Deborah Bird Rose', '1946-2018', 'pensees-vivant', []],
|
||||
['Lynn Margulis', '1938-2011', 'pensees-vivant', []],
|
||||
['James Lovelock', '1919-2022', 'pensees-vivant', []],
|
||||
['Serge Moscovici', '1925-2014', 'pensees-vivant', []],
|
||||
['Theodore Roszak', '1933-2011', 'pensees-vivant', []],
|
||||
['Baruch Spinoza', '1632-1677', 'pensees-vivant', []],
|
||||
];
|
||||
|
||||
// Special slug overrides (match v2.1 IDs + ligatures)
|
||||
const NAME_TO_SLUG_OVERRIDES = {
|
||||
'Malcom Ferdinand': 'malcolm-ferdinand',
|
||||
"Françoise d'Eaubonne": 'francoise-deaubonne',
|
||||
'Donella Meadows': 'donella-meadows',
|
||||
'Dennis Meadows': 'dennis-meadows',
|
||||
'Arne Næss': 'arne-naess',
|
||||
'Jørgen Randers': 'jorgen-randers',
|
||||
};
|
||||
|
||||
function slugify(name) {
|
||||
// Pre-process special ligatures and chars not handled by NFKD
|
||||
let pre = name
|
||||
.replace(/[æÆ]/g, 'ae')
|
||||
.replace(/[øØ]/g, 'o')
|
||||
.replace(/[œŒ]/g, 'oe')
|
||||
.replace(/ß/g, 'ss');
|
||||
// Remove diacritical marks
|
||||
const noAccent = pre.normalize('NFKD').replace(/[̀-ͯ]/g, '');
|
||||
return noAccent
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function getAuthorSlug(name) {
|
||||
if (NAME_TO_SLUG_OVERRIDES[name]) return NAME_TO_SLUG_OVERRIDES[name];
|
||||
return slugify(name);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const raw = fs.readFileSync(JSON_PATH, 'utf-8');
|
||||
const data = JSON.parse(raw);
|
||||
|
||||
const existingBySlug = {};
|
||||
for (const a of data.auteurs) existingBySlug[a.id] = a;
|
||||
|
||||
const newAuthors = [];
|
||||
const seenSlugs = new Set();
|
||||
|
||||
for (const [nom, dates, ecolePrincipale, ecolesSecondaires] of BONPOTE_AUTHORS) {
|
||||
const slug = getAuthorSlug(nom);
|
||||
if (seenSlugs.has(slug)) {
|
||||
console.error(`DUPLICATE SKIP: ${nom} -> ${slug}`);
|
||||
continue;
|
||||
}
|
||||
seenSlugs.add(slug);
|
||||
|
||||
const ingere = INGESTED_AUTHOR_SLUGS.has(slug);
|
||||
const ecoles = [ecolePrincipale, ...ecolesSecondaires];
|
||||
|
||||
if (existingBySlug[slug]) {
|
||||
// Preserve enriched entry
|
||||
const entry = { ...existingBySlug[slug], ingere };
|
||||
newAuthors.push(entry);
|
||||
} else {
|
||||
// New minimal entry
|
||||
const bioProvisoire = ingere
|
||||
? `Auteur·ice ingéré·e dans le RAG ATIS, bio à enrichir lors de PRG-5.`
|
||||
: `Théoricien·ne présent·e sur le poster Bonpote (${ecolePrincipale}), non ingéré·e dans le RAG ATIS.`;
|
||||
newAuthors.push({
|
||||
id: slug,
|
||||
nom,
|
||||
dates,
|
||||
ecoles,
|
||||
ecole_principale: ecolePrincipale,
|
||||
livres_rag: [],
|
||||
theses_cles_attendues: [],
|
||||
bio_courte_provisoire: bioProvisoire,
|
||||
ingere,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve any v2.1 author not in Bonpote list
|
||||
for (const [slug, entry] of Object.entries(existingBySlug)) {
|
||||
if (!seenSlugs.has(slug)) {
|
||||
const copy = { ...entry };
|
||||
if (!('ingere' in copy)) copy.ingere = INGESTED_AUTHOR_SLUGS.has(slug);
|
||||
newAuthors.push(copy);
|
||||
seenSlugs.add(slug);
|
||||
console.error(`NOTE: preserved v2.1 author not in Bonpote canonical: ${slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
const auteursCount = newAuthors.length;
|
||||
const auteursIngeresCount = newAuthors.filter(a => a.ingere).length;
|
||||
|
||||
data.meta.version = '3.0';
|
||||
data.meta.updated = '2026-05-12';
|
||||
data.meta.auteurs_count = auteursCount;
|
||||
data.meta.auteurs_ingeres_count = auteursIngeresCount;
|
||||
data.meta.source = 'FRACAS Bonpote V2 oct 2024 + LightRAG corpus 12/05/2026 (v3.0 sync)';
|
||||
data.meta.note_v3_0 = 'Phase 8.A sync corpus unifie : ~140 auteurs Bonpote integres, flag ingere:true/false selon LightRAG VPS. Auteurs non-ingeres = entrees minimales (bio provisoire, livres_rag vide), a enrichir lors de PRG-4/PRG-5.';
|
||||
|
||||
data.auteurs = newAuthors;
|
||||
|
||||
fs.writeFileSync(JSON_PATH, JSON.stringify(data, null, 2), 'utf-8');
|
||||
|
||||
// Validate parse-back
|
||||
const parsedBack = JSON.parse(fs.readFileSync(JSON_PATH, 'utf-8'));
|
||||
if (parsedBack.auteurs.length !== auteursCount) {
|
||||
console.error('PARSE-BACK MISMATCH');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Stats
|
||||
const schoolsStats = {};
|
||||
for (const a of newAuthors) {
|
||||
const ep = a.ecole_principale || '?';
|
||||
if (!schoolsStats[ep]) schoolsStats[ep] = { total: 0, ingere: 0 };
|
||||
schoolsStats[ep].total++;
|
||||
if (a.ingere) schoolsStats[ep].ingere++;
|
||||
}
|
||||
|
||||
console.log('\n=== JSON v3.0 written ===');
|
||||
console.log(`Total auteurs : ${auteursCount}`);
|
||||
console.log(`Ingeres : ${auteursIngeresCount}`);
|
||||
console.log(`Non-ingeres : ${auteursCount - auteursIngeresCount}`);
|
||||
console.log(`Parse-back : OK (${parsedBack.auteurs.length} auteurs)`);
|
||||
console.log('\nPer school (ecole_principale):');
|
||||
const sortedSchools = Object.entries(schoolsStats).sort((a, b) => b[1].total - a[1].total);
|
||||
for (const [school, st] of sortedSchools) {
|
||||
console.log(` ${school.padEnd(30)} total=${String(st.total).padStart(3)} ingere=${String(st.ingere).padStart(3)} non-ing=${String(st.total - st.ingere).padStart(3)}`);
|
||||
}
|
||||
|
||||
// Top 5 schools with most non-ingested
|
||||
const nonIngStats = sortedSchools
|
||||
.map(([k, v]) => [k, v.total - v.ingere])
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 5);
|
||||
console.log('\nTop 5 ecoles avec le plus de non-ingeres (PRG-4 priorities):');
|
||||
for (const [school, count] of nonIngStats) {
|
||||
console.log(` ${school.padEnd(30)} non-ing=${count}`);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
39
server/api/admin/rag-info.get.ts
Normal file
39
server/api/admin/rag-info.get.ts
Normal 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'
|
||||
}
|
||||
})
|
||||
205
server/api/chatbot-pensees.post.ts
Normal file
205
server/api/chatbot-pensees.post.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import type { H3Event } from 'h3'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||
|
||||
interface ChatbotPenseesRequest {
|
||||
query: string
|
||||
mode?: 'hybrid' | 'local' | 'global' | 'naive' | 'mix'
|
||||
corpus?: 'pensees' | 'projets' | 'both'
|
||||
filter_couche?: 'fond' | 'forme' | 'structure' | null
|
||||
filter_ecole?: string | null
|
||||
auteur_slug?: string | null
|
||||
history?: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||
}
|
||||
|
||||
interface LightRAGReference {
|
||||
reference_id?: string
|
||||
file_path?: string
|
||||
content?: string | null
|
||||
}
|
||||
|
||||
interface LightRAGQueryResponse {
|
||||
response: string
|
||||
references?: LightRAGReference[]
|
||||
}
|
||||
|
||||
interface AuteurMini {
|
||||
id: string
|
||||
nom: string
|
||||
ingere?: boolean
|
||||
}
|
||||
|
||||
const SYSTEM_PREFACE_PENSEES = `Tu es un agent du RAG Pensées Écologiques, infrastructure militante du collectif trans-former.fr.
|
||||
Tu réponds en t'appuyant STRICTEMENT sur le corpus ingéré (auteurs FRACAS Bonpote : écosocialisme, éco-anarchisme, écoféminismes, écologies décoloniales, technocritique, pensées du vivant, décroissance...).
|
||||
|
||||
Règles :
|
||||
- Cite les sources (auteur, livre) à chaque assertion importante.
|
||||
- Si la question dépasse le corpus, dis-le clairement. Pas d'hallucination.
|
||||
- Ton politique direct, pas de neutralité fade.
|
||||
- Réponse en français, dense, sans délayage.
|
||||
- Distingue les positions selon les écoles quand elles divergent.`
|
||||
|
||||
const SYSTEM_PREFACE_PROJETS = `Tu es un agent du RAG Projets de Jules Nény (architecte, collectif trans-former.fr).
|
||||
Tu réponds STRICTEMENT à partir des documents projet (fichiers butte-pinson__*.md et autres projets archi de Jules).
|
||||
N'utilise PAS le corpus FRACAS Pensées Écologiques pour répondre, sauf si l'usager te le demande explicitement.
|
||||
|
||||
Règles :
|
||||
- Cite les sources (nom de projet, document) à chaque assertion importante.
|
||||
- Si la question dépasse le corpus projet, dis-le clairement. Pas d'hallucination.
|
||||
- Ton praticien réflexif : 1ère personne quand pertinent, narration située.
|
||||
- Réponse en français, dense, sans délayage.`
|
||||
|
||||
const SYSTEM_PREFACE_BOTH = `Tu es un agent du RAG croisé Pensées x Projets de Jules Nény (architecte militant, collectif trans-former.fr).
|
||||
CENTRE TA RÉPONSE sur les documents PROJETS (fichiers butte-pinson__*.md et autres projets archi).
|
||||
Mobilise le corpus FRACAS Pensées (autres fichiers) UNIQUEMENT pour éclairer théoriquement les partis pris des projets, jamais l'inverse.
|
||||
|
||||
Pondération attendue : ~70% ancrage projet concret, ~30% éclairage théorique FRACAS.
|
||||
|
||||
Règles :
|
||||
- Cite les sources (auteur ou nom de projet, document) à chaque assertion.
|
||||
- Si un thème n'est pas couvert par les projets, dis-le clairement avant d'éventuellement étendre au corpus Pensées.
|
||||
- Pas d'hallucination, pas d'extrapolation hors corpus.
|
||||
- Ton praticien militant : direct, pas neutre, ancré dans la pratique architecturale.
|
||||
- Réponse en français, dense, sans délayage.`
|
||||
|
||||
function buildPrefaceAuteur(nomAuteur: string, slug: string): string {
|
||||
return `Tu réponds EXCLUSIVEMENT depuis les livres de ${nomAuteur} présents dans le RAG (fichiers commençant par "${slug}__").
|
||||
Si la question sort du périmètre de cet auteur, indique-le et propose de l'aborder sans le hashtag pour interroger la carte entière. Reste fidèle au style et à la pensée de ${nomAuteur}. Cite toujours le livre.
|
||||
|
||||
Règles :
|
||||
- Cite les sources (titre du livre) à chaque assertion.
|
||||
- Pas d'hallucination. Si l'info n'est pas dans le corpus de cet auteur, dis-le.
|
||||
- N'introduis JAMAIS d'autres auteurs sauf si ${nomAuteur} les commente explicitement.
|
||||
- Ton politique direct, pas de neutralité fade.
|
||||
- Réponse en français, dense, sans délayage.`
|
||||
}
|
||||
|
||||
// Chargement (et cache) de la liste des auteurs ingérés pour validation du slug
|
||||
let auteursIngeresCache: AuteurMini[] | null = null
|
||||
function loadAuteursIngeres(): AuteurMini[] {
|
||||
if (auteursIngeresCache) return auteursIngeresCache
|
||||
try {
|
||||
const jsonPath = join(process.cwd(), 'public', 'data', 'auteurs-pensees.json')
|
||||
const raw = readFileSync(jsonPath, 'utf-8')
|
||||
const data = JSON.parse(raw)
|
||||
const list: AuteurMini[] = (data.auteurs ?? [])
|
||||
.filter((a: any) => a.ingere === true)
|
||||
.map((a: any) => ({ id: String(a.id), nom: String(a.nom), ingere: true }))
|
||||
auteursIngeresCache = list
|
||||
return list
|
||||
} catch {
|
||||
auteursIngeresCache = []
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event: H3Event) => {
|
||||
const config = useRuntimeConfig(event)
|
||||
|
||||
// 1. Rate limit (20 req/jour/IP, IP hashée RGPD)
|
||||
const ip =
|
||||
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
|
||||
event.node.req.socket?.remoteAddress ||
|
||||
'0.0.0.0'
|
||||
|
||||
const allowed = checkRateLimitJson(ip, 'chatbot-pensees', 20)
|
||||
if (!allowed) {
|
||||
throw createError({ statusCode: 429, message: 'Limite de 20 questions par jour atteinte.' })
|
||||
}
|
||||
|
||||
// 2. Body parse + validation
|
||||
const body = await readBody<ChatbotPenseesRequest>(event)
|
||||
if (!body?.query || body.query.trim().length < 3 || body.query.trim().length > 500) {
|
||||
throw createError({ statusCode: 400, message: 'Query invalide (3-500 caractères).' })
|
||||
}
|
||||
|
||||
const query = body.query.trim()
|
||||
const mode = body.mode || 'hybrid'
|
||||
const corpus = body.corpus || 'both'
|
||||
const ragUrl = (config.ragPeUrl as string) || 'http://localhost:9621'
|
||||
|
||||
// Validation auteur_slug (Phase 8.E) : match contre la liste des auteurs ingérés
|
||||
const auteurSlug = body.auteur_slug?.trim().toLowerCase() || null
|
||||
let nomAuteurMatch: string | null = null
|
||||
if (auteurSlug) {
|
||||
const ingeres = loadAuteursIngeres()
|
||||
const auteur = ingeres.find(a => a.id === auteurSlug)
|
||||
nomAuteurMatch = auteur?.nom ?? null
|
||||
}
|
||||
|
||||
// Préface adaptative : auteur prioritaire si slug matché, sinon corpus
|
||||
let systemPreface: string
|
||||
if (auteurSlug && nomAuteurMatch) {
|
||||
systemPreface = buildPrefaceAuteur(nomAuteurMatch, auteurSlug)
|
||||
} else if (corpus === 'pensees') {
|
||||
systemPreface = SYSTEM_PREFACE_PENSEES
|
||||
} else if (corpus === 'projets') {
|
||||
systemPreface = SYSTEM_PREFACE_PROJETS
|
||||
} else {
|
||||
systemPreface = SYSTEM_PREFACE_BOTH
|
||||
}
|
||||
|
||||
// 3. Health guard — LightRAG down = erreur claire, pas de fallback hallucinatoire
|
||||
try {
|
||||
await $fetch(`${ragUrl}/health`, { timeout: 5000 })
|
||||
} catch {
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
message: 'RAG indisponible pour l\'instant — réessaie dans quelques minutes.',
|
||||
})
|
||||
}
|
||||
|
||||
// 4. Call LightRAG VPS — préface système injectée dans la query
|
||||
const ragQuery = `${systemPreface}\n\nQuestion : ${query}`
|
||||
|
||||
// Construction du body : hl_keywords + ll_keywords si auteur ciblé
|
||||
// NB : LightRAG ne supporte ni keyword_filter ni ids ni metadata_filter (preflight OpenAPI confirmé).
|
||||
// hl_keywords / ll_keywords sont les seuls leviers natifs de priorisation par auteur.
|
||||
const ragBody: Record<string, unknown> = { query: ragQuery, mode }
|
||||
if (auteurSlug && nomAuteurMatch) {
|
||||
ragBody.hl_keywords = [nomAuteurMatch, auteurSlug]
|
||||
ragBody.ll_keywords = [auteurSlug]
|
||||
}
|
||||
|
||||
let ragResponse: LightRAGQueryResponse
|
||||
try {
|
||||
ragResponse = await $fetch<LightRAGQueryResponse>(`${ragUrl}/query`, {
|
||||
method: 'POST',
|
||||
body: ragBody,
|
||||
timeout: 90000,
|
||||
})
|
||||
} catch (e: any) {
|
||||
const status = e?.response?.status
|
||||
if (status === 429) {
|
||||
throw createError({ statusCode: 429, message: 'RAG saturé — réessaie dans quelques instants.' })
|
||||
}
|
||||
throw createError({ statusCode: 504, message: 'RAG en cours de processing — réessaie dans quelques secondes.' })
|
||||
}
|
||||
|
||||
// Fallback post-process : si auteur ciblé et que les references LightRAG remontent
|
||||
// des chunks hors slug__, on l'indique pour transparence. La préface LLM est la garde principale.
|
||||
let chunksOffTarget = 0
|
||||
let chunksOnTarget = 0
|
||||
if (auteurSlug && nomAuteurMatch && Array.isArray(ragResponse.references)) {
|
||||
const slugPrefix = `${auteurSlug}__`
|
||||
for (const ref of ragResponse.references) {
|
||||
const fp = (ref.file_path ?? '').toLowerCase()
|
||||
if (!fp) continue
|
||||
if (fp.startsWith(slugPrefix)) chunksOnTarget++
|
||||
else chunksOffTarget++
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Retour formaté
|
||||
return {
|
||||
response: ragResponse.response ?? '',
|
||||
mode,
|
||||
corpus,
|
||||
auteur: auteurSlug && nomAuteurMatch ? { slug: auteurSlug, nom: nomAuteurMatch } : null,
|
||||
auteur_unmatched: auteurSlug && !nomAuteurMatch ? auteurSlug : null,
|
||||
auteur_chunks: auteurSlug && nomAuteurMatch ? { on_target: chunksOnTarget, off_target: chunksOffTarget } : null,
|
||||
filter: { couche: body.filter_couche ?? null, ecole: body.filter_ecole ?? null },
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
})
|
||||
125
server/api/chatbot-reseaux.post.ts
Normal file
125
server/api/chatbot-reseaux.post.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* POST /api/chatbot-reseaux
|
||||
* Chatbot Réseaux AEP — Carte 2 "Réseaux de bifurcation"
|
||||
* Keyword search sur reseaux-bifurcation.json + Mistral Small.
|
||||
*/
|
||||
// @ts-ignore — JSON import résolu par Rollup
|
||||
import reseauxData from '../../public/data/reseaux-bifurcation.json'
|
||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||
|
||||
interface Structure {
|
||||
id: string
|
||||
nom: string
|
||||
url?: string
|
||||
pays?: string
|
||||
ville?: string
|
||||
famille_principale?: string
|
||||
hashtags?: string[]
|
||||
description_courte?: string
|
||||
description_longue?: string
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `Tu es un conseiller expert au service des architectes engagés dans la transition écologique. Tu connais le réseau AEP (Architecture d'Écologie Politique) — une cartographie des collectifs, agences, initiatives et réseaux qui portent une vision alternative de l'architecture en France et en Europe.
|
||||
|
||||
Ton rôle : orienter l'architecte vers les structures les plus pertinentes pour sa situation, à partir des données ci-dessous.
|
||||
|
||||
RÈGLES :
|
||||
1. Ne cite QUE des structures présentes dans le contexte ci-dessous.
|
||||
2. Sois direct et engagé — c'est l'esprit AEP.
|
||||
3. Réponse max 250 mots, en français.
|
||||
4. Retourne UNIQUEMENT un JSON valide, sans texte avant ou après.
|
||||
|
||||
FORMAT :
|
||||
{
|
||||
"reponse_texte": "Ta réponse en prose, max 250 mots",
|
||||
"fiches_recommandees": [
|
||||
{ "id": "slug-id", "nom": "Nom structure", "explication": "Pourquoi en 1 phrase" }
|
||||
]
|
||||
}
|
||||
|
||||
STRUCTURES DISPONIBLES :
|
||||
{{STRUCTURES_JSON}}`
|
||||
|
||||
function scoreStructure(s: Structure, keywords: string[]): number {
|
||||
if (keywords.length === 0) return 1
|
||||
const haystack = [s.nom, s.famille_principale, s.description_courte, s.description_longue, (s.hashtags ?? []).join(' '), s.ville, s.pays]
|
||||
.filter(Boolean).join(' ').toLowerCase()
|
||||
return keywords.reduce((score, kw) => score + (haystack.includes(kw) ? 1 : 0), 0)
|
||||
}
|
||||
|
||||
function extractKeywords(q: string): string[] {
|
||||
return q.toLowerCase().replace(/[^\w\sàâäéèêëîïôùûüç-]/g, ' ').split(/\s+/).filter(w => w.length >= 3).slice(0, 10)
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const ip = getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() || event.node.req.socket?.remoteAddress || '0.0.0.0'
|
||||
const allowed = checkRateLimitJson(ip, 'chatbot-reseaux', 20)
|
||||
if (!allowed) throw createError({ statusCode: 429, message: 'Limite de 20 questions par jour atteinte.' })
|
||||
|
||||
const body = await readBody(event)
|
||||
const question: string = (body?.question ?? '').trim()
|
||||
if (!question || question.length < 5) throw createError({ statusCode: 400, message: 'Question trop courte.' })
|
||||
|
||||
const structures: Structure[] = ((reseauxData as any).structures ?? [])
|
||||
const keywords = extractKeywords(question)
|
||||
|
||||
const top = structures
|
||||
.map(s => ({ s, score: scoreStructure(s, keywords) }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 20)
|
||||
.map(x => x.s)
|
||||
|
||||
const context = top.map(s => ({
|
||||
id: s.id,
|
||||
nom: s.nom,
|
||||
famille: s.famille_principale ?? '',
|
||||
lieu: [s.ville, s.pays].filter(Boolean).join(', '),
|
||||
tags: (s.hashtags ?? []).slice(0, 5).join(', '),
|
||||
description: (s.description_courte ?? s.description_longue ?? '').slice(0, 200),
|
||||
}))
|
||||
|
||||
const systemPrompt = SYSTEM_PROMPT.replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0))
|
||||
|
||||
const nebiusApiKey = config.nebiusApiKey as string
|
||||
if (!nebiusApiKey) throw createError({ statusCode: 500, message: 'Clé API Nebius manquante.' })
|
||||
|
||||
let mistralRaw: string
|
||||
try {
|
||||
const res = await $fetch<{ choices: { message: { content: string } }[] }>(
|
||||
'https://api.tokenfactory.nebius.com/v1/chat/completions',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${nebiusApiKey}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'deepseek-ai/DeepSeek-V3.2',
|
||||
temperature: 0.3,
|
||||
max_tokens: 700,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: question },
|
||||
],
|
||||
}),
|
||||
}
|
||||
)
|
||||
mistralRaw = res.choices?.[0]?.message?.content ?? '{}'
|
||||
} catch {
|
||||
throw createError({ statusCode: 502, message: 'Erreur IA — réessaie dans quelques instants.' })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(mistralRaw)
|
||||
return {
|
||||
reponse_texte: parsed.reponse_texte ?? "Je n'ai pas pu analyser ta demande.",
|
||||
fiches_recommandees: (parsed.fiches_recommandees ?? []).map((r: any) => ({
|
||||
id: r.id,
|
||||
nom: r.nom ?? structures.find(s => s.id === r.id)?.nom ?? r.id,
|
||||
explication: r.explication ?? '',
|
||||
})),
|
||||
}
|
||||
} catch {
|
||||
return { reponse_texte: "Je n'ai pas pu analyser ta demande.", fiches_recommandees: [] }
|
||||
}
|
||||
})
|
||||
136
server/api/chatbot-taff.post.ts
Normal file
136
server/api/chatbot-taff.post.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* POST /api/chatbot-taff
|
||||
* Chatbot d'aiguillage — Carte 3 "Trouver du taf"
|
||||
* Lit plateformes-taff.json, appelle Mistral Small, retourne recommandations.
|
||||
*/
|
||||
|
||||
// @ts-ignore — JSON import résolu par Vite/Rollup
|
||||
import taffData from '../../public/data/plateformes-taff.json'
|
||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||
|
||||
interface PlateformeMinimal {
|
||||
id: string
|
||||
nom: string
|
||||
type: string
|
||||
description_courte: string
|
||||
scoring: {
|
||||
remuneration: string | null
|
||||
transparence: string | null
|
||||
pratiques: string | null
|
||||
ecologie: string | null
|
||||
matching: string | null
|
||||
tag_global: string
|
||||
justification_tag: string
|
||||
}
|
||||
secteurs_servis: string[]
|
||||
cout_entree: string
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `Tu es un conseiller expert au service des architectes indépendants français. Tu connais toutes les plateformes de mise en relation architecte↔particulier et les agrégateurs d'appels d'offres publics référencés par AEP (Architecture d'Écologie Politique).
|
||||
|
||||
Ton rôle : aider l'architecte à choisir LA ou LES plateformes adaptées à sa situation, en t'appuyant exclusivement sur les données ci-dessous.
|
||||
|
||||
RÈGLES :
|
||||
1. Ne recommande QUE des plateformes présentes dans le contexte ci-dessous.
|
||||
2. Sois direct et opinionné — c'est ça la valeur d'AEP.
|
||||
3. Si une plateforme est ❌ "À éviter", signale-le clairement.
|
||||
4. Réponse max 250 mots, en français, ton conseiller pair.
|
||||
5. Retourne UNIQUEMENT un JSON valide, sans texte avant ou après.
|
||||
|
||||
FORMAT :
|
||||
{
|
||||
"reponse_texte": "Ta réponse en prose, max 250 mots",
|
||||
"plateformes_recommandees": [
|
||||
{ "id": "slug-kebab", "nom": "Nom plateforme", "raison": "Pourquoi cette plateforme en 1 phrase" }
|
||||
]
|
||||
}
|
||||
|
||||
PLATEFORMES DISPONIBLES :
|
||||
{{PLATEFORMES_JSON}}`
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const ip =
|
||||
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
|
||||
event.node.req.socket?.remoteAddress ||
|
||||
'0.0.0.0'
|
||||
|
||||
const allowed = checkRateLimitJson(ip, 'chatbot-taff', 20)
|
||||
if (!allowed) {
|
||||
throw createError({ statusCode: 429, statusMessage: 'Limite de 20 questions par jour atteinte.' })
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const question: string = (body?.question ?? '').trim()
|
||||
if (!question || question.length < 5) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
|
||||
}
|
||||
|
||||
// Données bundlées statiquement à la compilation (import JSON)
|
||||
const plateformes: PlateformeMinimal[] = ((taffData as any).plateformes ?? []).map((p: any) => ({
|
||||
id: p.id,
|
||||
nom: p.nom,
|
||||
type: p.type,
|
||||
description_courte: p.description_courte,
|
||||
scoring: p.scoring,
|
||||
secteurs_servis: p.secteurs_servis,
|
||||
cout_entree: p.cout_entree,
|
||||
}))
|
||||
|
||||
const context = plateformes.map(p => ({
|
||||
id: p.id,
|
||||
nom: p.nom,
|
||||
type: p.type === 'b2c-mise-en-relation' ? 'B2C' : 'Appels offres publics',
|
||||
tag: p.scoring.tag_global,
|
||||
resume: p.description_courte,
|
||||
secteurs: p.secteurs_servis.join(', '),
|
||||
cout: p.cout_entree,
|
||||
justification: p.scoring.justification_tag,
|
||||
}))
|
||||
|
||||
const systemPrompt = SYSTEM_PROMPT.replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0))
|
||||
|
||||
const nebiusApiKey = config.nebiusApiKey as string
|
||||
if (!nebiusApiKey) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'Clé API Nebius manquante.' })
|
||||
}
|
||||
|
||||
let mistralRaw: string
|
||||
try {
|
||||
const res = await $fetch<{ choices: { message: { content: string } }[] }>(
|
||||
'https://api.tokenfactory.nebius.com/v1/chat/completions',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${nebiusApiKey}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'deepseek-ai/DeepSeek-V3.2',
|
||||
temperature: 0.3,
|
||||
max_tokens: 700,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: question },
|
||||
],
|
||||
}),
|
||||
}
|
||||
)
|
||||
mistralRaw = res.choices?.[0]?.message?.content ?? '{}'
|
||||
} catch {
|
||||
throw createError({ statusCode: 502, statusMessage: 'Erreur IA — réessaie dans quelques instants.' })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(mistralRaw)
|
||||
return {
|
||||
reponse_texte: parsed.reponse_texte ?? "Je n'ai pas pu analyser ta demande.",
|
||||
plateformes_recommandees: (parsed.plateformes_recommandees ?? []).map((r: any) => ({
|
||||
id: r.id,
|
||||
nom: r.nom ?? plateformes.find(p => p.id === r.id)?.nom ?? r.id,
|
||||
raison: r.raison ?? '',
|
||||
})),
|
||||
}
|
||||
} catch {
|
||||
return { reponse_texte: "Je n'ai pas pu analyser ta demande.", plateformes_recommandees: [] }
|
||||
}
|
||||
})
|
||||
197
server/api/chatbot-v2.post.ts
Normal file
197
server/api/chatbot-v2.post.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 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. Nebius DeepSeek-V3.2 - génération réponse
|
||||
const nebiusApiKey = config.nebiusApiKey as string
|
||||
if (!nebiusApiKey) throw createError({ statusCode: 500, statusMessage: 'Clé API Nebius manquante.' })
|
||||
|
||||
let mistralRaw: string
|
||||
try {
|
||||
const nebiusRes = await $fetch<{
|
||||
choices: { message: { content: string } }[]
|
||||
}>('https://api.tokenfactory.nebius.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${nebiusApiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'deepseek-ai/DeepSeek-V3.2',
|
||||
temperature: 0.3,
|
||||
max_tokens: 600,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: question }
|
||||
]
|
||||
})
|
||||
})
|
||||
mistralRaw = nebiusRes.choices?.[0]?.message?.content ?? '{}'
|
||||
} catch (e: any) {
|
||||
console.error('[chatbot-v2] Erreur Nebius DeepSeek :', e?.message ?? e)
|
||||
throw createError({ statusCode: 502, statusMessage: 'Erreur appel Nebius DeepSeek.' })
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
})
|
||||
@@ -247,13 +247,13 @@ export default defineEventHandler(async (event) => {
|
||||
JSON.stringify(fichesContext, null, 0),
|
||||
)
|
||||
|
||||
// 6. Appel Mistral Small
|
||||
const mistralApiKey = config.mistralApiKey as string
|
||||
// 6. Appel Nebius DeepSeek-V3.2
|
||||
const nebiusApiKey = config.nebiusApiKey as string
|
||||
|
||||
if (!mistralApiKey) {
|
||||
if (!nebiusApiKey) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Clé API Mistral manquante.',
|
||||
statusMessage: 'Clé API Nebius manquante.',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -262,17 +262,17 @@ export default defineEventHandler(async (event) => {
|
||||
let tokensOut = 0
|
||||
|
||||
try {
|
||||
const mistralRes = await $fetch<{
|
||||
const nebiusRes = await $fetch<{
|
||||
choices: { message: { content: string } }[]
|
||||
usage?: { prompt_tokens: number; completion_tokens: number }
|
||||
}>('https://api.mistral.ai/v1/chat/completions', {
|
||||
}>('https://api.tokenfactory.nebius.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${mistralApiKey}`,
|
||||
Authorization: `Bearer ${nebiusApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'mistral-small-latest',
|
||||
model: 'deepseek-ai/DeepSeek-V3.2',
|
||||
temperature: 0.3,
|
||||
max_tokens: 600,
|
||||
response_format: { type: 'json_object' },
|
||||
@@ -283,11 +283,11 @@ export default defineEventHandler(async (event) => {
|
||||
}),
|
||||
})
|
||||
|
||||
mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}'
|
||||
tokensIn = mistralRes.usage?.prompt_tokens ?? 0
|
||||
tokensOut = mistralRes.usage?.completion_tokens ?? 0
|
||||
mistralRaw = nebiusRes.choices?.[0]?.message?.content ?? '{}'
|
||||
tokensIn = nebiusRes.usage?.prompt_tokens ?? 0
|
||||
tokensOut = nebiusRes.usage?.completion_tokens ?? 0
|
||||
} catch (e: any) {
|
||||
console.error('[chatbot] Erreur Mistral Small:', e?.message ?? e)
|
||||
console.error('[chatbot] Erreur Nebius DeepSeek:', e?.message ?? e)
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
statusMessage: 'Erreur appel IA — réessaie dans quelques instants.',
|
||||
|
||||
9
server/api/plateformes-taff.get.ts
Normal file
9
server/api/plateformes-taff.get.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* GET /api/plateformes-taff
|
||||
* Retourne les données TAFF — JSON importé statiquement (bundlé par Rollup).
|
||||
* Utilisé par le chatbot-taff en interne et potentiellement par le front.
|
||||
*/
|
||||
// @ts-ignore — JSON import résolu par Vite/Rollup
|
||||
import data from '../../public/data/plateformes-taff.json'
|
||||
|
||||
export default defineEventHandler(() => data)
|
||||
96
server/utils/vectorSearch.ts
Normal file
96
server/utils/vectorSearch.ts
Normal 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 []
|
||||
}
|
||||
}
|
||||
91
types/structure-v2.ts
Normal file
91
types/structure-v2.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user