Compare commits

..
Author SHA1 Message Date
Jules NenyandClaude Opus 5.5 fa828e3231 feat(chatbot-pensees): attente progressive, timeout front 95 s et bouton Réessayer
États « Je fouille le corpus… » (0-6 s), « Je rédige… » (6-30 s), « Le corpus est vaste,
encore un instant… » (30 s+), annoncés via une région aria-live persistante ; points
fixes si prefers-reduced-motion. Timeout front 95 s (> 90 s serveur), message d'excuse
et bouton « Réessayer » qui reposte la même question sans dupliquer le message ;
messages humains distincts pour 429 (quota / saturé), 503, 504, réseau. États d'attente
et erreurs non persistés (M2 inchangé), contrat de la route inchangé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vcVGz8i4WpZm2dixKUiSD
2026-10-09 04:02:29 +02:00
Jules NenyandClaude Opus 5.5 85cd31d201 feat(chatbot-pensees): garde-fou serveur des citations (guillemets retirés si introuvables)
La route demande include_references + include_chunk_content à LightRAG et vérifie
chaque segment >= 6 mots entre « » / "…" / “…” dans les chunks réellement
récupérés (normalisation accents, casse, apostrophes, ponctuation, élisions [...]).
Un segment introuvable perd ses guillemets (texte conservé) ; champ optionnel
citations: { gardees, retirees }. Fonction pure dans server/utils/citations.ts,
réutilisée par scripts/verif-citations.mjs (qui gagne --max-total-tokens) ;
scripts/test-citations.mjs couvre les cas limites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vcVGz8i4WpZm2dixKUiSD
2026-10-09 03:59:32 +02:00
Jules NenyandClaude Opus 5.5 52b79e1e8e feat(chatbot): toggle de mode branché sur les 5 interfaces chatbot
Placeholder (/ et /agences desktop), Sheet (/ mobile), Réseaux (/agences mobile),
chatbot de /trouver-du-taf et Pensées (/media) envoient `profondeur` dans le body
et l'enregistrent sur chaque message (badge conservé à la restauration).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vcVGz8i4WpZm2dixKUiSD
2026-10-09 03:49:56 +02:00
Jules NenyandClaude Opus 5.5 4c57f30f6a feat(chatbot): composant ChatModeToggle (radiogroup) et état partagé du mode
Toggle sobre 2 options (role radiogroup, flèches/Home/End, focus visible,
reduced-motion), étiquette de mode sur les réponses approfondies, et
useChatProfondeur (état par clé, desktop et mobile synchronisés). Libellés lus
dans utils/chatProfondeur.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vcVGz8i4WpZm2dixKUiSD
2026-10-09 03:49:56 +02:00
Jules NenyandClaude Opus 5.5 80f69565aa feat(chatbot): mode profondeur côté serveur (rapide / approfondi) sur les 4 routes
Contrat unique `profondeur` ('rapide' | 'approfondi', défaut rapide) dans
utils/chatProfondeur.ts (libellés partagés). Les 3 routes Bifrost lisent
body.profondeur ?? body.mode, choisissent le tier (20b / 120b + replis), un
prompt système « réflexion » (Entraide, Réseaux, Taff) au même format JSON, et un
budget max_tokens adapté. Pensées lit body.profondeur (jamais body.mode, réservé à
LightRAG) : posture réflexion ajoutée au user_prompt + max_total_tokens 40000.
Modèle servi journalisé, et champ debug exposé uniquement en dev.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vcVGz8i4WpZm2dixKUiSD
2026-10-09 03:45:27 +02:00
Jules NenyandClaude Opus 5.5 75d8440601 feat(chatbot): persistance des conversations en sessionStorage + bouton Nouvelle conversation
- composables/useChatSession.ts : historique réactif partagé (useState) et persisté sous aep-chat-<clé>, version de schéma {v:1}, 40 derniers messages, restauration au mount uniquement (SSR-safe), try/catch partout, messages transient jamais écrits
- clés : accueil (Placeholder + Sheet), agences (Placeholder réseaux + ChatbotReseaux), taff, pensees (suffixée par l'auteur)
- ChatbotPlaceholder reçoit sa clé par prop chatKey (défaut accueil)
- ChatNewConversation : bouton sobre, visible seulement s'il y a un historique

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vcVGz8i4WpZm2dixKUiSD
2026-10-09 03:38:15 +02:00
Jules NenyandClaude Opus 5.5 31edbd37af refactor(chatbot): ChatbotPlaceholder paramétrable, /agences desktop sur chatbot-reseaux
- ChatbotPlaceholder : props endpoint, titre, placeholderReplie, placeholderSaisie, intro, exemple, fichesTitre, ficheLienModele, rendreMarkdown, messageLimite ; défauts = comportement actuel de l'accueil
- pages/agences.vue : le desktop appelle /api/chatbot-reseaux (le câblage croisé vers le backend Entraide est supprimé), textes orientés structures, rendu markdown, liens /agences#id
- suppression de server/api/chatbot-v2.post.ts et server/utils/vectorSearch.ts (aucun appelant) ; base recherche sémantique par embeddings retrouvable au commit 39602e26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vcVGz8i4WpZm2dixKUiSD
2026-10-09 03:32:18 +02:00
Jules NenyandClaude Opus 5.5 39602e26c7 fix(chatbot-pensees): règle anti-citations inventées, passée par user_prompt
- REGLE_CITATIONS : constante unique appliquée aux 4 préambules (pensées, projets, croisé, auteur) :
  ne citer entre guillemets que ce qui figure mot pour mot dans le contexte, sinon reformuler sans guillemets.
- Préambules + règle envoyés via le champ user_prompt de LightRAG /query ; query = question seule
  (la recherche n'est plus polluée par le préambule).
- scripts/verif-citations.mjs : contrôle réutilisable (segments entre guillemets vs contexte only_need_context).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vcVGz8i4WpZm2dixKUiSD
2026-10-09 03:21:48 +02:00
Jules NenyandClaude Opus 5.5 1054172ec9 docs(design-system): §Vue fiches — détail, fiche à fiche, historique, contrat #detail (B12)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGNSEFZdoKvbdsGyfSnu5S
2026-10-03 09:20:16 +02:00
Jules NenyandClaude Opus 5.5 a9befb25ab fix(vue-fiches): passe /autofeedback — Échap dans un champ garde le brouillon, titre d'onglet = fiche ouverte, contrastes et mini-carte décorative de FicheDetail
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGNSEFZdoKvbdsGyfSnu5S
2026-10-03 09:20:16 +02:00
Jules NenyandClaude Opus 5.5 a6dcf48c71 fix(fiche-detail): mini-carte dessinée quand la fiche s'ouvre côté client (conteneur ClientOnly attendu)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGNSEFZdoKvbdsGyfSnu5S
2026-10-03 09:09:37 +02:00
Jules NenyandClaude Opus 5.5 5e0027b861 feat(fiches): / et /agences ouvrent la fiche dans le panneau ; FicheReseauContenu extrait, FicheModal et FicheModalV2 retirées ; Voir sur la carte (B12-M4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGNSEFZdoKvbdsGyfSnu5S
2026-10-03 08:57:16 +02:00
Jules NenyandClaude Opus 5.5 1bb55538a4 feat(vue-fiches): panneau de détail à droite, plein écran au téléphone ; clavier, balayage, copier le lien (B12-M1/M2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGNSEFZdoKvbdsGyfSnu5S
2026-10-03 08:57:16 +02:00
Jules NenyandClaude Opus 5.5 fe1043e321 feat(vue-fiches): fiche= dans l'URL, fiche à fiche, historique push/replace/back (B12-M2/M3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGNSEFZdoKvbdsGyfSnu5S
2026-10-03 08:57:16 +02:00
Jules NenyandClaude Fable 5.1 55027ba482 fix(chatbot-pensees): défaut serveur = corpus Pensées seules (AEP-PREFACE, ok Jules 03/10)
Le mode croisé Pensées × Projets reste un choix explicite du visiteur (sélecteur du chatbot).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014PHyAMzRnXo8zPQYkUNopU
2026-10-03 00:28:00 +02:00
Jules NenyandClaude Opus 5.5 7c968c2054 fix(vue-fiches): avant le montage, une page sans état Mixte s'affiche en Fiches (passe /autofeedback, angle B12)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCt6e8wWiYqRXLJAuKqP5i
2026-10-02 18:12:05 +02:00
Jules NenyandClaude Opus 5.5 0166d66fc8 docs(design-system): §Vue fiches (contrat, états, URL, brancher une troisième page)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCt6e8wWiYqRXLJAuKqP5i
2026-10-02 18:10:35 +02:00
Jules NenyandClaude Opus 5.5 d4e72942fc fix(vue-fiches): accessibilité et contrôles M8 (Lighthouse 24/24 ≥ 95)
Nom accessible de la carte-fiche = son contenu ; résumé et « Sans localisation »
en color-mix(--text 78 %) ; libellé du tri gardé pour les lecteurs d'écran sous
768 px ; lien « sans adresse » hors du parcours clavier quand le compteur est
masqué ; bouton Chatbot flottant en fond plein ; option sélectionnée explicite
des <select> (rendu serveur) ; pastille « Sans adresse » ; cluster allumé au
survol ; pas d'init Leaflet sur un conteneur détaché.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCt6e8wWiYqRXLJAuKqP5i
2026-10-02 18:10:35 +02:00
Jules NenyandClaude Opus 5.5 a98342c5f1 feat(reseaux): /agences branché sur VueFiches ; recherche, tags, famille et carte= dans l'URL (B11-M6)
Hashtags désormais en OU dans leur dimension (ET avant), ET avec la famille.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCt6e8wWiYqRXLJAuKqP5i
2026-10-02 17:57:27 +02:00
Jules NenyandClaude Opus 5.5 2e82d412ed feat(entraide): / branché sur VueFiches ; NavSidebar, FichesPanel, MobileSheet et onglets remplacés (B11-M5)
Survol fiche ↔ épingle (cluster compris), invalidateSize au redimensionnement,
titre accessible des épingles, ?random=1 fiable au chargement direct.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCt6e8wWiYqRXLJAuKqP5i
2026-10-02 17:57:27 +02:00
Jules NenyandClaude Opus 5.5 5a4c7af79c feat(vue-fiches): composant VueFiches (Carte · Mixte · Fiches, poignée, barre d'outils) et filtres par dimension (B11-M3, M4)
Filtres à un seul endroit par état, chips à 0 grisées, repli au-delà de 14,
feuille « Filtrer » sous 768 px, pastilles actives en état Carte.
FicheMiniCard : racine <article> + bouton étiré pour rendre les chips cliquables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCt6e8wWiYqRXLJAuKqP5i
2026-10-02 17:57:27 +02:00
Jules NenyandClaude Opus 5.5 38df1d5eb5 feat(vue-fiches): état unique lu et écrit dans l'URL, filtres OU/ET, compteurs, tri, regroupement (B11-M1, M2 adaptateurs)
utils/vueFiches.ts porte la logique pure, composables/useVueFiches.ts la branche
sur la route ; MiniFiche gagne etiquettes, coords, date, ville, texte.
Test : node scripts/test-vue-fiches.mjs (42/42).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCt6e8wWiYqRXLJAuKqP5i
2026-10-02 17:43:43 +02:00
Jules NenyandClaude Opus 5.5 5ce431d055 fix(nav): fil d'échelle du groupe M replié sur sa pastille sous 400 px
Mesuré : 387 px dans 360 (Codev hors écran, sans indice de défilement).
Accroche gardée pour les lecteurs d'écran. Passe /autofeedback de B10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBu7CSLy7PJgL4HJaV5oHP
2026-10-02 17:05:40 +02:00
Jules NenyandClaude Opus 5.5 8d05873082 docs(design-system): §Navigation par échelles (groupes, seuils, source echelles.ts)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBu7CSLy7PJgL4HJaV5oHP
2026-10-02 17:02:35 +02:00
Jules NenyandClaude Opus 5.5 358c2954c3 feat(media): ?tab= suivi par watch, sous-onglets retirés, Publications ; Signaler en pied d'À propos
La nav XL porte Recherche média / Publications ; /media relit route.query.tab à
chaque changement (un lien /media → /media?tab=projets changeait l'URL sans
changer la page). B10 M5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBu7CSLy7PJgL4HJaV5oHP
2026-10-02 17:02:10 +02:00
Jules NenyandClaude Opus 5.5 79424d29cf feat(nav): barre par échelles S / M / XL, tiroir < 1280, fil d'échelle mobile
- NavEchelles : trois groupes (accroche pastille + titre, <ul> étiqueté,
  aria-current) ≥ 1280 ; groupe courant seul 768-1279 ; lu depuis utils/echelles.ts
- NavTiroir remplace le menu déroulant : tiroir à droite, voile, Échap, focus
  piégé et rendu, cibles 44 px ; bouton « Menu » masqué ≥ 1280
- FilEchelle < 768, dans le flux avant le conteneur de page
- Soutenir et À propos dans la barre ≥ 1280 ; Signaler quitte la barre
- barre de recherche d'en-tête retirée (/ garde sa recherche dans MobileSheet
  à 768 et 1000, vérifié au DOM)
- html lang="fr" (Lighthouse html-has-lang)
B10 M2-M4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBu7CSLy7PJgL4HJaV5oHP
2026-10-02 17:02:10 +02:00
Jules NenyandClaude Opus 5.5 7762b31f1d feat(nav): onglets S / M / XL et echelleDeRoute dans utils/echelles.ts
Chaque échelle porte ses onglets (libellé, cible, actif(route)) ; echelleDeRoute
rend le groupe courant ou null. Source unique de la nav B10. Test node :
scripts/test-echelles-nav.mjs (23/23). B10 M1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBu7CSLy7PJgL4HJaV5oHP
2026-10-02 16:48:28 +02:00
Jules NenyandClaude Opus 5.5 3f5ebe1c36 fix(fiches): la grille ?vue=fiches ne déborde plus (minmax(0, 1fr))
repeat(n, 1fr) prend pour minimum le contenu le plus large ; avec le nom de
fiche en nowrap, les colonnes dépassaient le conteneur (prod 29/09 : 1658 px
dans 1088 à 1440, 534 dans 328 à 360). B10 M0, SPEC-vue-fiches §1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBu7CSLy7PJgL4HJaV5oHP
2026-10-02 16:47:33 +02:00
Jules NenyandClaude Opus 5.5 a2b89489a6 fix(budget): une table sans aucune ligne datée n'est jamais « 0 € vérifié »
Passe /autofeedback (angle checkpoint) : si NocoDB renvoie des lignes
sans timestamp, CreatedAt ni created_at, checkBudget rendait
verified=true à 0 €, soit le fail-open silencieux d'avant sous une autre
forme. Désormais verified=false et avertissement au journal. 17/17.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174RrDEQFQTtySXkTcsUKuv
2026-09-29 00:27:01 +02:00
56 changed files with 4896 additions and 3201 deletions
+67 -8
View File
@@ -56,23 +56,56 @@ Pour une chip non cliquable (affichage seul, ex. liste de résultats) : `as="spa
3. Choisis l'ordre de repli du résumé : `buildResume([champA, champB, ...])` prend le premier champ non vide, en garde la première phrase, tronque à 110 caractères sur un mot. Vérifie quels champs de ton type contiennent du texte descriptif avant de les ordonner.
4. `chips` = 3 étiquettes maximum (fonctions, hashtags, ou l'équivalent pour ton type). `geoloc` = `latitude != null && longitude != null` si ton type a des coordonnées, sinon `true` (pas de fiche à masquer côté carte).
`components/FicheMiniCard.vue` consomme uniquement `MiniFiche` — il n'a jamais besoin de connaître `Org` ou `StructureV2`. C'est ce découplage qui permet d'ajouter une source sans toucher au composant d'affichage.
`components/FicheMiniCard.vue` consomme uniquement `MiniFiche` (depuis B11, `MiniFiche` porte aussi `etiquettes`, `chipsDimension`, `coords`, `date`, `ville`, `texte` : voir §Vue fiches) — il n'a jamais besoin de connaître `Org` ou `StructureV2`. C'est ce découplage qui permet d'ajouter une source sans toucher au composant d'affichage.
## FichesPanel — le panneau « toutes les fiches »
## Vue fiches (B11 02/10/2026, détail B12 03/10/2026) — `VueFiches` + `useVueFiches`
`components/FichesPanel.vue` : props `fiches: MiniFiche[]` (déjà filtrée par la page appelante — le panneau ne refiltre pas, ne crée pas de second champ de recherche), `hashtags: string[]` (options de filtre), `selected: string[]`, `query: string` (sert seulement à nuancer le message d'état vide), `intention: string`. Émet `update:selected` et `open(fiche)`.
Remplace `FichesPanel`, `NavSidebar`, `MobileSheet` et les onglets Métropolitain / Outre-mer / Toutes les fiches de `/` et `/agences` (tous retirés). Spécification : `1 PROJETS/DEV/AEP/Cadrage/SPEC-vue-fiches.md` (vault).
**Le panneau ne possède pas son propre état de filtre** : ses chips pilotent la variable réactive de la page appelante via `update:selected` (ex. `fonctions` sur `/`, `selectedHashtags` sur `/agences`). C'est ce qui garantit qu'une chip cochée dans le panneau et la même chip dans la sidebar (si elle est visible) affichent toujours le même état — une seule source de vérité, jamais deux.
**Trois fichiers, trois rôles.**
**Hashtags repliés** (B3-M4) : au-delà de 14 chips (`COLLAPSE_THRESHOLD`), seules les premières s'affichent + un chip-bouton « voir les N autres » qui déplie. Les chips déjà actives restent visibles même repliées (union entre les 14 premières et les actives hors de ce lot). État déplié non persistant — `ref` locale, reset à chaque montage. `/` (10 fonctions) ne dépasse jamais le seuil ; `/agences` (~60 hashtags) se replie par défaut.
| Fichier | Rôle | Testé par |
|---|---|---|
| `utils/vueFiches.ts` | logique pure : `lireQuery` / `ecrireQuery`, `filtrerFiches`, `compterEtiquettes`, `trierFiches`, `regrouper`, `ordreFiches` ; B12 : `lireFiche`, `queryAvecFiche`, `positionFiche`, `modeOuverture` / `modeFermeture`, `empreinteListe` | `node scripts/test-vue-fiches.mjs` |
| `composables/useVueFiches.ts` | branche la logique sur la route ; rend un objet `reactive` (`vf`) | navigateur |
| `components/VueFiches.vue` (+ `VueFichesFiltres.vue`) | barre d'outils, trois états, filtres, grille, poignée, feuille « Filtrer », panneau de détail (B12) | navigateur |
**Accès** : `?vue=fiches` dans l'URL, en plus des paramètres de filtre existants. Sur `/`, `desktopMapView`/`mobileMapView` gagnent une valeur `'fiches'` à côté de `'metropole'`/`'outremer'` ; sur `/agences`, à côté de `'metropole'`/`'outremer'`/`'graphe'`. Recharger l'URL avec `?vue=fiches` doit rouvrir directement le panneau, filtres compris.
**L'URL est la source de vérité.** L'état se lit dans `route.query` (surveillée) et s'écrit par `router.replace`. Paramètres : `vue` (`carte` · `mixte` · `fiches`, absent = défaut par largeur), `q`, un paramètre par dimension (valeurs séparées par des virgules), `adresse=sans`, `tri`, `groupe`, et celui des sous-vues de carte (`mode` sur `/`, `carte` sur `/agences`). Un paramètre inconnu ou invalide est ignoré ; un paramètre que la vue ne gère pas (`territoire`, `random`) est recopié tel quel, `fiche=` aussi (un filtre changé garde la fiche ouverte). La recherche part dans l'URL 250 ms après la dernière touche.
**Mobile** : le panneau ne se monte JAMAIS dans un `MobileSheet` (le composant sheet demi-hauteur pensé pour flotter au-dessus d'une carte) — il n'y a pas de carte derrière la vue fiches, donc pas de sheet à demi-hauteur : le panneau occupe toute la zone sous la barre d'onglets, avec son propre scroll interne. Piège vécu en B3 : monter le panneau dans un `MobileSheet` par réflexe (comme les autres onglets) laisse ~45 % de l'écran vide au-dessus, puisque la sheet démarre repliée à mi-hauteur et qu'il n'y a rien à voir en dessous.
**États.** Sans `vue=` : Mixte à partir de 1024 px, Fiches en dessous. Mixte n'existe pas sous 1024 px (il se lit Fiches). Avant le montage, la racine porte `vf--auto` et la CSS tranche seule, pour qu'un lien s'affiche sans saut. La poignée ‹ › bascule Mixte ↔ Carte ; le repli est retenu en `sessionStorage` (`aep_vue_fiches_liste_repliee`) et ne compte que si l'URL n'a pas de `vue=`. En état Fiches, la carte reste montée, invisible (Leaflet garde une taille) ; les cartes appellent `invalidateSize` par `ResizeObserver`.
**Filtres : OU dans une dimension, ET entre dimensions.** Compteur d'une chip = fiches qui portent l'étiquette parmi celles que laissent passer la recherche et les AUTRES dimensions ; 0 = chip grisée (`disabled`). Repli au-delà de 14 valeurs, dans l'ordre de la dimension (stable). Les filtres sont à UN endroit : panneau de gauche (Mixte), tête de grille (Fiches, ordinateur et tablette), feuille du bas (téléphone) ; en Carte, seules les pastilles actives, dans la barre. Une chip cliquée dans une carte-fiche ajoute le filtre (`MiniFiche.chipsDimension`).
**Tri** : `pertinence` (nombre d'étiquettes cochées, puis nom ; défaut dès qu'un filtre est actif), `nom` (défaut sinon), `recent` (date puis Id décroissant ; à n'offrir que si la source a une date). **Regroupement** : une fiche va sous sa PREMIÈRE étiquette de la dimension, jamais en double.
**Détail et fiche à fiche (B12).** Cliquer une carte-fiche (ou une épingle : la page appelle `vf.ouvrirFiche(id)`) écrit `fiche=<id>` par `router.push` ; passer d'une fiche à l'autre (Précédente / Suivante, ← →, balayage au téléphone) par `router.replace`. Fermer (✕, « ‹ N fiches », Échap) revient en arrière si l'entrée vient de la page et que la liste n'a pas changé depuis, sinon retire `fiche=` sur place (lien partagé, filtre changé fiche ouverte) : le retour du téléphone ferme la fiche, jamais la page. La fiche est cherchée dans TOUTES les fiches : hors du résultat, le panneau dit « Hors de vos filtres » et n'a pas de Précédente / Suivante. Panneau de 380 px en troisième colonne (ordinateur, dans les trois états ; la grille Fiches passe à 2 colonnes), 340 px sur tablette, plein écran par-dessus la vue au téléphone (barre, liste et carte en `inert`, fiche à fiche en pied). Le focus entre dans le panneau à l'ouverture et revient, à la fermeture, sur la carte-fiche de la dernière fiche lue ; la position de défilement de la liste est rendue. Actions du panneau : Ouvrir la page de la fiche (prop `lien-page`), Voir sur la carte (si la fiche a une épingle dans le résultat : Mixte sur ordinateur, Carte au téléphone ; la page lit `vf.centrage` et le passe à sa carte, prop `centrage` de `NavMap` / `NavMapV2`), Copier le lien (l'URL courante dit tout). Deux écritures d'URL dans le même tick s'enchaînent (la seconde part de la query en attente), elles ne s'écrasent plus.
**Contrat.**
```vue
<VueFiches :vf="vf" intention="Une ligne." libelle-recherche="Rechercher…" :pending="pending"
:lien-page="(f) => `/fiche/${f.id}`" @survol="…">
<template #carte> <!-- carte(s) de la page, une par sous-vue, v-show sur vf.etat.sousVue --> </template>
<template #detail="{ fiche, filtrer }"> <!-- contenu propre à la source ; filtrer(dimension, valeur) au clic d'une étiquette --> </template>
<template #vide> <!-- optionnel --> </template>
</VueFiches>
```
Contenus de détail en place : `/` → `FicheDetail` (`compact`, `etiquettes-cliquables`) + commentaires, l'`Org` venant de la liste (même enregistrement NocoDB que `/api/fiche/:id`) ; `/agences` → `FicheReseauContenu` (corps de l'ancienne `FicheModalV2`, une structure liée s'ouvre dans le même panneau). `FicheModal` et `FicheModalV2` sont retirées ; la page `/fiche/:id` reste.
`const vf = useVueFiches(fiches, config)` : `fiches` = `MiniFiche[]` déjà adaptées, non filtrées (ref, computed ou getter) ; `config: ConfigVueFiches` = `{ dimensions, tris, groupes, etats, sousVues? }` (types dans `utils/vueFiches.ts`). Le composant ne charge rien et ne connaît pas la carte. La page lit `vf.idsResultat` pour ne montrer sur la carte que le résultat courant, `vf.survolId` pour allumer l'épingle (et l'écrit au survol d'une épingle), `vf.ficheId` / `vf.ficheOuverte` / `vf.position` pour la fiche ouverte (B12), `vf.largeur` et `vf.vueAffichee` pour placer ses boutons flottants.
**Brancher une troisième page** (Codev en B12, Outils, vue Œuvres) :
1. Adaptateur dans `useFicheAdapter.ts` : remplir `etiquettes` avec une clé par dimension (= nom du paramètre d'URL), `chipsDimension`, `coords` si la source en a (sinon `geoloc: true`).
2. Une `ConfigVueFiches` dans la page : dimensions (variante `fonction` pour la principale, `echelle` pour la secondaire), tris (pas de `recent` sans date), groupes, `etats` (`['carte', 'fiches']` si la page n'a pas la place pour Mixte), `sousVues` si la carte en a.
3. Le slot `#carte`, et le slot `#detail` (contenu de la fiche ; une page sans contenu de détail affiche seulement les actions). Les épingles appellent `vf.ouvrirFiche(id)`.
4. Ajouter des cas à `scripts/test-vue-fiches.mjs` si la page introduit une règle nouvelle.
**`FicheMiniCard`** : racine `<article>`, toute la carte ouvre la fiche par un bouton étiré (`::after`) ; avec `etiquettes-cliquables`, les chips deviennent des boutons (impossible dans l'ancienne racine `<button>`). Props `actif` (liseré safran au survol d'épingle), `etiquettes-actives`. Résumé et « Sans localisation » en `color-mix(--text 78 %)` : `--text-muted` et `--text-subtle` échouaient au contraste.
## Intention d'abord
Principe de design validé par Jules pour B3 : un panneau (ou une carte) s'ouvre sur **une ligne d'intention** avant l'outillage (filtres, compteur, grille). La ligne d'intention dit en une phrase ce que montre l'écran et le premier geste possible (« Toutes les structures d'entraide, y compris celles sans adresse sur la carte. Cochez une fonction pour filtrer. »). Dans `FichesPanel`, l'ordre de l'en-tête est fixe : intention → compteur → chips → grille. Ne pas remonter le compteur ou les chips au-dessus de l'intention, même pour gagner de la place.
Principe de design validé par Jules pour B3 : un panneau (ou une carte) s'ouvre sur **une ligne d'intention** avant l'outillage (filtres, compteur, grille). La ligne d'intention dit en une phrase ce que montre l'écran et le premier geste possible (« Toutes les structures d'entraide, y compris celles sans adresse sur la carte. Cochez une fonction pour filtrer. »). Dans `VueFiches`, l'intention ouvre la liste, avant les filtres et la grille. Ne pas remonter le compteur ou les chips au-dessus de l'intention, même pour gagner de la place.
## Motif maillage (favicon)
@@ -82,3 +115,29 @@ Le favicon (`public/favicon.svg`, décliné en `favicon-32.png`, `favicon-16.png
1. **Texte sombre sur l'accent safran, jamais blanc.** `--accent`/`--on-accent` : le blanc sur `#f5b342` échoue le contrôle AA (décision DS du 20/07/2026). `--on-accent` est toujours une couleur sombre (`#1a2238` clair, `#111520` sombre). Vérifié au pixel sur la variante `posture` de `Chip.vue`.
2. **`--nav-text-on-primary` reste une valeur littérale**, pas un alias — voir l'explication détaillée dans la section tokens ci-dessus. Si un nouveau composant a besoin de texte de contraste sur un fond bleu nuit, vérifie d'abord si ce fond est `--nav-primary` (translucide) ou `--nav-primary-solid` (opaque) avant de choisir la variable de texte : elles n'ont pas la même réponse en mode sombre.
## Navigation par échelles S / M / XL (B10, 02/10/2026)
**Source unique : `utils/echelles.ts`.** Chaque échelle porte ses `onglets` (`libelle`, `to`, `actif(route)`) ; `ORDRE_ECHELLES` fixe l'ordre S → M → XL ; `echelleDeRoute(route)` rend le groupe et l'onglet courants, ou `null` hors groupe (Manifeste, À propos, Proposer, Signaler, fiche, `/rag`). Aucun libellé d'échelle ni d'onglet ne se recopie dans un composant : ajouter un onglet = une ligne dans ce fichier, et `scripts/test-echelles-nav.mjs` (une ligne de cas).
| Échelle | Accroche | Onglets |
|---|---|---|
| S | Partir de soi | Jobs (`/trouver-du-taf`) · Outils (`/outils`) |
| M | La communauté | Écosystème d'entraide (`/`) · Réseaux AEP (`/agences`) · Codev (`/codev*`) |
| XL | Contribuer | Recherche média (`/media`) · Publications (`/media?tab=projets`) |
**Seuils** (CSS des composants, pas de breakpoint Tailwind : 1280 et 1440 n'en sont pas) :
| Largeur | En-tête | Sous l'en-tête |
|---|---|---|
| ≥ 1440 | logo complet · trois groupes · Manifeste · À propos · Soutenir (contour safran) · + Proposer (plein safran) · sombre | — |
| 1280-1439 | idem, logo réduit au bloc « AEP » | — |
| 1024-1279 | logo complet · groupe courant seul · Manifeste · + Proposer · sombre · « Menu » | — |
| 768-1023 | logo réduit · groupe courant seul · Manifeste · + Proposer · sombre · « Menu » | — |
| < 768 | logo complet · sombre · + (icône) · ☰ | `FilEchelle` (28 px), sauf hors groupe |
**Composants** : `NavEchelles.vue` (barre ; chaque groupe est un `<ul>` étiqueté par son accroche via `aria-labelledby`, onglet courant en `aria-current="page"`), `NavTiroir.vue` (menu < 1280 : `role="dialog"`, voile, Échap, focus piégé et rendu à l'élément d'ouverture, fermeture au changement de route et au passage à ≥ 1280), `FilEchelle.vue` (< 768, monté dans `app.vue` entre `<header>` et le conteneur de page, **jamais dedans** : `/` y est en `overflow-hidden`). Le lien de don vit dans `utils/liens.ts` (`SOUTENIR_URL`).
**Texte atténué** : `color-mix(in srgb, var(--text) 78 %, transparent)`, pas `--text-muted` (3,7:1 sur blanc, sous AA à 0,8 rem) ni `opacity` (qui éteindrait aussi l'anneau de focus).
**Signaler** n'est plus dans la barre d'ordinateur : il reste dans le tiroir, dans chaque fiche, et en pied d'À propos.
+131 -191
View File
@@ -1,12 +1,12 @@
<template>
<div class="flex flex-col h-screen overflow-hidden" :class="{ dark: isDark }" style="background: var(--nav-bg);">
<!-- ═══════════════════════════════════════ TOP NAV GLOBAL -->
<!-- ═══════════════════════════════════════ TOP NAV GLOBAL (B10 : échelles S / M / XL) -->
<header
class="flex items-center justify-between px-4 py-2.5 shrink-0 relative z-[9999] shadow-sm gap-3"
class="app-header flex items-center px-4 shrink-0 relative z-[9999] shadow-sm gap-3"
style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);"
>
<!-- Logo -->
<!-- Logo : texte complet sauf 768-1023 et 1280-1439 (place des onglets) -->
<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-8 px-2 rounded-lg flex items-center justify-center shrink-0"
@@ -20,112 +20,38 @@
</div>
</a>
<!-- ── Onglets desktop (≥1024px) — remplace la barre de recherche ── -->
<nav class="hidden lg:flex flex-1 justify-center items-end gap-0 mx-6" aria-label="Navigation projets">
<NuxtLink
to="/outils"
class="nav-tab"
:class="{ 'nav-tab--active': route.path === '/outils' }"
>
Outils
</NuxtLink>
<NuxtLink
to="/"
class="nav-tab"
:class="{ 'nav-tab--active': route.path === '/' }"
>
Écosystème Entraide
</NuxtLink>
<NuxtLink
to="/agences"
class="nav-tab"
:class="{ 'nav-tab--active': route.path === '/agences' }"
>
Réseaux AEP
</NuxtLink>
<NuxtLink
to="/trouver-du-taf"
class="nav-tab"
:class="{ 'nav-tab--active': route.path === '/trouver-du-taf' }"
>
Jobs
</NuxtLink>
<NuxtLink
to="/codev"
class="nav-tab"
:class="{ 'nav-tab--active': route.path.startsWith('/codev') }"
>
Codev
</NuxtLink>
<NuxtLink
to="/media"
class="nav-tab"
:class="{ 'nav-tab--active': route.path.startsWith('/media') }"
>
recherche-média
</NuxtLink>
</nav>
<!-- ── Barre recherche mobile (640px–1024px) — masquée < 640px car accessible dans la sheet -->
<div class="hidden sm:flex lg:hidden flex-1 mx-2">
<label class="flex items-center gap-2 w-full px-3 py-1.5 rounded-xl border" style="background: var(--nav-bg); border-color: var(--nav-bg-alt);">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted); flex-shrink: 0;">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
v-model="headerSearch"
type="search"
placeholder="Rechercher…"
class="flex-1 bg-transparent border-0 outline-none text-sm"
style="color: var(--nav-text); font-family: var(--nav-font);"
autocomplete="off"
@input="onHeaderSearch"
@keydown.enter="onHeaderSearch"
/>
<button
v-if="headerSearch"
type="button"
@click.stop="clearHeaderSearch"
class="flex-shrink-0"
style="color: var(--nav-text-muted);"
aria-label="Effacer la recherche"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</label>
</div>
<!-- Échelles : trois groupes ≥ 1280, groupe courant 768-1279, rien < 768 (fil d'échelle) -->
<NavEchelles />
<div class="flex-1 md:hidden" aria-hidden="true" />
<!-- Actions droite -->
<div class="flex items-center gap-2 shrink-0">
<NuxtLink
to="/manifeste"
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all hover:opacity-80 hidden md:inline-flex items-center gap-1"
:style="route.path === '/manifeste' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text-muted);'"
class="header-lien hidden md:inline-flex"
:aria-current="route.path === '/manifeste' ? 'page' : undefined"
>
Manifeste
</NuxtLink>
<NuxtLink
to="/a-propos"
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all hover:opacity-80 hidden md:inline-flex items-center gap-1"
style="color: var(--nav-text-muted);"
class="header-lien header-xl"
:aria-current="route.path === '/a-propos' ? 'page' : undefined"
>
À propos
</NuxtLink>
<NuxtLink
to="/signaler"
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all hover:opacity-80 hidden lg:inline-flex items-center gap-1"
style="color: var(--nav-text-muted);"
<a
:href="SOUTENIR_URL"
target="_blank"
rel="noopener noreferrer"
class="header-soutenir header-xl"
>
Signaler
</NuxtLink>
<!-- Proposer une ressource (5 onglets) -->
Soutenir
</a>
<!-- Proposer une ressource -->
<NuxtLink
to="/proposer"
class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 hidden sm:inline-flex items-center gap-1"
style="background: var(--nav-accent); color: var(--nav-text);"
class="header-proposer hidden md:inline-flex"
>
+ Proposer
</NuxtLink>
@@ -153,68 +79,51 @@
<!-- Mobile : proposer icône -->
<NuxtLink
to="/proposer"
class="sm:hidden p-2 rounded-lg"
style="background: var(--nav-accent); color: var(--nav-text);"
class="md:hidden p-2 rounded-lg"
style="background: var(--accent); color: var(--on-accent);"
title="Proposer une ressource"
aria-label="Proposer"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</NuxtLink>
<!-- Menu déroulant — toutes largeurs (les onglets n'y apparaissent qu'en dessous de 1024px) ; porte « Soutenir » -->
<div class="relative">
<button
@click="hamburgerOpen = !hamburgerOpen"
class="p-2 rounded-lg transition-all hover:opacity-80"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
:aria-label="hamburgerOpen ? 'Fermer le menu' : 'Menu'"
:aria-expanded="hamburgerOpen"
>
<svg v-if="!hamburgerOpen" 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="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
<svg v-else 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
v-if="hamburgerOpen"
class="absolute right-0 top-full mt-1 rounded-lg shadow-lg min-w-[210px] py-1"
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
@click="hamburgerOpen = false"
>
<NuxtLink to="/outils" class="lg:hidden block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/outils' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Outils</NuxtLink>
<NuxtLink to="/" class="lg:hidden 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</NuxtLink>
<NuxtLink to="/agences" class="lg:hidden 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="lg:hidden 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="lg:hidden block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/media') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">recherche-média</NuxtLink>
<NuxtLink to="/codev" class="lg:hidden 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 class="lg:hidden" 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 mx-3 my-2 px-4 py-2 rounded-lg text-sm font-semibold text-center transition-opacity hover:opacity-80" style="background: var(--nav-accent); color: var(--nav-text);">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>
<!-- Menu (< 1280) : ouvre le tiroir ; le mot est écrit à partir de 768 -->
<button
type="button"
class="header-menu"
aria-label="Ouvrir le menu"
aria-haspopup="dialog"
:aria-expanded="menuOpen"
@click="menuOpen = true"
>
<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="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
<span class="header-menu__mot" aria-hidden="true">Menu</span>
</button>
</div>
</header>
<!-- Fil d'échelle (< 768) : dans le flux de la colonne, hors du conteneur de page -->
<FilEchelle />
<!-- Contenu page (flex-1 pour remplir l'espace) -->
<div class="flex-1" :class="route.path === '/' ? 'overflow-hidden' : 'overflow-y-auto'">
<div class="flex-1 min-h-0" :class="route.path === '/' ? 'overflow-hidden' : 'overflow-y-auto'">
<NuxtPage />
</div>
<NavTiroir :open="menuOpen" @close="menuOpen = false" />
</div>
</template>
<script setup lang="ts">
const router = useRouter()
import { SOUTENIR_URL } from '~/utils/liens'
const route = useRoute()
const hamburgerOpen = ref(false)
watch(() => route.path, () => { hamburgerOpen.value = false })
const menuOpen = ref(false)
// ── Dark mode ─────────────────────────────────────────────────────────────
const isDark = ref(false)
@@ -237,40 +146,14 @@ function toggleDark() {
localStorage.setItem('aep_theme', 'light')
}
}
// ── Barre de recherche header mobile ─────────────────────────────────────
const headerSearch = ref((route.query.q as string) ?? '')
// Sync depuis URL quand la route change
watch(() => route.query.q, (v) => {
headerSearch.value = (v as string) ?? ''
})
function onHeaderSearch() {
const q = headerSearch.value.trim()
if (route.path === '/') {
router.replace({ query: q ? { ...route.query, q } : { ...route.query, q: undefined } })
} else if (q) {
router.push({ path: '/', query: { q } })
}
}
function clearHeaderSearch() {
headerSearch.value = ''
if (route.path === '/') {
const q = { ...route.query }
delete q.q
router.replace({ query: Object.keys(q).length ? q : undefined })
}
}
// ── Fiche aléatoire ───────────────────────────────────────────────────────
function goRandom() {
router.push({ path: '/', query: { random: '1' } })
}
</script>
<style>
/* ── En-tête ──────────────────────────────────────────────────────────── */
.app-header {
min-height: 52px;
}
/* ── Logo header (texte 2 lignes) ─────────────────────────────────────── */
.logo-text {
line-height: 1.05;
@@ -285,39 +168,96 @@ function goRandom() {
@media (min-width: 1024px) {
.logo-line-1, .logo-line-2 { font-size: 0.85rem; }
}
/* Le groupe courant (768-1023) et les trois groupes (1280-1439) prennent la place du texte */
@media (min-width: 768px) and (max-width: 1023px), (min-width: 1280px) and (max-width: 1439px) {
.logo-text { display: none; }
}
/* ── Onglets header desktop ───────────────────────────────────────────── */
.nav-tab {
position: relative;
display: inline-flex;
flex-direction: column;
/* ── Liens et boutons de droite ───────────────────────────────────────── */
.header-lien {
align-items: center;
padding: 6px 16px 8px;
font-size: 0.8rem;
font-weight: 500;
color: var(--nav-text-muted);
padding: 6px 8px;
border-radius: var(--radius-md);
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
white-space: nowrap;
text-decoration: none;
transition: color 0.15s;
border-bottom: 2px solid transparent;
gap: 2px;
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.header-lien:hover,
.header-lien[aria-current="page"] {
color: var(--text);
}
.header-lien[aria-current="page"] {
font-weight: var(--fw-bold);
}
.nav-tab:hover {
color: var(--nav-text);
.header-soutenir {
align-items: center;
padding: 5px 10px;
border: 1.5px solid var(--accent);
border-radius: var(--radius-md);
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
white-space: nowrap;
text-decoration: none;
color: var(--text);
}
.nav-tab--active {
color: var(--nav-text);
border-bottom-color: var(--nav-primary-solid);
font-weight: 600;
.header-proposer {
align-items: center;
padding: 6px 11px;
border-radius: var(--radius-md);
background: var(--accent);
color: var(--on-accent);
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
white-space: nowrap;
text-decoration: none;
}
.nav-tab-badge {
font-size: 0.6rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--nav-text-muted);
opacity: 0.65;
.header-soutenir:hover,
.header-proposer:hover {
opacity: 0.85;
}
.header-lien:focus-visible,
.header-soutenir:focus-visible,
.header-proposer:focus-visible,
.header-menu:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
/* À propos et Soutenir : barre complète seulement (≥ 1280), sinon dans le tiroir */
.header-xl { display: none; }
@media (min-width: 1280px) {
.header-xl { display: inline-flex; }
}
.header-menu {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px;
border: 1px solid transparent;
border-radius: var(--radius-md);
background: var(--bg-alt);
color: var(--text);
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
cursor: pointer;
}
.header-menu__mot { display: none; }
@media (min-width: 768px) {
.header-menu {
padding: 5px 10px;
border-color: var(--border);
background: transparent;
}
.header-menu__mot { display: inline; }
}
@media (min-width: 1280px) {
.header-menu { display: none; }
}
</style>
+21
View File
@@ -0,0 +1,21 @@
<template>
<span v-if="profondeur === 'approfondi'" class="chat-mode-badge">{{ CHAT_PROFONDEUR_LIBELLES.approfondi }}</span>
</template>
<script setup lang="ts">
// Étiquette sobre sur une réponse donnée en mode approfondi (rien en mode rapide).
defineProps<{ profondeur?: string }>()
</script>
<style scoped>
.chat-mode-badge {
display: inline-block;
margin-bottom: 6px;
padding: 1px 8px;
border-radius: 9999px;
border: 1px solid var(--nav-bg-alt);
font-size: 0.66rem;
font-family: var(--nav-font);
color: var(--nav-text-muted);
}
</style>
+80
View File
@@ -0,0 +1,80 @@
<template>
<div class="chat-mode" role="radiogroup" aria-label="Mode de réponse">
<button
v-for="(p, i) in CHAT_PROFONDEURS"
:key="p"
:ref="(el) => (boutons[i] = el as HTMLButtonElement | null)"
type="button"
role="radio"
class="chat-mode-option"
:class="{ active: modelValue === p }"
:aria-checked="modelValue === p"
:tabindex="modelValue === p ? 0 : -1"
:title="CHAT_PROFONDEUR_AIDES[p]"
:disabled="disabled"
@click="choisir(p)"
@keydown="onKey($event, i)"
>{{ CHAT_PROFONDEUR_LIBELLES[p] }}</button>
</div>
</template>
<script setup lang="ts">
// Toggle 2 options partagé par les 5 chatbots. Libellés : utils/chatProfondeur.ts.
// radiogroup : flèches déplacent la sélection, Tab entre/sort du groupe (roving tabindex).
const props = defineProps<{ modelValue: 'rapide' | 'approfondi'; disabled?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: 'rapide' | 'approfondi'] }>()
const boutons = ref<(HTMLButtonElement | null)[]>([])
function choisir(p: ChatProfondeur) {
if (!props.disabled) emit('update:modelValue', p)
}
function onKey(e: KeyboardEvent, i: number) {
const n = CHAT_PROFONDEURS.length
let j = -1
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') j = (i + 1) % n
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') j = (i - 1 + n) % n
else if (e.key === 'Home') j = 0
else if (e.key === 'End') j = n - 1
if (j < 0) return
e.preventDefault()
choisir(CHAT_PROFONDEURS[j])
nextTick(() => boutons.value[j]?.focus())
}
</script>
<style scoped>
.chat-mode {
display: inline-flex;
gap: 2px;
padding: 2px;
border-radius: 9999px;
background: var(--nav-bg-alt);
align-self: flex-start;
}
.chat-mode-option {
border: none;
background: transparent;
color: var(--nav-text-muted);
font-family: var(--nav-font);
font-size: 0.72rem;
line-height: 1.2;
padding: 4px 10px;
border-radius: 9999px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.chat-mode-option:hover:not(.active):not(:disabled) { color: var(--nav-text); }
.chat-mode-option.active {
background: var(--nav-primary);
color: var(--nav-text-on-primary);
font-weight: 600;
}
.chat-mode-option:focus-visible { outline: 2px solid var(--nav-primary); outline-offset: 2px; }
.chat-mode-option:disabled { opacity: 0.5; cursor: not-allowed; }
@media (prefers-reduced-motion: reduce) {
.chat-mode-option { transition: none; }
}
</style>
+30
View File
@@ -0,0 +1,30 @@
<template>
<button type="button" class="chat-new-conversation" @click="emit('reset')">
Nouvelle conversation
</button>
</template>
<script setup lang="ts">
// Bouton sobre « Nouvelle conversation », partagé par les chatbots.
// Le parent le rend seulement s'il y a un historique et gère la purge.
const emit = defineEmits<{ reset: [] }>()
</script>
<style scoped>
.chat-new-conversation {
align-self: flex-end;
flex-shrink: 0;
background: transparent;
border: none;
padding: 4px 6px;
border-radius: 6px;
font-size: 0.72rem;
font-family: var(--nav-font);
color: var(--nav-text-muted);
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
.chat-new-conversation:hover { color: var(--nav-text); background: var(--nav-bg-alt); }
.chat-new-conversation:focus-visible { outline: 2px solid var(--nav-primary); outline-offset: 2px; }
</style>
+161 -31
View File
@@ -16,16 +16,6 @@
<span v-else class="chatbot-corpus-badge">FRACAS</span>
</div>
<div class="chatbot-header-right">
<button
v-if="messages.length > 0"
class="chatbot-clear-btn"
title="Effacer la conversation"
@click="clearMessages"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/>
</svg>
</button>
<select v-model="corpusMode" class="chatbot-corpus-select" title="Corpus interrogé">
<option value="pensees">Pensées éco.</option>
<option value="both">Pensées + Projets</option>
@@ -52,6 +42,7 @@
<template v-for="(msg, i) in messages" :key="i">
<div v-if="msg.role === 'user'" class="chatbot-bubble user">{{ msg.content }}</div>
<div v-else class="chatbot-bubble assistant">
<ChatModeBadge :profondeur="msg.profondeur" />
<div class="chatbot-md" v-html="renderMd(msg.content)" />
<div v-if="msg.auteur" class="chatbot-auteur-tag">
<span>{{ msg.auteur.nom }}</span>
@@ -59,13 +50,26 @@
</div>
</template>
<!-- Chargement -->
<div v-if="loading" class="chatbot-bubble assistant loading">
<!-- Chargement : texte d'attente progressif. L'annonce aux lecteurs d'écran passe par la région
live persistante ci-dessous (une région insérée à chaud n'est pas fiablement annoncée). -->
<div v-if="loading" class="chatbot-bubble assistant loading" aria-hidden="true">
<span class="dot" /><span class="dot" /><span class="dot" />
<span class="attente-texte">{{ attenteTexte }}</span>
</div>
<div class="sr-only" role="status" aria-live="polite" aria-atomic="true">{{ loading ? attenteTexte : '' }}</div>
<!-- Erreur -->
<div v-if="errorMsg" class="chatbot-error">{{ errorMsg }}</div>
<div v-if="errorMsg" class="chatbot-error" role="alert">
<span>{{ errorMsg }}</span>
<button v-if="peutReessayer" type="button" class="chatbot-retry-btn" @click="reessayer">Réessayer</button>
</div>
<ChatNewConversation v-if="hasHistory && !loading" @reset="clearMessages" />
</div>
<!-- Mode de réponse -->
<div class="chatbot-mode-row">
<ChatModeToggle v-model="profondeur" :disabled="loading" />
</div>
<!-- Input -->
@@ -102,6 +106,7 @@ interface ChatMessage {
role: 'user' | 'assistant'
content: string
auteur?: { slug: string; nom: string } | null
profondeur?: string
}
const props = defineProps<{
@@ -109,21 +114,78 @@ const props = defineProps<{
inline?: boolean
}>()
const messages = ref<ChatMessage[]>([])
// Une conversation par contexte (corpus général ou un auteur) : changer d'auteur
// ne mélange pas les historiques, et un rechargement restaure le bon.
const sessionKey = computed(() => props.auteurContext ? `pensees-${slugify(props.auteurContext)}` : 'pensees')
const { messages, hasHistory, clear } = useChatSession<ChatMessage>(sessionKey)
// `profondeur` (et non `mode`, réservé au mode de recherche LightRAG côté serveur)
const profondeur = useChatProfondeur('pensees')
const inputText = ref('')
const loading = ref(false)
const errorMsg = ref('')
const corpusMode = ref<'pensees' | 'projets' | 'both'>('pensees')
const messagesContainer = ref<HTMLElement | null>(null)
watch(() => props.auteurContext, () => {
messages.value = []
// --- Attente et erreurs : état d'interface uniquement, jamais persisté (hors useChatSession) ---
// Mesuré le 09/10 : récupération 2-5 s, génération 10-18 s, 19-29 s bout en bout. Les seuils suivent ces
// phases ; le texte est une minuterie (le serveur renvoie tout d'un bloc : il faut la réponse complète
// pour vérifier les citations, donc pas de flux de texte).
const SEUIL_REDIGE_S = 6
const SEUIL_LONG_S = 30
// Au-delà du timeout serveur (90 s) : le front ne lâche jamais avant la route, et ne tourne jamais sans fin.
const TIMEOUT_FRONT_MS = 95000
const attenteS = ref(0)
const attenteTexte = computed(() => {
if (attenteS.value >= SEUIL_LONG_S) return 'Le corpus est vaste, encore un instant…'
if (attenteS.value >= SEUIL_REDIGE_S) return 'Je rédige…'
return 'Je fouille le corpus…'
})
let attenteTimer: ReturnType<typeof setInterval> | null = null
function demarrerAttente() {
arreterAttente()
attenteS.value = 0
const t0 = Date.now()
attenteTimer = setInterval(() => { attenteS.value = Math.floor((Date.now() - t0) / 1000) }, 1000)
}
function arreterAttente() {
if (attenteTimer) { clearInterval(attenteTimer); attenteTimer = null }
}
onBeforeUnmount(arreterAttente)
interface Envoi { query: string; profondeur: string; corpus: string; auteurSlug?: string }
const dernierEnvoi = ref<Envoi | null>(null)
const peutReessayer = ref(false)
watch(sessionKey, () => {
errorMsg.value = ''
peutReessayer.value = false
dernierEnvoi.value = null
})
function clearMessages() {
messages.value = []
clear()
errorMsg.value = ''
peutReessayer.value = false
dernierEnvoi.value = null
}
function messageErreur(e: any): { texte: string; reessayer: boolean } {
const status = e?.statusCode ?? e?.status ?? e?.response?.status
const detail = String(e?.data?.message ?? e?.statusMessage ?? e?.message ?? '')
if (status === 429) {
// 429 de la route : quota du jour (pas de réessai utile) ou RAG saturé (réessai possible)
if (/satur/i.test(detail)) return { texte: 'Le corpus est très sollicité en ce moment. Patiente quelques secondes, puis réessaie.', reessayer: true }
return { texte: 'Tu as atteint la limite de 20 questions par jour. Reviens demain, ou explore les fiches auteurs en attendant.', reessayer: false }
}
if (status === 503) return { texte: 'La bibliothèque est indisponible pour l\'instant. Réessaie dans quelques minutes.', reessayer: true }
if (status === 504) return { texte: 'La bibliothèque met trop de temps à répondre : la question est peut-être trop large. Réessaie, ou reformule-la plus précisément.', reessayer: true }
if (!status && /timeout|timed out|abort/i.test(`${detail} ${e?.name ?? ''} ${e?.cause?.name ?? ''}`)) {
return { texte: 'Désolé, la réponse met plus de temps que prévu et n\'a pas abouti. Tu peux relancer la même question.', reessayer: true }
}
if (!status) return { texte: 'La connexion semble coupée : la question n\'est pas partie. Vérifie ton réseau puis réessaie.', reessayer: true }
return { texte: 'Une erreur est survenue. Réessaie dans quelques instants.', reessayer: true }
}
function scrollToBottom() {
@@ -137,9 +199,27 @@ async function sendMessage() {
if (!query || loading.value) return
inputText.value = ''
errorMsg.value = ''
messages.value.push({ role: 'user', content: query })
await envoyer({
query,
profondeur: profondeur.value,
corpus: corpusMode.value,
auteurSlug: props.auteurContext ? slugify(props.auteurContext) : undefined,
})
}
// Reposte la même question (même mode, même corpus) sans dupliquer le message de l'usager.
async function reessayer() {
if (!dernierEnvoi.value || loading.value) return
await envoyer(dernierEnvoi.value)
}
async function envoyer(envoi: Envoi) {
errorMsg.value = ''
peutReessayer.value = false
dernierEnvoi.value = envoi
loading.value = true
demarrerAttente()
await nextTick()
scrollToBottom()
@@ -150,29 +230,27 @@ async function sendMessage() {
auteur: { slug: string; nom: string } | null
}>('/api/chatbot-pensees', {
method: 'POST',
timeout: TIMEOUT_FRONT_MS,
body: {
query,
corpus: corpusMode.value,
auteur_slug: props.auteurContext ? slugify(props.auteurContext) : undefined,
query: envoi.query,
corpus: envoi.corpus,
profondeur: envoi.profondeur,
auteur_slug: envoi.auteurSlug,
},
})
messages.value.push({
role: 'assistant',
content: res.response,
auteur: res.auteur ?? null,
profondeur: envoi.profondeur,
})
dernierEnvoi.value = null
} catch (e: any) {
const status = e?.statusCode ?? e?.status
if (status === 429) {
errorMsg.value = 'Limite de 20 questions par jour atteinte.'
} else if (status === 503) {
errorMsg.value = 'Le RAG est indisponible pour l\'instant — réessaie dans quelques minutes.'
} else if (status === 504) {
errorMsg.value = 'Le RAG met du temps à répondre — réessaie dans quelques secondes.'
} else {
errorMsg.value = 'Une erreur est survenue. Réessaie dans quelques instants.'
}
const { texte, reessayer: ok } = messageErreur(e)
errorMsg.value = texte
peutReessayer.value = ok
} finally {
arreterAttente()
loading.value = false
await nextTick()
scrollToBottom()
@@ -361,6 +439,25 @@ function slugify(str: string): string {
padding: 10px 12px;
}
.attente-texte {
margin-left: 6px;
font-size: 0.775rem;
color: var(--nav-text-muted);
font-style: italic;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.dot {
width: 6px;
height: 6px;
@@ -377,6 +474,11 @@ function slugify(str: string): string {
40% { opacity: 1; }
}
/* Pas d'animation si l'usager la réduit : points fixes, le texte d'attente porte l'information */
@media (prefers-reduced-motion: reduce) {
.dot { animation: none; opacity: 0.6; }
}
/* Erreur */
.chatbot-error {
align-self: flex-start;
@@ -386,8 +488,36 @@ function slugify(str: string): string {
border-radius: 8px;
padding: 7px 10px;
max-width: 90%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.chatbot-retry-btn {
font: inherit;
font-size: 0.75rem;
font-weight: 600;
color: var(--nav-text-on-primary);
background: var(--nav-primary);
border: none;
border-radius: 6px;
padding: 4px 12px;
cursor: pointer;
}
.chatbot-retry-btn:hover { opacity: 0.85; }
.chatbot-retry-btn:focus-visible { outline: 2px solid var(--nav-primary); outline-offset: 2px; }
/* Mode de réponse */
.chatbot-mode-row {
flex-shrink: 0;
display: flex;
padding: 6px 10px 0;
border-top: 1px solid rgba(180, 170, 160, 0.22);
background: var(--nav-surface);
}
.chatbot-mode-row + .chatbot-input-row { border-top: none; }
/* Input */
.chatbot-input-row {
flex-shrink: 0;
+80 -13
View File
@@ -12,7 +12,7 @@
role="button"
tabindex="0"
:aria-expanded="isExpanded"
aria-label="Ouvrir l'assistant chatbot"
:aria-label="`Ouvrir l'assistant : ${titre}`"
@keydown.enter="toggleExpand"
@keydown.space.prevent="toggleExpand"
>
@@ -27,7 +27,7 @@
</div>
<span class="flex-1 text-sm" style="color: var(--nav-text-muted);">
{{ isExpanded ? 'Chatbot AEP' : 'Pose une question sur le réseau…' }}
{{ isExpanded ? titre : placeholderReplie }}
</span>
<!-- Chevron -->
@@ -52,21 +52,23 @@
<div class="chatbot-body-inner" ref="messagesContainer">
<!-- Onboarding -->
<div v-if="messages.length === 0" class="onboarding-bubble">
<p>Explore les 120 structures de la carte par la conversation. Je peux t'aider à trouver des collectifs, agences ou réseaux selon ta situation, ta pratique ou tes inspirations du moment.</p>
<p class="example">Exemple : "Je cherche des acteurs de la rénovation de maisons individuelles en France, plutôt en milieu rural, avec des approches biosourcées ou low-tech."</p>
<p>{{ intro }}</p>
<p v-if="exemple" class="example">{{ exemple }}</p>
</div>
<!-- Messages -->
<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">
<p>{{ msg.content }}</p>
<ChatModeBadge :profondeur="msg.profondeur" />
<div v-if="rendreMarkdown" v-html="renderMd(msg.content)" />
<p v-else>{{ msg.content }}</p>
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
<p class="fiches-title">Fiches recommandees :</p>
<p class="fiches-title">{{ fichesTitre }}</p>
<a
v-for="fiche in msg.fiches"
:key="fiche.id"
:href="`/fiche/${fiche.id}`"
:href="ficheLienModele.replace('{id}', String(fiche.id))"
class="fiche-card"
>
<span class="fiche-nom">{{ fiche.nom }}</span>
@@ -98,15 +100,22 @@
<!-- Erreur -->
<div v-if="errorMsg" class="error-bubble">{{ errorMsg }}</div>
<ChatNewConversation v-if="hasHistory && !loading" @reset="nouvelleConversation" />
</div>
<!-- Mode de réponse -->
<div class="chatbot-mode-row" style="border-top: 1px solid var(--nav-bg-alt);">
<ChatModeToggle v-model="profondeur" :disabled="loading" />
</div>
<!-- Input -->
<div class="chatbot-input-row" style="border-top: 1px solid var(--nav-bg-alt);">
<div class="chatbot-input-row">
<input
v-model="inputText"
type="text"
:disabled="loading"
placeholder="Pose ta question…"
:placeholder="placeholderSaisie"
class="chatbot-input"
@keydown.enter.prevent="sendMessage"
/>
@@ -127,6 +136,9 @@
</template>
<script setup lang="ts">
import { useMarkdown } from '~/composables/useMarkdown'
const { render: renderMd } = useMarkdown()
interface FicheReco {
id: number | string
nom: string
@@ -138,15 +150,58 @@ interface ChatMessage {
content: string
fiches?: FicheReco[]
suggestedHashtags?: string[]
profondeur?: string
}
// Composant unique pour les deux chatbots desktop. Les défauts reproduisent
// exactement le comportement de l'accueil (/api/chatbot, backend Entraide).
// Les deux backends renvoient { reponse_texte, fiches_recommandees } ; seul
// le rendu diffère (texte brut vs markdown, lien de fiche).
const props = withDefaults(defineProps<{
/** Route serveur appelée (body { question }) */
endpoint?: string
/** Titre affiché dans l'en-tête déplié */
titre?: string
/** Texte de l'en-tête replié */
placeholderReplie?: string
/** Placeholder du champ de saisie */
placeholderSaisie?: string
/** Texte d'accueil (première bulle) */
intro?: string
/** Exemple de question, affiché sous l'accueil */
exemple?: string
/** Intitulé de la liste de fiches/structures citées */
fichesTitre?: string
/** Modèle de lien vers une fiche, {id} est remplacé */
ficheLienModele?: string
/** Rendre la réponse en markdown (backend réseaux) plutôt qu'en texte brut */
rendreMarkdown?: boolean
/** Message affiché quand la limite quotidienne (429) est atteinte */
messageLimite?: string
/** Clé de persistance (sessionStorage). Même clé = même conversation (ex. mobile + desktop) */
chatKey?: string
}>(), {
chatKey: 'accueil',
endpoint: '/api/chatbot',
titre: 'Chatbot AEP',
placeholderReplie: 'Pose une question sur le réseau…',
placeholderSaisie: 'Pose ta question…',
intro: "Explore les 120 structures de la carte par la conversation. Je peux t'aider à trouver des collectifs, agences ou réseaux selon ta situation, ta pratique ou tes inspirations du moment.",
exemple: 'Exemple : "Je cherche des acteurs de la rénovation de maisons individuelles en France, plutôt en milieu rural, avec des approches biosourcées ou low-tech."',
fichesTitre: 'Fiches recommandees :',
ficheLienModele: '/fiche/{id}',
rendreMarkdown: false,
messageLimite: 'Limite de 10 questions par jour atteinte. Reviens demain.',
})
const emit = defineEmits<{
'highlightOrgs': [ids: (number | string)[]]
'applyHashtag': [tag: string]
}>()
const isExpanded = ref(false)
const messages = ref<ChatMessage[]>([])
const { messages, hasHistory, clear } = useChatSession<ChatMessage>(() => props.chatKey)
const profondeur = useChatProfondeur(() => props.chatKey)
const inputText = ref('')
const loading = ref(false)
const errorMsg = ref('')
@@ -183,6 +238,11 @@ function detectHashtagsFromQuery(query: string): string[] {
return detected.slice(0, 3)
}
function nouvelleConversation() {
clear()
errorMsg.value = ''
}
function toggleExpand() {
isExpanded.value = !isExpanded.value
}
@@ -195,6 +255,7 @@ async function sendMessage() {
errorMsg.value = ''
messages.value.push({ role: 'user', content: question })
loading.value = true
const mode = profondeur.value
await nextTick()
scrollToBottom()
@@ -203,9 +264,9 @@ async function sendMessage() {
const res = await $fetch<{
reponse_texte: string
fiches_recommandees: { id: number | string; nom: string; explication: string }[]
}>('/api/chatbot', {
}>(props.endpoint, {
method: 'POST',
body: { question },
body: { question, profondeur: mode },
})
const suggestedHashtags = detectHashtagsFromQuery(question)
@@ -214,6 +275,7 @@ async function sendMessage() {
content: res.reponse_texte,
fiches: res.fiches_recommandees || [],
suggestedHashtags: suggestedHashtags.length ? suggestedHashtags : undefined,
profondeur: mode,
}
messages.value.push(assistantMsg)
@@ -223,7 +285,7 @@ async function sendMessage() {
} catch (e: any) {
const status = e?.statusCode ?? e?.status
if (status === 429) {
errorMsg.value = 'Limite de 10 questions par jour atteinte. Reviens demain.'
errorMsg.value = props.messageLimite
} else if (status === 503) {
errorMsg.value = 'Le budget IA mensuel est épuisé. Soutiens NAV sur Liberapay pour continuer.'
} else {
@@ -305,6 +367,11 @@ function scrollToBottom() {
gap: 8px;
}
.chatbot-mode-row {
display: flex;
padding: 8px 12px 0;
flex-shrink: 0;
}
.chatbot-input-row {
display: flex;
align-items: center;
+14 -4
View File
@@ -54,6 +54,7 @@
<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">
<ChatModeBadge :profondeur="msg.profondeur" />
<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>
@@ -74,9 +75,11 @@
<span class="dot" /><span class="dot" /><span class="dot" />
</div>
<div v-if="errorMsg" class="error-bubble">{{ errorMsg }}</div>
<ChatNewConversation v-if="hasHistory && !loading" @reset="nouvelleConversation" />
</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));">
<ChatModeToggle v-model="profondeur" :disabled="loading" class="mb-2" />
<div class="flex items-center gap-2">
<input
v-model="inputText"
@@ -112,12 +115,13 @@ 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[] }
interface ChatMessage { role: 'user' | 'assistant'; content: string; fiches?: FicheReco[]; profondeur?: string }
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const messages = ref<ChatMessage[]>([])
const { messages, hasHistory, clear } = useChatSession<ChatMessage>('agences')
const profondeur = useChatProfondeur('agences')
const inputText = ref('')
const loading = ref(false)
const errorMsg = ref('')
@@ -136,6 +140,11 @@ onUnmounted(() => {
}
})
function nouvelleConversation() {
clear()
errorMsg.value = ''
}
async function sendMessage() {
const question = inputText.value.trim()
if (!question || loading.value) return
@@ -147,11 +156,12 @@ async function sendMessage() {
if (messagesContainer.value) messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
try {
const mode = profondeur.value
const res = await $fetch<{ reponse_texte: string; fiches_recommandees: FicheReco[] }>(
'/api/chatbot-reseaux',
{ method: 'POST', body: { question } }
{ method: 'POST', body: { question, profondeur: mode } }
)
messages.value.push({ role: 'assistant', content: res.reponse_texte, fiches: res.fiches_recommandees || [] })
messages.value.push({ role: 'assistant', content: res.reponse_texte, fiches: res.fiches_recommandees || [], profondeur: mode })
} catch (e: any) {
const s = e?.statusCode ?? e?.status
errorMsg.value = s === 429
+15 -2
View File
@@ -87,6 +87,7 @@
<!-- Message assistant -->
<div v-else class="assistant-bubble">
<ChatModeBadge :profondeur="msg.profondeur" />
<div class="md-content" v-html="renderMd(msg.content)" />
<!-- Fiches recommandées -->
@@ -114,6 +115,8 @@
<div v-if="errorMsg" class="error-bubble">
{{ errorMsg }}
</div>
<ChatNewConversation v-if="hasHistory && !loading" @reset="nouvelleConversation" />
</div>
<!-- Input -->
@@ -121,6 +124,7 @@
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));"
>
<ChatModeToggle v-model="profondeur" :disabled="loading" class="mb-2" />
<div class="flex items-center gap-2">
<input
v-model="inputText"
@@ -172,6 +176,7 @@ interface ChatMessage {
role: 'user' | 'assistant'
content: string
fiches?: FicheReco[]
profondeur?: string
}
const props = defineProps<{
@@ -183,7 +188,8 @@ const emit = defineEmits<{
'highlightOrgs': [ids: (number | string)[]]
}>()
const messages = ref<ChatMessage[]>([])
const { messages, hasHistory, clear } = useChatSession<ChatMessage>('accueil')
const profondeur = useChatProfondeur('accueil')
const inputText = ref('')
const loading = ref(false)
const errorMsg = ref('')
@@ -207,6 +213,11 @@ onUnmounted(() => {
}
})
function nouvelleConversation() {
clear()
errorMsg.value = ''
}
async function sendMessage() {
const question = inputText.value.trim()
if (!question || loading.value) return
@@ -215,6 +226,7 @@ async function sendMessage() {
errorMsg.value = ''
messages.value.push({ role: 'user', content: question })
loading.value = true
const mode = profondeur.value
await nextTick()
scrollToBottom()
@@ -225,13 +237,14 @@ async function sendMessage() {
fiches_recommandees: { id: number | string; nom: string; explication: string }[]
}>('/api/chatbot', {
method: 'POST',
body: { question },
body: { question, profondeur: mode },
})
const assistantMsg: ChatMessage = {
role: 'assistant',
content: res.reponse_texte,
fiches: res.fiches_recommandees || [],
profondeur: mode,
}
messages.value.push(assistantMsg)
-40
View File
@@ -1,40 +0,0 @@
<template>
<div class="space-y-1.5">
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Échelle</p>
<ChipGroup aria-label="Filtrer par échelle">
<Chip
v-for="option in ECHELLES"
:key="option"
:label="option"
variant="echelle"
:active="isSelected(option)"
@toggle="toggle(option)"
/>
</ChipGroup>
</div>
</template>
<script setup lang="ts">
const ECHELLES = ['National', 'Régional', 'Local'] as const
const props = defineProps<{
modelValue: string[]
counts: Record<string, number>
}>()
const emit = defineEmits<{
'update:modelValue': [value: string[]]
}>()
function isSelected(option: string): boolean {
return props.modelValue.includes(option)
}
function toggle(option: string) {
if (isSelected(option)) {
emit('update:modelValue', props.modelValue.filter(v => v !== option))
} else {
emit('update:modelValue', [...props.modelValue, option])
}
}
</script>
+57 -26
View File
@@ -6,12 +6,14 @@
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);"
>
<!-- Bandeau titre -->
<div class="px-6 pt-6 pb-4" style="border-bottom: 1px solid var(--nav-bg-alt);">
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 mb-3">
<h1
class="text-2xl font-bold leading-snug"
<div :class="compact ? 'px-4 pt-4 pb-3' : 'px-6 pt-6 pb-4'" style="border-bottom: 1px solid var(--nav-bg-alt);">
<div class="flex flex-col gap-3 mb-3" :class="compact ? '' : 'sm:flex-row sm:items-start sm:justify-between'">
<component
:is="compact ? 'h2' : 'h1'"
class="font-bold leading-snug"
:class="compact ? 'text-xl' : 'text-2xl'"
style="color: var(--nav-text);"
>{{ org.nom }}</h1>
>{{ org.nom }}</component>
<a
v-if="org.url"
@@ -33,18 +35,28 @@
</div>
<!-- Méta : échelle + ville -->
<div class="flex flex-wrap items-center gap-2 text-sm mb-3" style="color: var(--nav-text-muted);">
<div class="flex flex-wrap items-center gap-2 text-sm mb-3" style="color: color-mix(in srgb, var(--nav-text) 78%, transparent);">
<span
v-if="org.echelle"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
style="background: var(--nav-primary-solid); color: var(--nav-text-on-primary);"
>{{ org.echelle }}</span>
<span v-if="org.territoire && org.territoire !== 'Métropole'" class="text-xs" style="color: var(--nav-text-muted);">{{ org.territoire }}</span>
<span v-if="org.localisation_ville" style="color: var(--nav-text-muted);">{{ org.localisation_ville }}</span>
<span v-if="org.territoire && org.territoire !== 'Métropole'" class="text-xs" style="color: color-mix(in srgb, var(--nav-text) 78%, transparent);">{{ org.territoire }}</span>
<span v-if="org.localisation_ville" style="color: color-mix(in srgb, var(--nav-text) 78%, transparent);">{{ org.localisation_ville }}</span>
</div>
<!-- Tags fonction -->
<div v-if="fonctionTags.length" class="flex flex-wrap gap-1.5">
<ChipGroup v-if="fonctionTags.length && etiquettesCliquables" :aria-label="`Fonctions de ${org.nom}`">
<Chip
v-for="tag in fonctionTags"
:key="tag"
variant="fonction"
:label="tag"
:aria-label="`Filtrer par ${tag}`"
@toggle="emit('etiquette', tag)"
/>
</ChipGroup>
<div v-else-if="fonctionTags.length" class="flex flex-wrap gap-1.5">
<span
v-for="tag in fonctionTags"
:key="tag"
@@ -55,13 +67,13 @@
</div>
<!-- Corps : description + mini-carte -->
<div class="p-6 flex flex-col lg:flex-row gap-6">
<div class="flex flex-col gap-6" :class="compact ? 'p-4' : 'p-6 lg:flex-row'">
<!-- Descriptions -->
<div class="flex-1 min-w-0">
<!-- Description soumise par le contributeur -->
<div v-if="descriptionUser" class="mb-4">
<p class="text-sm leading-relaxed" style="color: var(--nav-text-muted);">Description communauté</p>
<p class="text-sm leading-relaxed" style="color: color-mix(in srgb, var(--nav-text) 78%, transparent);">Description communauté</p>
<p class="mt-1 leading-relaxed" style="color: var(--nav-text);">{{ descriptionUser }}</p>
</div>
@@ -70,7 +82,7 @@
<!-- Description enrichie IA -->
<div v-if="descriptionEnrichie" class="mb-4">
<p class="text-sm leading-relaxed flex items-center gap-1.5" style="color: var(--nav-text-muted);">
<p class="text-sm leading-relaxed flex items-center gap-1.5" style="color: color-mix(in srgb, var(--nav-text) 78%, transparent);">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
@@ -88,7 +100,7 @@
<!-- Points clés -->
<div v-if="pointsCles.length" class="mt-4">
<p class="text-sm font-medium mb-2" style="color: var(--nav-text-muted);">Points clés</p>
<p class="text-sm font-medium mb-2" style="color: color-mix(in srgb, var(--nav-text) 78%, transparent);">Points clés</p>
<ul class="space-y-1">
<li
v-for="(point, i) in pointsCles"
@@ -106,15 +118,16 @@
<!-- Mini-carte Leaflet -->
<div
v-if="hasCoords"
class="shrink-0 lg:w-56 xl:w-64 rounded-xl overflow-hidden"
class="shrink-0 rounded-xl overflow-hidden"
:class="compact ? '' : 'lg:w-56 xl:w-64'"
style="height: 180px; border: 1px solid var(--nav-bg-alt);"
>
<ClientOnly>
<div ref="mapContainer" class="w-full h-full"></div>
<div ref="mapContainer" class="w-full h-full" role="img" :aria-label="`Carte : ${org.nom}${org.localisation_ville ? `, ${org.localisation_ville}` : ''}`"></div>
<template #fallback>
<div
class="w-full h-full flex items-center justify-center text-xs"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
style="background: var(--nav-bg-alt); color: color-mix(in srgb, var(--nav-text) 78%, transparent);"
>Carte…</div>
</template>
</ClientOnly>
@@ -140,7 +153,7 @@
<Transition name="report-form">
<div v-if="reportOpen" class="report-panel">
<p class="text-xs mb-3" style="color: var(--nav-text-muted);">
<p class="text-xs mb-3" style="color: color-mix(in srgb, var(--nav-text) 78%, transparent);">
Tes suggestions seront transmises à l'équipe AEP par email.
</p>
<div class="flex flex-col gap-3">
@@ -171,7 +184,7 @@
</div>
<p v-if="reportError" class="text-xs" style="color: #e53e3e;">{{ reportError }}</p>
<p v-if="reportSuccess" class="text-xs" style="color: #38a169;">{{ reportSuccess }}</p>
<p class="text-xs" style="color: var(--nav-text-muted); opacity: 0.6;">
<p class="text-xs" style="color: color-mix(in srgb, var(--nav-text) 78%, transparent); opacity: 0.6;">
{{ reportMessage.length }}/500
</p>
</div>
@@ -186,7 +199,15 @@
const baseTiles = useBaseTiles()
import type { Org } from '~/types/org'
const props = defineProps<{ org: Org }>()
const props = defineProps<{
org: Org
/** Panneau de détail de la vue fiches (B12, 380 px) : tout en colonne, titre en h2. */
compact?: boolean
/** Fonctions en chips cliquables : la vue fiches ajoute le filtre. */
etiquettesCliquables?: boolean
}>()
const emit = defineEmits<{ etiquette: [fonction: string] }>()
// ── Champs ──────────────────────────────────────────────────────────────
const descriptionUser = computed(() =>
@@ -264,15 +285,24 @@ async function submitReport() {
// ── Mini-carte Leaflet ────────────────────────────────────────────────
const mapContainer = ref<HTMLElement | null>(null)
onMounted(async () => {
if (!hasCoords.value || !mapContainer.value) return
// Le conteneur est dans <ClientOnly>, rendu APRÈS le onMounted de ce composant
// quand la fiche s'ouvre côté client (panneau de la vue fiches, B12) : on
// attend qu'il existe au lieu de le lire au montage (il valait null, la
// mini-carte restait un cadre vide).
let mapInit = false
watch(mapContainer, (el) => { if (el) initMap(el) }, { flush: 'post' })
onMounted(() => { if (mapContainer.value) initMap(mapContainer.value) })
async function initMap(el: HTMLElement) {
if (mapInit || !hasCoords.value) return
mapInit = true
const L = (await import('leaflet')).default
const lat = props.org.latitude as number
const lng = props.org.longitude as number
const map = L.map(mapContainer.value, {
const map = L.map(el, {
center: [lat, lng],
zoom: 10,
zoomControl: false,
@@ -301,8 +331,9 @@ onMounted(async () => {
iconAnchor: [7, 7],
})
L.marker([lat, lng], { icon }).addTo(map)
})
// Épingle décorative : ni clic ni clavier (la mini-carte est une image, nommée par son conteneur)
L.marker([lat, lng], { icon, interactive: false, keyboard: false }).addTo(map)
}
</script>
<style scoped>
@@ -312,7 +343,7 @@ onMounted(async () => {
align-items: center;
gap: 6px;
font-size: 0.78rem;
color: var(--nav-text-muted);
color: color-mix(in srgb, var(--nav-text) 78%, transparent);
background: none;
border: none;
cursor: pointer;
+83 -16
View File
@@ -1,12 +1,21 @@
<!--
FicheMiniCard — B3-M2, B11-M2
La carte entière ouvre la fiche (bouton étiré par ::after) ; avec
`etiquettesCliquables`, les chips deviennent des boutons qui émettent
`etiquette` (la vue fiches ajoute le filtre). Un bouton ne pouvant pas en
contenir d'autres, la racine est un <article>, plus un <button> : apparence
inchangée, page /dev/fiche-mini-card comprise.
-->
<template>
<button
type="button"
class="fiche-mini-card"
:aria-label="fiche.nom"
@click="emit('open')"
>
<span class="fiche-mini-card__nom">{{ fiche.nom }}</span>
<span v-if="fiche.resume" class="fiche-mini-card__resume">{{ fiche.resume }}</span>
<article class="fiche-mini-card" :class="{ 'fiche-mini-card--actif': actif }">
<button
type="button"
class="fiche-mini-card__ouvrir"
@click="emit('open')"
>
<span class="fiche-mini-card__nom">{{ fiche.nom }}</span>
<span v-if="fiche.resume" class="fiche-mini-card__resume">{{ fiche.resume }}</span>
</button>
<ChipGroup
v-if="fiche.chips.length || fiche.echelle"
@@ -14,11 +23,24 @@
class="fiche-mini-card__chips"
>
<Chip v-if="fiche.echelle" as="span" variant="echelle" :label="fiche.echelle" />
<Chip v-for="chip in fiche.chips" :key="chip" as="span" variant="fonction" :label="chip" />
<template v-if="etiquettesCliquables">
<Chip
v-for="chip in fiche.chips"
:key="chip"
variant="fonction"
:label="chip"
:active="etiquettesActives?.includes(chip)"
:aria-label="`Filtrer par ${chip}`"
@toggle="emit('etiquette', chip)"
/>
</template>
<template v-else>
<Chip v-for="chip in fiche.chips" :key="chip" as="span" variant="fonction" :label="chip" />
</template>
</ChipGroup>
<span v-if="!fiche.geoloc" class="fiche-mini-card__no-geo">Sans localisation</span>
</button>
</article>
</template>
<script setup lang="ts">
@@ -26,30 +48,37 @@ import type { MiniFiche } from '~/composables/useFicheAdapter'
defineProps<{
fiche: MiniFiche
etiquettesCliquables?: boolean
/** Libellés de chips déjà filtrés (affichés pressés). */
etiquettesActives?: string[]
/** Fiche survolée sur la carte ou sélectionnée. */
actif?: boolean
}>()
const emit = defineEmits<{
open: []
etiquette: [label: string]
}>()
</script>
<style scoped>
.fiche-mini-card {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
min-width: 0;
padding: var(--space-sm);
border: 1px solid transparent;
border-radius: var(--radius-md);
background: var(--surface);
color: var(--text);
text-align: left;
cursor: pointer;
font-family: inherit;
box-sizing: border-box;
transition: background-color var(--dur-fast) var(--ease-out),
box-shadow var(--dur-fast) var(--ease-out);
box-shadow var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out);
}
@media (prefers-reduced-motion: reduce) {
@@ -63,11 +92,42 @@ const emit = defineEmits<{
box-shadow: var(--shadow-sm);
}
.fiche-mini-card:focus-visible {
/* Survol d'une épingle ou fiche sélectionnée : liseré safran */
.fiche-mini-card--actif {
border-color: var(--accent);
box-shadow: inset 3px 0 0 var(--accent);
}
.fiche-mini-card:has(.fiche-mini-card__ouvrir:focus-visible) {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
/* Bouton étiré : toute la carte ouvre la fiche, les chips cliquables passent dessus */
.fiche-mini-card__ouvrir {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
min-width: 0;
padding: 0;
border: none;
background: none;
color: inherit;
text-align: left;
font: inherit;
cursor: pointer;
}
.fiche-mini-card__ouvrir::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
}
.fiche-mini-card__ouvrir:focus-visible {
outline: none;
}
.fiche-mini-card__nom {
width: 100%;
font-size: var(--fs-sm);
@@ -84,7 +144,8 @@ const emit = defineEmits<{
margin-top: var(--space-2xs);
font-size: var(--fs-xs);
line-height: var(--lh-base);
color: var(--text-muted);
/* --text-muted ne donne que 3,7:1 sur --surface (DESIGN-SYSTEM §Navigation) */
color: color-mix(in srgb, var(--text) 78%, transparent);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
@@ -92,12 +153,18 @@ const emit = defineEmits<{
}
.fiche-mini-card__chips {
position: relative;
z-index: 1;
margin-top: var(--space-xs);
pointer-events: none;
}
.fiche-mini-card__chips :deep(button) {
pointer-events: auto;
}
.fiche-mini-card__no-geo {
margin-top: var(--space-2xs);
font-size: var(--fs-xs);
color: var(--text-subtle);
color: color-mix(in srgb, var(--text) 78%, transparent);
}
</style>
-183
View File
@@ -1,183 +0,0 @@
<template>
<Teleport to="body">
<!-- Backdrop -->
<Transition name="backdrop">
<div
v-if="modelValue && orgId != null"
class="fixed inset-0 z-[1500]"
style="background: rgba(26,34,56,0.55);"
@click="close"
aria-hidden="true"
/>
</Transition>
<!-- Modal centré -->
<Transition name="modal">
<div
v-if="modelValue && orgId != null"
class="fiche-modal fixed z-[1501] left-1/2 -translate-x-1/2 flex flex-col"
style="
width: min(768px, 92vw);
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="org?.nom ?? 'Fiche organisation'"
@keydown.esc="close"
>
<!-- Header modal -->
<div
class="flex items-center justify-between px-5 py-3 shrink-0 border-b"
style="background: var(--nav-surface); border-color: var(--nav-bg-alt);"
>
<span class="text-sm font-semibold" style="color: var(--nav-text-muted);">Fiche détaillée</span>
<div class="flex items-center gap-2">
<!-- Lien fiche complète -->
<a
v-if="orgId"
:href="`/fiche/${orgId}`"
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>
Ouvrir
</a>
<!-- Fermer -->
<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">
<!-- Chargement -->
<div v-if="pending" class="py-12 text-center text-sm" style="color: var(--nav-text-muted);">
Chargement de la fiche…
</div>
<!-- Erreur -->
<div v-else-if="error" class="py-12 text-center">
<p class="text-base font-semibold mb-2" style="color: var(--nav-text);">Fiche introuvable</p>
<p class="text-sm" style="color: var(--nav-text-muted);">L'organisation demandée n'existe pas ou a été supprimée.</p>
</div>
<!-- Contenu -->
<template v-else-if="org">
<FicheDetail :org="org" />
<div class="mb-5" style="height: 1px; background: var(--nav-bg-alt);"></div>
<CommentSection :org-id="org.Id" :refresh="commentRefreshTick" />
<CommentForm :org-id="org.Id" @submitted="onCommentSubmitted" />
</template>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { Org } from '~/types/org'
const props = defineProps<{
modelValue: boolean
orgId: number | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
function close() {
emit('update:modelValue', 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))
})
// Fetch fiche quand orgId change
const org = ref<Org | null>(null)
const pending = ref(false)
const error = ref(false)
watch(() => props.orgId, async (id) => {
if (id == null) return
pending.value = true
error.value = false
org.value = null
try {
org.value = await $fetch<Org>(`/api/fiche/${id}`)
} catch {
error.value = true
} finally {
pending.value = false
}
}, { immediate: true })
const commentRefreshTick = ref(0)
function onCommentSubmitted() {
commentRefreshTick.value++
}
</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; }
/* 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>
-360
View File
@@ -1,360 +0,0 @@
<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>
+291
View File
@@ -0,0 +1,291 @@
<!--
FicheReseauContenu — B12-M4. Le corps de l'ancienne FicheModalV2 (retirée),
devenu un contenu : la vue fiches le pose dans son panneau de détail
(slot #detail de VueFiches). Badges, hashtags, descriptions, pensées,
projets, structures liées, sources.
Hashtag cliqué → `etiquette` (la vue fiches ajoute le filtre) ; structure
liée cliquée → `voisin` (la page l'ouvre dans le même panneau).
-->
<template>
<article v-if="structure" class="frc">
<div class="frc-famille" :style="`border-bottom-color: ${familleColor};`">
<span class="frc-famille__pastille" :style="`background: ${familleColor};`" aria-hidden="true" />
<span class="frc-famille__libelle">{{ familleLabel }}</span>
<span v-if="structure.badges.centre_ressources" class="frc-badge" style="background: #2d8a6b22; color: #1f6650;">Centre ressources</span>
<span v-if="structure.badges.mouvement_manifeste" class="frc-badge" style="background: #c44a2f22; color: #9a3520;">Manifeste</span>
<span v-if="structure.badges.contre_pouvoir_spatial" class="frc-badge" style="background: #1a3a6b22; color: #1a3a6b;">Contre-pouvoir</span>
<span v-if="structure.badges.f6_recherche_politique" class="frc-badge" style="background: #6b3fa022; color: #5a3388;">Recherche pol.</span>
</div>
<h2 class="frc-nom">{{ structure.nom }}</h2>
<p class="frc-meta">
<span>{{ structure.type_principal }}</span>
<span aria-hidden="true"> · </span>
<span>{{ structure.ville }}, {{ structure.pays }}</span>
</p>
<a
v-if="structure.url"
:href="structure.url"
target="_blank"
rel="noopener noreferrer"
class="frc-site"
>
<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<span class="frc-sr"> (nouvel onglet)</span>
</a>
<ChipGroup v-if="structure.hashtags.length" :aria-label="`Hashtags de ${structure.nom}`" class="frc-tags">
<Chip
v-for="tag in structure.hashtags"
:key="tag"
variant="fonction"
:label="tag"
:active="etiquettesActives?.includes(tag)"
:aria-label="`Filtrer par ${tag}`"
@toggle="emit('etiquette', tag)"
/>
</ChipGroup>
<p class="frc-texte">{{ structure.description_courte }}</p>
<div v-if="structure.description_longue" class="frc-bloc">
<div class="frc-texte frc-long" :class="{ 'frc-long--replie': !showFullDesc }">{{ structure.description_longue }}</div>
<button type="button" class="frc-lien" :aria-expanded="showFullDesc" @click="showFullDesc = !showFullDesc">
{{ showFullDesc ? 'Réduire' : 'Lire la suite…' }}
</button>
</div>
<section v-if="structure.pensees && structure.pensees.length" class="frc-bloc">
<h3 class="frc-titre">Pensées rattachées</h3>
<div class="frc-liste-inline">
<span
v-for="pensee in structure.pensees"
:key="pensee.id"
class="frc-pensee"
:class="{ 'frc-pensee--ia': pensee.confiance === 'ia_suggested' }"
:title="pensee.confiance === 'ia_suggested' ? 'IA suggéré' : ''"
>{{ pensee.label }}<span v-if="pensee.confiance === 'ia_suggested'" aria-hidden="true"> ~</span><span v-if="pensee.confiance === 'ia_suggested'" class="frc-sr"> (suggéré par l'IA)</span></span>
</div>
</section>
<section v-if="projetsStructure.length" class="frc-bloc">
<h3 class="frc-titre">Projets emblématiques</h3>
<div class="frc-projets">
<div v-for="projet in projetsStructure.slice(0, 5)" :key="projet.id" class="frc-projet">
<div class="frc-projet__tete">
<span class="frc-projet__nom">{{ projet.nom }}</span>
<span v-if="projet.annee" class="frc-discret">{{ projet.annee }}</span>
</div>
<div v-if="projet.lieu" class="frc-discret">{{ projet.lieu }}</div>
<p class="frc-projet__desc">{{ projet.description.slice(0, 120) }}{{ projet.description.length > 120 ? '…' : '' }}</p>
<a v-if="projet.url" :href="projet.url" target="_blank" rel="noopener noreferrer" class="frc-lien">En savoir plus<span class="frc-sr"> sur {{ projet.nom }} (nouvel onglet)</span> →</a>
</div>
</div>
</section>
<section v-if="structuresVoisines.length" class="frc-bloc">
<h3 class="frc-titre">Structures liées</h3>
<div class="frc-liste-inline">
<button
v-for="voisine in structuresVoisines.slice(0, 6)"
:key="voisine.id"
type="button"
class="frc-voisin"
@click="emit('voisin', voisine.id)"
>{{ voisine.nom }}</button>
</div>
</section>
<section v-if="structure.sources && structure.sources.length" class="frc-bloc">
<h3 class="frc-titre">Sources</h3>
<ul class="frc-sources">
<li v-for="(source, i) in structure.sources" :key="i">
<span class="frc-source__type">{{ source.type }}</span>
<a v-if="source.url" :href="source.url" target="_blank" rel="noopener noreferrer" class="frc-lien">{{ source.titre }}</a>
<span v-else>{{ source.titre }}</span>
</li>
</ul>
</section>
<p class="frc-cta">
<NuxtLink to="/contribuer" class="frc-lien">Signaler une erreur</NuxtLink>
<NuxtLink to="/contribuer" class="frc-lien">Réclamer cette fiche</NuxtLink>
</p>
</article>
</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<{
structureId: string
data: ReseauxBifurcationData | null
/** Hashtags actifs dans les filtres (avec leur « # ») : la chip s'allume. */
etiquettesActives?: string[]
}>()
const emit = defineEmits<{
etiquette: [hashtag: string]
voisin: [id: string]
}>()
const showFullDesc = ref(false)
watch(() => props.structureId, () => { showFullDesc.value = false })
const structure = computed<StructureV2 | null>(() =>
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[]>(() =>
props.data?.projets?.filter((p) => p.structure_parent === props.structureId) ?? [],
)
const structuresVoisines = computed<StructureV2[]>(() => {
if (!props.data) return []
const edges = props.data.graphe?.edges ?? []
return 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)
.map((id) => props.data!.structures.find((s) => s.id === id))
.filter(Boolean) as StructureV2[]
})
</script>
<style scoped>
.frc { color: var(--text); font-size: var(--fs-sm); }
.frc-sr {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.frc-famille {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2xs) var(--space-xs);
padding-bottom: var(--space-xs);
margin-bottom: var(--space-sm);
border-bottom: 3px solid;
}
.frc-famille__pastille { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
.frc-famille__libelle { font-weight: var(--fw-semibold); color: color-mix(in srgb, var(--text) 78%, transparent); }
.frc-badge {
padding: 2px var(--space-xs);
border-radius: var(--radius-full);
font-size: var(--fs-xs, 0.75rem);
font-weight: var(--fw-medium);
}
.frc-nom { margin: 0 0 var(--space-2xs); font-size: 1.25rem; font-weight: var(--fw-bold, 700); line-height: 1.25; }
.frc-meta { margin: 0 0 var(--space-sm); color: color-mix(in srgb, var(--text) 78%, transparent); }
.frc-site {
display: inline-flex;
align-items: center;
gap: var(--space-2xs);
min-height: 32px;
padding: 0 var(--space-sm);
margin-bottom: var(--space-sm);
border-radius: var(--radius-md);
background: var(--bg-alt);
color: var(--text);
font-weight: var(--fw-medium);
text-decoration: none;
}
.frc-tags { margin-bottom: var(--space-sm); }
.frc-texte { margin: 0 0 var(--space-sm); line-height: var(--lh-base, 1.55); }
.frc-long { white-space: pre-wrap; margin: 0; }
.frc-long--replie { max-height: 120px; overflow: hidden; }
.frc-bloc { margin-bottom: var(--space-md); }
.frc-titre {
margin: 0 0 var(--space-xs);
font-size: var(--fs-xs, 0.75rem);
font-weight: var(--fw-bold, 700);
text-transform: uppercase;
letter-spacing: 0.04em;
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.frc-liste-inline { display: flex; flex-wrap: wrap; gap: var(--space-2xs); }
.frc-pensee {
padding: 2px var(--space-xs);
border-radius: var(--radius-sm);
background: var(--bg-alt);
font-size: var(--fs-xs, 0.75rem);
}
.frc-pensee--ia { color: color-mix(in srgb, var(--text) 78%, transparent); border: 1px dashed var(--border); }
.frc-projets { display: flex; flex-direction: column; gap: var(--space-xs); }
.frc-projet { padding: var(--space-sm); border-radius: var(--radius-md); background: var(--bg); border: 1px solid var(--border); }
.frc-projet__tete { display: flex; justify-content: space-between; gap: var(--space-xs); }
.frc-projet__nom { font-weight: var(--fw-medium); }
.frc-projet__desc { margin: var(--space-2xs) 0; font-size: var(--fs-xs, 0.75rem); line-height: 1.5; color: color-mix(in srgb, var(--text) 78%, transparent); }
.frc-discret { font-size: var(--fs-xs, 0.75rem); color: color-mix(in srgb, var(--text) 78%, transparent); }
.frc-voisin {
min-height: 32px;
padding: 0 var(--space-xs);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-alt);
color: var(--text);
font: inherit;
font-size: var(--fs-xs, 0.75rem);
cursor: pointer;
text-align: left;
}
.frc-sources { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2xs); }
.frc-sources li { display: flex; align-items: baseline; gap: var(--space-xs); min-width: 0; font-size: var(--fs-xs, 0.75rem); overflow-wrap: anywhere; }
.frc-source__type { flex-shrink: 0; padding: 1px var(--space-2xs); border-radius: var(--radius-sm); background: var(--bg-alt); }
.frc-lien {
padding: 0;
border: none;
background: none;
color: var(--text);
font: inherit;
font-size: var(--fs-xs, 0.75rem);
text-decoration: underline;
cursor: pointer;
}
.frc-cta { display: flex; gap: var(--space-sm); padding-top: var(--space-xs); margin: 0; border-top: 1px solid var(--border); }
.frc-site:focus-visible,
.frc-lien:focus-visible,
.frc-voisin:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
</style>
-168
View File
@@ -1,168 +0,0 @@
<!--
FichesPanel — B3-M3
Panneau « toutes les fiches » : reçoit une liste déjà filtrée (recherche +
filtres en cours côté page), l'affiche en grille de FicheMiniCard, et
expose ses propres chips de filtrage branchées sur le MÊME état que la
page appelante (pas un second état — voir vérification du brief).
Ne crée pas de second champ de recherche : `query` sert uniquement à
nuancer le message d'état vide.
-->
<template>
<div class="fiches-panel">
<div class="fiches-panel__head">
<p class="fiches-panel__intention">{{ intention }}</p>
<div class="fiches-panel__count">
{{ fiches.length }} fiche{{ fiches.length > 1 ? 's' : '' }}
</div>
<ChipGroup v-if="hashtags.length" aria-label="Filtrer les fiches" class="fiches-panel__chips">
<Chip
v-for="tag in visibleHashtags"
:key="tag"
:label="tag"
variant="fonction"
:active="selected.includes(tag)"
@toggle="toggle(tag)"
/>
<Chip
v-if="isCollapsible"
:label="expanded ? 'Réduire' : `Voir les ${hiddenCount} autres`"
variant="echelle"
:active="expanded"
@toggle="expanded = !expanded"
/>
</ChipGroup>
</div>
<div class="fiches-panel__body">
<p v-if="fiches.length === 0" class="fiches-panel__empty">
Aucun résultat<template v-if="query"> pour « {{ query }} »</template>.
</p>
<div v-else class="fiches-panel__grid">
<FicheMiniCard
v-for="fiche in fiches"
:key="`${fiche.kind}-${fiche.id}`"
:fiche="fiche"
@open="emit('open', fiche)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { MiniFiche } from '~/composables/useFicheAdapter'
const props = defineProps<{
fiches: MiniFiche[]
hashtags: string[]
selected: string[]
query: string
intention: string
}>()
const emit = defineEmits<{
'update:selected': [value: string[]]
open: [fiche: MiniFiche]
}>()
// Hashtags repliés (B3-M4) — au-delà de COLLAPSE_THRESHOLD chips, n'affiche
// que les premières + un Chip-bouton « voir les N autres ». Les chips déjà
// actives restent toujours visibles même repliées. `/` (10 fonctions) ne
// dépasse jamais le seuil ; `/agences` (~60 hashtags) se replie. État
// déplié non persistant (ref locale, reset à chaque montage du composant).
const COLLAPSE_THRESHOLD = 14
const expanded = ref(false)
const isCollapsible = computed(() => props.hashtags.length > COLLAPSE_THRESHOLD)
const visibleHashtags = computed(() => {
if (expanded.value || !isCollapsible.value) return props.hashtags
const head = props.hashtags.slice(0, COLLAPSE_THRESHOLD)
const headSet = new Set(head)
const activeOutsideHead = props.hashtags.filter(
(tag) => props.selected.includes(tag) && !headSet.has(tag)
)
return [...head, ...activeOutsideHead]
})
const hiddenCount = computed(() => props.hashtags.length - visibleHashtags.value.length)
function toggle(tag: string) {
if (props.selected.includes(tag)) {
emit('update:selected', props.selected.filter((t) => t !== tag))
} else {
emit('update:selected', [...props.selected, tag])
}
}
</script>
<style scoped>
.fiches-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.fiches-panel__head {
flex-shrink: 0;
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.fiches-panel__intention {
margin: 0 0 var(--space-xs);
font-size: var(--fs-sm);
line-height: var(--lh-base);
color: var(--text-muted);
}
.fiches-panel__count {
margin: 0 0 var(--space-xs);
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--text-subtle);
}
.fiches-panel__chips {
margin-top: var(--space-2xs);
}
.fiches-panel__body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--space-md);
}
.fiches-panel__empty {
margin: var(--space-xl) 0;
text-align: center;
font-size: var(--fs-sm);
color: var(--text-muted);
}
.fiches-panel__grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-sm);
}
@media (min-width: 768px) {
.fiches-panel__grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1280px) {
.fiches-panel__grid {
grid-template-columns: repeat(3, 1fr);
}
}
</style>
+99
View File
@@ -0,0 +1,99 @@
<!--
FilEchelle — B10-M4
< 768 px : une ligne de 28 px sous la barre, « S · Partir de soi · Outils · Jobs » :
l'échelle courante, l'onglet courant, puis ses voisins du groupe (liens).
Absente sur les pages hors groupe (Manifeste, À propos, Proposer, Signaler, fiche).
Montée dans le flux de la colonne d'app.vue, AVANT le conteneur de page : la carte
de / perd ces 28 px à vue, jamais en dessous de la ligne de flottaison.
-->
<template>
<nav v-if="courant" class="fil-echelle" :class="{ 'fil-echelle--long': voisins.length > 1 }" aria-label="Échelle courante">
<span class="fil-echelle__pastille">{{ courant.echelle }}</span>
<span class="fil-echelle__titre">{{ ECHELLES[courant.echelle].titre }}</span>
<span class="fil-echelle__sep" aria-hidden="true">·</span>
<span class="fil-echelle__courant" aria-current="page">{{ courant.onglet.libelle }}</span>
<template v-for="o in voisins" :key="o.to">
<span class="fil-echelle__sep" aria-hidden="true">·</span>
<NuxtLink :to="o.to" class="fil-echelle__lien">{{ o.libelle }}</NuxtLink>
</template>
</nav>
</template>
<script setup lang="ts">
import { ECHELLES, echelleDeRoute } from '~/utils/echelles'
const route = useRoute()
const courant = computed(() => echelleDeRoute(route))
const voisins = computed(() =>
courant.value ? ECHELLES[courant.value.echelle].onglets.filter((o) => o !== courant.value!.onglet) : [],
)
</script>
<style scoped>
.fil-echelle {
display: flex;
align-items: center;
flex-shrink: 0;
height: 28px;
padding: 0 var(--space-sm);
gap: 5px;
overflow-x: auto;
scrollbar-width: none;
white-space: nowrap;
font-size: 0.72rem;
line-height: 1;
background: var(--surface);
border-bottom: 1px solid var(--bg-alt);
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.fil-echelle::-webkit-scrollbar {
display: none;
}
.fil-echelle__pastille {
padding: 2px 5px;
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--on-accent);
font-weight: var(--fw-bold);
}
.fil-echelle__courant {
font-weight: var(--fw-bold);
color: var(--text);
}
.fil-echelle__lien {
color: inherit;
text-decoration: underline;
text-decoration-color: var(--border);
text-underline-offset: 3px;
}
.fil-echelle__lien:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 1px;
}
/* Groupe à trois onglets (M) sous 400 px : l'accroche se replie sur sa pastille
(lue quand même par les lecteurs d'écran), sinon « Codev » sort de l'écran. */
@media (max-width: 399px) {
.fil-echelle--long .fil-echelle__titre {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
.fil-echelle--long .fil-echelle__titre + .fil-echelle__sep {
display: none;
}
}
@media (min-width: 768px) {
.fil-echelle {
display: none;
}
}
</style>
-52
View File
@@ -1,52 +0,0 @@
<template>
<div class="space-y-1.5">
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Fonction</p>
<ChipGroup aria-label="Filtrer par fonction">
<Chip
v-for="fn in FONCTIONS"
:key="fn"
:label="fn"
variant="fonction"
:active="modelValue.includes(fn)"
:count="modelValue.includes(fn) ? (modelValue.indexOf(fn) + 1) : (counts[fn] ?? 0)"
@toggle="toggle(fn)"
/>
</ChipGroup>
<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>
</p>
</div>
</template>
<script setup lang="ts">
const FONCTIONS = [
'Juridique',
'Technique',
'Économique',
'Administratif',
'Chantier',
'Comptabilité',
'Développement',
'Formation',
"Gestion d'agence",
'Santé mentale',
] as const
const props = defineProps<{
modelValue: string[]
counts: Record<string, number>
}>()
const emit = defineEmits<{
'update:modelValue': [value: string[]]
}>()
function toggle(fn: string) {
if (props.modelValue.includes(fn)) {
emit('update:modelValue', props.modelValue.filter(f => f !== fn))
} else {
emit('update:modelValue', [...props.modelValue, fn])
}
}
</script>
-95
View File
@@ -1,95 +0,0 @@
<template>
<div class="hashtag-filter" style="padding: 8px 12px; background: var(--nav-surface);">
<!-- Filtres famille — couleurs propres par famille (hors grammaire dimension Chip, non touché B3-M1) -->
<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>
<ChipGroup v-if="hashtagsVisible" aria-label="Filtrer par hashtag">
<Chip
v-for="tag in props.allHashtags"
:key="tag"
:label="tag"
variant="fonction"
:active="selectedHashtags.includes(tag)"
@toggle="toggleHashtag(tag)"
/>
</ChipGroup>
</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;
}
</style>
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<div class="media-tab-projets" style="padding: 1.5rem; overflow-y: auto;">
<div style="max-width: 70ch; margin-bottom: 1.5rem;">
<h2 style="font-weight: 700; font-size: 1.1rem; margin-bottom: 0.5rem; color: var(--nav-text);">PFE engagés</h2>
<h2 style="font-weight: 700; font-size: 1.1rem; margin-bottom: 0.5rem; color: var(--nav-text);">Publications</h2>
<p style="font-size: 0.9rem; line-height: 1.6; color: var(--nav-text);">
Mutualiser le savoir. Voici les PFE engagés publiés en ligne dont nous avons connaissance.
Partage-nous le lien de ton travail si tu veux participer à cette initiative.
-218
View File
@@ -1,218 +0,0 @@
<!--
MobileSheet — Bottom sheet swipable 3 états (collapsed / half / full)
Pattern Google Maps mobile
États :
- collapsed : juste la poignée + compteur (~56px)
- half : ~50dvh (état initial)
- full : ~92dvh (plein écran)
Déclenché par touch/drag sur la poignée
-->
<template>
<div
class="mobile-sheet"
:class="`mobile-sheet--${state}`"
:style="{ transform: `translateY(${dragOffset}px)` }"
>
<!-- Poignée drag -->
<div
class="sheet-handle-area"
@touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove"
@touchend="onTouchEnd"
>
<div class="sheet-handle-bar" />
</div>
<!-- Compteur compact (toujours visible) -->
<div class="sheet-header" @click="onHeaderClick">
<span class="sheet-counter">
<span v-if="pending">Chargement…</span>
<span v-else>{{ resultCount }} fiche{{ resultCount > 1 ? 's' : '' }}</span>
</span>
<svg
width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round" aria-hidden="true"
class="sheet-chevron"
:class="{ 'sheet-chevron--up': state !== 'collapsed' }"
style="color: var(--nav-text-muted); flex-shrink: 0;"
>
<polyline points="18 15 12 9 6 15"/>
</svg>
</div>
<!-- Contenu scrollable (caché si collapsed) -->
<div
v-show="state !== 'collapsed'"
ref="contentEl"
class="sheet-content"
>
<slot />
</div>
</div>
</template>
<script setup lang="ts">
type SheetState = 'collapsed' | 'half' | 'full'
const props = defineProps<{
resultCount: number
pending?: boolean
}>()
const emit = defineEmits<{
'state-change': [state: SheetState]
}>()
const state = ref<SheetState>('half')
const dragOffset = ref(0)
const contentEl = ref<HTMLElement | null>(null)
let touchStartY = 0
let touchCurrentY = 0
let isDragging = false
// Cycle états au clic header
function onHeaderClick() {
if (state.value === 'collapsed') {
setSheetState('half')
} else if (state.value === 'half') {
setSheetState('full')
} else {
setSheetState('collapsed')
}
}
function setSheetState(next: SheetState) {
state.value = next
dragOffset.value = 0
emit('state-change', next)
}
// Touch handlers
function onTouchStart(e: TouchEvent) {
touchStartY = e.touches[0].clientY
touchCurrentY = touchStartY
isDragging = true
dragOffset.value = 0
}
function onTouchMove(e: TouchEvent) {
if (!isDragging) return
touchCurrentY = e.touches[0].clientY
const delta = touchCurrentY - touchStartY
// Limiter le drag visuellement (résistance)
dragOffset.value = delta * 0.6
}
function onTouchEnd() {
if (!isDragging) return
isDragging = false
const delta = touchCurrentY - touchStartY
const threshold = 60 // px minimum pour changer d'état
if (delta > threshold) {
// Swipe vers le bas → état inférieur
if (state.value === 'full') setSheetState('half')
else if (state.value === 'half') setSheetState('collapsed')
else setSheetState('collapsed')
} else if (delta < -threshold) {
// Swipe vers le haut → état supérieur
if (state.value === 'collapsed') setSheetState('half')
else if (state.value === 'half') setSheetState('full')
else setSheetState('full')
} else {
// Snap back
dragOffset.value = 0
}
// Transition smooth au relâchement
dragOffset.value = 0
}
</script>
<style scoped>
.mobile-sheet {
position: fixed;
inset-x: 0;
bottom: 0;
z-index: 500;
background: var(--nav-surface);
border-radius: 16px 16px 0 0;
box-shadow: 0 -4px 24px rgba(26, 34, 56, 0.14);
display: flex;
flex-direction: column;
transition: height 0.3s cubic-bezier(0.32, 0.72, 0, 1), transform 0.05s linear;
will-change: height, transform;
overflow: hidden;
}
/* ── États hauteur ───────────────────────────────────────────────────── */
.mobile-sheet--collapsed {
height: 56px;
}
.mobile-sheet--half {
height: 50dvh;
min-height: 200px;
}
.mobile-sheet--full {
height: 92dvh;
}
/* ── Poignée ─────────────────────────────────────────────────────────── */
.sheet-handle-area {
display: flex;
justify-content: center;
padding: 10px 0 4px;
cursor: grab;
flex-shrink: 0;
}
.sheet-handle-area:active {
cursor: grabbing;
}
.sheet-handle-bar {
width: 36px;
height: 4px;
border-radius: 2px;
background: var(--nav-bg-alt);
}
/* ── Header compteur ─────────────────────────────────────────────────── */
.sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px 8px;
cursor: pointer;
flex-shrink: 0;
gap: 8px;
}
.sheet-counter {
font-size: 0.8rem;
font-weight: 600;
color: var(--nav-text-muted);
}
.sheet-chevron {
transition: transform 0.2s ease;
}
.sheet-chevron--up {
transform: rotate(180deg);
}
/* ── Contenu scrollable ──────────────────────────────────────────────── */
.sheet-content {
flex: 1;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
</style>
+148
View File
@@ -0,0 +1,148 @@
<!--
NavEchelles — B10-M2
Barre des trois échelles S / M / XL, lue depuis utils/echelles.ts (source unique).
≥ 1280 px : les trois groupes ; 768-1279 px : le seul groupe de la page courante ;
< 768 px : rien (FilEchelle + NavTiroir prennent le relais).
Chaque groupe = <ul> étiqueté par son accroche (pastille + titre de l'échelle).
-->
<template>
<nav class="nav-echelles" aria-label="Échelles">
<div
v-for="id in ORDRE_ECHELLES"
:key="id"
class="nav-echelles__grp"
:class="{ 'nav-echelles__grp--courant': courant?.echelle === id }"
>
<span :id="`nav-echelle-${id}`" class="nav-echelles__kick">
<span class="nav-echelles__pastille">{{ id }}</span>{{ ECHELLES[id].titre }}
</span>
<ul class="nav-echelles__tabs" :aria-labelledby="`nav-echelle-${id}`">
<li v-for="o in ECHELLES[id].onglets" :key="o.to">
<NuxtLink
:to="o.to"
class="nav-echelles__tab"
:class="{ 'nav-echelles__tab--actif': o.actif(route) }"
:aria-current="o.actif(route) ? 'page' : undefined"
>{{ o.libelle }}</NuxtLink>
</li>
</ul>
</div>
</nav>
</template>
<script setup lang="ts">
import { ECHELLES, ORDRE_ECHELLES, echelleDeRoute } from '~/utils/echelles'
const route = useRoute()
const courant = computed(() => echelleDeRoute(route))
</script>
<style scoped>
.nav-echelles {
display: none;
align-self: stretch;
flex: 1 1 auto;
min-width: 0;
gap: var(--space-lg);
}
.nav-echelles__grp {
position: relative;
display: none;
flex-direction: column;
justify-content: flex-end;
min-width: 0;
}
.nav-echelles__kick {
padding: 0 var(--space-xs);
font-size: 0.66rem;
font-weight: var(--fw-semibold);
letter-spacing: 0.04em;
line-height: 1.3;
white-space: nowrap;
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.nav-echelles__grp--courant .nav-echelles__kick {
color: var(--text);
}
.nav-echelles__pastille {
display: inline-block;
margin-right: 5px;
padding: 0 5px;
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--on-accent);
font-weight: var(--fw-bold);
}
.nav-echelles__tabs {
display: flex;
margin: 0;
padding: 0;
list-style: none;
}
.nav-echelles__tab {
display: inline-block;
padding: 4px 9px 9px;
font-size: 0.8rem;
font-weight: var(--fw-medium);
white-space: nowrap;
text-decoration: none;
color: color-mix(in srgb, var(--text) 78%, transparent);
border-bottom: 2px solid transparent;
transition: color var(--dur-fast) var(--ease-out);
}
.nav-echelles__tab:hover {
color: var(--text);
}
.nav-echelles__tab:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: -2px;
border-radius: var(--radius-sm);
}
.nav-echelles__tab--actif {
color: var(--text);
font-weight: var(--fw-semibold);
border-bottom-color: var(--nav-primary-solid);
}
/* 768-1279 : le seul groupe courant, aligné à gauche après le logo */
@media (min-width: 768px) {
.nav-echelles {
display: flex;
justify-content: flex-start;
}
.nav-echelles__grp--courant {
display: flex;
}
}
/* ≥ 1280 : les trois groupes, centrés, séparés par un filet */
@media (min-width: 1280px) {
.nav-echelles {
justify-content: center;
}
.nav-echelles__grp {
display: flex;
}
.nav-echelles__grp + .nav-echelles__grp::before {
content: "";
position: absolute;
left: calc(var(--space-lg) / -2);
top: 10px;
bottom: 10px;
border-left: 1px solid var(--border);
}
}
@media (prefers-reduced-motion: reduce) {
.nav-echelles__tab { transition: none; }
}
</style>
+64 -11
View File
@@ -24,10 +24,16 @@ interface Org {
const props = defineProps<{
orgs: Org[]
selectedId?: number | null
/** Fiche survolée dans la liste (B11) : épingle safran, sans recentrer. */
survolId?: number | null
/** « Voir sur la carte » (B12) : zoome jusqu'à l'épingle, sortie de son cluster ; `n` change à chaque demande. */
centrage?: { id: number; n: number } | null
}>()
const emit = defineEmits<{
'select-org': [id: number]
/** Survol d'une épingle (B11) : la liste allume la carte-fiche. */
survol: [id: number | null]
}>()
const mapContainer = ref<HTMLElement | null>(null)
@@ -35,14 +41,15 @@ let mapInstance: Map | null = null
let clusterGroup: any = null
const markers = new Map<number, Marker>()
let tileLayerInstance: any = null
let resizeObserver: ResizeObserver | null = null
// Créer une DivIcon pour les pins personnalisés
function createPinIcon(isPrioritaire: boolean, isSelected = false): DivIcon {
function createPinIcon(isPrioritaire: boolean, isSelected = false, isSurvol = false): DivIcon {
const L = (window as any).L
const bg = isPrioritaire ? '#f5b342' : 'rgba(26, 34, 56, 0.6)'
const border = isPrioritaire ? '#1a2238' : '#ffffff'
const size = isSelected ? 18 : 14
const shadow = isSelected ? '0 0 0 4px rgba(245,179,66,0.5)' : 'none'
const bg = isSurvol || isPrioritaire ? '#f5b342' : 'rgba(26, 34, 56, 0.6)'
const border = isPrioritaire || isSurvol ? '#1a2238' : '#ffffff'
const size = isSelected || isSurvol ? 18 : 14
const shadow = isSelected || isSurvol ? '0 0 0 4px rgba(245,179,66,0.5)' : 'none'
return L.divIcon({
className: '',
@@ -76,6 +83,7 @@ async function initMap() {
;(window as any).L = L
// @ts-ignore — étend L.MarkerClusterGroup en side effect
await import('leaflet.markercluster')
if (!mapContainer.value?.isConnected) return // page quittée pendant le chargement (ex. ?random=1)
const MarkerClusterGroup = L.MarkerClusterGroup
mapInstance = L.map(mapContainer.value, {
@@ -125,6 +133,14 @@ async function initMap() {
mapInstance.addLayer(clusterGroup)
updateMarkers(L)
// B11 : la carte change de largeur sans que la fenêtre bouge (Mixte ↔ Carte ↔ Fiches)
resizeObserver = new ResizeObserver(() => mapInstance?.invalidateSize({ pan: false }))
resizeObserver.observe(mapContainer.value)
}
function iconeDe(org: Org): DivIcon {
return createPinIcon(!!org.prioritaire, org.Id === props.selectedId, org.Id === props.survolId)
}
function updateMarkers(L?: any) {
@@ -141,10 +157,7 @@ function updateMarkers(L?: any) {
)
orgsWithCoords.forEach((org) => {
const isSelected = org.Id === props.selectedId
const icon = createPinIcon(!!org.prioritaire, isSelected)
const marker = leaflet.marker([org.latitude!, org.longitude!], { icon })
const marker = leaflet.marker([org.latitude!, org.longitude!], { icon: iconeDe(org), title: org.nom })
const fonctions = org.tags_fonction
? org.tags_fonction.split(',').map((f: string) => f.trim()).filter(Boolean).slice(0, 2).join(', ')
@@ -163,6 +176,8 @@ function updateMarkers(L?: any) {
`, { maxWidth: 240 })
marker.on('click', () => emit('select-org', org.Id))
marker.on('mouseover', () => emit('survol', org.Id))
marker.on('mouseout', () => emit('survol', null))
markers.set(org.Id, marker)
clusterGroup.addLayer(marker)
@@ -189,7 +204,7 @@ watch(
const oldMarker = markers.get(oldId)
const oldOrg = props.orgs.find(o => o.Id === oldId)
if (oldMarker && oldOrg) {
oldMarker.setIcon(createPinIcon(!!oldOrg.prioritaire, false))
oldMarker.setIcon(iconeDe(oldOrg))
}
}
// Mettre en avant le nouveau marker
@@ -197,7 +212,7 @@ watch(
const newMarker = markers.get(newId)
const newOrg = props.orgs.find(o => o.Id === newId)
if (newMarker && newOrg) {
newMarker.setIcon(createPinIcon(!!newOrg.prioritaire, true))
newMarker.setIcon(iconeDe(newOrg))
// Centrer si visible
const latLng = newMarker.getLatLng()
mapInstance.panTo(latLng, { animate: true })
@@ -206,6 +221,27 @@ watch(
}
)
// Survol depuis la liste : épingle safran, sans recentrer la carte
watch(
() => props.survolId,
(newId, oldId) => {
for (const id of [oldId, newId]) {
if (id == null) continue
const m = markers.get(id)
const o = props.orgs.find(x => x.Id === id)
if (m && o) m.setIcon(iconeDe(o))
allumerCluster(m, id === newId)
}
}
)
// Épingle regroupée dans un cluster : c'est le cluster visible qui s'allume
function allumerCluster(m: Marker | undefined, on: boolean) {
const parent = m && clusterGroup?.getVisibleParent(m)
const el = parent && parent !== m ? (parent as any)._icon?.firstElementChild as HTMLElement | undefined : undefined
if (el) el.style.boxShadow = on ? '0 0 0 4px #f5b342' : ''
}
// Watcher dark mode — switch tuile CartoDB light_all ↔ dark_all
function updateTileTheme(dark: boolean) {
if (!mapInstance || !tileLayerInstance) return
@@ -235,9 +271,26 @@ onMounted(() => {
onUnmounted(() => {
themeObserver?.disconnect()
resizeObserver?.disconnect()
if (mapInstance) {
mapInstance.remove()
mapInstance = null
}
})
// « Voir sur la carte » (B12) : la carte vient peut-être de s'afficher (Fiches → Mixte) ;
// on attend sa taille réelle, puis on sort l'épingle de son cluster.
watch(
() => props.centrage?.n,
() => {
const c = props.centrage
if (!c) return
setTimeout(() => {
if (!mapInstance || !clusterGroup) return
mapInstance.invalidateSize({ pan: false })
const m = markers.get(c.id)
if (m) clusterGroup.zoomToShowLayer(m, () => mapInstance?.panTo(m.getLatLng(), { animate: true }))
}, 260)
},
)
</script>
+63 -10
View File
@@ -21,10 +21,16 @@ const FAMILLE_COLORS: Record<number, string> = {
const props = defineProps<{
structures: StructureV2[]
selectedId?: string | null
/** Fiche survolée dans la liste (B11) : épingle cerclée de safran, sans recentrer. */
survolId?: string | null
/** « Voir sur la carte » (B12) : zoome jusqu'à l'épingle, sortie de son cluster ; `n` change à chaque demande. */
centrage?: { id: string; n: number } | null
}>()
const emit = defineEmits<{
'select-structure': [id: string]
/** Survol d'une épingle (B11) : la liste allume la carte-fiche. */
survol: [id: string | null]
}>()
const mapContainer = ref<HTMLElement | null>(null)
@@ -32,17 +38,18 @@ let mapInstance: Map | null = null
let clusterGroup: any = null
const markers = new Map<string, Marker>()
let tileLayerInstance: any = null
let resizeObserver: ResizeObserver | null = null
function getFamilleColor(famille: number): string {
return FAMILLE_COLORS[famille] ?? '#888888'
}
function createPinIcon(famille: number, isSelected = false): DivIcon {
function createPinIcon(famille: number, isSelected = false, isSurvol = 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'
const size = isSelected || isSurvol ? 20 : 14
const border = isSurvol ? '#f5b342' : isSelected ? 'white' : 'rgba(255,255,255,0.7)'
const shadow = isSurvol ? '0 0 0 4px rgba(245,179,66,0.6)' : isSelected ? `0 0 0 4px ${bg}55` : 'none'
return L.divIcon({
className: '',
@@ -76,6 +83,7 @@ async function initMap() {
;(window as any).L = L
// @ts-ignore
await import('leaflet.markercluster')
if (!mapContainer.value?.isConnected) return // page quittée pendant le chargement (ex. ?random=1)
const MarkerClusterGroup = L.MarkerClusterGroup
mapInstance = L.map(mapContainer.value, {
@@ -121,6 +129,14 @@ async function initMap() {
mapInstance.addLayer(clusterGroup)
updateMarkers(L)
// B11 : la carte change de largeur sans que la fenêtre bouge (Mixte ↔ Carte ↔ Fiches)
resizeObserver = new ResizeObserver(() => mapInstance?.invalidateSize({ pan: false }))
resizeObserver.observe(mapContainer.value)
}
function iconeDe(s: StructureV2): DivIcon {
return createPinIcon(s.famille_principale, s.id === props.selectedId, s.id === props.survolId)
}
function updateMarkers(L?: any) {
@@ -136,10 +152,7 @@ function updateMarkers(L?: any) {
)
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 marker = leaflet.marker([structure.latitude!, structure.longitude!], { icon: iconeDe(structure), title: structure.nom })
const hashtagsHtml = structure.hashtags.slice(0, 2)
.map(h => `<span style="font-size:10px;color:var(--nav-text-muted);">${h}</span>`)
@@ -165,6 +178,8 @@ function updateMarkers(L?: any) {
`, { maxWidth: 260 })
marker.on('click', () => emit('select-structure', structure.id))
marker.on('mouseover', () => emit('survol', structure.id))
marker.on('mouseout', () => emit('survol', null))
markers.set(structure.id, marker)
clusterGroup.addLayer(marker)
@@ -193,14 +208,14 @@ watch(
const oldMarker = markers.get(oldId)
const oldStructure = props.structures.find(s => s.id === oldId)
if (oldMarker && oldStructure) {
oldMarker.setIcon(createPinIcon(oldStructure.famille_principale, false))
oldMarker.setIcon(iconeDe(oldStructure))
}
}
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))
newMarker.setIcon(iconeDe(newStructure))
const latLng = newMarker.getLatLng()
mapInstance.panTo(latLng, { animate: true })
}
@@ -208,6 +223,27 @@ watch(
}
)
// Survol depuis la liste : épingle cerclée de safran, sans recentrer la carte
watch(
() => props.survolId,
(newId, oldId) => {
for (const id of [oldId, newId]) {
if (id == null) continue
const m = markers.get(id)
const s = props.structures.find(x => x.id === id)
if (m && s) m.setIcon(iconeDe(s))
allumerCluster(m, id === newId)
}
}
)
// Épingle regroupée dans un cluster : c'est le cluster visible qui s'allume
function allumerCluster(m: Marker | undefined, on: boolean) {
const parent = m && clusterGroup?.getVisibleParent(m)
const el = parent && parent !== m ? (parent as any)._icon?.firstElementChild as HTMLElement | undefined : undefined
if (el) el.style.boxShadow = on ? '0 0 0 4px #f5b342' : ''
}
function updateTileTheme(dark: boolean) {
if (!mapInstance || !tileLayerInstance) return
const L = (window as any).L
@@ -232,9 +268,26 @@ onMounted(() => {
onUnmounted(() => {
document.removeEventListener('nav-v2-select', onNavV2Select as EventListener)
themeObserver?.disconnect()
resizeObserver?.disconnect()
if (mapInstance) {
mapInstance.remove()
mapInstance = null
}
})
// « Voir sur la carte » (B12) : la carte vient peut-être de s'afficher (Fiches → Mixte) ;
// on attend sa taille réelle, puis on sort l'épingle de son cluster.
watch(
() => props.centrage?.n,
() => {
const c = props.centrage
if (!c) return
setTimeout(() => {
if (!mapInstance || !clusterGroup) return
mapInstance.invalidateSize({ pan: false })
const m = markers.get(c.id)
if (m) clusterGroup.zoomToShowLayer(m, () => mapInstance?.panTo(m.getLatLng(), { animate: true }))
}, 260)
},
)
</script>
-257
View File
@@ -1,257 +0,0 @@
<template>
<aside
class="flex flex-col h-full overflow-hidden"
style="background: var(--nav-surface); border-right: 1px solid var(--nav-bg-alt);"
>
<!-- ═══════════════════════════════════ BARRE DE RECHERCHE (tout en haut) -->
<div
class="shrink-0 px-4 pt-4 pb-3 border-b"
style="border-color: var(--nav-bg-alt);"
>
<label class="sidebar-search-label" aria-label="Rechercher une organisation">
<svg
width="15" height="15" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true"
class="sidebar-search-icon"
>
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
ref="searchInputEl"
:value="search"
type="search"
placeholder="Rechercher une organisation…"
class="sidebar-search-input"
autocomplete="off"
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
/>
<button
v-if="search"
type="button"
class="sidebar-search-clear"
aria-label="Effacer la recherche"
@click.stop="emit('update:search', '')"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</label>
</div>
<!-- ═══════════════════════════════════ FILTRES (haut, compact) -->
<div
class="shrink-0 px-4 pt-3 pb-3 space-y-4 border-b"
style="border-color: var(--nav-bg-alt);"
>
<!-- Échelle (checkbox compactes) -->
<EchelleFilter
:modelValue="echelle"
:counts="echelleCount"
@update:modelValue="emit('update:echelle', $event)"
/>
<!-- Fonctions (checkbox compactes) -->
<FonctionFilter
:modelValue="fonctions"
:counts="fonctionCount"
@update:modelValue="emit('update:fonctions', $event)"
/>
</div>
<!-- ═══════════════════════════════════ LISTE FICHES (milieu, scrollable) -->
<div class="flex-1 flex flex-col min-h-0">
<!-- Header liste -->
<div
class="shrink-0 flex items-center justify-between px-4 py-2 border-b"
style="border-color: var(--nav-bg-alt);"
>
<span class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">
{{ resultCount }} résultat{{ resultCount > 1 ? 's' : '' }}
</span>
<button
v-if="hasActiveFilters"
@click="emit('reset-filters')"
class="text-xs underline hover:opacity-70"
style="color: var(--nav-text-muted);"
>
Effacer les filtres
</button>
</div>
<!-- Liste scrollable -->
<div class="flex-1 overflow-y-auto px-3 py-2 space-y-1.5">
<div
v-if="pending"
class="flex items-center justify-center py-8"
style="color: var(--nav-text-muted);"
>
Chargement…
</div>
<div v-else-if="orgs.length === 0" class="text-center py-8">
<p class="text-xs" style="color: var(--nav-text-muted);">Aucun résultat</p>
</div>
<!-- Card fiche compacte -->
<div
v-for="org in orgs"
:key="org.Id"
class="rounded-lg px-3 py-2 cursor-pointer transition-all"
:style="selectedId === org.Id
? 'background: var(--nav-bg-alt); border-left: 3px solid var(--nav-accent); padding-left: 9px;'
: 'background: var(--nav-bg); border-left: 3px solid transparent; padding-left: 9px;'"
@click="emit('select-org', org.Id)"
@mouseenter="emit('hover-org', org.Id)"
@mouseleave="emit('hover-org', null)"
>
<div class="flex items-start justify-between gap-1.5">
<span
class="font-semibold text-sm leading-snug"
style="color: var(--nav-text);"
>{{ org.nom }}</span>
<span
v-if="org.echelle"
class="shrink-0 px-1.5 py-0.5 rounded-full text-xs"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted); margin-top: 1px;"
>{{ org.echelle }}</span>
</div>
<div v-if="orgFonctions(org).length" class="mt-1 flex flex-wrap gap-1">
<span
v-for="fn in orgFonctions(org)"
:key="fn"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted); border: 1px solid var(--nav-bg-alt); letter-spacing: 0.01em;"
>{{ fn }}</span>
</div>
<div v-if="org.localisation_ville" class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">
{{ org.localisation_ville }}
</div>
</div>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
interface Org {
Id: number
nom: string
echelle?: string
tags_fonction?: string
territoire?: string
localisation_ville?: string
latitude?: number | null
longitude?: number | null
prioritaire?: boolean
}
const props = defineProps<{
search: string
modeValue: string // 'metropole' | 'outremer'
echelle: string[]
fonctions: string[]
territoire: string | null
echelleCount: Record<string, number>
fonctionCount: Record<string, number>
territoireCount: Record<string, number>
resultCount: number
orgs: Org[] // fiches filtrées à afficher dans la liste
selectedId: number | null
hasActiveFilters: boolean
pending?: boolean
}>()
const emit = defineEmits<{
'update:search': [value: string]
'update:mode': [value: string]
'update:echelle': [value: string[]]
'update:fonctions': [value: string[]]
'update:territoire': [value: string | null]
'select-org': [id: number]
'hover-org': [id: number | null]
'reset-filters': []
}>()
const searchInputEl = ref<HTMLInputElement | null>(null)
function orgFonctions(org: Org): string[] {
return (org.tags_fonction ?? '').split(',').map(f => f.trim()).filter(Boolean).slice(0, 2)
}
</script>
<style scoped>
.sidebar-search-label {
display: flex;
align-items: center;
gap: 8px;
border: 1.5px solid var(--nav-bg-alt);
border-radius: 10px;
background: var(--nav-bg);
padding: 7px 10px;
cursor: text;
width: 100%;
box-sizing: border-box;
transition: border-color 0.2s;
}
.sidebar-search-label:focus-within {
border-color: var(--nav-primary);
background: var(--nav-surface);
}
.sidebar-search-icon {
color: var(--nav-text-muted);
flex-shrink: 0;
transition: color 0.2s;
}
.sidebar-search-label:focus-within .sidebar-search-icon {
color: var(--nav-primary-solid);
}
.sidebar-search-input {
border: none;
outline: none;
background: transparent;
color: var(--nav-text);
font-size: 13px;
width: 100%;
min-width: 0;
font-family: var(--nav-font);
}
.sidebar-search-input::placeholder {
color: var(--nav-text-muted);
}
.sidebar-search-input::-webkit-search-cancel-button {
display: none;
}
.sidebar-search-clear {
display: flex;
align-items: center;
justify-content: center;
color: var(--nav-text-muted);
flex-shrink: 0;
padding: 2px;
border-radius: 50%;
transition: color 0.15s, background 0.15s;
background: transparent;
border: none;
cursor: pointer;
}
.sidebar-search-clear:hover {
color: var(--nav-text);
background: var(--nav-bg-alt);
}
</style>
+313
View File
@@ -0,0 +1,313 @@
<!--
NavTiroir — B10-M3
Menu < 1280 px : tiroir pleine hauteur à droite, voile, Échap, focus piégé,
focus rendu à l'élément qui l'a ouvert. Les trois échelles dans l'ordre de la
barre (utils/echelles.ts), puis Manifeste, À propos, Signaler, et Soutenir.
Se ferme seul au changement de route et si la fenêtre passe à ≥ 1280 px.
-->
<template>
<Teleport to="body">
<Transition name="nav-tiroir">
<div v-if="open" class="nav-tiroir" @keydown="onKeydown">
<div class="nav-tiroir__voile" aria-hidden="true" @click="fermer" />
<div
ref="panneau"
class="nav-tiroir__panneau"
role="dialog"
aria-modal="true"
aria-labelledby="nav-tiroir-titre"
>
<div class="nav-tiroir__tete">
<h2 id="nav-tiroir-titre" class="nav-tiroir__titre">Menu</h2>
<button type="button" class="nav-tiroir__fermer" aria-label="Fermer le menu" @click="fermer">
<svg width="18" height="18" 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>
<nav class="nav-tiroir__corps" aria-label="Échelles et pages">
<p class="nav-tiroir__cadre">{{ ECHELLES_CADRE_COURT }}</p>
<div v-for="id in ORDRE_ECHELLES" :key="id" class="nav-tiroir__grp">
<span :id="`nav-tiroir-echelle-${id}`" class="nav-tiroir__kick">
<span class="nav-tiroir__pastille">{{ id }}</span>{{ ECHELLES[id].titre }}
</span>
<ul class="nav-tiroir__liste" :aria-labelledby="`nav-tiroir-echelle-${id}`">
<li v-for="o in ECHELLES[id].onglets" :key="o.to">
<NuxtLink
:to="o.to"
class="nav-tiroir__lien"
:class="{ 'nav-tiroir__lien--actif': o.actif(route) }"
:aria-current="o.actif(route) ? 'page' : undefined"
@click="fermer"
>{{ o.libelle }}</NuxtLink>
</li>
</ul>
</div>
<hr class="nav-tiroir__sep" />
<ul class="nav-tiroir__liste">
<li v-for="p in PAGES" :key="p.to">
<NuxtLink
:to="p.to"
class="nav-tiroir__second"
:aria-current="route.path === p.to ? 'page' : undefined"
@click="fermer"
>{{ p.libelle }}</NuxtLink>
</li>
</ul>
<a :href="SOUTENIR_URL" target="_blank" rel="noopener noreferrer" class="nav-tiroir__soutenir">Soutenir</a>
</nav>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ECHELLES, ECHELLES_CADRE_COURT, ORDRE_ECHELLES } from '~/utils/echelles'
import { SOUTENIR_URL } from '~/utils/liens'
const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{ close: [] }>()
const PAGES = [
{ libelle: 'Manifeste', to: '/manifeste' },
{ libelle: 'À propos', to: '/a-propos' },
{ libelle: 'Signaler', to: '/signaler' },
]
const route = useRoute()
const panneau = ref<HTMLElement | null>(null)
let retourFocus: HTMLElement | null = null
function fermer() {
emit('close')
}
function focusables(): HTMLElement[] {
if (!panneau.value) return []
return [...panneau.value.querySelectorAll<HTMLElement>('a[href], button:not([disabled])')]
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
fermer()
return
}
if (e.key !== 'Tab') return
const els = focusables()
if (!els.length) return
const premier = els[0]
const dernier = els[els.length - 1]
if (e.shiftKey && document.activeElement === premier) {
e.preventDefault()
dernier.focus()
} else if (!e.shiftKey && document.activeElement === dernier) {
e.preventDefault()
premier.focus()
}
}
watch(() => props.open, async (ouvert) => {
if (ouvert) {
retourFocus = document.activeElement as HTMLElement | null
await nextTick()
focusables()[0]?.focus()
} else if (retourFocus) {
// Le bouton d'ouverture peut ne plus être visible (fenêtre élargie) : focus() est alors sans effet.
retourFocus.focus()
retourFocus = null
}
})
watch(() => route.fullPath, () => { if (props.open) fermer() })
let mq: MediaQueryList | null = null
function onLarge(e: MediaQueryListEvent) { if (e.matches && props.open) fermer() }
onMounted(() => {
mq = window.matchMedia('(min-width: 1280px)')
mq.addEventListener('change', onLarge)
})
onBeforeUnmount(() => mq?.removeEventListener('change', onLarge))
</script>
<style scoped>
.nav-tiroir {
position: fixed;
inset: 0;
z-index: 10000;
}
.nav-tiroir__voile {
position: absolute;
inset: 0;
background: rgba(var(--nav-primary-raw), 0.35);
}
.nav-tiroir__panneau {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: min(320px, 88vw);
display: flex;
flex-direction: column;
background: var(--surface);
color: var(--text);
box-shadow: var(--shadow-lg);
}
.nav-tiroir__tete {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: var(--space-xs) var(--space-sm) var(--space-xs) var(--space-md);
border-bottom: 1px solid var(--border);
}
.nav-tiroir__titre {
margin: 0;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
}
.nav-tiroir__fermer {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--text);
cursor: pointer;
}
.nav-tiroir__corps {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--space-sm) var(--space-md) var(--space-lg);
}
.nav-tiroir__cadre {
margin: 0 0 var(--space-xs);
font-size: var(--fs-xs);
line-height: var(--lh-base);
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.nav-tiroir__grp {
margin-top: var(--space-sm);
}
.nav-tiroir__kick {
display: block;
margin-bottom: var(--space-2xs);
font-size: 0.72rem;
font-weight: var(--fw-bold);
letter-spacing: 0.04em;
text-transform: uppercase;
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.nav-tiroir__pastille {
display: inline-block;
margin-right: 6px;
padding: 0 5px;
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--on-accent);
}
.nav-tiroir__liste {
margin: 0;
padding: 0;
list-style: none;
}
.nav-tiroir__lien,
.nav-tiroir__second {
display: flex;
align-items: center;
min-height: 44px;
padding: 0 var(--space-xs);
border-radius: var(--radius-md);
text-decoration: none;
}
.nav-tiroir__lien {
font-size: var(--fs-base);
color: var(--text);
}
.nav-tiroir__lien--actif {
background: var(--bg-alt);
font-weight: var(--fw-semibold);
}
.nav-tiroir__second {
font-size: var(--fs-sm);
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.nav-tiroir__lien:focus-visible,
.nav-tiroir__second:focus-visible,
.nav-tiroir__soutenir:focus-visible,
.nav-tiroir__fermer:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.nav-tiroir__sep {
margin: var(--space-sm) 0;
border: 0;
border-top: 1px solid var(--border);
}
.nav-tiroir__soutenir {
display: flex;
align-items: center;
justify-content: center;
min-height: 44px;
margin-top: var(--space-sm);
border-radius: var(--radius-md);
background: var(--accent);
color: var(--on-accent);
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
text-decoration: none;
}
.nav-tiroir-enter-active,
.nav-tiroir-leave-active {
transition: opacity var(--dur-base) var(--ease-out);
}
.nav-tiroir-enter-active .nav-tiroir__panneau,
.nav-tiroir-leave-active .nav-tiroir__panneau {
transition: transform var(--dur-base) var(--ease-out);
}
.nav-tiroir-enter-from,
.nav-tiroir-leave-to {
opacity: 0;
}
.nav-tiroir-enter-from .nav-tiroir__panneau,
.nav-tiroir-leave-to .nav-tiroir__panneau {
transform: translateX(100%);
}
@media (prefers-reduced-motion: reduce) {
.nav-tiroir-enter-active,
.nav-tiroir-leave-active,
.nav-tiroir-enter-active .nav-tiroir__panneau,
.nav-tiroir-leave-active .nav-tiroir__panneau {
transition: none;
}
}
</style>
File diff suppressed because it is too large Load Diff
+118
View File
@@ -0,0 +1,118 @@
<!--
VueFichesFiltres — B11-M4. Les chips de filtre d'une vue fiches, par
dimension (Chip.vue, variante de la dimension). Monté à UN seul endroit selon
l'état (SPEC §3) : panneau de gauche en Mixte, tête de grille en Fiches,
feuille du bas sur téléphone. Compteur dans le résultat courant ; chip à 0
grisée et inactive ; au-delà de 14 valeurs, repli « + N » (actives toujours
visibles) ; « Tout effacer » dès qu'un filtre est actif.
-->
<template>
<div class="vff">
<ChipGroup v-if="vf.etat.sansAdresse" aria-label="Filtre de localisation">
<Chip as="span" removable active variant="echelle" label="Sans adresse" @remove="vf.setSansAdresse(false)" />
</ChipGroup>
<div v-for="d in vf.cfg.dimensions" :key="d.cle" class="vff__dim">
<p :id="`${idBase}-${d.cle}`" class="vff__titre">{{ d.libelle }}</p>
<ChipGroup :aria-label="`Filtrer par ${d.libelle.toLowerCase()}`" :aria-describedby="`${idBase}-${d.cle}`">
<Chip
v-for="v in visibles(d)"
:key="v.valeur"
:label="v.libelle"
:variant="d.variante"
:active="actif(d.cle, v.valeur)"
:count="vf.compteurs[d.cle]?.[v.valeur] ?? 0"
:disabled="!actif(d.cle, v.valeur) && !(vf.compteurs[d.cle]?.[v.valeur])"
class="vff__chip"
@toggle="vf.toggle(d.cle, v.valeur)"
/>
<button
v-if="d.valeurs.length > SEUIL"
type="button"
class="vff__plus"
:aria-expanded="!!deplies[d.cle]"
@click="deplies[d.cle] = !deplies[d.cle]"
>{{ deplies[d.cle] ? 'Réduire' : `+ ${d.valeurs.length - visibles(d).length}` }}</button>
</ChipGroup>
</div>
<button
v-if="vf.nbActifs || vf.q"
type="button"
class="vff__effacer"
@click="vf.effacerTout()"
>Tout effacer</button>
</div>
</template>
<script setup lang="ts">
import type { VueFichesEtat } from '~/composables/useVueFiches'
import type { DimensionFiltre } from '~/utils/vueFiches'
const props = defineProps<{ vf: VueFichesEtat; idBase: string }>()
const SEUIL = 14
const deplies = reactive<Record<string, boolean>>({})
function actif(dim: string, valeur: string) {
return (props.vf.etat.filtres[dim] ?? []).includes(valeur)
}
/** Les 14 premières dans l'ordre de la dimension (stable : les chips ne bougent pas au clic) + les actives hors de ce lot. */
function visibles(d: DimensionFiltre) {
if (d.valeurs.length <= SEUIL || deplies[d.cle]) return d.valeurs
return d.valeurs.filter((v, i) => i < SEUIL || actif(d.cle, v.valeur))
}
</script>
<style scoped>
.vff {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.vff__titre {
margin: 0 0 var(--space-2xs);
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.vff__chip:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
}
.vff__plus {
min-height: 32px;
padding: var(--space-2xs) var(--space-sm);
border: 1.5px dashed color-mix(in srgb, var(--text) 40%, transparent);
border-radius: var(--radius-full);
background: none;
color: var(--text);
font: inherit;
font-size: var(--fs-xs);
cursor: pointer;
}
.vff__effacer {
align-self: flex-start;
padding: var(--space-2xs) 0;
border: none;
background: none;
color: var(--text);
font: inherit;
font-size: var(--fs-xs);
text-decoration: underline;
cursor: pointer;
}
.vff__plus:focus-visible,
.vff__effacer:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
</style>
+13
View File
@@ -0,0 +1,13 @@
/**
* Mode de profondeur courant d'un chatbot (M3). État partagé par clé via useState :
* desktop et mobile d'un même chatbot (clés 'accueil', 'agences'…) restent synchronisés.
* Volontairement non persisté : le mode est un choix par question, 'rapide' à chaque visite.
*/
export function useChatProfondeur(key: string | (() => string) = 'defaut') {
const k = typeof key === 'function' ? key : () => key
const store = useState<Record<string, ChatProfondeur>>('aep-chat-profondeur', () => ({}))
return computed<ChatProfondeur>({
get: () => store.value[k()] ?? CHAT_PROFONDEUR_DEFAUT,
set: (v) => { store.value = { ...store.value, [k()]: normaliserProfondeur(v) } },
})
}
+103
View File
@@ -0,0 +1,103 @@
import type { MaybeRefOrGetter } from 'vue'
/**
* Persistance de l'historique d'un chatbot dans sessionStorage.
*
* - Clé de stockage : `aep-chat-<pageKey>`, valeur `{ v: 1, messages: [...] }`
* (une valeur d'une autre version est ignorée).
* - SSR-safe : aucun accès au stockage hors de onMounted. Côté serveur et à
* l'hydratation l'historique est vide, il est restauré juste après le mount.
* - Partagé : deux composants (desktop + mobile) qui déclarent la même clé
* lisent et écrivent le même tableau (useState), donc un redimensionnement
* ne perd rien et un composant n'écrase pas l'autre.
* - Tolérant : toute lecture/écriture est dans un try/catch (navigation
* privée, stockage bloqué, quota). En cas d'échec le chatbot fonctionne
* comme avant, sans persistance.
* - Les messages marqués `transient: true` (erreurs) restent affichés mais ne
* sont jamais écrits. Chargement et erreurs vivent hors de l'historique.
* - La forme des messages est libre (fiches, auteur, mode...) : tout champ
* sérialisable est conservé.
*/
export interface ChatSessionMessage {
role: string
content: string
transient?: boolean
}
const STORAGE_PREFIX = 'aep-chat-'
const SCHEMA_VERSION = 1
const DEFAULT_MAX = 40
export function useChatSession<T extends ChatSessionMessage>(
pageKey: MaybeRefOrGetter<string>,
options: { max?: number } = {},
) {
const max = options.max ?? DEFAULT_MAX
const store = useState<Record<string, T[]>>('aep-chat-store', () => ({}))
const restored = useState<Record<string, boolean>>('aep-chat-restored', () => ({}))
const key = computed(() => toValue(pageKey))
const mounted = ref(false)
function ensure(k: string) {
if (!store.value[k]) store.value[k] = []
}
const messages = computed<T[]>({
get: () => store.value[key.value] ?? [],
set: (list) => { store.value[key.value] = list },
})
const hasHistory = computed(() => messages.value.length > 0)
function isValid(m: any): m is T {
return !!m && typeof m === 'object' && typeof m.role === 'string' && typeof m.content === 'string'
}
function restore() {
const k = key.value
if (restored.value[k]) return
restored.value[k] = true
ensure(k)
try {
const raw = sessionStorage.getItem(STORAGE_PREFIX + k)
if (!raw) return
const data = JSON.parse(raw)
if (!data || data.v !== SCHEMA_VERSION || !Array.isArray(data.messages)) return
const valid = (data.messages as unknown[]).filter(isValid).slice(-max)
if (valid.length && store.value[k].length === 0) store.value[k] = valid
} catch {
/* stockage indisponible ou valeur corrompue : on repart de zéro */
}
}
function save() {
if (!mounted.value) return
try {
const list = messages.value.filter(m => !m.transient).slice(-max)
const sk = STORAGE_PREFIX + key.value
if (list.length === 0) sessionStorage.removeItem(sk)
else sessionStorage.setItem(sk, JSON.stringify({ v: SCHEMA_VERSION, messages: list }))
} catch {
/* quota, navigation privée : pas de persistance, pas d'erreur */
}
}
/** Efface l'historique affiché et la clé de stockage. */
function clear() {
store.value[key.value] = []
try { sessionStorage.removeItem(STORAGE_PREFIX + key.value) } catch { /* ignoré */ }
}
watch(key, (k) => {
ensure(k)
if (mounted.value) restore()
}, { immediate: true })
watch(messages, save, { deep: true })
onMounted(() => {
restore()
mounted.value = true
})
return { messages, hasHistory, clear }
}
+75 -20
View File
@@ -1,12 +1,18 @@
/**
* useFicheAdapter — B3-M2
* useFicheAdapter — B3-M2, étendu en B11-M2
* Adaptateur léger Org | StructureV2 → MiniFiche, format commun consommé par
* FicheMiniCard / FichesPanel. Aucune mutation des types sources.
* FicheMiniCard / VueFiches. Aucune mutation des types sources.
*
* Repli résumé (brief B3, le champ `resume_court` du batch B2b n'existe pas
* encore) : première phrase du premier champ non vide parmi une liste de
* candidats ordonnée par type, tronquée proprement à ~110 caractères sur un
* mot (jamais au milieu).
*
* B11 : chaque fiche porte ses `etiquettes` par dimension (clé = paramètre
* d'URL de la dimension : `fonctions`, `echelle`, `territoire` pour l'Entraide ;
* `tags`, `famille`, `pays` pour Réseaux AEP). C'est ce que filtre, trie et
* regroupe `utils/vueFiches.ts` ; `chips` et `echelle` restent l'affichage
* de la carte-fiche, inchangé.
*/
import type { Org } from '~/types/org'
import type { StructureV2 } from '~/types/structure-v2'
@@ -15,12 +21,25 @@ export interface MiniFiche {
id: string | number
nom: string
resume: string
/** Étiquettes affichées sur la carte-fiche (3 au plus). */
chips: string[]
/** Dimension d'où viennent `chips` : les rend cliquables dans la vue fiches. */
chipsDimension?: string
echelle?: string
geoloc: boolean
kind: 'org' | 'structure'
/** Étiquettes complètes par dimension (valeurs telles qu'écrites dans l'URL). */
etiquettes?: Record<string, string[]>
coords?: [number, number]
/** Date ISO si la source en a une (tri « récent »). */
date?: string
ville?: string
/** Texte cherchable en plus du nom, de la ville et du résumé. */
texte?: string
}
export const DOM_TOM = ['Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte']
const RESUME_MAX = 110
/** Première phrase d'un texte (coupe sur ". "/"! "/"? " ou fin de chaîne). */
@@ -53,32 +72,68 @@ function orgFonctions(org: Org): string[] {
.split(',')
.map((f) => f.trim())
.filter(Boolean)
.slice(0, 3)
}
export function useFicheAdapter() {
function toMini(source: Org | StructureV2): MiniFiche {
if (isStructure(source)) {
return {
id: source.id,
nom: source.nom,
resume: buildResume([source.description_courte, source.description_longue]),
chips: (source.hashtags ?? []).slice(0, 3),
geoloc: source.latitude != null && source.longitude != null,
kind: 'structure',
}
}
function coordsDe(lat?: number | null, lon?: number | null): [number, number] | undefined {
return lat != null && lon != null ? [lat, lon] : undefined
}
/** Hashtag sans son « # » : c'est la forme de l'URL (`tags=reemploi-structurel`). */
export function sansDiese(tag: string): string {
return tag.replace(/^#/, '')
}
/** Familles d'une structure : principale, secondaires, et 6 pour le badge recherche politique. */
function famillesDe(s: StructureV2): string[] {
const ids = [s.famille_principale, ...(s.familles_secondaires ?? [])]
if (s.badges?.f6_recherche_politique) ids.push(6 as any)
return [...new Set(ids.map(String))]
}
export function toMini(source: Org | StructureV2): MiniFiche {
if (isStructure(source)) {
const hashtags = source.hashtags ?? []
return {
id: source.Id,
id: source.id,
nom: source.nom,
resume: buildResume([source.description_enrichie, source.description]),
chips: orgFonctions(source),
echelle: source.echelle,
resume: buildResume([source.description_courte, source.description_longue]),
chips: hashtags.slice(0, 3),
chipsDimension: 'tags',
geoloc: source.latitude != null && source.longitude != null,
kind: 'org',
kind: 'structure',
etiquettes: {
tags: hashtags.map(sansDiese),
famille: famillesDe(source),
pays: source.pays ? [source.pays] : [],
},
coords: coordsDe(source.latitude, source.longitude),
ville: source.ville,
// Portée de la recherche d'avant B11 sur /agences : description courte et hashtags.
texte: [source.description_courte, ...hashtags].filter(Boolean).join(' '),
}
}
const fonctions = orgFonctions(source)
return {
id: source.Id,
nom: source.nom,
resume: buildResume([source.description_enrichie, source.description]),
chips: fonctions.slice(0, 3),
chipsDimension: 'fonctions',
echelle: source.echelle,
geoloc: source.latitude != null && source.longitude != null,
kind: 'org',
etiquettes: {
fonctions,
echelle: source.echelle ? [source.echelle] : [],
territoire: [source.territoire && DOM_TOM.includes(source.territoire) ? source.territoire : 'Métropole'],
},
coords: coordsDe(source.latitude, source.longitude),
date: source.submitted_at,
ville: source.localisation_ville,
}
}
export function useFicheAdapter() {
return { toMini }
}
+252
View File
@@ -0,0 +1,252 @@
/**
* useVueFiches — B11-M1. L'état de la vue fiches, branché sur l'URL.
*
* L'URL est la source de vérité (SPEC-vue-fiches §5) : l'état se LIT dans
* `route.query` (surveillée : un lien interne vers `?vue=fiches` depuis la même
* page change l'écran) et s'ÉCRIT par `router.replace` (pas d'entrée
* d'historique par clic de filtre). Seule la recherche a un tampon local, pour
* ne pas perdre de lettres pendant la frappe : elle part dans l'URL 250 ms
* après la dernière touche.
*
* La page crée l'état (elle en a besoin pour sa carte) et le passe tel quel à
* `<VueFiches :vf="vf">`. Logique pure et tests : `utils/vueFiches.ts`,
* `scripts/test-vue-fiches.mjs`.
*/
import type { MaybeRefOrGetter } from 'vue'
import type { MiniFiche } from '~/composables/useFicheAdapter'
import {
lireQuery, ecrireQuery, filtresValides, filtrerFiches, compterEtiquettes,
triEffectif, trierFiches, regrouper, ordreFiches, nbFiltresActifs,
lireFiche, queryAvecFiche, positionFiche, modeOuverture, modeFermeture, empreinteListe,
type ConfigVueFiches, type EtatFiches, type EtatVue, type TriFiches,
} from '~/utils/vueFiches'
/** Largeur d'écran ; null avant le montage (SSR) : la CSS tranche alors seule. */
export type Largeur = 'telephone' | 'tablette' | 'ordinateur' | null
const CLE_REPLI = 'aep_vue_fiches_liste_repliee'
export function useVueFiches(
fiches: MaybeRefOrGetter<MiniFiche[]>,
config: MaybeRefOrGetter<ConfigVueFiches>,
) {
const route = useRoute()
const router = useRouter()
const cfg = computed(() => toValue(config))
// ── Lecture ──
const brut = computed(() => lireQuery(route.query, cfg.value))
const q = ref(brut.value.q)
let qEcrit = q.value
let minuteurQ: ReturnType<typeof setTimeout> | null = null
watch(() => brut.value.q, (v) => {
if (v !== qEcrit) { q.value = v; qEcrit = v }
})
const etat = computed<EtatFiches>(() => ({
...brut.value,
q: q.value,
filtres: filtresValides(brut.value.filtres, cfg.value),
}))
// ── Écriture ──
// La navigation est asynchrone : une query écrite n'est dans `route.query`
// qu'au tick suivant. Deux écritures du même tick (« Voir sur la carte » :
// Mixte, puis la sous-vue de la page) partent donc de la dernière query
// écrite, sinon la seconde efface la première.
let enAttente: Record<string, string> | null = null
watch(() => route.query, () => { enAttente = null })
const queryBase = () => enAttente ?? route.query
function naviguer(query: Record<string, string>, mode: 'push' | 'replace' = 'replace') {
enAttente = query
if (mode === 'push') router.push({ query })
else router.replace({ query })
}
function etatDepuis(base: Record<string, unknown>): EtatFiches {
const b = lireQuery(base, cfg.value)
return { ...b, q: q.value, filtres: filtresValides(b.filtres, cfg.value) }
}
function ecrire(partiel: Partial<EtatFiches>) {
const base = queryBase()
const nouvel = { ...etatDepuis(base), ...partiel }
qEcrit = nouvel.q
if (minuteurQ && partiel.q === undefined) { clearTimeout(minuteurQ); minuteurQ = null }
naviguer(ecrireQuery(nouvel, cfg.value, base))
}
function setQ(v: string) {
q.value = v
if (minuteurQ) clearTimeout(minuteurQ)
minuteurQ = setTimeout(() => { minuteurQ = null; ecrire({ q: v }) }, 250)
}
function avecFiltre(dim: string, valeurs: string[]) {
return { ...etat.value.filtres, [dim]: valeurs }
}
function toggle(dim: string, valeur: string) {
const vals = etat.value.filtres[dim] ?? []
ecrire({ filtres: avecFiltre(dim, vals.includes(valeur) ? vals.filter((v) => v !== valeur) : [...vals, valeur]) })
}
function ajouterFiltre(dim: string, valeur: string) {
const vals = etat.value.filtres[dim] ?? []
if (!vals.includes(valeur)) ecrire({ filtres: avecFiltre(dim, [...vals, valeur]) })
}
function effacerTout() {
q.value = ''
const vides: Record<string, string[]> = {}
for (const d of cfg.value.dimensions) vides[d.cle] = []
ecrire({ q: '', filtres: vides, sansAdresse: false })
}
const setTri = (tri: TriFiches) => ecrire({ tri })
const setGroupe = (groupe: string | null) => ecrire({ groupe })
const setSousVue = (sousVue: string) => ecrire({ sousVue })
const setSansAdresse = (sansAdresse: boolean) => ecrire({ sansAdresse })
// ── Largeur et état affiché ──
const largeur = ref<Largeur>(null)
const listeRepliee = ref(false)
onMounted(() => {
const mq768 = window.matchMedia('(min-width: 768px)')
const mq1024 = window.matchMedia('(min-width: 1024px)')
const maj = () => {
largeur.value = mq1024.matches ? 'ordinateur' : mq768.matches ? 'tablette' : 'telephone'
}
maj()
mq768.addEventListener('change', maj)
mq1024.addEventListener('change', maj)
onBeforeUnmount(() => {
mq768.removeEventListener('change', maj)
mq1024.removeEventListener('change', maj)
})
try { listeRepliee.value = sessionStorage.getItem(CLE_REPLI) === '1' } catch {}
})
/**
* État rendu. `auto` avant le montage quand l'URL ne tranche pas (ou dit
* `mixte`) : la CSS rend Mixte à partir de 1024 px et Fiches en dessous, sans
* saut à l'hydratation. Mixte n'existe pas sous 1024 px : il se lit Fiches.
*/
const vueAffichee = computed<EtatVue | 'auto'>(() => {
const etats = cfg.value.etats
const v = etat.value.vue
// `auto` rend Mixte à partir de 1024 px : seulement pour une page qui a cet état
if (largeur.value === null) return v && v !== 'mixte' ? v : etats.includes('mixte') ? 'auto' : 'fiches'
const ordinateur = largeur.value === 'ordinateur'
if (v === 'mixte' || v === null) {
if (!ordinateur || !etats.includes('mixte')) return 'fiches'
if (v === null && listeRepliee.value) return 'carte'
return 'mixte'
}
return v
})
function setVue(v: EtatVue) {
if (largeur.value === 'ordinateur' && (v === 'mixte' || v === 'carte')) {
listeRepliee.value = v === 'carte'
try { sessionStorage.setItem(CLE_REPLI, listeRepliee.value ? '1' : '0') } catch {}
}
ecrire({ vue: v })
}
// ── Résultat ──
const sansAdresseIgnore = computed(() => ({ ...etat.value, sansAdresse: false }))
const filtrees = computed(() => filtrerFiches(toValue(fiches), etat.value, cfg.value))
const tri = computed(() => triEffectif(etat.value, cfg.value))
const resultat = computed(() => trierFiches(filtrees.value, tri.value, etat.value))
const groupeDef = computed(() => cfg.value.groupes.find((g) => g.cle === etat.value.groupe) ?? null)
const groupes = computed(() => regrouper(resultat.value, groupeDef.value, cfg.value))
const ordre = computed(() => ordreFiches(groupes.value))
const compteurs = computed(() => compterEtiquettes(toValue(fiches), etat.value, cfg.value))
const idsResultat = computed(() => new Set(resultat.value.map((f) => String(f.id))))
const nbSansAdresse = computed(() =>
filtrerFiches(toValue(fiches), sansAdresseIgnore.value, cfg.value).filter((f) => !f.geoloc).length,
)
const total = computed(() => toValue(fiches).length)
const nbActifs = computed(() => nbFiltresActifs(etat.value))
function voirSansAdresse() {
ecrire({ sansAdresse: true, vue: 'fiches' })
}
/** Fiche survolée (liste ↔ épingle) ; la carte de la page s'en sert, et peut l'écrire. */
const survolId = ref<string | number | null>(null)
// ── Fiche ouverte (B12) : `fiche=` dans l'URL, lu aussi au rendu serveur ──
const ficheId = computed(() => lireFiche(route.query))
/** La fiche ouverte, cherchée dans TOUTES les fiches (elle peut être hors filtres). null = pas encore chargée ou inconnue. */
const ficheOuverte = computed<MiniFiche | null>(() => {
const id = ficheId.value
return id == null ? null : toValue(fiches).find((f) => String(f.id) === id) ?? null
})
/** Rang dans l'ordre courant ; null = hors de vos filtres (ou pas de fiche). */
const position = computed(() => positionFiche(ordre.value, ficheId.value))
/**
* Empreinte de la liste au moment où CETTE page a ajouté l'entrée
* d'historique de la fiche ; null = fiche venue d'un lien partagé.
*/
let listeALOuverture: string | null = null
function ouvrirFiche(id: string | number) {
if (String(id) === lireFiche(queryBase())) return
const base = queryBase()
const mode = modeOuverture(lireFiche(base))
if (mode === 'push') listeALOuverture = empreinteListe(base)
naviguer(queryAvecFiche(base, id), mode)
}
function fermerFiche() {
if (lireFiche(queryBase()) == null) return
const mode = modeFermeture(listeALOuverture != null, listeALOuverture === empreinteListe(queryBase()))
listeALOuverture = null
if (mode === 'back') { enAttente = null; router.back() }
else naviguer(queryAvecFiche(queryBase(), null))
}
/** Étiquette cliquée dans le détail : ajoute le filtre ; `fermer` (téléphone) referme la fiche dans le même geste. */
function filtrerDepuisFiche(dim: string, valeur: string, fermer: boolean) {
const base = queryBase()
const e = etatDepuis(base)
const vals = e.filtres[dim] ?? []
const filtres = vals.includes(valeur) ? e.filtres : { ...e.filtres, [dim]: [...vals, valeur] }
const query = ecrireQuery({ ...e, filtres }, cfg.value, base)
if (fermer) listeALOuverture = null
naviguer(fermer ? queryAvecFiche(query, null) : query)
}
// Retour arrière du navigateur (ou lien interne) : l'URL a perdu `fiche=`.
watch(ficheId, (id) => { if (id == null) listeALOuverture = null })
const fichePrecedente = () => { if (position.value?.precedente) ouvrirFiche(position.value.precedente.id) }
const ficheSuivante = () => { if (position.value?.suivante) ouvrirFiche(position.value.suivante.id) }
/**
* « Voir sur la carte » : ordinateur → Mixte, la fiche reste ouverte ;
* téléphone et tablette (pas de Mixte) → Carte, la fiche se ferme (elle
* couvrirait la carte). La page lit `centrage` et centre son épingle.
*/
const centrage = ref<{ id: string; n: number } | null>(null)
function voirSurCarte(id: string | number) {
centrage.value = { id: String(id), n: (centrage.value?.n ?? 0) + 1 }
if (largeur.value === 'ordinateur' && cfg.value.etats.includes('mixte')) {
setVue('mixte')
return
}
listeALOuverture = null
const base = queryBase()
naviguer(queryAvecFiche(ecrireQuery({ ...etatDepuis(base), vue: 'carte' }, cfg.value, base), null))
}
return reactive({
cfg, etat, q, largeur, vueAffichee, tri, resultat, groupes, ordre, compteurs,
idsResultat, nbSansAdresse, total, nbActifs, survolId,
ficheId, ficheOuverte, position, centrage,
setVue, setQ, toggle, ajouterFiltre, effacerTout, setTri, setGroupe, setSousVue,
setSansAdresse, voirSansAdresse,
ouvrirFiche, fermerFiche, fichePrecedente, ficheSuivante, voirSurCarte, filtrerDepuisFiche,
})
}
export type VueFichesEtat = ReturnType<typeof useVueFiches>
+1
View File
@@ -3,6 +3,7 @@ export default defineNuxtConfig({
app: {
head: {
htmlAttrs: { lang: 'fr' },
link: [
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
{ rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32.png' },
+18
View File
@@ -185,6 +185,12 @@
</div>
</section>
<!-- B10-M5 : Signaler a quitté la barre d'ordinateur ; il reste ici, dans le menu et dans chaque fiche -->
<p class="section-text apropos-pied">
Une fiche fausse, un lien mort, un contenu à retirer ?
<NuxtLink to="/signaler">Signaler un problème</NuxtLink>
</p>
</div>
</div>
</template>
@@ -270,6 +276,18 @@ function copyApiUrl() {
margin: 0 0 1rem;
}
.apropos-pied {
padding-top: var(--space-md);
border-top: 1px solid var(--border);
}
.apropos-pied a {
color: var(--text);
font-weight: var(--fw-semibold);
text-decoration: underline;
text-underline-offset: 3px;
}
.section-intro {
color: var(--nav-text-muted);
margin-bottom: 1.25rem;
+144 -549
View File
@@ -1,453 +1,105 @@
<template>
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
<div class="relative h-full overflow-hidden" style="background: var(--nav-bg);">
<!-- ═══════════════════════════════════════ SIDEBAR DESKTOP (>= 1024px) -->
<!-- Masquée en vue « Toutes les fiches » (B3-M3, décision Jules axe 2) :
le panneau EST la liste (+ ses propres chips hashtags), la sidebar
ferait doublon. IntentionBanner reste monté (Teleport, insensible au
v-show de son conteneur). -->
<div
v-show="desktopMapView !== 'fiches'"
class="hidden lg:block overflow-y-auto"
style="width: 320px; min-width: 320px; flex-shrink: 0; border-right: 1px solid var(--nav-bg-alt); height: 100%;"
<!-- IntentionBanner s'auto-affiche via Teleport (overlay plein écran) -->
<IntentionBanner />
<!-- ═══════════════════════════════════════ VUE FICHES (B11) : Carte · Mixte · Fiches -->
<VueFiches
:vf="vf"
intention="120 réseaux, collectifs et agences où des pensées écologiques deviennent des pratiques d'architecture."
libelle-recherche="Rechercher une structure…"
:pending="pending"
:selected-id="selectedId"
>
<!-- IntentionBanner s'auto-affiche via Teleport (overlay plein ecran) -->
<IntentionBanner />
<!-- Filtres familles + hashtags -->
<HashtagFilter
:allHashtags="allHashtags"
:selectedHashtags="selectedHashtags"
:selectedFamille="selectedFamille"
@update:selectedHashtags="selectedHashtags = $event"
@update:selectedFamille="selectedFamille = $event"
/>
<!-- Separateur -->
<div style="height: 1px; background: var(--nav-bg-alt);"></div>
<!-- Barre de recherche -->
<div class="px-3 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
<label class="sidebar-search-label" aria-label="Rechercher une structure">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="sidebar-search-icon">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
v-model="search"
type="search"
placeholder="Rechercher une structure..."
class="sidebar-search-input"
autocomplete="off"
/>
<button
v-if="search"
type="button"
class="sidebar-search-clear"
aria-label="Effacer"
@click.stop="search = ''"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</label>
</div>
<!-- Header compteur + reset -->
<div class="flex items-center justify-between px-4 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
<span class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">
{{ filtered.length }} structure{{ filtered.length > 1 ? 's' : '' }}
</span>
<button
v-if="hasActiveFilters"
@click="resetFilters"
class="text-xs underline hover:opacity-70"
style="color: var(--nav-text-muted);"
>Effacer les filtres</button>
</div>
<!-- Liste fiches (sidebar entiere scroll - pas de scroll interne) -->
<div class="px-3 py-2 space-y-1.5">
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
Chargement...
</div>
<div v-else-if="filtered.length === 0" class="text-center py-8">
<p class="text-xs" style="color: var(--nav-text-muted);">Aucun résultat</p>
</div>
<div
v-for="structure in filtered"
:key="structure.id"
class="rounded-lg px-3 py-2 cursor-pointer transition-all"
:style="selectedId === structure.id
? `background: var(--nav-bg-alt); border-left: 3px solid ${familleColor(structure.famille_principale)}; padding-left: 9px;`
: 'background: var(--nav-bg); border-left: 3px solid transparent; padding-left: 9px;'"
@click="onSelectStructure(structure.id)"
@mouseenter="hoveredId = structure.id"
@mouseleave="hoveredId = null"
>
<div class="flex items-start justify-between gap-1.5">
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ structure.nom }}</span>
<span
class="shrink-0 w-2.5 h-2.5 rounded-full mt-1"
:style="`background: ${familleColor(structure.famille_principale)};`"
/>
</div>
<div class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">{{ structure.type_principal }} - {{ structure.ville }}</div>
<ChipGroup v-if="structure.hashtags.length" class="mt-1" :aria-label="`Hashtags de ${structure.nom}`">
<Chip
v-for="tag in structure.hashtags.slice(0, 2)"
:key="tag"
as="span"
variant="fonction"
:label="tag"
/>
</ChipGroup>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════ ZONE CENTRALE (carte) -->
<main class="flex-1 flex flex-col overflow-hidden relative">
<!-- ── VUE DESKTOP : Onglets Métro/Outre-mer ── -->
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
<!-- Onglets desktop -->
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'metropole'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="setDesktopView('metropole')"
>Métropolitain</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="setDesktopView('outremer')"
>Outre-mer</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'graphe'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="setDesktopView('graphe')"
>Vue graphique</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'fiches'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="setDesktopView('fiches')"
>Toutes les fiches</button>
</div>
<!-- Carte Métropole desktop -->
<div v-show="desktopMapView === 'metropole'" class="flex-1 flex flex-col overflow-hidden">
<div class="relative flex-1" style="min-height: 200px;">
<ClientOnly>
<NavMapV2
ref="navMapRef"
:structures="metropoleStructures"
:selectedId="selectedId"
@select-structure="onSelectStructure"
/>
<template #fallback>
<div
class="w-full h-full flex items-center justify-center"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>
Chargement de la carte…
</div>
</template>
</ClientOnly>
</div>
<ChatbotPlaceholder
@highlightOrgs="() => {}"
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
/>
</div>
<!-- Carte Outre-mer desktop -->
<div v-show="desktopMapView === 'outremer'" class="flex-1 overflow-y-auto" style="background: var(--nav-bg);">
<ClientOnly>
<OutremerMap
:orgs="outremerOrgsLegacy"
:selectedId="selectedIdLegacyNum"
@select-org="() => {}"
/>
<template #fallback>
<div class="flex items-center justify-center h-full text-sm" style="color: var(--nav-text-muted);">
Chargement…
</div>
</template>
</ClientOnly>
</div>
<!-- Vue graphique desktop -->
<div v-show="desktopMapView === 'graphe'" class="flex-1 overflow-hidden flex flex-col">
<div class="flex-1 overflow-hidden relative">
<ClientOnly>
<GraphView
:data="bifurcationData"
:allHashtags="allHashtags"
:active="desktopMapView === 'graphe'"
@select-structure="onSelectStructure"
/>
<template #fallback>
<div class="flex items-center justify-center h-full" style="color: var(--nav-text-muted);">
Chargement du graphe...
</div>
</template>
</ClientOnly>
</div>
<ChatbotPlaceholder
@highlightOrgs="() => {}"
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
/>
</div>
<!-- Panneau « Toutes les fiches » desktop (B3-M3) — la sidebar est
masquée dans cette vue (décision Jules axe 2), le panneau EST
la liste, avec ses propres chips hashtags sur le même état. -->
<div v-show="desktopMapView === 'fiches'" class="flex-1 flex flex-col overflow-hidden">
<FichesPanel
:fiches="allStructuresMini"
:hashtags="allHashtags"
:selected="selectedHashtags"
:query="search"
intention="Toutes les agences et réseaux du répertoire, filtrables par hashtag."
@update:selected="selectedHashtags = $event"
@open="(fiche) => onSelectStructure(String(fiche.id))"
/>
</div>
</div>
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer/Graphique/Fiches + sheet swipable ── -->
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'metropole'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="setMobileView('metropole')"
>Métropolitain</button>
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="setMobileView('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="setMobileView('graphe')"
>Graphe</button>
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'fiches'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="setMobileView('fiches')"
>Fiches</button>
</div>
<div class="lg:hidden flex-1 relative overflow-hidden">
<!-- Carte mobile Métropole -->
<div v-show="mobileMapView === 'metropole'" class="absolute inset-0">
<template #carte>
<!-- Métropole -->
<div v-show="vf.etat.sousVue === 'metropole'" class="relative flex-1 min-h-0">
<ClientOnly>
<NavMapV2
ref="navMapMobileRef"
:structures="metropoleStructures"
:selectedId="selectedId"
@select-structure="onSelectStructureMobile"
:survolId="vf.survolId == null ? null : String(vf.survolId)"
:centrage="centrageCarte"
@select-structure="onSelectStructure"
@survol="(id) => (vf.survolId = id)"
/>
<template #fallback>
<div class="w-full h-full flex items-center justify-center" style="background: var(--nav-bg-alt); color: var(--nav-text-muted);">
Chargement de la carte…
</div>
<div class="w-full h-full flex items-center justify-center" style="background: var(--nav-bg-alt); color: var(--nav-text-muted);">Chargement de la carte…</div>
</template>
</ClientOnly>
</div>
<!-- Carte mobile Outre-mer -->
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
<!-- Outre-mer : pas de structure V2 en DOM-TOM pour l'instant (composant gardé vide) -->
<div v-show="vf.etat.sousVue === 'outremer'" class="flex-1 min-h-0 overflow-y-auto" style="background: var(--nav-bg);">
<ClientOnly>
<OutremerMap
:orgs="outremerOrgsLegacy"
:selectedId="selectedIdLegacyNum"
@select-org="() => {}"
/>
<OutremerMap :orgs="[]" :selectedId="null" @select-org="() => {}" />
<template #fallback>
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
Chargement…
</div>
<div class="flex items-center justify-center h-48 text-sm" style="color: var(--nav-text-muted);">Chargement…</div>
</template>
</ClientOnly>
</div>
<!-- Vue graphique mobile -->
<div v-show="mobileMapView === 'graphe'" class="absolute inset-0 overflow-hidden" style="background: var(--nav-bg);">
<!-- Vue graphique -->
<div v-show="vf.etat.sousVue === 'graphe'" class="relative flex-1 min-h-0 overflow-hidden">
<ClientOnly>
<GraphView
:data="bifurcationData"
:allHashtags="allHashtags"
:active="mobileMapView === 'graphe'"
@select-structure="onSelectStructureMobile"
:active="vf.etat.sousVue === 'graphe' && vf.vueAffichee !== 'fiches'"
@select-structure="onSelectStructure"
/>
<template #fallback>
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
Chargement du graphe…
</div>
<div class="flex items-center justify-center h-full" style="color: var(--nav-text-muted);">Chargement du graphe…</div>
</template>
</ClientOnly>
</div>
<!-- Zone « Fiches » mobile (B3-M3) — pleine hauteur sous la barre d'onglets,
pas de carte dessous donc pas de sheet demi-hauteur (même logique que
la vue graphe qui masque déjà le MobileSheet). Fix 2026-09-25 : la
version précédente empilait un second MobileSheet (état half par
défaut) au-dessus d'un fond neutre plein écran, laissant ~45% de
hauteur vide au-dessus du panneau. -->
<div
v-show="mobileMapView === 'fiches'"
class="absolute inset-0 flex flex-col overflow-hidden"
style="background: var(--nav-bg-alt);"
>
<FichesPanel
:fiches="allStructuresMini"
:hashtags="allHashtags"
:selected="selectedHashtags"
:query="search"
intention="Toutes les agences et réseaux du répertoire, filtrables par hashtag."
@update:selected="selectedHashtags = $event"
@open="(fiche) => onSelectStructureMobile(String(fiche.id))"
<!-- Chatbot sous la carte (ordinateur) -->
<div v-if="vf.etat.sousVue !== 'outremer'" class="hidden lg:block shrink-0">
<ChatbotPlaceholder
endpoint="/api/chatbot-reseaux"
chat-key="agences"
titre="Assistant Réseaux AEP"
placeholder-replie="Pose une question sur les structures et agences du réseau…"
placeholder-saisie="Décris ta situation…"
intro="Je connais les 120 réseaux, collectifs et agences cartographiés dans AEP — ceux qui portent une vision écologique et politique de l'architecture. Décris ta situation : tu cherches un collectif, une agence inspirante, un partenaire sur un projet ?"
exemple="Exemple : « Quelles agences travaillent sur le réemploi ? »"
fiches-titre="Structures recommandées :"
fiche-lien-modele="/agences#{id}"
rendre-markdown
message-limite="Limite de 20 questions par jour atteinte."
@highlightOrgs="() => {}"
@applyHashtag="(tag) => vf.ajouterFiltre('tags', sansDiese(tag))"
/>
</div>
</template>
<!-- Bottom sheet swipable (masqué en vue graphique et en vue fiches) -->
<ClientOnly v-if="mobileMapView !== 'graphe' && mobileMapView !== 'fiches'">
<MobileSheet :resultCount="filtered.length" :pending="pending">
<!-- Bandeau intention mobile -->
<div class="px-3 py-2" style="background: var(--bifurc-banner-bg, #faf8f5); border-bottom: 1px solid var(--bifurc-banner-border, #e0d8cc);">
<p class="text-xs leading-relaxed" style="color: var(--bifurc-banner-text, #2c2416); margin: 0;">
120 réseaux, collectifs et agences où des pensées écologiques deviennent des pratiques d'architecture.
</p>
</div>
<!-- Filtres hashtags mobile -->
<div class="px-3 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
<HashtagFilter
:allHashtags="allHashtags"
:selectedHashtags="selectedHashtags"
:selectedFamille="selectedFamille"
@update:selectedHashtags="selectedHashtags = $event"
@update:selectedFamille="selectedFamille = $event"
/>
</div>
<!-- Barre recherche mobile -->
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
<label class="mobile-search-label" aria-label="Rechercher une structure">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted); flex-shrink: 0;">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
v-model="search"
type="search"
placeholder="Rechercher…"
class="mobile-search-input"
autocomplete="off"
/>
<button
v-if="search"
type="button"
class="mobile-search-clear"
aria-label="Effacer"
@click.stop="search = ''"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</label>
<button
v-if="hasActiveFilters"
@click="resetFilters"
class="mt-1 text-xs"
style="color: var(--nav-text-muted); text-decoration: underline;"
>Effacer les filtres</button>
</div>
<!-- Liste fiches mobile -->
<div class="px-3 py-2">
<div class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">
{{ filtered.length }} structure{{ filtered.length > 1 ? 's' : '' }}
</div>
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
Chargement des fiches…
</div>
<div v-else-if="filtered.length === 0" class="text-center py-8">
<p class="text-sm mb-2" style="color: var(--nav-text-muted);">Aucun résultat pour ces filtres.</p>
<button @click="resetFilters" class="text-sm underline" style="color: var(--nav-primary-solid);">
Effacer les filtres
</button>
</div>
<div class="space-y-2">
<div
v-for="structure in filtered"
:key="structure.id"
class="block rounded-lg p-3 transition-all cursor-pointer"
:style="selectedId === structure.id
? `background: var(--nav-bg-alt); border-left: 3px solid ${familleColor(structure.famille_principale)};`
: 'background: var(--nav-surface); border-left: 3px solid transparent;'"
@click="onSelectStructureMobile(structure.id)"
>
<div class="flex items-start justify-between gap-2">
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ structure.nom }}</span>
<span
class="shrink-0 w-2.5 h-2.5 rounded-full mt-1"
:style="`background: ${familleColor(structure.famille_principale)};`"
/>
</div>
<div class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">{{ structure.type_principal }} · {{ structure.ville }}</div>
</div>
</div>
</div>
</MobileSheet>
</ClientOnly>
</div>
</main>
<!-- ═══════════════════════════════════════ MODAL FICHE V2 (desktop) -->
<FicheModalV2
v-model="ficheModalOpen"
:structureId="ficheModalId"
:data="bifurcationData"
@update:structureId="ficheModalId = $event"
/>
<!-- Détail (B12) : le corps de l'ancienne FicheModalV2 ; une structure liée s'ouvre dans le même panneau -->
<template #detail="{ fiche, filtrer }">
<FicheReseauContenu
:structure-id="String(fiche.id)"
:data="bifurcationData"
:etiquettes-actives="(vf.etat.filtres.tags ?? []).map((t) => `#${t}`)"
@etiquette="(tag) => filtrer('tags', sansDiese(tag))"
@voisin="(id) => vf.ouvrirFiche(id)"
/>
</template>
</VueFiches>
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
<button
class="lg:hidden fixed bottom-6 right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
style="
class="lg:hidden fixed right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
:style="`
bottom: ${basFlottants}px;
height: 48px;
background: var(--nav-primary);
opacity: 0.92;
background: var(--nav-primary-solid);
color: var(--nav-text-on-primary);
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
font-family: var(--nav-font);
font-size: 0.875rem;
font-weight: 600;
"
`"
aria-label="Ouvrir l'assistant Chatbot"
@click="chatbotOpen = true"
>
@@ -467,6 +119,7 @@
<button
class="reseaux-info-btn"
type="button"
:style="vf.largeur === 'telephone' ? `bottom: ${basFlottants}px;` : undefined"
@click="missionOpen = true"
aria-label="À propos des réseaux AEP cartographiés"
title="À propos de cette carte"
@@ -490,7 +143,7 @@
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 «&nbsp;vertes&nbsp;»&nbsp;: 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&nbsp;: 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&nbsp;: <NuxtLink to="/contribuer" @click.stop>propose-le</NuxtLink>.
Six familles structurent la cartographie&nbsp;: militants, agences engagées, collectifs de production, ressources communes, recherche, formations alternatives. Filtre par hashtag, ouvre la fiche d'une structure, navigue le graphe («&nbsp;Vue graphique&nbsp;» dans le sélecteur de carte) pour voir les affinités. Si tu animes ou connais un réseau qui devrait y être&nbsp;: <NuxtLink to="/contribuer" @click.stop>propose-le</NuxtLink>.
</p>
</MissionPopup>
@@ -499,73 +152,8 @@
<script setup lang="ts">
import type { ReseauxBifurcationData, StructureV2 } from '~/types/structure-v2'
// ── Couleurs familles ──────────────────────────────────────────────────────
const FAMILLE_COLORS: Record<number, string> = {
1: '#a85d3e',
2: '#c4a472',
3: '#d4a017',
4: '#5a7a4a',
5: '#3d6a8c',
6: '#6b3fa0',
}
function familleColor(f: number): string {
return FAMILLE_COLORS[f] ?? '#888'
}
// ── État UI ────────────────────────────────────────────────────────────────
type MapView = 'metropole' | 'outremer' | 'graphe' | 'fiches'
const route = useRoute()
const router = useRouter()
const selectedId = ref<string | null>(null)
const hoveredId = ref<string | null>(null)
const ficheModalOpen = ref(false)
const ficheModalId = ref<string | null>(null)
const chatbotOpen = ref(false)
const mobileMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
const desktopMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
const missionOpen = ref(false)
// Sync URL — B3-M3 : pose/retire ?vue=fiches uniquement (agences.vue n'avait
// aucune synchronisation URL avant B3 pour search/famille/hashtags/vue ; hors
// périmètre de cette mission de l'étendre, seul ?vue=fiches est ajouté ici).
function setDesktopView(v: MapView) {
desktopMapView.value = v
syncViewUrl()
}
function setMobileView(v: MapView) {
mobileMapView.value = v
syncViewUrl()
}
function syncViewUrl() {
const q: Record<string, string> = { ...(route.query as Record<string, string>) }
if (desktopMapView.value === 'fiches' || mobileMapView.value === 'fiches') {
q.vue = 'fiches'
} else {
delete q.vue
}
router.replace({ query: Object.keys(q).length ? q : undefined })
}
onMounted(() => {
try {
if (!localStorage.getItem('aep_reseaux_seen')) {
missionOpen.value = true
}
} catch {}
})
// Filtres
const search = ref('')
const selectedFamille = ref<number | null>(null)
const selectedHashtags = ref<string[]>([])
// Refs cartes
const navMapRef = ref<any>(null)
const navMapMobileRef = ref<any>(null)
import type { ConfigVueFiches, ValeurFiltre } from '~/utils/vueFiches'
import { sansDiese } from '~/composables/useFicheAdapter'
// ── Données V2 - JSON statique ─────────────────────────────────────────────
const bifurcationData = ref<ReseauxBifurcationData | null>(null)
@@ -583,91 +171,98 @@ onMounted(async () => {
const structures = computed<StructureV2[]>(() => bifurcationData.value?.structures ?? [])
// Tous les hashtags uniques triés
// Tous les hashtags uniques triés (le graphe les attend avec leur « # »)
const allHashtags = computed<string[]>(() => {
const set = new Set<string>()
structures.value.forEach(s => s.hashtags.forEach(h => set.add(h)))
return Array.from(set).sort()
})
// ── Filtrage ───────────────────────────────────────────────────────────────
const filtered = computed<StructureV2[]>(() => {
let result = structures.value
// ── Vue fiches (B11) : l'état vit dans l'URL (tags=, famille=, carte=…) ────
// Familles : 1-5 = famille principale ou secondaire, 6 = badge « recherche politique »
const FAMILLES: ValeurFiltre[] = [
{ valeur: '1', libelle: 'Réemploi' },
{ valeur: '2', libelle: 'Frugalité' },
{ valeur: '3', libelle: 'Social' },
{ valeur: '4', libelle: 'Collectifs' },
{ valeur: '5', libelle: 'Urbanisme' },
{ valeur: '6', libelle: 'Recherche' },
]
const PAYS: Record<string, string> = {
FR: 'France', BE: 'Belgique', DE: 'Allemagne', UK: 'Royaume-Uni', ES: 'Espagne', IT: 'Italie',
PT: 'Portugal', CH: 'Suisse', AT: 'Autriche', DK: 'Danemark', PL: 'Pologne', NL: 'Pays-Bas',
SE: 'Suède', USA: 'États-Unis', INT: 'International',
}
// Filtre texte
if (search.value.trim()) {
const q = search.value.toLowerCase()
result = result.filter(
s =>
s.nom.toLowerCase().includes(q) ||
s.ville.toLowerCase().includes(q) ||
s.description_courte.toLowerCase().includes(q) ||
s.hashtags.some(h => h.toLowerCase().includes(q))
)
}
/** Valeurs d'une étiquette, de la plus fréquente à la plus rare (ordre stable des chips et des groupes). */
function parFrequence(cle: (s: StructureV2) => string[], libelle: (v: string) => string): ValeurFiltre[] {
const n = new Map<string, number>()
structures.value.forEach(s => cle(s).forEach(v => n.set(v, (n.get(v) ?? 0) + 1)))
return [...n.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], 'fr'))
.map(([v]) => ({ valeur: v, libelle: libelle(v) }))
}
// Filtre famille - F6 = badge_f6_recherche_politique, pas famille_principale
if (selectedFamille.value !== null) {
if (selectedFamille.value === 6) {
result = result.filter(s => (s.badges as any)?.f6_recherche_politique === true)
} else {
result = result.filter(
s => s.famille_principale === selectedFamille.value ||
(s.familles_secondaires ?? []).includes(selectedFamille.value!)
)
}
}
const config = computed<ConfigVueFiches>(() => ({
dimensions: [
{
cle: 'tags', libelle: 'Hashtag', variante: 'fonction',
valeurs: parFrequence(s => s.hashtags.map(sansDiese), v => `#${v}`),
},
{ cle: 'famille', libelle: 'Famille', variante: 'echelle', valeurs: FAMILLES },
],
tris: ['pertinence', 'nom'],
groupes: [
{ cle: 'famille', libelle: 'Famille', dimension: 'famille', sans: 'Sans famille' },
{ cle: 'pays', libelle: 'Pays', dimension: 'pays', sans: 'Pays inconnu', valeurs: parFrequence(s => [s.pays], v => PAYS[v] ?? v) },
],
etats: ['carte', 'mixte', 'fiches'],
sousVues: {
parametre: 'carte',
defaut: 'metropole',
valeurs: [
{ valeur: 'metropole', libelle: 'Métropole' },
{ valeur: 'outremer', libelle: 'Outre-mer' },
{ valeur: 'graphe', libelle: 'Vue graphique' },
],
},
}))
// Filtre hashtags (AND logique si plusieurs)
if (selectedHashtags.value.length) {
result = result.filter(
s => selectedHashtags.value.every(h => s.hashtags.includes(h))
)
}
const { toMini } = useFicheAdapter()
const fichesMini = computed(() => structures.value.map((s) => toMini(s)))
const vf = useVueFiches(fichesMini, config)
return result
// La carte ne montre que le résultat courant (recherche + filtres)
const metropoleStructures = computed<StructureV2[]>(() => structures.value.filter((s) => vf.idsResultat.has(s.id)))
// ── Sélection (B12) : la fiche ouverte vit dans l'URL (`fiche=`), détail en panneau ──
const selectedId = computed<string | null>(() => vf.ficheId)
function onSelectStructure(id: string) {
vf.ouvrirFiche(id)
}
// « Voir sur la carte » : l'épingle est sur la carte Métropole (pas dans le graphe)
const centrageCarte = ref<{ id: string; n: number } | null>(null)
watch(() => vf.centrage, (c) => {
if (!c) return
if (vf.etat.sousVue !== 'metropole') vf.setSousVue('metropole')
centrageCarte.value = { id: c.id, n: c.n }
})
const hasActiveFilters = computed(
() => !!search.value || selectedFamille.value !== null || selectedHashtags.value.length > 0
)
// ── Boutons flottants (téléphone) : au-dessus du pied « Voir les N fiches » en état Carte ──
const chatbotOpen = ref(false)
const missionOpen = ref(false)
const basFlottants = computed(() => (vf.largeur === 'telephone' && vf.vueAffichee === 'carte' ? 76 : 24))
function resetFilters() {
search.value = ''
selectedFamille.value = null
selectedHashtags.value = []
}
onMounted(() => {
try {
if (!localStorage.getItem('aep_reseaux_seen')) missionOpen.value = true
} catch {}
})
// Structures métropole (pays != DOM-TOM, et avec coordonnées)
// Pour simplifier : toutes les structures (la carte gère les sans-coords)
const metropoleStructures = computed<StructureV2[]>(() => filtered.value)
// Panneau « Toutes les fiches » (B3-M3) — mêmes structures que `filtered`,
// sans filtrer par géolocalisation (adaptateur mutualisé avec `/`).
const { toMini } = useFicheAdapter()
const allStructuresMini = computed(() => filtered.value.map((s) => toMini(s)))
// Outre-mer : pas de structures V2 DOM-TOM pour l'instant - garder le composant existant vide
// OutremerMap attend le format Org legacy - on passe un tableau vide
const outremerOrgsLegacy = computed(() => [])
const selectedIdLegacyNum = computed(() => null)
// ── Sélection ─────────────────────────────────────────────────────────────
function onSelectStructure(id: string) {
selectedId.value = selectedId.value === id ? null : id
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
ficheModalId.value = id
ficheModalOpen.value = true
}
}
function onSelectStructureMobile(id: string) {
selectedId.value = id
ficheModalId.value = id
ficheModalOpen.value = true
}
useHead({ title: "AEP - Réseaux de bifurcation architecturale" })
// Une fiche ouverte donne son nom à l'onglet (les données arrivent après le rendu serveur ici)
useHead({ title: computed(() => (vf.ficheOuverte ? `${vf.ficheOuverte.nom} — AEP` : 'AEP - Réseaux de bifurcation architecturale')) })
</script>
<style scoped>
@@ -692,6 +287,6 @@ useHead({ title: "AEP - Réseaux de bifurcation architecturale" })
.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; }
.reseaux-info-btn { bottom: 10px; left: auto; right: 52px; }
}
</style>
+155 -588
View File
@@ -1,362 +1,91 @@
<template>
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
<div class="relative h-full overflow-hidden" style="background: var(--nav-bg);">
<!-- ═══════════════════════════════════════ SIDEBAR DESKTOP (≥ 1024px) -->
<div class="hidden lg:flex w-80 shrink-0 flex-col overflow-hidden">
<NavSidebar
:search="search"
:modeValue="territoireMode"
:echelle="echelle"
:fonctions="fonctions"
:territoire="territoire"
:echelleCount="echelleCount"
:fonctionCount="fonctionCount"
:territoireCount="territoireCount"
:resultCount="filtered.length"
:orgs="filtered"
:selectedId="selectedId"
:hasActiveFilters="hasActiveFilters"
:pending="pending"
@update:search="onSearch"
@update:mode="onMode"
@update:echelle="onEchelle"
@update:fonctions="onFonctions"
@update:territoire="onTerritoire"
@select-org="onSelectOrg"
@hover-org="onHoverOrg"
@reset-filters="resetFilters"
/>
<!-- Indicateur source dev -->
<div
v-if="dataSource === 'seed'"
class="absolute bottom-2 left-1/2 -translate-x-1/2 z-[1002] px-2 py-1 rounded text-xs pointer-events-none"
style="background: var(--nav-accent); color: var(--nav-text);"
>
Mode dev — données seed
</div>
<!-- ═══════════════════════════════════════ ZONE CENTRALE (carte) -->
<main class="flex-1 flex flex-col overflow-hidden relative">
<!-- Indicateur source dev -->
<div
v-if="dataSource === 'seed'"
class="absolute top-2 left-2 z-[500] px-2 py-1 rounded text-xs"
style="background: var(--nav-accent); color: var(--nav-text);"
>
Mode dev — données seed
</div>
<!-- ── VUE DESKTOP : Onglets Métropole / Outre-mer / Toutes les fiches ── -->
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
<!-- Barre onglets desktop -->
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'metropole'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="setDesktopView('metropole')"
>Métropolitain</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="setDesktopView('outremer')"
>Outre-mer</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'fiches'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="setDesktopView('fiches')"
>Toutes les fiches</button>
</div>
<!-- Carte Métropole desktop -->
<div v-show="desktopMapView === 'metropole'" class="flex-1 flex flex-col overflow-hidden">
<div class="relative flex-1" style="min-height: 200px;">
<ClientOnly>
<NavMap
ref="navMapRef"
:orgs="metropoleOrgs"
:selectedId="selectedId"
@select-org="onSelectOrg"
/>
<template #fallback>
<div class="w-full h-full flex items-center justify-center" style="background: var(--nav-bg-alt); color: var(--nav-text-muted);">Chargement de la carte…</div>
</template>
</ClientOnly>
</div>
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
</div>
<!-- Carte Outre-mer desktop -->
<div v-show="desktopMapView === 'outremer'" class="flex-1 flex flex-col overflow-hidden">
<div class="flex-1 overflow-y-auto">
<ClientOnly>
<OutremerMap
:orgs="outremerOrgs"
:selectedId="selectedId"
@select-org="onSelectOrg"
/>
<template #fallback>
<div class="flex items-center justify-center h-full text-sm" style="color: var(--nav-text-muted);">Chargement…</div>
</template>
</ClientOnly>
</div>
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
</div>
<!-- Panneau « Toutes les fiches » desktop (B3-M3) — remplace la carte,
fiches sans géoloc incluses (gain principal de cette mission) -->
<div v-show="desktopMapView === 'fiches'" class="flex-1 flex flex-col overflow-hidden">
<div
class="shrink-0 px-4 py-2"
style="border-bottom: 1px solid var(--nav-bg-alt); background: var(--nav-surface);"
>
<button
type="button"
class="text-xs font-medium underline"
style="color: var(--nav-text-muted);"
@click="setDesktopView('metropole')"
>← Carte</button>
</div>
<FichesPanel
:fiches="allFichesMini"
:hashtags="[...FONCTIONS]"
:selected="fonctions"
:query="search"
intention="Toutes les structures d'entraide, y compris celles sans adresse sur la carte. Cochez une fonction pour filtrer."
@update:selected="onFonctions"
@open="(fiche) => onSelectOrg(Number(fiche.id))"
/>
</div>
</div>
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + carte pleine hauteur + sheet swipable ── -->
<!-- Onglets Métropolitain / Outre-mer / Toutes les fiches -->
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'metropole'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="setMobileView('metropole')"
>Métropolitain</button>
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="setMobileView('outremer')"
>Outre-mer</button>
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'fiches'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="setMobileView('fiches')"
>Fiches</button>
</div>
<div class="lg:hidden flex-1 relative overflow-hidden">
<!-- Carte Métropole -->
<div v-show="mobileMapView === 'metropole'" class="absolute inset-0">
<!-- ═══════════════════════════════════════ VUE FICHES (B11) : Carte · Mixte · Fiches -->
<VueFiches
:vf="vf"
intention="Les structures d'entraide du métier, y compris celles sans adresse sur la carte."
libelle-recherche="Rechercher une structure…"
:pending="pending"
:selected-id="selectedId"
:lien-page="(f) => `/fiche/${f.id}`"
>
<template #carte>
<!-- Métropole -->
<div v-show="vf.etat.sousVue === 'metropole'" class="relative flex-1 min-h-0">
<ClientOnly>
<NavMap
ref="navMapMobileRef"
:orgs="metropoleOrgs"
:selectedId="selectedId"
@select-org="onSelectOrgMobile"
:survolId="survolNum"
:centrage="centrageMetropole"
@select-org="onSelectOrg"
@survol="(id) => (vf.survolId = id)"
/>
<template #fallback>
<div
class="w-full h-full flex items-center justify-center"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>
Chargement de la carte…
</div>
<div class="w-full h-full flex items-center justify-center" style="background: var(--nav-bg-alt); color: var(--nav-text-muted);">Chargement de la carte…</div>
</template>
</ClientOnly>
</div>
<!-- Carte Outre-mer (scroll vertical, pleine largeur) -->
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
<!-- Outre-mer -->
<div v-show="vf.etat.sousVue === 'outremer'" class="flex-1 min-h-0 overflow-y-auto" style="background: var(--nav-bg);">
<ClientOnly>
<OutremerMap
:orgs="outremerOrgs"
:selectedId="selectedId"
@select-org="onSelectOrgMobile"
@select-org="onSelectOrg"
/>
<template #fallback>
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
Chargement…
</div>
<div class="flex items-center justify-center h-48 text-sm" style="color: var(--nav-text-muted);">Chargement…</div>
</template>
</ClientOnly>
</div>
<!-- Zone « Fiches » mobile (B3-M3) — pleine hauteur sous la barre d'onglets,
pas de carte dessous donc pas de sheet demi-hauteur (même logique que
la vue graphe qui masque aussi le MobileSheet). Fix 2026-09-25 : la
version précédente empilait un second MobileSheet (état half par
défaut) au-dessus d'un fond neutre plein écran, laissant ~45% de
hauteur vide au-dessus du panneau. -->
<div
v-show="mobileMapView === 'fiches'"
class="absolute inset-0 flex flex-col overflow-hidden"
style="background: var(--nav-bg-alt);"
>
<FichesPanel
:fiches="allFichesMini"
:hashtags="[...FONCTIONS]"
:selected="fonctions"
:query="search"
intention="Toutes les structures d'entraide, y compris celles sans adresse sur la carte. Cochez une fonction pour filtrer."
@update:selected="onFonctions"
@open="(fiche) => onSelectOrgMobile(Number(fiche.id))"
/>
<!-- Chatbot sous la carte (ordinateur) -->
<div class="hidden lg:block shrink-0">
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
</div>
</template>
<!-- Bottom sheet swipable (Métropole et Outre-mer uniquement) -->
<ClientOnly v-if="mobileMapView !== 'fiches'">
<MobileSheet :resultCount="filtered.length" :pending="pending">
<!-- Barre recherche -->
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
<label class="mobile-search-label" aria-label="Rechercher une organisation">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted); flex-shrink: 0;">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
v-model="mobileSearch"
type="search"
placeholder="Rechercher…"
class="mobile-search-input"
autocomplete="off"
@input="onSearch(mobileSearch)"
/>
<button
v-if="mobileSearch"
type="button"
class="mobile-search-clear"
aria-label="Effacer"
@click.stop="mobileSearch = ''; onSearch('')"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</label>
<!-- Filtres ÉCHELLE — Chip variant echelle (B3-M1 : alignées sur EchelleFilter.vue,
dupliquées ici car la sheet mobile ne passe pas par NavSidebar) -->
<div class="mt-2">
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">ÉCHELLE</span>
<ChipGroup aria-label="Filtrer par échelle">
<Chip
v-for="opt in ECHELLES"
:key="opt"
:label="opt"
variant="echelle"
:active="echelle.includes(opt)"
@toggle="toggleEchelle(opt)"
/>
</ChipGroup>
</div>
<!-- Filtres FONCTION — Chip variant fonction (B3-M1, idem) -->
<div class="mt-2">
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">FONCTION</span>
<ChipGroup aria-label="Filtrer par fonction">
<Chip
v-for="fn in FONCTIONS"
:key="fn"
:label="fn"
variant="fonction"
:active="fonctions.includes(fn)"
:count="fonctions.includes(fn) ? (fonctions.indexOf(fn) + 1) : (fonctionCount[fn] ?? 0)"
@toggle="toggleFonction(fn)"
/>
</ChipGroup>
</div>
<button
v-if="hasActiveFilters"
@click="resetFilters"
class="mt-2 text-xs"
style="color: var(--nav-text-muted); text-decoration: underline;"
>✕ Effacer les filtres</button>
</div>
<!-- Compteur + Liste fiches -->
<div class="px-3 py-2">
<div class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">
{{ filtered.length }} résultat{{ filtered.length > 1 ? 's' : '' }}
</div>
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
Chargement des fiches…
</div>
<div v-else-if="filtered.length === 0" class="text-center py-8">
<p class="text-sm mb-2" style="color: var(--nav-text-muted);">Aucun résultat pour ces filtres.</p>
<button @click="resetFilters" class="text-sm underline" style="color: var(--nav-primary-solid);">
Effacer les filtres
</button>
</div>
<div class="space-y-2">
<div
v-for="org in filtered"
:key="org.Id"
class="block rounded-lg p-3 transition-all cursor-pointer"
:style="selectedId === org.Id
? 'background: var(--nav-bg-alt); border-left: 3px solid var(--nav-accent);'
: 'background: var(--nav-surface); border-left: 3px solid transparent;'"
@click="onSelectOrgMobile(org.Id)"
>
<div class="flex items-start justify-between gap-2">
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ org.nom }}</span>
<span
v-if="org.echelle"
class="shrink-0 px-2 py-0.5 rounded-full text-xs font-medium"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ org.echelle }}</span>
</div>
<div v-if="fonctionsList(org).length" class="mt-1 flex flex-wrap gap-1">
<span
v-for="fn in fonctionsList(org)"
:key="fn"
class="px-1.5 py-0.5 rounded text-xs"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ fn }}</span>
</div>
<div v-if="org.localisation_ville" class="mt-1 text-xs" style="color: var(--nav-text-muted);">
{{ org.localisation_ville }}
</div>
</div>
</div>
</div>
</MobileSheet>
</ClientOnly>
</div>
</main>
<!-- ═══════════════════════════════════════ MODAL FICHE (desktop) -->
<FicheModal
v-model="ficheModalOpen"
:orgId="ficheModalId"
/>
<!-- Détail (B12) : la fiche complète vient de la liste (même enregistrement NocoDB que /api/fiche/:id) -->
<template #detail="{ fiche, filtrer }">
<template v-if="orgParId.get(String(fiche.id))">
<FicheDetail
:key="fiche.id"
:org="orgParId.get(String(fiche.id))!"
compact
etiquettes-cliquables
@etiquette="(t) => filtrer('fonctions', t)"
/>
<CommentSection :key="`c-${fiche.id}`" :org-id="Number(fiche.id)" :refresh="commentaires" />
<CommentForm :key="`f-${fiche.id}`" :org-id="Number(fiche.id)" @submitted="commentaires++" />
</template>
</template>
</VueFiches>
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
<button
class="lg:hidden fixed bottom-6 right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
style="
class="lg:hidden fixed right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
:style="`
bottom: ${basFlottants}px;
height: 48px;
background: var(--nav-primary);
opacity: 0.92;
background: var(--nav-primary-solid);
color: var(--nav-text-on-primary);
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
font-family: var(--nav-font);
font-size: 0.875rem;
font-weight: 600;
"
`"
aria-label="Ouvrir l'assistant Chatbot"
@click="chatbotOpen = true"
>
@@ -377,6 +106,7 @@
<button
class="mission-info-btn"
type="button"
:style="vf.largeur === 'telephone' ? `bottom: ${basFlottants}px;` : undefined"
@click="missionOpen = true"
aria-label="À propos de cette carte d'entraide"
title="À propos de cette carte"
@@ -399,291 +129,128 @@
<script setup lang="ts">
import type { Org } from '~/types/org'
import type { ConfigVueFiches } from '~/utils/vueFiches'
import { DOM_TOM } from '~/composables/useFicheAdapter'
// ── URL query params sync ─────────────────────────────────────────────────
const route = useRoute()
const router = useRouter()
const search = ref<string>((route.query.q as string) ?? '')
const echelle = ref<string[]>(
route.query.echelle
? (route.query.echelle as string).split(',').filter(Boolean)
: []
)
const fonctions = ref<string[]>(
route.query.fonctions
? (route.query.fonctions as string).split(',').filter(Boolean)
: []
)
const territoire = ref<string | null>((route.query.territoire as string) ?? null)
const territoireMode = ref<string>(
(route.query.mode as string) === 'outremer' ? 'outremer' : 'metropole'
)
// ── Données ───────────────────────────────────────────────────────────────
const { data, pending } = await useFetch<{ list: Org[]; source: string }>('/api/organisations')
type MapView = 'metropole' | 'outremer' | 'fiches'
const orgs = computed<Org[]>(() => data.value?.list ?? [])
const dataSource = computed(() => data.value?.source ?? 'nocodb')
const desktopMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
const selectedId = ref<number | null>(null)
const chatbotOpen = ref(false)
const ficheModalOpen = ref(false)
const ficheModalId = ref<number | null>(null)
const mobileMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
const missionOpen = ref(false)
onMounted(() => {
try {
if (!localStorage.getItem('aep_mission_seen')) {
missionOpen.value = true
}
} catch {}
// `territoire=` (lien existant) : pré-filtre de page, hors de la vue fiches
const orgsTerritoire = computed<Org[]>(() => {
const t = route.query.territoire
return typeof t === 'string' && t ? orgs.value.filter((o) => o.territoire === t) : orgs.value
})
// Surlignage temporaire (5 sec) suite à une réponse chatbot
// → sélectionne le premier ID recommandé sur la carte, puis remet à null
// ── Vue fiches (B11) : l'état vit dans l'URL ──────────────────────────────
const FONCTIONS = ['Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier', 'Comptabilité', 'Développement', 'Formation', "Gestion d'agence", 'Santé mentale']
const ECHELLES = ['National', 'Régional', 'Local']
const enValeurs = (l: string[]) => l.map((v) => ({ valeur: v, libelle: v }))
const config: ConfigVueFiches = {
dimensions: [
{ cle: 'fonctions', libelle: 'Fonction', variante: 'fonction', valeurs: enValeurs(FONCTIONS) },
{ cle: 'echelle', libelle: 'Échelle', variante: 'echelle', valeurs: enValeurs(ECHELLES) },
],
tris: ['pertinence', 'nom', 'recent'],
groupes: [
{ cle: 'fonction', libelle: 'Fonction', dimension: 'fonctions', sans: 'Sans fonction' },
{ cle: 'echelle', libelle: 'Échelle', dimension: 'echelle', sans: 'Sans échelle' },
{ cle: 'territoire', libelle: 'Territoire', dimension: 'territoire', sans: 'Sans territoire', valeurs: enValeurs(['Métropole', ...DOM_TOM]) },
],
etats: ['carte', 'mixte', 'fiches'],
sousVues: {
parametre: 'mode',
defaut: 'metropole',
valeurs: [{ valeur: 'metropole', libelle: 'Métropole' }, { valeur: 'outremer', libelle: 'Outre-mer' }],
},
}
const { toMini } = useFicheAdapter()
const fichesMini = computed(() => orgsTerritoire.value.map((o) => toMini(o)))
const vf = useVueFiches(fichesMini, config)
// La carte ne montre que le résultat courant (recherche + filtres)
const orgsFiltres = computed<Org[]>(() => orgsTerritoire.value.filter((o) => vf.idsResultat.has(String(o.Id))))
const metropoleOrgs = computed<Org[]>(() => orgsFiltres.value.filter((o) => !o.territoire || !DOM_TOM.includes(o.territoire)))
const outremerOrgs = computed<Org[]>(() => orgsFiltres.value.filter((o) => o.territoire && DOM_TOM.includes(o.territoire)))
const survolNum = computed(() => (vf.survolId == null ? null : Number(vf.survolId)))
// ── Sélection (B12) : la fiche ouverte vit dans l'URL (`fiche=`), détail en panneau ──
const orgParId = computed(() => new Map(orgs.value.map((o) => [String(o.Id), o])))
// Épingle et carte-fiche allumées : la fiche ouverte, ou 5 s celle que le chatbot désigne
const surligneId = ref<number | null>(null)
const selectedId = computed<number | null>(() => surligneId.value ?? (vf.ficheId != null ? Number(vf.ficheId) : null))
const commentaires = ref(0)
function onSelectOrg(id: number) {
vf.ouvrirFiche(id)
}
// « Voir sur la carte » : la bonne sous-vue (Métropole ou Outre-mer), puis l'épingle
const centrageMetropole = ref<{ id: number; n: number } | null>(null)
watch(() => vf.centrage, (c) => {
if (!c) return
const o = orgParId.value.get(c.id)
const dom = !!o?.territoire && DOM_TOM.includes(o.territoire)
if (vf.etat.sousVue !== (dom ? 'outremer' : 'metropole')) vf.setSousVue(dom ? 'outremer' : 'metropole')
if (!dom) centrageMetropole.value = { id: Number(c.id), n: c.n }
})
// Le bouton retour de /fiche/:id (« Ouvrir la page de la fiche ») revient à la même vue
if (import.meta.client) {
watch(() => route.fullPath, () => {
try {
sessionStorage.setItem('nav_back_filters', new URLSearchParams(route.query as Record<string, string>).toString())
} catch {}
}, { immediate: true })
}
// Surlignage temporaire (5 s) suite à une réponse chatbot
let highlightTimer: ReturnType<typeof setTimeout> | null = null
const prevSelectedId = ref<number | null>(null)
function onHighlightOrgs(ids: (number | string)[]) {
if (!ids.length) return
const firstId = typeof ids[0] === 'string' ? parseInt(ids[0], 10) : ids[0]
if (isNaN(firstId)) return
// Sauvegarde la sélection courante
prevSelectedId.value = selectedId.value
selectedId.value = firstId
surligneId.value = firstId
if (highlightTimer) clearTimeout(highlightTimer)
highlightTimer = setTimeout(() => {
// Restaure la sélection précédente (ou null)
selectedId.value = prevSelectedId.value
prevSelectedId.value = null
surligneId.value = null
highlightTimer = null
}, 5000)
}
// Ref locale barre de recherche mobile (synchronisée avec search via onSearch)
const mobileSearch = ref<string>((route.query.q as string) ?? '')
// Refs vers les instances NavMap (desktop + mobile séparées via deux <ClientOnly>)
const navMapRef = ref<any>(null)
const navMapMobileRef = ref<any>(null)
// Sync URL <-> état filtres
function syncUrl() {
const q: Record<string, string> = {}
if (search.value) q.q = search.value
if (echelle.value.length) q.echelle = echelle.value.join(',')
if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
if (territoire.value) q.territoire = territoire.value
if (territoireMode.value === 'outremer') q.mode = 'outremer'
if (desktopMapView.value === 'fiches' || mobileMapView.value === 'fiches') q.vue = 'fiches'
router.replace({ query: Object.keys(q).length ? q : undefined })
}
// Changement de vue (carte/fiches) — passe par syncUrl pour poser/retirer ?vue=fiches
function setDesktopView(v: MapView) {
desktopMapView.value = v
syncUrl()
}
function setMobileView(v: MapView) {
mobileMapView.value = v
syncUrl()
}
// Sauvegarde les filtres courants dans sessionStorage pour le bouton retour des fiches
function storeFiltersForBack() {
if (typeof window === 'undefined') return
const q: Record<string, string> = {}
if (search.value) q.q = search.value
if (echelle.value.length) q.echelle = echelle.value.join(',')
if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
if (territoire.value) q.territoire = territoire.value
if (territoireMode.value === 'outremer') q.mode = 'outremer'
const qs = new URLSearchParams(q).toString()
sessionStorage.setItem('nav_back_filters', qs)
}
function onSearch(v: string) { search.value = v; syncUrl(); storeFiltersForBack() }
function onMode(v: string) { territoireMode.value = v; syncUrl(); storeFiltersForBack() }
function onEchelle(v: string[]) { echelle.value = v; syncUrl(); storeFiltersForBack() }
function onFonctions(v: string[]) { fonctions.value = v; syncUrl(); storeFiltersForBack() }
function onTerritoire(v: string | null) { territoire.value = v; syncUrl(); storeFiltersForBack() }
function onSelectOrg(id: number) {
selectedId.value = selectedId.value === id ? null : id
// Desktop : ouvrir le modal fiche
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
ficheModalId.value = id
ficheModalOpen.value = true
}
}
// Tap card mobile → ouvre la fiche détaillée
function onSelectOrgMobile(id: number) {
selectedId.value = id
storeFiltersForBack()
router.push(`/fiche/${id}`)
}
function onHoverOrg(id: number | null) {
if (id !== null) selectedId.value = id
}
const hasActiveFilters = computed(() =>
!!search.value || echelle.value.length > 0 || fonctions.value.length > 0 || !!territoire.value
)
function resetFilters() {
search.value = ''
echelle.value = []
fonctions.value = []
territoire.value = null
router.replace({ query: undefined })
}
// Tagging compact mobile — toggle direct
function toggleEchelle(opt: string) {
if (echelle.value.includes(opt)) {
onEchelle(echelle.value.filter(v => v !== opt))
} else {
onEchelle([...echelle.value, opt])
}
}
function toggleFonction(fn: string) {
if (fonctions.value.includes(fn)) {
onFonctions(fonctions.value.filter(f => f !== fn))
} else {
onFonctions([...fonctions.value, fn])
}
}
// Sync recherche depuis app.vue top nav (via URL ?q=)
watch(() => route.query.q, (v) => {
search.value = (v as string) ?? ''
})
// ── Données ───────────────────────────────────────────────────────────────
const { data, pending, error } = await useFetch<{ list: Org[]; source: string }>('/api/organisations')
const orgs = computed<Org[]>(() => data.value?.list ?? [])
const dataSource = computed(() => data.value?.source ?? 'nocodb')
// Fiche aléatoire — réagit au ?random=1
watch(() => route.query.random, (v) => {
if (v === '1' && orgs.value.length > 0) {
const randomOrg = orgs.value[Math.floor(Math.random() * orgs.value.length)]
router.replace({ path: `/fiche/${randomOrg.Id}` })
}
})
// ── Filtrage côté client ──────────────────────────────────────────────────
const filtered = computed<Org[]>(() => {
let result = orgs.value
if (search.value.trim()) {
const q = search.value.toLowerCase()
result = result.filter(
(o) =>
o.nom?.toLowerCase().includes(q) ||
o.localisation_ville?.toLowerCase().includes(q)
)
}
if (echelle.value.length) {
result = result.filter((o) => o.echelle && echelle.value.includes(o.echelle))
}
if (fonctions.value.length) {
// Garde les orgs qui matchent au moins 1 fonction sélectionnée
result = result.filter((o) => {
const orgFns = (o.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean)
return fonctions.value.some((fn) => orgFns.includes(fn))
})
// Tri par score pondéré : priorité 1 (1er cliqué) = poids le plus fort
const n = fonctions.value.length
const score = (o: Org) =>
fonctions.value.reduce((s, fn, i) => {
const fns = (o.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean)
return s + (fns.includes(fn) ? (n - i) : 0)
}, 0)
result = [...result].sort((a, b) => score(b) - score(a))
}
if (territoire.value) {
result = result.filter((o) => o.territoire === territoire.value)
}
return result
})
// Panneau « Toutes les fiches » (B3-M3) — mêmes fiches que `filtered`, sans
// filtrer par géolocalisation : c'est le gain principal de cette mission,
// les fiches sans latitude/longitude (invisibles sur la carte) y apparaissent.
const { toMini } = useFicheAdapter()
const allFichesMini = computed(() => filtered.value.map((org) => toMini(org)))
const DOM_TOM = ['Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte']
const DOM_TOM_LIST = DOM_TOM
const metropoleOrgs = computed<Org[]>(() =>
filtered.value.filter(o => !o.territoire || !DOM_TOM.includes(o.territoire))
)
const outremerOrgs = computed<Org[]>(() => {
if (territoire.value && DOM_TOM.includes(territoire.value)) {
return filtered.value.filter(o => o.territoire === territoire.value)
}
return filtered.value.filter(o => o.territoire && DOM_TOM.includes(o.territoire))
})
const outremerCountByDom = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {}
DOM_TOM.forEach(d => { counts[d] = 0 })
filtered.value.forEach(o => {
if (o.territoire && DOM_TOM.includes(o.territoire)) {
counts[o.territoire] = (counts[o.territoire] ?? 0) + 1
// Fiche aléatoire — ?random=1, au chargement comme en navigation interne
if (import.meta.client) {
watch([() => route.query.random, () => orgs.value.length], ([v, n]) => {
if (v === '1' && n > 0) {
const randomOrg = orgs.value[Math.floor(Math.random() * n)]
router.replace({ path: `/fiche/${randomOrg.Id}` })
}
})
return counts
})
// ── Compteurs ─────────────────────────────────────────────────────────────
const ECHELLES = ['National', 'Régional', 'Local'] as const
const ECHELLE_LABELS: Record<string, string> = { National: 'Nat', Régional: 'Rég', Local: 'Loc' }
const FONCTIONS = ['Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier', 'Comptabilité', 'Développement', 'Formation', "Gestion d'agence", 'Santé mentale'] as const
const TERRITOIRES = ['Métropole', 'Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte']
const echelleCount = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {}
ECHELLES.forEach((e) => { counts[e] = 0 })
orgs.value.forEach((o) => { if (o.echelle) counts[o.echelle] = (counts[o.echelle] ?? 0) + 1 })
return counts
})
const fonctionCount = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {}
FONCTIONS.forEach((f) => { counts[f] = 0 })
orgs.value.forEach((o) => {
const fns = (o.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean)
fns.forEach((fn) => { counts[fn] = (counts[fn] ?? 0) + 1 })
})
return counts
})
const territoireCount = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {}
TERRITOIRES.forEach((t) => { counts[t] = 0 })
orgs.value.forEach((o) => { if (o.territoire) counts[o.territoire] = (counts[o.territoire] ?? 0) + 1 })
counts['Métropole'] = orgs.value.filter(o => !o.territoire || !DOM_TOM.includes(o.territoire)).length
return counts
})
// ── Helpers ───────────────────────────────────────────────────────────────
function fonctionsList(org: Org): string[] {
return (org.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean).slice(0, 3)
}, { immediate: true })
}
useHead({ title: 'AEP — Cartographie de l\'écologie politique architecturale' })
// ── Boutons flottants (téléphone) : au-dessus du pied « Voir les N fiches » en état Carte ──
const chatbotOpen = ref(false)
const missionOpen = ref(false)
const basFlottants = computed(() => (vf.largeur === 'telephone' && vf.vueAffichee === 'carte' ? 76 : 24))
onMounted(() => {
try {
if (!localStorage.getItem('aep_mission_seen')) missionOpen.value = true
} catch {}
})
// Un lien partagé avec fiche= porte le nom de la fiche (onglet, aperçu de lien), dès le rendu serveur
useHead({ title: computed(() => (vf.ficheOuverte ? `${vf.ficheOuverte.nom} — AEP` : 'AEP — Cartographie de l\'écologie politique architecturale')) })
</script>
<style scoped>
@@ -708,6 +275,6 @@ useHead({ title: 'AEP — Cartographie de l\'écologie politique architecturale'
.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; }
.mission-info-btn { bottom: 10px; left: auto; right: 52px; }
}
</style>
+7 -40
View File
@@ -1,56 +1,23 @@
<template>
<!-- B10-M5 : plus de sous-onglets ici, la nav XL porte « Recherche média » et « Publications » (?tab=projets) -->
<div class="media-page" style="background: var(--nav-bg);">
<nav class="subtabs" style="display:flex; gap:0; border-bottom: 1px solid var(--nav-bg-alt); background: var(--nav-surface); padding: 0 1rem;">
<button
:class="['subtab-btn', { active: tab === 'visuel' }]"
@click="tab = 'visuel'"
>
📚 bibliothèque des pensées écologiques
</button>
<button
:class="['subtab-btn', { active: tab === 'projets' }]"
@click="tab = 'projets'"
>
📐 Projets
</button>
</nav>
<MediaTabVisuel v-if="tab === 'visuel'" />
<MediaTabProjets v-else-if="tab === 'projets'" />
<MediaTabProjets v-else />
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const router = useRouter()
const tab = ref<'visuel' | 'projets'>(
(['visuel', 'projets'].includes(route.query.tab as string)
? route.query.tab as 'visuel' | 'projets'
: 'visuel')
)
watch(tab, (newTab) => {
router.replace({ query: { ...route.query, tab: newTab } })
// Lu à chaque changement de ?tab= (un lien /media → /media?tab=projets ne remonte pas la page).
const tab = ref<'visuel' | 'projets'>(route.query.tab === 'projets' ? 'projets' : 'visuel')
watch(() => route.query.tab, (t) => {
tab.value = t === 'projets' ? 'projets' : 'visuel'
})
useHead({ title: 'AEP - Media' })
useHead(() => ({ title: tab.value === 'projets' ? 'AEP - Publications' : 'AEP - Recherche média' }))
</script>
<style scoped>
.media-page { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.subtabs { display: flex; gap: 0; flex-shrink: 0; }
.subtab-btn {
padding: 10px 18px;
font-size: 0.85rem;
font-weight: 500;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
color: var(--nav-text-muted);
transition: color 0.15s, border-color 0.15s;
}
.subtab-btn:hover { color: var(--nav-text); }
.subtab-btn.active { color: var(--nav-primary-solid); border-bottom-color: var(--nav-primary-solid); font-weight: 600; }
</style>
+23 -5
View File
@@ -254,8 +254,11 @@
<!-- 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>
<template v-if="msg.role === 'bot'">
<ChatModeBadge :profondeur="msg.profondeur" />
<div class="md-content" v-html="renderMd(msg.content)" />
</template>
<span v-if="msg.role !== 'bot'">{{ msg.content }}</span>
</div>
<!-- Plateformes recommandées -->
<div v-if="msg.role === 'bot' && msg.recommandations?.length" class="taff-chat-recos">
@@ -275,6 +278,13 @@
<div v-if="chatLoading" class="taff-msg taff-msg--bot">
<span class="taff-typing"><span/><span/><span/></span>
</div>
<ChatNewConversation v-if="chatHasHistory && !chatLoading" @reset="clearChat" />
</div>
<!-- Mode de réponse -->
<div class="taff-chat-mode">
<ChatModeToggle v-model="chatProfondeur" :disabled="chatLoading" />
</div>
<!-- Input -->
@@ -581,6 +591,9 @@ interface ChatMessage {
role: 'user' | 'bot'
content: string
recommandations?: { id: string; nom: string; raison: string }[]
profondeur?: string
/** Message d'erreur : affiché mais jamais persisté */
transient?: boolean
}
const { render: renderMd } = useMarkdown()
@@ -588,7 +601,8 @@ const { render: renderMd } = useMarkdown()
const chatOpen = ref(false)
const chatInput = ref('')
const chatLoading = ref(false)
const chatMessages = ref<ChatMessage[]>([])
const { messages: chatMessages, hasHistory: chatHasHistory, clear: clearChat } = useChatSession<ChatMessage>('taff')
const chatProfondeur = useChatProfondeur('taff')
const chatMessagesEl = ref<HTMLElement | null>(null)
async function sendChat() {
@@ -597,20 +611,22 @@ async function sendChat() {
chatMessages.value.push({ role: 'user', content: q })
chatInput.value = ''
chatLoading.value = true
const mode = chatProfondeur.value
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 } }
{ method: 'POST', body: { question: q, profondeur: mode } }
)
chatMessages.value.push({
role: 'bot',
content: res.reponse_texte,
recommandations: res.plateformes_recommandees ?? [],
profondeur: mode,
})
} catch (e: any) {
chatMessages.value.push({ role: 'bot', content: e?.data?.statusMessage ?? 'Erreur — réessaie dans un instant.' })
chatMessages.value.push({ role: 'bot', content: e?.data?.statusMessage ?? 'Erreur — réessaie dans un instant.', transient: true })
} finally {
chatLoading.value = false
await nextTick()
@@ -968,6 +984,8 @@ const parsedDescription = computed(() => {
40% { transform: translateY(-5px); opacity: 1; }
}
.taff-chat-mode { padding: 0.5rem 1rem 0; }
.taff-chat-input-wrap {
display: flex;
align-items: flex-end;
+10
View File
@@ -128,6 +128,16 @@ try {
check('journal : lignes sans date signalées', logs.some(([, t]) => t.includes('1 sans date ignorées')))
}
// 3bis. Aucune ligne datée : jamais « 0 € vérifié »
{
const rows = [{ Id: 1, cout_eur: 30 }, { Id: 2, cout_eur: 1 }]
const { fetchJson } = fauxNocodb({ schema: ['Id', 'cout_eur'], rows })
logs.length = 0
const s = await checkBudget({ ...base, fetchJson })
check('aucune date : verified=false, pas 0 € vérifié', s.verified === false && s.lignesSansDate === 2)
check('aucune date : avertissement « NON vérifié »', logs.some(([niv, t]) => niv === 'warn' && t.includes('NON vérifié')))
}
// 4. Pagination : 2 500 lignes du mois à 0,01 € → 25 € → bloqué
{
const rows = Array.from({ length: 2500 }, (_, i) => ({ Id: i + 1, cout_eur: 0.01, CreatedAt: '2026-09-20T00:00:00Z' }))
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env node
// Test du garde-fou des citations (server/utils/citations.ts). Usage : node scripts/test-citations.mjs
import assert from 'node:assert/strict'
import { verifierCitations, contenusChunks } from '../server/utils/citations.ts'
const ctx = ["La technique est devenue autonome, et forme un monde dévorant.\r\nIl n’y a plus de liberté de choix. Plus on parle d’une valeur, plus c'est le signe de son absence."]
const t = (rep, c = ctx) => verifierCitations(rep, c)
// retrouvée (apostrophes, casse, ponctuation de bord, retours chariot) : gardée
let r = t('Ellul écrit : « Il n\'y a plus de liberté de choix ! » donc.')
assert.equal(r.gardees, 1); assert.equal(r.retirees, 0); assert.ok(r.texte.includes('«'))
// introuvable : guillemets retirés, texte conservé
r = t('Il dit : « La technique est une machine à fabriquer du pouvoir social inévitable ». Fin.')
assert.equal(r.retirees, 1); assert.equal(r.texte, 'Il dit : La technique est une machine à fabriquer du pouvoir social inévitable. Fin.')
// guillemets droits et anglais
r = t('"Une phrase inventée qui ne figure nulle part dans le texte" et “Une autre phrase inventée qui ne figure nulle part ici”')
assert.equal(r.retirees, 2); assert.ok(!/["“”]/.test(r.texte))
// court (< 6 mots) : intact, non compté
r = t('Le « rift métabolique » et la « ville intelligente ».')
assert.equal(r.gardees + r.retirees, 0); assert.ok(r.texte.includes('« rift métabolique »'))
// élision [...] : chaque morceau doit exister
r = t('« Plus on parle d\'une valeur [...] le signe de son absence »')
assert.equal(r.gardees, 1)
r = t('« Plus on parle d\'une valeur [...] le signe de sa présence éclatante »')
assert.equal(r.retirees, 1)
// pas de correspondance en milieu de mot
r = t('« ormation autonome, et forme un monde dévorant »')
assert.equal(r.retirees, 1)
// aucun contexte : rien de long n'est gardé
r = t('« Il n\'y a plus de liberté de choix »', [])
assert.equal(r.retirees, 1)
// contenusChunks : tableau de chaînes ou chaîne
assert.deepEqual(contenusChunks([{ content: ['a', 'b'] }, { content: 'c' }, {}, null]), ['a', 'b', 'c'])
assert.deepEqual(contenusChunks(undefined), [])
console.log('citations : tous les cas passent')
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env node
/**
* Test des données de nav S / M / XL (B10-M1).
* Charge le VRAI fichier utils/echelles.ts via jiti (même patron que test-submit-libre.mjs) :
* chaque route → bon groupe et bon onglet actif, un seul onglet actif par route.
* Usage : node scripts/test-echelles-nav.mjs
*/
import { createJiti } from 'jiti'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
const __dirname = dirname(fileURLToPath(import.meta.url))
const jiti = createJiti(import.meta.url)
const { ECHELLES, ORDRE_ECHELLES, ECHELLES_CADRE_COURT, echelleDeRoute } = await jiti.import(
join(__dirname, '..', 'utils', 'echelles.ts'),
)
const cases = [
// [path, query, groupe attendu, onglet attendu]
['/trouver-du-taf', {}, 'S', 'Jobs'],
['/outils', {}, 'S', 'Outils'],
['/', {}, 'M', "Écosystème d'entraide"],
['/', { vue: 'fiches' }, 'M', "Écosystème d'entraide"],
['/agences', {}, 'M', 'Réseaux AEP'],
['/agences', { vue: 'fiches' }, 'M', 'Réseaux AEP'],
['/codev', {}, 'M', 'Codev'],
['/codev/demo', {}, 'M', 'Codev'],
['/codev/carto', {}, 'M', 'Codev'],
['/media', {}, 'XL', 'Recherche média'],
['/media', { tab: 'visuel' }, 'XL', 'Recherche média'],
['/media', { tab: 'projets' }, 'XL', 'Publications'],
['/manifeste', {}, null, null],
['/a-propos', {}, null, null],
['/proposer', {}, null, null],
['/signaler', {}, null, null],
['/fiche/42', {}, null, null],
['/outilsXYZ', {}, null, null],
['/codevelopper', {}, null, null],
]
let fail = 0
for (const [path, query, groupe, onglet] of cases) {
const route = { path, query }
const r = echelleDeRoute(route)
const gotG = r?.echelle ?? null
const gotO = r?.onglet.libelle ?? null
// Un seul onglet actif sur tout le site pour une route donnée.
const actifs = ORDRE_ECHELLES.flatMap((id) => ECHELLES[id].onglets.filter((o) => o.actif(route)))
const ok = gotG === groupe && gotO === onglet && actifs.length === (groupe ? 1 : 0)
if (!ok) fail++
console.log(`${ok ? 'OK ' : 'FAIL'} ${path}${Object.keys(query).length ? '?' + new URLSearchParams(query) : ''} → ${gotG ?? '∅'} / ${gotO ?? '∅'} (actifs : ${actifs.length})`)
}
// Ordre et contenu des groupes (directive Jules du 29/09 + arbitrage 1A).
const attendu = {
S: ['Jobs', 'Outils'],
M: ["Écosystème d'entraide", 'Réseaux AEP', 'Codev'],
XL: ['Recherche média', 'Publications'],
}
for (const id of ORDRE_ECHELLES) {
const got = ECHELLES[id].onglets.map((o) => o.libelle)
const ok = JSON.stringify(got) === JSON.stringify(attendu[id])
if (!ok) fail++
console.log(`${ok ? 'OK ' : 'FAIL'} groupe ${id} = ${got.join(' · ')}`)
}
const cadreOk = ECHELLES_CADRE_COURT === 'Trois échelles, imbriquées. Entrez par celle qui presse.'
if (!cadreOk) fail++
console.log(`${cadreOk ? 'OK ' : 'FAIL'} cadre court = « ${ECHELLES_CADRE_COURT} »`)
const total = cases.length + ORDRE_ECHELLES.length + 1
console.log(`\n${total - fail}/${total}`)
process.exit(fail ? 1 : 0)
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env node
/**
* Tests de la vue fiches (B11-M1) : logique pure de utils/vueFiches.ts et
* adaptateurs de composables/useFicheAdapter.ts, chargés tels quels via jiti
* (même patron que test-echelles-nav.mjs).
* Usage : node scripts/test-vue-fiches.mjs
*/
import { createJiti } from 'jiti'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
const __dirname = dirname(fileURLToPath(import.meta.url))
const jiti = createJiti(import.meta.url)
const V = await jiti.import(join(__dirname, '..', 'utils', 'vueFiches.ts'))
const A = await jiti.import(join(__dirname, '..', 'composables', 'useFicheAdapter.ts'))
let n = 0
let fail = 0
function ok(cond, libelle, detail = '') {
n++
if (!cond) fail++
console.log(`${cond ? 'OK ' : 'FAIL'} ${libelle}${!cond && detail ? ` → ${detail}` : ''}`)
}
const trie = (o) => (o && typeof o === "object" && !Array.isArray(o) ? Object.fromEntries(Object.keys(o).sort().map((k) => [k, trie(o[k])])) : o)
const eq = (a, b) => JSON.stringify(trie(a)) === JSON.stringify(trie(b))
// ── Données : configuration Entraide, fiches adaptées depuis de vrais Org ──
const FONCTIONS = ['Juridique', 'Technique', 'Formation', 'Santé mentale']
const cfg = {
dimensions: [
{ cle: 'fonctions', libelle: 'Fonction', variante: 'fonction', valeurs: FONCTIONS.map((v) => ({ valeur: v, libelle: v })) },
{ cle: 'echelle', libelle: 'Échelle', variante: 'echelle', valeurs: ['National', 'Régional', 'Local'].map((v) => ({ valeur: v, libelle: v })) },
],
tris: ['pertinence', 'nom', 'recent'],
groupes: [
{ cle: 'fonction', libelle: 'Fonction', dimension: 'fonctions', sans: 'Sans fonction' },
{ cle: 'echelle', libelle: 'Échelle', dimension: 'echelle', sans: 'Sans échelle' },
{ cle: 'territoire', libelle: 'Territoire', dimension: 'territoire', sans: 'Sans territoire' },
],
etats: ['carte', 'mixte', 'fiches'],
sousVues: { parametre: 'mode', defaut: 'metropole', valeurs: [{ valeur: 'metropole', libelle: 'Métropole' }, { valeur: 'outremer', libelle: 'Outre-mer' }] },
}
const orgs = [
{ Id: 1, nom: 'CNOA', echelle: 'National', tags_fonction: 'Juridique', localisation_ville: 'Paris', latitude: 48.8, longitude: 2.3, description: "Ordre des architectes. Deuxième phrase." },
{ Id: 2, nom: 'Collectif Fil', echelle: 'Régional', tags_fonction: 'Formation,Juridique', localisation_ville: 'Nantes', latitude: 47.2, longitude: -1.5, description: 'Recherche-action en architecture.' },
{ Id: 3, nom: 'Écoute Archi', echelle: 'Local', tags_fonction: 'Santé mentale', localisation_ville: 'Fort-de-France', territoire: 'Martinique', latitude: null, longitude: null, description: 'Soutien psychologique.' },
{ Id: 4, nom: 'Bureau Technique', echelle: 'Régional', tags_fonction: 'Technique', localisation_ville: 'Lyon', latitude: 45.7, longitude: 4.8, submitted_at: '2026-09-01T10:00:00Z' },
{ Id: 5, nom: 'Atelier sans tag', localisation_ville: 'Rennes', latitude: 48.1, longitude: -1.6 },
]
const fiches = orgs.map(A.toMini)
const etat0 = (over = {}) => ({ ...V.lireQuery({}, cfg), ...over })
const ids = (fs) => fs.map((f) => f.id)
// ── Adaptateurs (M2) ──
ok(eq(fiches[1].etiquettes, { fonctions: ['Formation', 'Juridique'], echelle: ['Régional'], territoire: ['Métropole'] }), 'org → etiquettes par dimension (fonctions complètes, échelle, territoire)')
ok(fiches[2].etiquettes.territoire[0] === 'Martinique' && fiches[2].geoloc === false && fiches[2].coords === undefined, 'org DOM sans coordonnées → territoire Martinique, geoloc false, coords absentes')
ok(eq(fiches[0].coords, [48.8, 2.3]) && fiches[0].resume === 'Ordre des architectes.', 'org → coords et résumé (première phrase) inchangé')
ok(eq(fiches[1].chips, ['Formation', 'Juridique']) && fiches[1].chipsDimension === 'fonctions' && fiches[1].echelle === 'Régional', 'org → chips et echelle d’affichage inchangés')
const structure = A.toMini({
id: 'f1-rotor', nom: 'Rotor', url: '', pays: 'BE', ville: 'Bruxelles', famille_principale: 1, familles_secondaires: [3],
hashtags: ['#reemploi-structurel', '#plateforme-materiaux'], type_principal: 'Coopérative',
badges: { centre_ressources: true, mouvement_manifeste: false, contre_pouvoir_spatial: false, f6_recherche_politique: true },
description_courte: 'Réemploi des matériaux de construction.', description_longue: '', pensees: [], sources: [], already_in_v1: false, eligible_v2: true,
latitude: 50.8, longitude: 4.3,
})
ok(eq(structure.etiquettes, { tags: ['reemploi-structurel', 'plateforme-materiaux'], famille: ['1', '3', '6'], pays: ['BE'] }), 'structure → tags sans #, familles principale + secondaires + 6 (badge), pays')
ok(eq(structure.chips, ['#reemploi-structurel', '#plateforme-materiaux']) && structure.chipsDimension === 'tags', 'structure → chips d’affichage avec # inchangés')
// ── URL ↔ état ──
const q1 = { vue: 'fiches', q: 'ordre', fonctions: 'Juridique,Formation', echelle: 'Régional', tri: 'nom', groupe: 'fonction', mode: 'outremer', adresse: 'sans' }
const e1 = V.lireQuery(q1, cfg)
ok(e1.vue === 'fiches' && e1.q === 'ordre' && eq(e1.filtres, { fonctions: ['Juridique', 'Formation'], echelle: ['Régional'] }) && e1.tri === 'nom' && e1.groupe === 'fonction' && e1.sousVue === 'outremer' && e1.sansAdresse, 'lecture : tous les paramètres de la SPEC §5')
ok(eq(V.ecrireQuery(e1, cfg), q1), 'aller-retour URL → état → URL identique')
const e2 = etat0({ vue: 'mixte', filtres: { fonctions: ['Technique'], echelle: [] }, groupe: 'echelle' })
ok(eq(V.lireQuery(V.ecrireQuery(e2, cfg), cfg), e2), 'aller-retour état → URL → état identique')
ok(eq(V.ecrireQuery(etat0(), cfg), {}), 'état par défaut → URL vide (sous-vue par défaut non écrite)')
ok(eq(V.ecrireQuery(etat0({ q: 'x' }), cfg, { territoire: 'Guyane', random: '1', vue: 'carte', q: 'ancien' }), { territoire: 'Guyane', random: '1', q: 'x' }), 'paramètres non gérés (territoire, random) conservés, gérés remplacés')
// Rétrocompatibilité et paramètres invalides
ok(V.lireQuery({ vue: 'fiches' }, cfg).vue === 'fiches', 'rétrocompat : ?vue=fiches (lien B3 diffusé) → état Fiches')
ok(V.lireQuery({ mode: 'outremer' }, cfg).sousVue === 'outremer', 'rétrocompat : ?mode=outremer → sous-vue Outre-mer')
ok(V.lireQuery({}, cfg).vue === null, 'sans vue= → null (défaut par largeur)')
const inv = V.lireQuery({ vue: 'plein-ecran', tri: 'distance', groupe: 'couleur', mode: 'lune', adresse: 'oui', zzz: '1' }, cfg)
ok(inv.vue === null && inv.tri === null && inv.groupe === null && inv.sousVue === 'metropole' && inv.sansAdresse === false, 'paramètres invalides ignorés sans erreur (vue, tri, groupe, mode, adresse)')
ok(eq(V.filtresValides({ fonctions: ['Juridique', 'Astrologie'], echelle: ['Mondial'] }, cfg), { fonctions: ['Juridique'], echelle: [] }), 'valeurs de filtre inconnues ignorées')
const cfgVide = { ...cfg, dimensions: [{ ...cfg.dimensions[0], valeurs: [] }] }
ok(eq(V.filtresValides({ fonctions: ['Astrologie'] }, cfgVide), { fonctions: ['Astrologie'] }), 'dimension pas encore chargée : valeurs gardées (lien partagé non perdu)')
ok(eq(V.lireQuery({ fonctions: ['Juridique,Formation', 'x'], q: ['a', 'b'] }, cfg).filtres.fonctions, ['Juridique', 'Formation']), 'paramètre répété : seule la première occurrence est lue')
ok(eq(V.lireQuery({ fonctions: ' Juridique ,,Juridique' }, cfg).filtres.fonctions, ['Juridique']), 'espaces, vides et doublons nettoyés')
// ── Filtrage : OU dans une dimension, ET entre ──
const filtre = (over) => ids(V.filtrerFiches(fiches, etat0(over), cfg))
ok(eq(filtre({ filtres: { fonctions: ['Juridique', 'Technique'], echelle: [] } }), [1, 2, 4]), 'OU dans une dimension : Juridique OU Technique')
ok(eq(filtre({ filtres: { fonctions: ['Juridique', 'Technique'], echelle: ['Régional'] } }), [2, 4]), 'ET entre dimensions : (Juridique OU Technique) ET Régional')
ok(eq(filtre({ filtres: { fonctions: [], echelle: ['National', 'Local'] } }), [1, 3]), 'OU sur la dimension secondaire')
ok(eq(filtre({ q: 'ecoute' }), [3]), 'recherche insensible aux accents et à la casse (« ecoute » → Écoute)')
ok(eq(filtre({ q: 'NANTES' }), [2]), 'recherche sur la ville')
ok(eq(filtre({ q: 'psychologique' }), [3]), 'recherche sur le résumé')
ok(eq(filtre({ sansAdresse: true }), [3]), 'adresse=sans → seulement les fiches non localisées')
// ── Compteurs ──
const c = V.compterEtiquettes(fiches, etat0({ filtres: { fonctions: ['Juridique'], echelle: ['Régional'] } }), cfg)
ok(c.fonctions.Juridique === 1 && c.fonctions.Technique === 1 && c.fonctions.Formation === 1 && c.fonctions['Santé mentale'] === 0, 'compteurs fonction : dans le résultat des AUTRES dimensions (Régional)', JSON.stringify(c.fonctions))
ok(c.echelle.National === 1 && c.echelle.Régional === 1 && c.echelle.Local === 0, 'compteurs échelle : dans le résultat des autres dimensions (Juridique)', JSON.stringify(c.echelle))
const cq = V.compterEtiquettes(fiches, etat0({ q: 'paris' }), cfg)
ok(cq.fonctions.Juridique === 1 && cq.fonctions.Formation === 0, 'compteurs : la recherche s’applique')
ok(V.nbFiltresActifs(etat0({ filtres: { fonctions: ['A', 'B'], echelle: ['C'] }, sansAdresse: true })) === 4, 'nombre de filtres actifs (pour « Filtrer · n »)')
// ── Tri ──
ok(V.triEffectif(etat0(), cfg) === 'nom', 'tri par défaut sans filtre : nom')
ok(V.triEffectif(etat0({ filtres: { fonctions: ['Juridique'], echelle: [] } }), cfg) === 'pertinence', 'tri par défaut avec filtre : pertinence')
ok(V.triEffectif(etat0({ tri: 'recent', filtres: { fonctions: ['Juridique'], echelle: [] } }), cfg) === 'recent', 'tri explicite prioritaire')
ok(eq(ids(V.trierFiches(fiches, 'nom', etat0())), [5, 4, 1, 2, 3]), 'tri nom A → Z (« Écoute » rangé à E)')
const ePert = etat0({ filtres: { fonctions: ['Juridique', 'Formation'], echelle: [] } })
ok(eq(ids(V.trierFiches(V.filtrerFiches(fiches, ePert, cfg), 'pertinence', ePert)), [2, 1]), 'pertinence : la fiche qui coche le plus d’étiquettes d’abord')
ok(eq(ids(V.trierFiches(fiches, 'recent', etat0())), [4, 5, 3, 2, 1]), 'récent : date d’abord, puis Id décroissant')
ok(V.triEffectif(etat0(), { ...cfg, tris: ['pertinence', 'nom'] }) === 'nom', 'Réseaux AEP (sans récent) : défaut nom')
// ── Regroupement ──
const g = V.regrouper(V.trierFiches(fiches, 'nom', etat0()), cfg.groupes[0], cfg)
ok(eq(g.map((x) => `${x.libelle}:${ids(x.fiches).join('+')}`), ['Juridique:1', 'Technique:4', 'Formation:2', 'Santé mentale:3', 'Sans fonction:5']), 'groupe fonction : première étiquette, ordre de la dimension, « sans » en dernier', JSON.stringify(g.map((x) => x.libelle)))
ok(g.reduce((s, x) => s + x.fiches.length, 0) === fiches.length, 'regroupement : chaque fiche une seule fois (compteurs justes)')
const gt = V.regrouper(fiches, cfg.groupes[2], cfg)
ok(eq(gt.map((x) => x.libelle), ['Martinique', 'Métropole']), 'groupe territoire (dimension sans chips) : ordre A → Z')
ok(V.regrouper(fiches, null, cfg).length === 1, 'sans regroupement : un seul bloc')
ok(eq(ids(V.ordreFiches(g)), [1, 4, 2, 3, 5]), 'ordre fiche à fiche = ordre affiché des groupes (B12)')
// ── Fiche ouverte : URL, fiche à fiche, historique (B12-M2/M3) ──
ok(V.lireFiche({ fiche: '412' }) === '412' && V.lireFiche({ fiche: 'f1-rotor' }) === 'f1-rotor', 'ouverture par URL : fiche= lu (id numérique ou texte)')
ok(V.lireFiche({}) === null && V.lireFiche({ fiche: '' }) === null && V.lireFiche({ fiche: ' ' }) === null, 'fiche= absent ou vide → pas de fiche')
ok(V.lireFiche({ fiche: ['7', '8'] }) === '7', 'fiche= répété → la première valeur')
const qListe = { vue: 'fiches', fonctions: 'Juridique,Formation', tri: 'nom' }
ok(eq(V.queryAvecFiche(qListe, 412), { ...qListe, fiche: '412' }), 'queryAvecFiche pose fiche= et garde filtres, tri, vue')
ok(eq(V.queryAvecFiche({ ...qListe, fiche: '412' }, null), qListe), 'queryAvecFiche(null) retire fiche= et rien d’autre')
const qEcrite = V.ecrireQuery({ ...V.lireQuery({ ...qListe, fiche: '412' }, cfg), tri: 'pertinence' }, cfg, { ...qListe, fiche: '412' })
ok(qEcrite.fiche === '412' && qEcrite.tri === 'pertinence', 'un changement de tri garde la fiche ouverte')
ok(V.empreinteListe({ ...qListe, fiche: '1' }) === V.empreinteListe({ fiche: '9', tri: 'nom', fonctions: 'Juridique,Formation', vue: 'fiches' }), 'empreinte de la liste : indépendante de fiche= et de l’ordre des clés')
const ordre = V.ordreFiches(g) // [1, 4, 2, 3, 5]
const p = V.positionFiche(ordre, '2')
ok(p && p.rang === 3 && p.total === 5 && p.precedente.id === 4 && p.suivante.id === 3, '« 3 / 5 » : précédente et suivante dans l’ordre courant')
ok(V.positionFiche(ordre, 1).precedente === null && V.positionFiche(ordre, 5).suivante === null, 'pas de bouclage aux extrémités')
const ordreFiltre = V.ordreFiches(V.regrouper(V.filtrerFiches(fiches, etat0({ filtres: { fonctions: ['Juridique'], echelle: [] } }), cfg), null, cfg))
ok(V.positionFiche(ordreFiltre, '4') === null, 'fiche hors filtres → position null (« hors de vos filtres », sans précédente/suivante)')
ok(V.positionFiche(ordre, null) === null && V.positionFiche(ordre, '999') === null, 'pas de fiche ou id inconnu → null')
// Historique simulé, avec les mêmes décisions que composables/useVueFiches.ts
function navigateur(depart) {
const pile = [depart]
let i = 0
let liste = null // listeALOuverture
const cur = () => pile[i]
return {
cur,
longueur: () => i + 1,
ouvrir(id) {
const mode = V.modeOuverture(V.lireFiche(cur()))
if (mode === 'push') { liste = V.empreinteListe(cur()); pile.splice(i + 1); pile.push(V.queryAvecFiche(cur(), id)); i++ }
else pile[i] = V.queryAvecFiche(cur(), id)
},
filtrer(q) { pile[i] = q },
fermer() {
const mode = V.modeFermeture(liste != null, liste === V.empreinteListe(cur()))
liste = null
if (mode === 'back') i--
else pile[i] = V.queryAvecFiche(cur(), null)
},
retour() { i--; if (V.lireFiche(cur()) == null) liste = null },
}
}
let nav = navigateur(qListe)
nav.ouvrir(1); nav.ouvrir(4); nav.ouvrir(2)
ok(nav.longueur() === 2 && nav.cur().fiche === '2', 'ouvrir puis deux fois « suivante » : une seule entrée d’historique ajoutée')
nav.retour()
ok(nav.longueur() === 1 && V.lireFiche(nav.cur()) === null && eq(nav.cur(), qListe), 'retour arrière (téléphone) : la fiche se ferme, la liste et ses filtres restent')
nav = navigateur(qListe)
nav.ouvrir(1); nav.fermer()
ok(nav.longueur() === 1 && eq(nav.cur(), qListe), 'fermer une fiche ouverte depuis la liste : retour en arrière, historique propre')
nav = navigateur({ ...qListe, fiche: '412' })
nav.fermer()
ok(nav.longueur() === 1 && eq(nav.cur(), qListe), 'lien partagé : fermer retire fiche= sur place, sans quitter la page')
nav = navigateur(qListe)
nav.ouvrir(1); nav.filtrer({ ...nav.cur(), echelle: 'National' }); nav.fermer()
ok(nav.cur().echelle === 'National' && V.lireFiche(nav.cur()) === null, 'filtre changé fiche ouverte : fermer garde le nouveau filtre (pas de retour arrière)')
console.log(`\n${n - fail}/${n}`)
process.exit(fail ? 1 : 0)
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env node
/**
* Contrôle des citations d'un chatbot LightRAG : chaque segment entre guillemets
* de la réponse doit figurer mot pour mot dans le contexte réellement récupéré.
*
* Usage :
* node scripts/verif-citations.mjs --rag http://HOTE:9621 --query "Cite ..." \
* [--route http://localhost:3334/api/chatbot-pensees] [--mode hybrid] [--auteur slug] \
* [--response-file reponse.txt] [--ctx-query-file autre-query.txt] [--min-mots 6] [--max-total-tokens 40000 (mode approfondi)] [--json]
*
* - Sans --response-file : la réponse est demandée à --route (POST {query, mode, auteur_slug}).
* - Contexte : POST {rag}/query {query, mode, only_need_context:true}. --ctx-query-file ajoute
* un second contexte (ex. la requête telle qu'envoyée avant amendement, préambule inclus) ;
* un segment est « trouvé » s'il figure dans l'un ou l'autre.
* - Segment >= min-mots (6) absent du contexte = inventé (algorithme : server/utils/citations.ts, partagé avec la route). Plus court : signalé, non compté.
* Lecture seule côté LightRAG. Réutilisable pour l'instance 9623 (--rag http://HOTE:9623).
*/
import { readFileSync } from 'node:fs'
// Node >= 22.18 charge le .ts directement (syntaxe effaçable uniquement dans citations.ts).
import { verifierCitations } from '../server/utils/citations.ts'
const args = Object.fromEntries(process.argv.slice(2).reduce((a, x, i, arr) => {
if (x.startsWith('--')) a.push([x.slice(2), arr[i + 1] && !arr[i + 1].startsWith('--') ? arr[i + 1] : true])
return a
}, []))
if (!args.rag || !args.query) { console.error('--rag et --query requis'); process.exit(2) }
const mode = args.mode || 'hybrid'
const minMots = Number(args['min-mots'] || 6)
async function post(url, body, timeout = 120000) {
const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(timeout) })
if (!r.ok) throw new Error(`${url} -> HTTP ${r.status}`)
return r.json()
}
async function contexte(q) {
const j = await post(`${args.rag}/query`, { query: q, mode, only_need_context: true, ...(args['max-total-tokens'] ? { max_total_tokens: Number(args['max-total-tokens']) } : {}) })
return typeof j.response === 'string' ? j.response : JSON.stringify(j)
}
let reponse
if (args['response-file']) reponse = readFileSync(args['response-file'], 'utf8')
else {
if (!args.route) { console.error('--route ou --response-file requis'); process.exit(2) }
const b = { query: args.query, mode }
if (args.auteur) b.auteur_slug = args.auteur
reponse = (await post(args.route, b)).response
}
const ctxs = [await contexte(args.query)]
if (args['ctx-query-file']) ctxs.push(await contexte(readFileSync(args['ctx-query-file'], 'utf8')))
// Même fonction pure que la route (server/utils/citations.ts) : un seul algorithme, deux usages.
const v = verifierCitations(reponse, ctxs, minMots)
const lignes = v.segments.map(l => ({ segment: l.segment, mots: l.mots, trouve: l.trouve }))
const longs = lignes.filter(l => l.mots >= minMots)
const res = {
query: args.query, mode,
segments: lignes.length, longs: longs.length,
longs_trouves: longs.filter(l => l.trouve).length,
inventes: longs.filter(l => !l.trouve).length,
courts_non_comptes: lignes.length - longs.length,
contexte_chars: ctxs.map(c => c.length),
detail: lignes, reponse,
}
if (args.json) console.log(JSON.stringify(res, null, 2))
else {
console.log(`Q: ${res.query}\nsegments=${res.segments} longs(>=${minMots} mots)=${res.longs} trouvés=${res.longs_trouves} INVENTÉS=${res.inventes} courts=${res.courts_non_comptes}`)
for (const l of lignes) console.log(` [${l.mots >= minMots ? (l.trouve ? 'OK ' : 'INV') : 'crt'}] (${l.mots}) ${l.segment.slice(0, 160)}`)
}
+62 -4
View File
@@ -6,6 +6,18 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { profondeurDuBody, REGLE_GUILLEMETS } from '~/server/utils/bifrost'
import { verifierCitations, contenusChunks } from '~/server/utils/citations'
/**
* Règle citations — commune aux 4 préambules (mesure M7 du 05/10 : le modèle invente des
* citations entre guillemets quand l'extrait n'est pas dans le contexte récupéré).
*/
const REGLE_CITATIONS = `RÈGLE DES CITATIONS (prioritaire sur toute autre consigne) :
- Ne mets entre guillemets (« ») que ce qui figure MOT POUR MOT dans le contexte fourni ci-dessus. Jamais de citation reconstituée de mémoire, de paraphrase ou de résumé entre guillemets. Même si la question demande des phrases « exactes » ou « mot pour mot », ne cède jamais : mieux vaut peu de citations, ou aucune, qu'une citation approximative.
- Avant d'écrire un guillemet, repère dans le contexte le passage exact : si tu ne le retrouves pas à l'identique, ne le cite pas. Au plus 3 citations, les plus courtes et les plus sûres ; recopie-les sans les raccourcir, sans les enchaîner, sans les compléter.
- Si aucun passage correspondant n'est disponible dans le contexte, dis explicitement « je ne cite pas : aucun passage exact n'est disponible dans les extraits » et reformule sans guillemets, en indiquant qu'il s'agit d'une synthèse.
- Nomme l'œuvre dont provient le passage dans le contexte, pas une autre.`
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...).
@@ -40,6 +52,28 @@ Règles :
- Ton praticien militant : direct, pas neutre, ancré dans la pratique architecturale.
- Réponse en français, dense, sans délayage.`
/**
* Posture « réflexion » (mode approfondi, v1 — prompts définitifs par onglet : B7/B9).
* Ajoutée APRÈS le préambule du corpus et AVANT REGLE_CITATIONS (qui reste prioritaire).
* Pensées n'est pas servi par Bifrost : le LLM de génération est réglé côté LightRAG, donc
* ce mode change la consigne et les paramètres de récupération, pas le modèle.
*/
const POSTURE_REFLEXION = `MODE RÉFLEXION : l'usager veut penser, pas seulement s'informer.
- Mets les idées en relation : reformule d'abord ce que la question engage, puis fais dialoguer les auteurs et les écoles présents dans le contexte.
- Nomme les tensions et les désaccords entre écoles (ce qu'elles partagent, là où elles divergent, ce que chacune ne voit pas), sans trancher à la place de l'usager.
- Reste strictement dans le corpus fourni : si une idée n'y est pas, dis-le au lieu de la compléter de mémoire.
- Termine par 2 questions ouvertes qui aident l'usager à situer sa propre position.
- Réponse structurée en paragraphes courts, 450 mots maximum.
- ${REGLE_GUILLEMETS}`
/**
* Paramètres de récupération LightRAG du mode réflexion. Mesuré le 09/10 (contexte seul, hybrid) :
* top_k 60 / chunk_top_k 30 ne changent rien (115,7 → 115,9 Ko, le budget de tokens plafonne) ;
* max_total_tokens 50000 monte le contexte à 153 Ko. On prend 40000 : plus de matière pour croiser
* les écoles sans approcher la fenêtre du LLM de génération (réglé côté serveur).
*/
const PARAMS_RAG_REFLEXION = { max_total_tokens: 40000 }
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.
@@ -88,8 +122,10 @@ export default defineEventHandler(async (event) => {
}
const query: string = body.query.trim()
// jamais body.mode ici : réservé au mode de recherche LightRAG (hybrid…)
const profondeur = profondeurDuBody({ profondeur: body.profondeur })
const mode: string = body.mode || 'hybrid'
const corpus: string = body.corpus || 'both'
const corpus: string = body.corpus || 'pensees'
const ragUrl: string = (config.ragPeUrl as string) || 'http://localhost:9621'
const auteurSlug: string | null = body.auteur_slug?.trim().toLowerCase() || null
@@ -117,8 +153,20 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 503, message: 'RAG indisponible pour l\'instant — réessaie dans quelques minutes.' })
}
const ragQuery = `${systemPreface}\n\nQuestion : ${query}`
const ragBody: Record<string, any> = { query: ragQuery, mode }
// user_prompt (LightRAG) : instructions ajoutées au prompt de génération, sans polluer la recherche
// (vérifié sur 9621 le 09/10 : une consigne de fin de réponse est appliquée). La requête de
// recherche est la question seule.
const ragBody: Record<string, any> = {
query,
mode,
// textes des chunks utilisés : sert au garde-fou des citations (server/utils/citations.ts)
include_references: true,
include_chunk_content: true,
user_prompt: profondeur === 'approfondi'
? `${systemPreface}\n\n${POSTURE_REFLEXION}\n\n${REGLE_CITATIONS}`
: `${systemPreface}\n\n${REGLE_CITATIONS}`,
}
if (profondeur === 'approfondi') Object.assign(ragBody, PARAMS_RAG_REFLEXION)
if (auteurSlug && nomAuteurMatch) {
ragBody.hl_keywords = [nomAuteurMatch, auteurSlug]
ragBody.ll_keywords = [auteurSlug]
@@ -149,8 +197,15 @@ export default defineEventHandler(async (event) => {
}
}
// Garde-fou : un segment long entre guillemets absent des chunks récupérés perd ses guillemets.
const verif = verifierCitations(String(ragResponse.response ?? ''), contenusChunks(ragResponse.references))
if (verif.retirees > 0) {
console.warn(`[chatbot-pensees] ${verif.retirees} citation(s) non retrouvée(s) dans le contexte, guillemets retirés (${verif.gardees} gardée(s))`)
}
return {
response: ragResponse.response ?? '',
response: verif.texte,
citations: { gardees: verif.gardees, retirees: verif.retirees },
mode,
corpus,
auteur: auteurSlug && nomAuteurMatch ? { slug: auteurSlug, nom: nomAuteurMatch } : null,
@@ -158,5 +213,8 @@ export default defineEventHandler(async (event) => {
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(),
...(import.meta.dev
? { debug: { profondeur, user_prompt_chars: ragBody.user_prompt.length, rag_params: profondeur === 'approfondi' ? PARAMS_RAG_REFLEXION : null } }
: {}),
}
})
+31 -4
View File
@@ -6,7 +6,7 @@
// @ts-ignore — JSON import résolu par Rollup
import reseauxData from '../../public/data/reseaux-bifurcation.json'
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { pickBifrostTier, bifrostContent, REASONING_MARGIN_TOKENS, type BifrostChatResponse } from '~/server/utils/bifrost'
import { pickBifrostTier, profondeurDuBody, bifrostMaxTokens, bifrostDebug, bifrostContent, REGLE_GUILLEMETS, type BifrostChatResponse } from '~/server/utils/bifrost'
interface Structure {
id: string
@@ -41,6 +41,29 @@ FORMAT :
STRUCTURES DISPONIBLES :
{{STRUCTURES_JSON}}`
// Prompt « réflexion » (mode approfondi, v1 — prompts définitifs par onglet : B7/B9). Même format JSON.
const SYSTEM_PROMPT_REFLEXION = `Tu es un conseiller archi, 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.
POSTURE : avant de recommander des structures, tu questionnes le projet de l'usager. Tu reformules ce que tu comprends de son projet, tu nommes ce qui reste flou ou en tension (échelle, maîtrise d'ouvrage, filière de matériaux, rapport au territoire, posture professionnelle, moyens), tu poses 2 ou 3 questions précises qui l'aident à se situer, PUIS tu orientes vers les structures du contexte qui correspondent à ce qu'il a dit, en expliquant ce que chacune lui apporterait ou lui ferait choisir.
RÈGLES :
1. Ne cite QUE des structures présentes dans le contexte ci-dessous.
2. ${REGLE_GUILLEMETS}
3. Sois direct et engagé — c'est l'esprit AEP — mais n'affirme pas ce que tu ne sais pas de son projet : demande.
4. Réponse de 300 mots maximum, en français, en prose, tutoiement.
5. Retourne UNIQUEMENT un JSON valide, sans texte avant ou après.
FORMAT :
{
"reponse_texte": "Ta réponse en prose, max 300 mots : reformulation, tension, questions, puis structures",
"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]
@@ -62,7 +85,8 @@ export default defineEventHandler(async (event) => {
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 tier = pickBifrostTier(body?.mode)
const profondeur = profondeurDuBody(body)
const tier = pickBifrostTier(profondeur)
const structures: Structure[] = ((reseauxData as any).structures ?? [])
const keywords = extractKeywords(question)
@@ -82,13 +106,14 @@ export default defineEventHandler(async (event) => {
description: (s.description_courte ?? s.description_longue ?? '').slice(0, 200),
}))
const systemPrompt = SYSTEM_PROMPT.replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0))
const systemPrompt = (profondeur === 'approfondi' ? SYSTEM_PROMPT_REFLEXION : SYSTEM_PROMPT).replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0))
const bifrostUrl = config.bifrostUrl as string
const bifrostVk = config.bifrostVk as string
if (!bifrostVk) throw createError({ statusCode: 500, message: 'Clé Bifrost manquante.' })
let mistralRaw: string
let debugExtra: Record<string, unknown> = {}
try {
const res = await $fetch<BifrostChatResponse>(
`${bifrostUrl}/v1/chat/completions`,
@@ -99,7 +124,7 @@ export default defineEventHandler(async (event) => {
model: tier.model,
fallbacks: tier.fallbacks,
temperature: 0.3,
max_tokens: 700 + REASONING_MARGIN_TOKENS,
max_tokens: bifrostMaxTokens(700, profondeur),
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: systemPrompt },
@@ -109,6 +134,7 @@ export default defineEventHandler(async (event) => {
}
)
mistralRaw = bifrostContent(res, 'chatbot-reseaux')
debugExtra = bifrostDebug(res, 'chatbot-reseaux', profondeur)
} catch {
throw createError({ statusCode: 502, message: 'Erreur IA — réessaie dans quelques instants.' })
}
@@ -122,6 +148,7 @@ export default defineEventHandler(async (event) => {
nom: r.nom ?? structures.find(s => s.id === r.id)?.nom ?? r.id,
explication: r.explication ?? '',
})),
...debugExtra,
}
} catch {
return { reponse_texte: "Je n'ai pas pu analyser ta demande.", fiches_recommandees: [] }
+31 -4
View File
@@ -7,7 +7,7 @@
// @ts-ignore — JSON import résolu par Vite/Rollup
import taffData from '../../public/data/plateformes-taff.json'
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { pickBifrostTier, bifrostContent, REASONING_MARGIN_TOKENS, type BifrostChatResponse } from '~/server/utils/bifrost'
import { pickBifrostTier, profondeurDuBody, bifrostMaxTokens, bifrostDebug, bifrostContent, REGLE_GUILLEMETS, type BifrostChatResponse } from '~/server/utils/bifrost'
interface PlateformeMinimal {
id: string
@@ -49,6 +49,29 @@ FORMAT :
PLATEFORMES DISPONIBLES :
{{PLATEFORMES_JSON}}`
// Prompt « réflexion » (mode approfondi, v1 — prompts définitifs par onglet : B7/B9). Même format JSON.
const SYSTEM_PROMPT_REFLEXION = `Tu es un conseiller pour les architectes indépendants français qui cherchent des missions. 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).
POSTURE : avant de recommander une plateforme, tu aides l'architecte à clarifier ce qu'il cherche et qui il est : type de missions visées (particuliers, public, réno, neuf), volume et rythme, statut et assurances, ce qu'il accepte de céder (commission, abonnement, temps de réponse), ses valeurs. Tu reformules son profil tel que tu le comprends, tu nommes un arbitrage que sa demande laisse ouvert, tu poses 2 ou 3 questions qui l'aident à se situer, PUIS tu orientes vers LA ou LES plateformes du contexte qui correspondent, avec leurs limites.
RÈGLES :
1. Ne recommande QUE des plateformes présentes dans le contexte ci-dessous. Si l'une est « À éviter », signale-le clairement.
2. ${REGLE_GUILLEMETS}
3. Sois direct et opinionné — c'est la valeur d'AEP — mais ne présume pas de ce que tu ignores de son profil : demande.
4. Réponse de 300 mots maximum, en français, en prose, ton conseiller pair, tutoiement.
5. Retourne UNIQUEMENT un JSON valide, sans texte avant ou après.
FORMAT :
{
"reponse_texte": "Ta réponse en prose, max 300 mots : reformulation, arbitrage, questions, puis plateformes",
"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()
@@ -67,7 +90,8 @@ export default defineEventHandler(async (event) => {
if (!question || question.length < 5) {
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
}
const tier = pickBifrostTier(body?.mode)
const profondeur = profondeurDuBody(body)
const tier = pickBifrostTier(profondeur)
// Données bundlées statiquement à la compilation (import JSON)
const plateformes: PlateformeMinimal[] = ((taffData as any).plateformes ?? []).map((p: any) => ({
@@ -91,7 +115,7 @@ export default defineEventHandler(async (event) => {
justification: p.scoring.justification_tag,
}))
const systemPrompt = SYSTEM_PROMPT.replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0))
const systemPrompt = (profondeur === 'approfondi' ? SYSTEM_PROMPT_REFLEXION : SYSTEM_PROMPT).replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0))
const bifrostUrl = config.bifrostUrl as string
const bifrostVk = config.bifrostVk as string
@@ -100,6 +124,7 @@ export default defineEventHandler(async (event) => {
}
let mistralRaw: string
let debugExtra: Record<string, unknown> = {}
try {
const res = await $fetch<BifrostChatResponse>(
`${bifrostUrl}/v1/chat/completions`,
@@ -110,7 +135,7 @@ export default defineEventHandler(async (event) => {
model: tier.model,
fallbacks: tier.fallbacks,
temperature: 0.3,
max_tokens: 700 + REASONING_MARGIN_TOKENS,
max_tokens: bifrostMaxTokens(700, profondeur),
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: systemPrompt },
@@ -120,6 +145,7 @@ export default defineEventHandler(async (event) => {
}
)
mistralRaw = bifrostContent(res, 'chatbot-taff')
debugExtra = bifrostDebug(res, 'chatbot-taff', profondeur)
} catch {
throw createError({ statusCode: 502, statusMessage: 'Erreur IA — réessaie dans quelques instants.' })
}
@@ -133,6 +159,7 @@ export default defineEventHandler(async (event) => {
nom: r.nom ?? plateformes.find(p => p.id === r.id)?.nom ?? r.id,
raison: r.raison ?? '',
})),
...debugExtra,
}
} catch {
return { reponse_texte: "Je n'ai pas pu analyser ta demande.", plateformes_recommandees: [] }
-194
View File
@@ -1,194 +0,0 @@
/**
* POST /api/chatbot-v2
*
* Chatbot V2 - Embedding-based search sur structures bifurcation
* Coexiste avec /api/chatbot (keyword NocoDB) pendant la transition.
*
* SETUP AVANT DEPLOY :
* cd nav-carte && MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js
* Coût estimé : ~0.10 EUR pour 120 fiches
*
* Flow :
* 1. Rate limit (réutilise checkRateLimitJson, 10 req/IP/jour)
* 2. Embed la query via Mistral Embed (mistral-embed)
* 3. Top-5 cosine similarity sur embeddings-v2.json
* 4. Si embeddings absents : réponse graceful (v2_ready: false)
* 5. Construit contexte RAG depuis les fiches candidates
* 6. Génère réponse Mistral Small (json_object)
* 7. Retourne { reponse_texte, fiches_recommandees, sources, v2_ready }
*
* Variables d'env :
* MISTRAL_API_KEY - Clé Mistral (partagée avec chatbot v1)
* RAG_V1_ENABLED - true/false (défaut: true) - coexistence pendant transition
* RAG_V1_DEPRECATION_DATE - Date prévue deprecation v1 (ex: 2026-05-18)
*/
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { loadEmbeddingsV2, topKSearch } from '~/server/utils/vectorSearch'
// ── System prompt V2 ───────────────────────────────────────────────────────────
const SYSTEM_PROMPT_V2 = `Tu es un assistant pour la carte des réseaux de bifurcation en architecture (projet AEP).
Tu réponds aux questions sur les structures, les pratiques, les pensées écologiques.
Règles :
- Cite chaque structure par son nom exact et son fiche_id
- Indique la famille (1-5) entre parenthèses après chaque nom
- Reste sobre et descriptif - pas militant agressif
- Tirets longs interdits : utilise des - ou des ;
- Max 200 mots par réponse
- Si hors-scope (pas archi/habiter/écologie), redirige poliment vers la carte
- Retourne UNIQUEMENT un JSON valide, sans texte avant ou après
Familles :
1 - Réemploi et filières
2 - Frugalité et low-tech
3 - Architecture sociale et précarités
4 - Collectifs, écolieux et AMO
5 - Urbanisme de transition et territoires
FORMAT DE SORTIE :
{
"reponse_texte": "Ta réponse en prose (max 200 mots)",
"fiches_recommandees": [
{ "fiche_id": "f1-rotor", "nom": "Rotor", "explication": "1-2 phrases pourquoi cette fiche" }
]
}
CONTEXTE - Structures disponibles :
{{CONTEXTE_RAG}}`
// ── Handler ────────────────────────────────────────────────────────────────────
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
// 1. Rate limit
const ip =
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
event.node.req.socket?.remoteAddress ||
'0.0.0.0'
const allowed = checkRateLimitJson(ip, 'chatbot-v2', 10)
if (!allowed) {
throw createError({
statusCode: 429,
statusMessage: 'Limite de 10 questions par jour atteinte.'
})
}
// 2. Validation body
const body = await readBody(event)
const question: string = (body?.question ?? '').trim()
if (!question || question.length < 3) {
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
}
const mistralApiKey = config.mistralApiKey as string
if (!mistralApiKey) {
throw createError({ statusCode: 500, statusMessage: 'Clé API Mistral manquante.' })
}
// 3. Charger embeddings V2 (lazy, cachés en mémoire)
const embeddingsV2 = loadEmbeddingsV2()
// Graceful fallback si le script vectorize-v2.js n'a pas encore été lancé
if (embeddingsV2.length === 0) {
return {
reponse_texte: "La base vectorielle V2 est en cours de préparation. Merci d'utiliser le chatbot classique en attendant.",
fiches_recommandees: [],
sources: [],
v2_ready: false
}
}
// 4. Embed la query via Mistral Embed
let queryEmbedding: number[]
try {
const embedRes = await $fetch<{ data: { embedding: number[] }[] }>(
'https://api.mistral.ai/v1/embeddings',
{
method: 'POST',
headers: {
Authorization: `Bearer ${mistralApiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'mistral-embed',
inputs: [question]
})
}
)
queryEmbedding = embedRes.data[0].embedding
} catch (e: any) {
console.error('[chatbot-v2] Erreur embedding Mistral :', e?.message ?? e)
throw createError({ statusCode: 502, statusMessage: 'Erreur embedding Mistral.' })
}
// 5. Top-5 cosine similarity
const v2Results = topKSearch(embeddingsV2, queryEmbedding, 5)
// 6. Contexte RAG
const candidatesContext = v2Results.map(r => ({
fiche_id: r.fiche_id,
nom: r.nom,
famille: r.famille,
hashtags: r.hashtags,
score: r.score,
preview: r.text_preview
}))
const contextStr = candidatesContext
.map(c => `[${c.fiche_id}] ${c.nom} (famille ${c.famille}, score: ${c.score.toFixed(2)})\n${c.preview}`)
.join('\n\n---\n\n')
const systemPrompt = SYSTEM_PROMPT_V2.replace('{{CONTEXTE_RAG}}', contextStr)
// 7. Mistral Small - génération réponse
let mistralRaw: string
try {
const mistralRes = await $fetch<{
choices: { message: { content: string } }[]
}>('https://api.mistral.ai/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${mistralApiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'mistral-small-latest',
temperature: 0.3,
max_tokens: 600,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: question }
]
})
})
mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}'
} catch (e: any) {
console.error('[chatbot-v2] Erreur Mistral Small :', e?.message ?? e)
throw createError({ statusCode: 502, statusMessage: 'Erreur appel Mistral Small.' })
}
// 8. Parse JSON
let parsed: { reponse_texte: string; fiches_recommandees: any[] }
try {
parsed = JSON.parse(mistralRaw)
if (!parsed.reponse_texte) throw new Error('reponse_texte absent')
} catch {
parsed = {
reponse_texte: "Impossible d'analyser la réponse.",
fiches_recommandees: []
}
}
return {
reponse_texte: parsed.reponse_texte,
fiches_recommandees: parsed.fiches_recommandees ?? [],
sources: candidatesContext,
v2_ready: true
}
})
+34 -5
View File
@@ -19,7 +19,7 @@
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
import { pickBifrostTier, bifrostContent, REASONING_MARGIN_TOKENS, type BifrostChatResponse } from '~/server/utils/bifrost'
import { pickBifrostTier, profondeurDuBody, bifrostMaxTokens, bifrostDebug, bifrostContent, REGLE_GUILLEMETS, type BifrostChatResponse } from '~/server/utils/bifrost'
// ── Types ──────────────────────────────────────────────────────────────────────
@@ -70,6 +70,32 @@ FORMAT DE SORTIE :
CONTEXTE — Organisations disponibles dans la base NAV :
{{FICHES_JSON}}`
// ── Prompt « réflexion » (mode approfondi, v1 — prompts définitifs par onglet : B7/B9) ──────
// Même format de sortie JSON que SYSTEM_PROMPT (le front en dépend) ; seule la posture change.
const SYSTEM_PROMPT_REFLEXION = `Tu es un conseiller-ressources au service de la transition écologique des pratiques architecturales, sur AEP (Architecture d'Écologie Politique) — Écosystème Entraide, une base collaborative qui référence les acteurs de l'écologie politique appliquée à l'architecture et au territoire (organisations, outils, ressources) pour les architectes en France.
POSTURE : l'usager pose rarement le bon problème du premier coup. Tu l'aides d'abord à formuler son besoin, puis tu l'orientes vers les ressources et fiches du contexte. Tu es dialectique : tu reformules ce que tu comprends, tu nommes une tension ou un choix que la question laisse ouvert, tu poses 2 ou 3 questions qui l'aident à se situer (où en est son projet, avec qui, à quelle échelle, quelle contrainte pèse le plus), PUIS tu proposes des pistes.
RÈGLES ABSOLUES :
1. Tu ne peux recommander QUE des organisations présentes dans le contexte ci-dessous. Ne jamais inventer d'organisation absente.
2. Cite chaque organisation recommandée par son nom exact et son identifiant id. Si aucune n'est pertinente, dis-le honnêtement.
3. ${REGLE_GUILLEMETS}
4. Réponse de 300 mots maximum, en français, en prose (pas de liste à puces), ton de pair, tutoiement.
5. Si la question est hors du champ architecture / écologie / territoire / transition, recadre poliment vers le périmètre de la carte.
6. Retourne UNIQUEMENT un objet JSON valide, sans texte avant ou après.
FORMAT DE SORTIE :
{
"reponse_texte": "Ta réponse en prose (max 300 mots) : reformulation, tension, questions, puis pistes",
"fiches_recommandees": [
{ "id": 123, "explication": "Pourquoi cette fiche répond à la question (1-2 phrases max)" }
]
}
CONTEXTE — Organisations disponibles dans la base NAV :
{{FICHES_JSON}}`
// ── Recherche par mots-clés ────────────────────────────────────────────────────
function scoreOrg(org: OrgRow, keywords: string[]): number {
@@ -182,7 +208,8 @@ export default defineEventHandler(async (event) => {
const body = await readBody(event)
const question: string = (body?.question ?? '').trim()
const filters: { fonction?: string; echelle?: string } = body?.filters ?? {}
const tier = pickBifrostTier(body?.mode)
const profondeur = profondeurDuBody(body)
const tier = pickBifrostTier(profondeur)
if (!question || question.length < 3) {
throw createError({
@@ -245,7 +272,7 @@ export default defineEventHandler(async (event) => {
ville: o.localisation_ville ?? '',
}))
const systemPrompt = SYSTEM_PROMPT.replace(
const systemPrompt = (profondeur === 'approfondi' ? SYSTEM_PROMPT_REFLEXION : SYSTEM_PROMPT).replace(
'{{FICHES_JSON}}',
JSON.stringify(fichesContext, null, 0),
)
@@ -265,6 +292,7 @@ export default defineEventHandler(async (event) => {
let tokensIn = 0
let tokensOut = 0
let realModel = tier.model
let debugExtra: Record<string, unknown> = {}
try {
const bifrostRes = await $fetch<BifrostChatResponse>(`${bifrostUrl}/v1/chat/completions`, {
@@ -277,7 +305,7 @@ export default defineEventHandler(async (event) => {
model: tier.model,
fallbacks: tier.fallbacks,
temperature: 0.3,
max_tokens: 600 + REASONING_MARGIN_TOKENS,
max_tokens: bifrostMaxTokens(600, profondeur),
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: systemPrompt },
@@ -287,6 +315,7 @@ export default defineEventHandler(async (event) => {
})
mistralRaw = bifrostContent(bifrostRes, 'chatbot')
debugExtra = bifrostDebug(bifrostRes, 'chatbot', profondeur)
tokensIn = bifrostRes.usage?.prompt_tokens ?? 0
tokensOut = bifrostRes.usage?.completion_tokens ?? 0
realModel =
@@ -340,5 +369,5 @@ export default defineEventHandler(async (event) => {
coutEur,
})
return parsed
return { ...parsed, ...debugExtra }
})
+43 -5
View File
@@ -4,8 +4,9 @@
* Auth : header x-bf-vk
*
* 2 tiers (Mission M3 du build Bifrost ; modèles primaires changés le 28/09, session AEP front 4) :
* RAPIDE — défaut, pas de toggle UI mode rapide/approfondi sur le site actuellement
* APPROFONDI — activable via body.mode === 'approfondi' (prêt pour un futur toggle front)
* RAPIDE — défaut (mode « Question » du toggle front)
* APPROFONDI — body.profondeur === 'approfondi' (mode « Réflexion plus profonde », B1-M3 ;
* body.mode reste lu en compat ascendante). Contrat : utils/chatProfondeur.ts
*
* ⚠ 28/09 : Groq a retiré groq/llama-3.1-8b-instant (RAPIDE) et groq/llama-3.3-70b-versatile
* (APPROFONDI) — 404 model_not_found via Bifrost. Depuis le 23/09 environ, chaque appel
@@ -32,6 +33,8 @@
* juste plus dans cette chaîne tant que ce n'est pas fiabilisé.
*/
import { normaliserProfondeur, type ChatProfondeur } from '~/utils/chatProfondeur'
export const BIFROST_TIER_RAPIDE = {
model: 'groq/openai/gpt-oss-20b',
fallbacks: [
@@ -57,11 +60,34 @@ export const BIFROST_TIER_APPROFONDI = {
*/
export const REASONING_MARGIN_TOKENS = 800
/** Sélectionne le tier selon le param optionnel body.mode. */
export function pickBifrostTier(mode?: string) {
return mode === 'approfondi' ? BIFROST_TIER_APPROFONDI : BIFROST_TIER_RAPIDE
/** Sélectionne le tier selon la profondeur ('approfondi' → APPROFONDI, tout le reste → RAPIDE). */
export function pickBifrostTier(profondeur?: string) {
return profondeur === 'approfondi' ? BIFROST_TIER_APPROFONDI : BIFROST_TIER_RAPIDE
}
/**
* Profondeur demandée par le body : `profondeur`, sinon `mode` (compat ascendante des
* routes Bifrost avant B1-M3). Valeur inconnue → 'rapide'.
*/
export function profondeurDuBody(body: any): ChatProfondeur {
return normaliserProfondeur(body?.profondeur ?? body?.mode)
}
/**
* Budget max_tokens = contenu + marge de raisonnement. Le mode approfondi autorise une
* réponse un peu plus longue (+200) et une marge plus large (+400) : le 120b raisonne plus.
* Reste sous les limites par minute d'une clé Groq (prompt ~3-4 k + 2,2 k max).
*/
export function bifrostMaxTokens(contenu: number, profondeur: ChatProfondeur): number {
return profondeur === 'approfondi'
? contenu + 200 + REASONING_MARGIN_TOKENS + 400
: contenu + REASONING_MARGIN_TOKENS
}
/** Règle guillemets, commune à tous les prompts « réflexion » (cf. REGLE_CITATIONS de Pensées). */
export const REGLE_GUILLEMETS =
'Ne mets entre guillemets que ce qui figure mot pour mot dans le contexte fourni ; sinon reformule sans guillemets.'
export interface BifrostChatResponse {
/** reasoning : présent avec gpt-oss, jamais lu (la réponse utile est dans content). */
choices: { message: { content: string; reasoning?: string }; finish_reason?: string }[]
@@ -69,6 +95,18 @@ export interface BifrostChatResponse {
extra_fields?: { provider?: string; resolved_model_used?: string }
}
/**
* Preuve du tier réellement servi : journal serveur toujours (modèle dans le log seulement),
* et champ `debug` dans la réponse UNIQUEMENT en dev (import.meta.dev, éliminé du build de
* production). Aucun nom de modèle n'est exposé publiquement.
*/
export function bifrostDebug(res: BifrostChatResponse, route: string, profondeur: ChatProfondeur) {
const modele = res.extra_fields?.resolved_model_used ?? null
const fournisseur = res.extra_fields?.provider ?? null
console.log(`[${route}] profondeur=${profondeur} fournisseur=${fournisseur} modèle=${modele}`)
return import.meta.dev ? { debug: { profondeur, fournisseur, modele } } : {}
}
/**
* Contenu JSON de la réponse. Une troncature (finish_reason 'length') ne lève aucune erreur
* et finit en message générique côté usager : on la signale au journal pour la voir.
+9
View File
@@ -113,6 +113,15 @@ export async function checkBudget(config: {
return { cumulEur: 0, blocked: false, warning: false, verified: false, lignesLues, lignesMois, lignesSansDate }
}
// Des lignes, mais aucune datée : on ne sait pas ce qui relève du mois.
// Rendre « 0 € vérifié » reproduirait le fail-open silencieux d'avant AF5.
if (lignesLues > 0 && lignesSansDate === lignesLues) {
console.warn(
`[circuitBreaker] budget NON vérifié : ${lignesLues} lignes stats_usage sans timestamp, CreatedAt ni created_at`,
)
return { cumulEur: 0, blocked: false, warning: false, verified: false, lignesLues, lignesMois, lignesSansDate }
}
const status: BudgetStatus = {
cumulEur,
blocked: cumulEur >= BUDGET_MAX_EUR,
+99
View File
@@ -0,0 +1,99 @@
/**
* Garde-fou des citations (B1-M4) — fonctions pures, sans dépendance (réutilisables par les scripts Node).
*
* Problème : une consigne de prompt (REGLE_CITATIONS) ne suffit pas, le modèle met entre guillemets des
* phrases qui ne figurent pas dans le contexte récupéré (mesuré le 09/10 : 7/7 introuvables sur une
* question ouverte). Une fausse citation attribuée à un auteur réel engage le site : on la vérifie dans
* le code. Tout segment long entre guillemets absent des textes de chunks réellement récupérés perd ses
* guillemets (le texte reste, sans valeur de citation).
*/
export const MIN_MOTS_CITATION = 6
/** Sans accents, minuscules, tout ce qui n'est ni lettre ni chiffre devient un espace (apostrophes, \r\n, markdown). */
export function normaliserTexte(s: string): string {
return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim()
}
export interface SegmentCitation {
/** texte tel qu'il apparaît entre les guillemets */
segment: string
mots: number
/** true si segment court (< minMots) : jamais vérifié, jamais retiré */
court: boolean
/** true si retrouvé dans le contexte (toujours true pour un segment court) */
trouve: boolean
}
export interface ResultatCitations {
/** réponse dont les citations longues introuvables ont perdu leurs guillemets */
texte: string
gardees: number
retirees: number
segments: SegmentCitation[]
}
// « … » (espaces fines ou insécables tolérés), “ … ”, " … ". Les « » sont traités en premier :
// un "…" imbriqué ne doit pas casser l'appariement.
const MOTIFS: RegExp[] = [
/«[\s  ]*([^»]+?)[\s  ]*»/g,
/“([^”]+?)”/g,
/"([^"\n]+?)"/g,
]
// Élision dans une citation : [...], […], … ou ... — chaque morceau doit être retrouvé séparément.
const ELISION = /\[\s*(?:\.{3}|…)\s*\]|…|\.{3}/
function mots(n: string): number {
return n ? n.split(' ').length : 0
}
/**
* Vérifie les citations de `reponse` contre `contextes` (textes des chunks récupérés).
* - segment >= minMots mots : cherché (normalisé, borné aux mots entiers) ; absent => guillemets retirés.
* - segment < minMots mots (titres, expressions) : laissé tel quel.
* Aucun contexte exploitable => aucune citation longue ne peut être vérifiée, donc aucune n'est gardée.
*/
export function verifierCitations(reponse: string, contextes: string[], minMots: number = MIN_MOTS_CITATION): ResultatCitations {
const ctx = ` ${normaliserTexte(contextes.join('\n'))} `
const segments: SegmentCitation[] = []
let gardees = 0
let retirees = 0
const trouve = (seg: string): boolean => {
const parts = seg.split(ELISION).map(normaliserTexte).filter(Boolean)
return parts.length > 0 && parts.every(p => ctx.includes(` ${p} `))
}
let texte = reponse
for (const motif of MOTIFS) {
texte = texte.replace(motif, (entier: string, seg: string) => {
const n = normaliserTexte(seg.split(ELISION).join(' '))
const nb = mots(n)
if (nb < minMots) {
segments.push({ segment: seg, mots: nb, court: true, trouve: true })
return entier
}
const ok = trouve(seg)
segments.push({ segment: seg, mots: nb, court: false, trouve: ok })
if (ok) { gardees++; return entier }
retirees++
return seg
})
}
return { texte, gardees, retirees, segments }
}
/** Concatène les textes de chunks d'une réponse LightRAG (`references[].content`, tableau de chaînes ou chaîne). */
export function contenusChunks(references: unknown): string[] {
if (!Array.isArray(references)) return []
const out: string[] = []
for (const r of references) {
const c = (r as { content?: unknown })?.content
if (Array.isArray(c)) for (const x of c) { if (typeof x === 'string') out.push(x) }
else if (typeof c === 'string') out.push(c)
}
return out
}
-96
View File
@@ -1,96 +0,0 @@
/**
* 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 []
}
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Mode de profondeur des chatbots (M3) — contrat partagé front/serveur.
* Champ de body : `profondeur` = 'rapide' | 'approfondi' (défaut 'rapide').
* Les libellés affichés vivent ICI, à un seul endroit : les changer ne touche ni
* le composant ni le serveur.
*/
export type ChatProfondeur = 'rapide' | 'approfondi'
export const CHAT_PROFONDEUR_DEFAUT: ChatProfondeur = 'rapide'
export const CHAT_PROFONDEUR_LIBELLES: Record<ChatProfondeur, string> = {
rapide: 'Question',
approfondi: 'Réflexion plus profonde',
}
/** Sous-texte (title/aria) de chaque option. */
export const CHAT_PROFONDEUR_AIDES: Record<ChatProfondeur, string> = {
rapide: 'Réponse directe et concise',
approfondi: 'Une réponse plus longue, qui questionne ton besoin avant de répondre',
}
export const CHAT_PROFONDEURS = Object.keys(CHAT_PROFONDEUR_LIBELLES) as ChatProfondeur[]
/** Normalise une valeur inconnue (body, stockage) vers un mode valide. */
export function normaliserProfondeur(v: unknown): ChatProfondeur {
return v === 'approfondi' ? 'approfondi' : 'rapide'
}
+50 -1
View File
@@ -1,10 +1,23 @@
/**
* Les trois échelles du deck du 23/09/2026 (conférence AEP), slides 15 à 18.
* Texte repris mot pour mot : c'est la tête de chaque onglet (directive Jules du 25/09, point 2).
* Source unique : la future nav S / M / XL (arbitrage n° 1, table R) lira aussi ce fichier.
* Source unique : la nav S / M / XL (B10 : NavEchelles, NavTiroir, FilEchelle) lit aussi ce fichier.
* Aucun libellé d'échelle ni d'onglet ne se recopie ailleurs.
*/
export type EchelleId = 'S' | 'M' | 'XL'
/** Ce que la nav lit d'une route : compatible avec `useRoute()` sans en dépendre (testable en node). */
export interface RouteNav {
path: string
query?: Record<string, unknown>
}
export interface OngletNav {
libelle: string
to: string
actif: (route: RouteNav) => boolean
}
export interface Echelle {
id: EchelleId
titre: string
@@ -13,8 +26,13 @@ export interface Echelle {
phrase: string
/** Catégorie de /proposer présélectionnée par le lien de contribution. */
categorieProposer: 'ecosysteme' | 'reseau' | 'jobs' | 'outils' | 'references'
/** Onglets du groupe, dans l'ordre de la barre, du menu et du fil d'échelle (B10, directive Jules du 29/09 + arbitrage 1A du 26/09). */
onglets: OngletNav[]
}
const sousChemin = (base: string) => (r: RouteNav) => r.path === base || r.path.startsWith(`${base}/`)
const surPublications = (r: RouteNav) => r.query?.tab === 'projets'
export const ECHELLES: Record<EchelleId, Echelle> = {
S: {
id: 'S',
@@ -31,6 +49,10 @@ export const ECHELLES: Record<EchelleId, Echelle> = {
],
phrase: 'Des ressources, des méthodes, des pairs : matériel et mutualisé.',
categorieProposer: 'outils',
onglets: [
{ libelle: 'Jobs', to: '/trouver-du-taf', actif: sousChemin('/trouver-du-taf') },
{ libelle: 'Outils', to: '/outils', actif: sousChemin('/outils') },
],
},
M: {
id: 'M',
@@ -46,6 +68,11 @@ export const ECHELLES: Record<EchelleId, Echelle> = {
],
phrase: "C'est le groupe qui remet un individu debout. L'affinité fait tenir, la confrontation fait avancer.",
categorieProposer: 'ecosysteme',
onglets: [
{ libelle: "Écosystème d'entraide", to: '/', actif: (r) => r.path === '/' },
{ libelle: 'Réseaux AEP', to: '/agences', actif: sousChemin('/agences') },
{ libelle: 'Codev', to: '/codev', actif: sousChemin('/codev') },
],
},
XL: {
id: 'XL',
@@ -61,8 +88,30 @@ export const ECHELLES: Record<EchelleId, Echelle> = {
],
phrase: 'La bibliothèque, les récits, la filiation entre projets.',
categorieProposer: 'references',
onglets: [
{ libelle: 'Recherche média', to: '/media', actif: (r) => sousChemin('/media')(r) && !surPublications(r) },
{ libelle: 'Publications', to: '/media?tab=projets', actif: (r) => sousChemin('/media')(r) && surPublications(r) },
],
},
}
/** Ordre des groupes dans la barre et le menu. */
export const ORDRE_ECHELLES: EchelleId[] = ['S', 'M', 'XL']
/** Slide 15, commune aux trois échelles. */
export const ECHELLES_CADRE = "Trois échelles, imbriquées. Pas un escalier : ça circule dans les deux sens. Entrez par celle qui presse."
/** Version courte de la slide 15 (tête du menu) : première et dernière phrase, tirées du texte ci-dessus. */
export const ECHELLES_CADRE_COURT = (() => {
const phrases = ECHELLES_CADRE.match(/[^.]+\./g) ?? [ECHELLES_CADRE]
return phrases.length > 1 ? `${phrases[0].trim()} ${phrases[phrases.length - 1].trim()}` : ECHELLES_CADRE
})()
/** Groupe de la page courante et son onglet actif, ou null hors groupe (Manifeste, À propos, Proposer, fiche…). */
export function echelleDeRoute(route: RouteNav): { echelle: EchelleId; onglet: OngletNav } | null {
for (const id of ORDRE_ECHELLES) {
const onglet = ECHELLES[id].onglets.find((o) => o.actif(route))
if (onglet) return { echelle: id, onglet }
}
return null
}
+2
View File
@@ -0,0 +1,2 @@
/** Lien de don (Liberapay), partagé par la barre, le tiroir et À propos. */
export const SOUTENIR_URL = 'https://liberapay.com/trans-former.fr/donate'
+367
View File
@@ -0,0 +1,367 @@
/**
* vueFiches — B11-M1. Logique pure de la vue fiches (SPEC-vue-fiches §2-§5) :
* lecture et écriture de l'URL, filtrage, compteurs, tri, regroupement.
* Aucune dépendance à Vue ni au routeur : `composables/useVueFiches.ts`
* branche ces fonctions sur la route, `scripts/test-vue-fiches.mjs` les teste.
*
* Grammaire des filtres : OU dans une dimension, ET entre dimensions.
* L'URL est la source de vérité ; un paramètre inconnu ou invalide est ignoré.
*/
import type { MiniFiche } from '../composables/useFicheAdapter'
export type EtatVue = 'carte' | 'mixte' | 'fiches'
export type TriFiches = 'pertinence' | 'nom' | 'recent'
export const ETATS_VUE: EtatVue[] = ['carte', 'mixte', 'fiches']
export const LIBELLES_TRI: Record<TriFiches, string> = {
pertinence: 'Pertinence',
nom: 'A → Z',
recent: 'Récentes',
}
export interface ValeurFiltre {
valeur: string
libelle: string
}
export interface DimensionFiltre {
/** Paramètre d'URL ET clé de `MiniFiche.etiquettes`. */
cle: string
libelle: string
valeurs: ValeurFiltre[]
variante: 'fonction' | 'echelle' | 'posture'
}
export interface GroupeDef {
/** Valeur de `groupe=` dans l'URL. */
cle: string
libelle: string
/** Clé de `MiniFiche.etiquettes` : la fiche va sous sa PREMIÈRE étiquette. */
dimension: string
/** En-tête des fiches qui n'ont aucune étiquette dans cette dimension. */
sans: string
/** Libellés et ordre des groupes, sinon ceux de la dimension de filtre, sinon A → Z. */
valeurs?: ValeurFiltre[]
}
export interface SousVuesCarte {
/** Paramètre d'URL : `mode` sur `/` (existant), `carte` sur `/agences`. */
parametre: string
valeurs: ValeurFiltre[]
defaut: string
}
export interface ConfigVueFiches {
dimensions: DimensionFiltre[]
tris: TriFiches[]
groupes: GroupeDef[]
etats: EtatVue[]
sousVues?: SousVuesCarte
}
export interface EtatFiches {
/** null = pas de `vue=` dans l'URL : défaut par largeur (Mixte sur ordinateur, Fiches sur téléphone). */
vue: EtatVue | null
q: string
filtres: Record<string, string[]>
/** `adresse=sans` : seulement les fiches sans coordonnées (lien « N sans adresse »). */
sansAdresse: boolean
/** null = défaut automatique (pertinence dès qu'un filtre est actif, sinon nom). */
tri: TriFiches | null
/** null = aucun regroupement. */
groupe: string | null
sousVue: string
}
export interface GroupeRendu {
cle: string
libelle: string
fiches: MiniFiche[]
}
type Query = Record<string, unknown>
function premiere(v: unknown): string {
if (Array.isArray(v)) return typeof v[0] === 'string' ? v[0] : ''
return typeof v === 'string' ? v : ''
}
function liste(v: unknown): string[] {
const brut = premiere(v)
return [...new Set(brut.split(',').map((s) => s.trim()).filter(Boolean))]
}
/** Minuscules, sans accents : « Régional » et « regional » se valent. */
export function normaliser(s: string): string {
return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
}
// ── URL ↔ état ─────────────────────────────────────────────────────────────
export function lireQuery(query: Query, cfg: ConfigVueFiches): EtatFiches {
const vueBrute = premiere(query.vue) as EtatVue
const triBrut = premiere(query.tri) as TriFiches
const groupeBrut = premiere(query.groupe)
const sv = cfg.sousVues
const svBrute = sv ? premiere(query[sv.parametre]) : ''
const filtres: Record<string, string[]> = {}
for (const d of cfg.dimensions) filtres[d.cle] = liste(query[d.cle])
return {
vue: cfg.etats.includes(vueBrute) ? vueBrute : null,
q: premiere(query.q),
filtres,
sansAdresse: premiere(query.adresse) === 'sans',
tri: cfg.tris.includes(triBrut) ? triBrut : null,
groupe: cfg.groupes.some((g) => g.cle === groupeBrut) ? groupeBrut : null,
sousVue: sv && sv.valeurs.some((v) => v.valeur === svBrute) ? svBrute : (sv?.defaut ?? ''),
}
}
/**
* Query à écrire pour un état. Les paramètres que la vue fiches ne gère pas
* (`territoire`, `random`…) sont recopiés depuis `base`, `fiche` aussi : un
* filtre changé garde la fiche ouverte (elle peut passer « hors de vos filtres »).
*/
export function ecrireQuery(etat: EtatFiches, cfg: ConfigVueFiches, base: Query = {}): Record<string, string> {
const geres = new Set(['vue', 'q', 'adresse', 'tri', 'groupe', ...cfg.dimensions.map((d) => d.cle)])
if (cfg.sousVues) geres.add(cfg.sousVues.parametre)
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(base)) {
if (!geres.has(k) && typeof premiere(v) === 'string' && premiere(v) !== '') out[k] = premiere(v)
}
if (etat.vue) out.vue = etat.vue
if (etat.q.trim()) out.q = etat.q
for (const d of cfg.dimensions) {
const vals = etat.filtres[d.cle] ?? []
if (vals.length) out[d.cle] = vals.join(',')
}
if (etat.sansAdresse) out.adresse = 'sans'
if (etat.tri) out.tri = etat.tri
if (etat.groupe) out.groupe = etat.groupe
if (cfg.sousVues && etat.sousVue && etat.sousVue !== cfg.sousVues.defaut) {
out[cfg.sousVues.parametre] = etat.sousVue
}
return out
}
/**
* Ne garde que les valeurs connues de chaque dimension. Une dimension dont la
* liste de valeurs est encore vide (données pas chargées) garde tout : le
* filtre s'appliquera dès l'arrivée des données, sans perdre le lien partagé.
*/
export function filtresValides(filtres: Record<string, string[]>, cfg: ConfigVueFiches): Record<string, string[]> {
const out: Record<string, string[]> = {}
for (const d of cfg.dimensions) {
const vals = filtres[d.cle] ?? []
out[d.cle] = d.valeurs.length ? vals.filter((v) => d.valeurs.some((x) => x.valeur === v)) : vals
}
return out
}
export function nbFiltresActifs(etat: EtatFiches): number {
return Object.values(etat.filtres).reduce((n, v) => n + v.length, 0) + (etat.sansAdresse ? 1 : 0)
}
// ── Filtrage ───────────────────────────────────────────────────────────────
function porte(f: MiniFiche, dimension: string, valeur: string): boolean {
return (f.etiquettes?.[dimension] ?? []).includes(valeur)
}
export function correspondRecherche(f: MiniFiche, q: string): boolean {
const n = normaliser(q.trim())
if (!n) return true
return [f.nom, f.ville, f.resume, f.texte].some((t) => !!t && normaliser(t).includes(n))
}
/**
* Fiches qui passent la recherche, `adresse=sans` et les filtres de toutes les
* dimensions sauf `ignorer` (sert aux compteurs). OU dans une dimension, ET entre.
*/
export function filtrerFiches(
fiches: MiniFiche[],
etat: EtatFiches,
cfg: ConfigVueFiches,
ignorer?: string,
): MiniFiche[] {
return fiches.filter((f) => {
if (etat.sansAdresse && f.geoloc) return false
if (!correspondRecherche(f, etat.q)) return false
for (const d of cfg.dimensions) {
if (d.cle === ignorer) continue
const vals = etat.filtres[d.cle] ?? []
if (vals.length && !vals.some((v) => porte(f, d.cle, v))) return false
}
return true
})
}
/**
* Compteur de chaque chip : fiches qui portent cette étiquette parmi celles que
* laissent passer la recherche et les AUTRES dimensions. Une chip active affiche
* donc combien de fiches elle apporte, une chip inactive combien on en aurait
* en la cochant seule dans sa dimension ; 0 = chip grisée.
*/
export function compterEtiquettes(
fiches: MiniFiche[],
etat: EtatFiches,
cfg: ConfigVueFiches,
): Record<string, Record<string, number>> {
const out: Record<string, Record<string, number>> = {}
for (const d of cfg.dimensions) {
const base = filtrerFiches(fiches, etat, cfg, d.cle)
const c: Record<string, number> = {}
for (const v of d.valeurs) c[v.valeur] = 0
for (const f of base) for (const v of f.etiquettes?.[d.cle] ?? []) if (v in c) c[v]++
out[d.cle] = c
}
return out
}
// ── Tri ────────────────────────────────────────────────────────────────────
export function triEffectif(etat: EtatFiches, cfg: ConfigVueFiches): TriFiches {
if (etat.tri) return etat.tri
const filtreEtiquette = Object.values(etat.filtres).some((v) => v.length > 0)
if (filtreEtiquette && cfg.tris.includes('pertinence')) return 'pertinence'
return cfg.tris.includes('nom') ? 'nom' : cfg.tris[0] ?? 'nom'
}
/** Nombre d'étiquettes cochées que porte la fiche. */
export function pertinence(f: MiniFiche, etat: EtatFiches): number {
let s = 0
for (const [dim, vals] of Object.entries(etat.filtres)) for (const v of vals) if (porte(f, dim, v)) s++
return s
}
const collator = new Intl.Collator('fr', { sensitivity: 'base', numeric: true })
function parNom(a: MiniFiche, b: MiniFiche): number {
return collator.compare(a.nom ?? '', b.nom ?? '')
}
function idNum(f: MiniFiche): number {
const n = Number(f.id)
return Number.isFinite(n) ? n : -Infinity
}
export function trierFiches(fiches: MiniFiche[], tri: TriFiches, etat: EtatFiches): MiniFiche[] {
const copie = [...fiches]
if (tri === 'nom') return copie.sort(parNom)
if (tri === 'pertinence') {
const score = new Map(copie.map((f) => [f, pertinence(f, etat)]))
return copie.sort((a, b) => score.get(b)! - score.get(a)! || parNom(a, b))
}
// récent : date décroissante, puis Id décroissant (Entraide : Id = ordre d'arrivée)
return copie.sort((a, b) => {
if (a.date && b.date && a.date !== b.date) return a.date < b.date ? 1 : -1
if (!!a.date !== !!b.date) return a.date ? -1 : 1
return idNum(b) - idNum(a) || parNom(a, b)
})
}
// ── Regroupement ───────────────────────────────────────────────────────────
/**
* Une fiche va sous sa PREMIÈRE étiquette de la dimension (sinon le compteur
* de groupe ment). Ordre des groupes : celui de `def.valeurs`, sinon de la
* dimension de filtre, sinon A → Z ; « sans » en dernier. L'ordre des fiches
* dans chaque groupe est celui reçu (déjà trié).
*/
export function regrouper(fiches: MiniFiche[], def: GroupeDef | null, cfg: ConfigVueFiches): GroupeRendu[] {
if (!def) return [{ cle: '', libelle: '', fiches }]
const ref = def.valeurs ?? cfg.dimensions.find((d) => d.cle === def.dimension)?.valeurs ?? []
const libelle = (v: string) => ref.find((x) => x.valeur === v)?.libelle ?? v
const parCle = new Map<string, MiniFiche[]>()
for (const f of fiches) {
const cle = f.etiquettes?.[def.dimension]?.[0] ?? ''
if (!parCle.has(cle)) parCle.set(cle, [])
parCle.get(cle)!.push(f)
}
const rang = (cle: string) => {
if (cle === '') return Number.MAX_SAFE_INTEGER
const i = ref.findIndex((x) => x.valeur === cle)
return i === -1 ? ref.length : i
}
return [...parCle.keys()]
.sort((a, b) => rang(a) - rang(b) || collator.compare(libelle(a), libelle(b)))
.map((cle) => ({ cle, libelle: cle === '' ? def.sans : libelle(cle), fiches: parCle.get(cle)! }))
}
/** Ordre de lecture courant (filtres + tri + regroupement) : le fiche à fiche de B12 le suit. */
export function ordreFiches(groupes: GroupeRendu[]): MiniFiche[] {
return groupes.flatMap((g) => g.fiches)
}
// ── Fiche ouverte (B12) ────────────────────────────────────────────────────
/** `fiche=` de l'URL ; null si absent ou vide. L'id reste une chaîne (Entraide : nombre, Réseaux : `f1-rotor`). */
export function lireFiche(query: Query): string | null {
const v = premiere(query.fiche).trim()
return v || null
}
/** Query avec `fiche=` posé (id) ou retiré (null), tout le reste recopié tel quel. */
export function queryAvecFiche(query: Query, id: string | number | null): Record<string, string> {
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(query)) {
if (k !== 'fiche' && premiere(v) !== '') out[k] = premiere(v)
}
if (id != null && String(id) !== '') out.fiche = String(id)
return out
}
export interface PositionFiche {
/** Rang dans l'ordre courant, de 1 à total. */
rang: number
total: number
precedente: MiniFiche | null
suivante: MiniFiche | null
}
/**
* Place de la fiche dans l'ordre courant (filtres + tri + regroupement).
* null = fiche hors de l'ordre courant (« hors de vos filtres ») : pas de
* précédente ni de suivante. Pas de bouclage aux extrémités.
*/
export function positionFiche(ordre: MiniFiche[], id: string | number | null): PositionFiche | null {
if (id == null) return null
const i = ordre.findIndex((f) => String(f.id) === String(id))
if (i === -1) return null
return {
rang: i + 1,
total: ordre.length,
precedente: i > 0 ? ordre[i - 1] : null,
suivante: i < ordre.length - 1 ? ordre[i + 1] : null,
}
}
/**
* Historique (SPEC §5) : OUVRIR une fiche depuis la liste ajoute une entrée
* (le retour du téléphone la referme) ; passer d'une fiche à l'autre la
* remplace (le retour ne rejoue pas les vingt fiches parcourues).
*/
export function modeOuverture(ficheCourante: string | null): 'push' | 'replace' {
return ficheCourante == null ? 'push' : 'replace'
}
/**
* FERMER : si l'entrée a été ajoutée par cette page ET que la liste derrière
* n'a pas changé depuis (mêmes filtres, tri, vue), on revient en arrière :
* l'historique reste propre. Sinon (lien partagé, rechargement, filtre changé
* fiche ouverte), on retire `fiche=` sur place : un retour arrière aurait
* défait les filtres choisis entre-temps.
*/
export function modeFermeture(ouverteParLaPage: boolean, listeInchangee: boolean): 'back' | 'replace' {
return ouverteParLaPage && listeInchangee ? 'back' : 'replace'
}
/** Empreinte de la liste derrière la fiche : la query sans `fiche=`, clés triées. */
export function empreinteListe(query: Query): string {
const q = queryAvecFiche(query, null)
return Object.keys(q).sort().map((k) => `${k}=${q[k]}`).join('&')
}