Compare commits
30
Commits
+67
-8
@@ -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.
|
||||
|
||||
@@ -204,6 +204,8 @@ cout_eur = ((tokens_in × 0.02 + tokens_out × 0.04) / 1_000_000) × 0.93
|
||||
|
||||
Le filtre NocoDB par date (`gte,YYYY-MM-DD`) n'est pas supporté en v0.301.5. Contournement : récupération de tous les records stats_usage (limit=1000) et filtre JavaScript par mois/année.
|
||||
|
||||
**Côté chatbot (AF5, 29/09)** : `server/utils/circuitBreaker.ts` n'appliquait pas ce contournement. Il envoyait `where=(timestamp,gte,…)&fields=cout_eur,timestamp` et recevait un 422 à chaque appel : fail-open silencieux, budget jamais vérifié depuis la V2. Il suit désormais le worker : **aucune colonne nommée dans la requête** (ni `where`, ni `fields`, ni `sort`), lecture paginée (`limit`/`offset` 1000), mois filtré en JS sur `timestamp`, sinon `CreatedAt`, sinon `created_at`. Chaque lecture écrit au journal `[circuitBreaker] budget lu : X € / 20 € (n lignes AAAA-MM sur N lues)` ; un échec écrit `budget NON vérifié`. Test : `node scripts/test-circuit-breaker.mjs` (faux NocoDB qui rejette en 422 toute colonne absente). Seule `/api/chatbot` vérifie le budget ; `-reseaux` et `-taff` ne le lisent ni ne l'écrivent (asymétrie pré-existante), et les appels servis par un tier gratuit s'inscrivent à 0 €.
|
||||
|
||||
---
|
||||
|
||||
## 7. Infrastructure
|
||||
@@ -330,3 +332,91 @@ Pattern à généraliser :
|
||||
4. Normalize(tags) → taxonomie contrôlée
|
||||
5. Log(usage) → stats_usage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. B5 (2026-09-27) — Formulaire assoupli + pipe adapté (fusion, pas réécriture)
|
||||
|
||||
Ce qui suit documente les écarts posés par le batch B5 (décisions de la
|
||||
maîtrise d'œuvre du 27/09). Les sections 1 à 10 ci-dessus restent la
|
||||
référence de fond (taxonomie, système prompt d'origine, circuit breaker) ;
|
||||
cette section documente ce qui a changé dans `worker/enrich.js` et le pipe
|
||||
de soumission (`server/api/submit/`).
|
||||
|
||||
### 11.1 Mapping soumission libre → colonnes NocoDB existantes (M1)
|
||||
|
||||
Le formulaire `/proposer` assoupli (`components/FormLibre.vue`,
|
||||
`utils/submitLibre.ts`) écrit dans la table orgas existante, **aucune colonne
|
||||
nouvelle** :
|
||||
|
||||
| Champ formulaire | Colonne NocoDB | Règle |
|
||||
|---|---|---|
|
||||
| 1er lien de la liste | `url` | `null` si aucun lien |
|
||||
| texte « pourquoi » + tous les liens | `description_user` | `{texte}\n\nLiens :\n{lien1}\n{lien2}...` — et si aucun type suggéré, la ligne `Type : non précisé` est préfixée en tête |
|
||||
| — | `nom` | hostname du 1er lien, ou 60 premiers caractères du texte si aucun lien, préfixé `[à qualifier] ` |
|
||||
| chip type suggéré | `submission_type` | valeur choisie (`ecosysteme`/`reseau`/`job`/`outil`) ; si aucune chip → `ecosysteme` par défaut (voir ligne `Type : non précisé` ci-dessus) |
|
||||
| chip « Références » | *(table `ressources_references`, pas orgas)* | `titre`/`auteur` placeholders, `description` = texte + liens, comme les autres |
|
||||
| email (optionnel) | `submitted_by_email` | — |
|
||||
|
||||
**À faire au déploiement** : ajouter l'option `libre` à la liste de valeurs
|
||||
acceptées par `submission_type` si l'on veut un jour distinguer une
|
||||
soumission libre d'une soumission via un des 5 formulaires détaillés — non
|
||||
fait ici pour ne pas tester un ALTER contre la prod sans l'avoir vérifié.
|
||||
|
||||
### 11.2 Worker — écarts (M2)
|
||||
|
||||
| Sujet | NAV V2 (§1-10 ci-dessus) | AEP B5 |
|
||||
|---|---|---|
|
||||
| LLM | Mistral Nemo, appel direct | **Bifrost** (`${BIFROST_URL}/v1/chat/completions`, header `x-bf-vk`), modèle `WORKER_MODEL` (défaut `groq/openai/gpt-oss-20b` depuis le 28/09, voir §11.3) |
|
||||
| Scrape | crawl4ai (Python, `AsyncHTTPCrawlerStrategy`) | **fetch natif Node 22** — timeout 8 s, corps plafonné 500 Ko, extraction titre + meta description + `og:*` + texte visible tronqué à 4000 caractères, `User-Agent: AEP/2.0 contact@trans-former.fr` |
|
||||
| Notification | Resend (email Jules) | **ntfy** (`POST https://ntfy.sh/$NTFY_TOPIC`) — le message ne contient JAMAIS l'email ni le texte libre du contributeur, seulement id NocoDB / nom suggéré / type / confiance |
|
||||
| Seuil « 5 fiches pending » | Email si ≥ 5 en attente | **Retiré** (décision MOE : volume faible, une notif par fiche traitée suffit — à réactiver si le volume monte) |
|
||||
| Sortie JSON du LLM | `description_enrichie, points_cles, tags_fonction, echelle, territoire, localisation_ville, confiance` | `{nom, description, type_suggere, ville, tags[], confiance}` — le worker écrit ensuite dans les colonnes existantes (`description_enrichie`, `tags_fonction`, `localisation_ville`) |
|
||||
| Écriture de `nom` | jamais réécrit | réécrit **seulement** si le nom actuel commence par `[à qualifier]` (placeholder posé par le formulaire assoupli) |
|
||||
| Écriture de `submission_type` | jamais réécrit | réécrit **seulement** si `description_user` commence par `Type : non précisé` (l'utilisateur n'a pas choisi de chip) et que le type suggéré par le LLM est valide |
|
||||
| Prix des tokens | fixe (prix Mistral Nemo) | variables `WORKER_PRICE_IN_USD_PER_M` / `WORKER_PRICE_OUT_USD_PER_M`, défaut **0** (tier Groq du tier RAPIDE Bifrost, gratuit) |
|
||||
| Lock | `/tmp/nav-worker.lock` | `/tmp/aep-worker.lock` (configurable `WORKER_LOCK_FILE`) |
|
||||
| Chemin VPS | `/opt/nav-carte/worker/` | `/opt/aep-worker/` (voir `deploy/aep-worker/README.md`) |
|
||||
| Timer | 5 min | 15 min (`deploy/aep-worker/aep-worker.timer`) |
|
||||
|
||||
Le mode `--dry-run` (`node enrich.js --dry-run`) lit
|
||||
`worker/fixtures/dry-run-rows.json` au lieu de NocoDB, n'écrit rien, et par
|
||||
défaut mock aussi le scrape et l'appel Bifrost (`DRY_RUN_LIVE=1` pour
|
||||
forcer de vrais appels réseau sans jamais toucher NocoDB).
|
||||
|
||||
### 11.3 Modèles Groq retirés — bascule sur gpt-oss (2026-09-28)
|
||||
|
||||
Groq ne sert plus `llama-3.1-8b-instant` ni `llama-3.3-70b-versatile`
|
||||
(404 `model_not_found` via Bifrost, constaté au test M3 du 28/09).
|
||||
|
||||
| Consommateur | Avant | Après | Où |
|
||||
|---|---|---|---|
|
||||
| Worker (`worker/enrich.js`) | `groq/llama-3.1-8b-instant` | `groq/openai/gpt-oss-20b` | prod : `WORKER_MODEL` dans `/opt/aep/.env` depuis le 28/09 ; défaut du code aligné |
|
||||
| Chatbots, tier RAPIDE | `groq/llama-3.1-8b-instant` | `groq/openai/gpt-oss-20b` | `server/utils/bifrost.ts`, replis inchangés |
|
||||
| Chatbots, tier APPROFONDI | `groq/llama-3.3-70b-versatile` | `groq/openai/gpt-oss-120b` | `server/utils/bifrost.ts`, replis inchangés |
|
||||
| RAG Pensées (LightRAG) | `gemini-oai/models/gemini-2.5-flash-lite` | inchangé | `/opt/lightrag/.env`, hors de ce routage |
|
||||
|
||||
Entre le retrait et ce correctif, chaque appel chatbot partait sur le
|
||||
premier repli Gemini (`stats_usage` depuis le 23/09 environ) : service
|
||||
rendu, modèle primaire jamais servi.
|
||||
|
||||
**gpt-oss est un modèle à raisonnement.** Deux conséquences :
|
||||
- la réponse porte `choices[0].message.reasoning` à côté de `content`. Les
|
||||
trois routes (`chatbot`, `chatbot-reseaux`, `chatbot-taff`) et le worker ne
|
||||
lisent que `content` (JSON), `usage` et `extra_fields` : le champ est
|
||||
ignoré, rien n'en dépend. `chatbot-pensees` lit la réponse de LightRAG
|
||||
(`response`, `references`), pas celle de Bifrost ;
|
||||
- les tokens de raisonnement sont décomptés de `max_tokens`. Si le
|
||||
fournisseur rend un JSON tronqué en HTTP 200, Bifrost ne bascule pas et
|
||||
l'usager lit « Je n'ai pas pu analyser ta demande » (s'il le rejette en
|
||||
400, le repli Gemini prend le relais : dégradé, pas cassé). Les routes chatbot ajoutent donc
|
||||
`REASONING_MARGIN_TOKENS` (800) à leur budget de contenu (600 ou 700), et
|
||||
`bifrostContent()` écrit un avertissement au journal quand
|
||||
`finish_reason` vaut `length`. Le worker garde `max_tokens: 800` (sortie
|
||||
courte, M3 passé en 1,6 s) ; à relever si `tokens_out` approche 800 dans
|
||||
`stats_usage`.
|
||||
|
||||
**Non testé depuis Windows** (Bifrost écoute sur le loopback du VPS). Test
|
||||
réel au checkpoint : un appel par route, relever `extra_fields` (modèle
|
||||
réellement servi), `usage.completion_tokens`, `finish_reason`, et le
|
||||
modèle inscrit dans `stats_usage` pour `/api/chatbot`.
|
||||
|
||||
@@ -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>
|
||||
|
||||
+31
-24
@@ -77,8 +77,12 @@ const emit = defineEmits<{
|
||||
}
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
transform: translateY(-1px);
|
||||
/* Survol : uniquement sur pointeur capable de survol. Sur tactile, :hover
|
||||
reste collé après un tap (chip « allumé » sans être actif). */
|
||||
@media (hover: hover) {
|
||||
.chip:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.chip:focus-visible {
|
||||
@@ -92,19 +96,21 @@ const emit = defineEmits<{
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
}
|
||||
.chip--echelle:hover {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
color: var(--on-primary);
|
||||
@media (hover: hover) {
|
||||
.chip--echelle:hover {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
}
|
||||
/* Actif = fond PLEIN. --primary est translucide dans le thème AEP
|
||||
(rgba(26,34,56,.6) : 4,16:1 sur le fond de page, sous AA) ; le couple
|
||||
--nav-primary-solid / --nav-text-on-primary (≈14,6:1) reste lisible aussi
|
||||
en sombre. Le survol d'un chip actif ne change pas de fond. */
|
||||
.chip--echelle.chip--active {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
.chip--echelle.chip--active:hover {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
background: var(--nav-primary-solid);
|
||||
border-color: var(--nav-primary-solid);
|
||||
color: var(--nav-text-on-primary);
|
||||
}
|
||||
|
||||
/* ── Fonction/hashtag : pleine bleu, inactif = bg-alt ────────────────────── */
|
||||
@@ -113,16 +119,15 @@ const emit = defineEmits<{
|
||||
background: var(--bg-alt);
|
||||
color: var(--text);
|
||||
}
|
||||
.chip--fonction:hover {
|
||||
background: var(--primary-hover);
|
||||
color: var(--on-primary);
|
||||
@media (hover: hover) {
|
||||
.chip--fonction:hover {
|
||||
background: var(--primary-hover);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
}
|
||||
.chip--fonction.chip--active {
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
.chip--fonction.chip--active:hover {
|
||||
background: var(--primary-hover);
|
||||
background: var(--nav-primary-solid);
|
||||
color: var(--nav-text-on-primary);
|
||||
}
|
||||
|
||||
/* ── Posture (préparée pour plus tard) : outline safran ──────────────────── */
|
||||
@@ -131,9 +136,11 @@ const emit = defineEmits<{
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
}
|
||||
.chip--posture:hover {
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
@media (hover: hover) {
|
||||
.chip--posture:hover {
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
}
|
||||
.chip--posture.chip--active {
|
||||
background: var(--accent);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,310 @@
|
||||
<template>
|
||||
<form @submit.prevent="submit" class="proposer-form proposer-form--libre" novalidate>
|
||||
<div class="field-group" :class="{ 'field-error': errors.texte }">
|
||||
<label id="libre-liens-label">Tes liens <span class="label-hint">(1 ou plusieurs — sites, PDF, articles...)</span></label>
|
||||
<div class="liens-list" role="group" aria-labelledby="libre-liens-label">
|
||||
<div v-for="(lien, i) in liens" :key="i" class="lien-row">
|
||||
<input
|
||||
:id="`libre-lien-${i}`"
|
||||
v-model="liens[i]"
|
||||
type="url"
|
||||
inputmode="url"
|
||||
placeholder="https://..."
|
||||
autocomplete="off"
|
||||
@blur="validateAll(false)"
|
||||
/>
|
||||
<button
|
||||
v-if="liens.length > 1"
|
||||
type="button"
|
||||
class="lien-remove"
|
||||
:aria-label="`Retirer ce lien`"
|
||||
@click="removeLien(i)"
|
||||
>×</button>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="liens.length < 10" type="button" class="btn-add-lien" @click="addLien">+ un autre lien</button>
|
||||
<span v-if="errors.texte" id="libre-texte-error" class="error-msg" role="alert">{{ errors.texte }}</span>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label for="libre-texte">Pourquoi c'est utile, pour toi ? <span class="label-hint">(pas de format imposé)</span></label>
|
||||
<textarea
|
||||
id="libre-texte"
|
||||
v-model="texte"
|
||||
rows="4"
|
||||
placeholder="Dis-le avec tes mots : ce que ça t'a apporté, pourquoi tu le partages..."
|
||||
:aria-describedby="errors.texte ? 'libre-texte-error' : undefined"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label id="libre-type-label">Type suggéré <span class="label-hint">(optionnel — un ou deux si tu hésites)</span></label>
|
||||
<ChipGroup aria-label="Type suggéré" aria-describedby="libre-type-hint">
|
||||
<Chip
|
||||
v-for="t in TYPES_SUGGERES"
|
||||
:key="t"
|
||||
:label="TYPES_SUGGERES_LABELS[t]"
|
||||
variant="fonction"
|
||||
:active="typeSuggere === t || typeSecondaire === t"
|
||||
:class="{ 'chip--secondaire': typeSecondaire === t }"
|
||||
@toggle="toggleType(t)"
|
||||
/>
|
||||
</ChipGroup>
|
||||
<span id="libre-type-hint" class="label-hint">
|
||||
Écosystème : {{ TYPES_SUGGERES_HINTS.ecosysteme }} · Réseau : {{ TYPES_SUGGERES_HINTS.reseau }} · Jobs : {{ TYPES_SUGGERES_HINTS.jobs }} · Outils : {{ TYPES_SUGGERES_HINTS.outils }} · Références : {{ TYPES_SUGGERES_HINTS.references }}
|
||||
</span>
|
||||
<!-- Distinction non visuelle premier choix / second choix pour lecteurs d'écran :
|
||||
les deux chips actifs partagent le même aria-pressed, sans quoi rien ne dit
|
||||
lequel est principal. -->
|
||||
<span class="sr-only" aria-live="polite">
|
||||
<template v-if="typeSuggere && typeSecondaire">
|
||||
Principal : {{ TYPES_SUGGERES_LABELS[typeSuggere] }} · second choix : {{ TYPES_SUGGERES_LABELS[typeSecondaire] }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
|
||||
<label for="libre-email">Ton email <span class="label-hint">(optionnel — pour qu'on te tienne au courant)</span></label>
|
||||
<input
|
||||
id="libre-email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
placeholder="ton@email.fr"
|
||||
autocomplete="email"
|
||||
@blur="validateAll(false)"
|
||||
/>
|
||||
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">{{ errors.submitted_by_email }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Honeypot anti-spam : caché visuellement et aux lecteurs d'écran, un bot le remplit. -->
|
||||
<div class="hp-field" aria-hidden="true">
|
||||
<label for="libre-site-web">Site web</label>
|
||||
<input id="libre-site-web" v-model="siteWeb" type="text" tabindex="-1" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div v-if="serverError" class="server-error" role="alert" aria-live="assertive">
|
||||
<strong>Erreur :</strong> {{ serverError }}
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary" :disabled="submitting">
|
||||
{{ submitting ? 'Envoi en cours...' : 'Proposer →' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="form-note">Ta proposition sera examinée par l'équipe avant publication.</p>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LibreSubmitSchema, TYPES_SUGGERES, TYPES_SUGGERES_LABELS, TYPES_SUGGERES_HINTS, type TypeSuggere } from '~/utils/submitLibre'
|
||||
|
||||
const props = defineProps<{ presetType?: TypeSuggere }>()
|
||||
const emit = defineEmits<{ success: [] }>()
|
||||
|
||||
const liens = reactive<string[]>([''])
|
||||
const texte = ref('')
|
||||
const email = ref('')
|
||||
const typeSuggere = ref<TypeSuggere | undefined>(props.presetType)
|
||||
// Deuxième chip optionnelle (B5-M2b) — pour les indécis entre deux types.
|
||||
const typeSecondaire = ref<TypeSuggere | undefined>()
|
||||
const siteWeb = ref('') // honeypot
|
||||
|
||||
const errors = reactive<Record<string, string>>({})
|
||||
const submitting = ref(false)
|
||||
const serverError = ref('')
|
||||
|
||||
function addLien() {
|
||||
if (liens.length < 10) liens.push('')
|
||||
}
|
||||
|
||||
function removeLien(i: number) {
|
||||
liens.splice(i, 1)
|
||||
if (liens.length === 0) liens.push('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Deux chips maximum. Premier clic = principal, deuxième = secondaire.
|
||||
* Décocher le principal alors qu'un secondaire existe promeut ce dernier.
|
||||
* Troisième clic sur un chip inactif quand deux sont déjà actifs : remplace
|
||||
* le secondaire par le nouveau chip (choix le plus simple — pas de file d'attente).
|
||||
*/
|
||||
function toggleType(t: TypeSuggere) {
|
||||
if (typeSuggere.value === t) {
|
||||
typeSuggere.value = typeSecondaire.value
|
||||
typeSecondaire.value = undefined
|
||||
return
|
||||
}
|
||||
if (typeSecondaire.value === t) {
|
||||
typeSecondaire.value = undefined
|
||||
return
|
||||
}
|
||||
if (typeSuggere.value === undefined) {
|
||||
typeSuggere.value = t
|
||||
} else if (typeSecondaire.value === undefined) {
|
||||
typeSecondaire.value = t
|
||||
} else {
|
||||
typeSecondaire.value = t
|
||||
}
|
||||
}
|
||||
|
||||
function currentPayload() {
|
||||
return {
|
||||
liens: liens.map((l) => l.trim()).filter(Boolean),
|
||||
texte: texte.value,
|
||||
submitted_by_email: email.value || undefined,
|
||||
type_suggere: typeSuggere.value,
|
||||
type_secondaire: typeSecondaire.value,
|
||||
site_web: siteWeb.value,
|
||||
}
|
||||
}
|
||||
|
||||
function validateAll(scrollToError = true): boolean {
|
||||
const result = LibreSubmitSchema.safeParse(currentPayload())
|
||||
if (!result.success) {
|
||||
const flat = result.error.flatten().fieldErrors
|
||||
Object.keys(errors).forEach((k) => delete errors[k])
|
||||
Object.assign(errors, Object.fromEntries(Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])))
|
||||
if (scrollToError) {
|
||||
nextTick(() => {
|
||||
document.querySelector('.field-error')?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
Object.keys(errors).forEach((k) => delete errors[k])
|
||||
return true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
serverError.value = ''
|
||||
if (!validateAll(true)) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await $fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
body: { mode: 'libre', ...currentPayload() },
|
||||
})
|
||||
emit('success')
|
||||
} catch (e: any) {
|
||||
const status = e?.status ?? e?.statusCode
|
||||
if (status === 429) {
|
||||
serverError.value = 'Tu as déjà soumis 3 fiches aujourd\'hui. Réessaie demain.'
|
||||
} else if (status === 422 && e?.data) {
|
||||
Object.assign(errors, Object.fromEntries(Object.entries(e.data as Record<string, string[]>).map(([k, v]) => [k, Array.isArray(v) ? v[0] : String(v)])))
|
||||
serverError.value = 'Vérifie les erreurs ci-dessus.'
|
||||
} else {
|
||||
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import './form-styles.css';
|
||||
|
||||
.liens-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
.lien-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.lien-row input {
|
||||
flex: 1;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--nav-text);
|
||||
background: var(--nav-surface);
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.lien-row input:focus {
|
||||
outline: none;
|
||||
border-color: var(--nav-primary-solid);
|
||||
box-shadow: 0 0 0 2px rgba(245, 179, 66, 0.4);
|
||||
}
|
||||
.lien-remove {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--nav-text-muted);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lien-remove:hover {
|
||||
border-color: #c0392b;
|
||||
color: #c0392b;
|
||||
}
|
||||
.btn-add-lien {
|
||||
align-self: flex-start;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px dashed rgba(26, 34, 56, 0.3);
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--nav-text-muted);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-add-lien:hover {
|
||||
border-color: var(--nav-primary-solid);
|
||||
color: var(--nav-text);
|
||||
}
|
||||
|
||||
.hp-field {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── B5-M2c : distinction principal / second choix ──
|
||||
Le principal actif (fond plein, ≈14,6:1) et le survol gardé par
|
||||
@media (hover: hover) viennent de Chip.vue depuis AF5 : les surcharges
|
||||
locales de AF4 (principal plein, survol collant neutralisé sur tactile)
|
||||
ont été retirées. */
|
||||
|
||||
/* Chip secondaire (deuxième choix) : contour au lieu de plein, pour ne
|
||||
jamais se confondre avec le principal — ni à l'œil ni au clic. Texte
|
||||
--nav-text sur le fond de page : contraste très large (>10:1), stable
|
||||
dans tous les états. */
|
||||
.field-group :deep(.chip--secondaire.chip--active) {
|
||||
background: transparent !important;
|
||||
border-color: var(--nav-primary-solid) !important;
|
||||
color: var(--nav-text) !important;
|
||||
}
|
||||
/* Survol du secondaire : pointeur capable de survol seulement, comme Chip.vue
|
||||
(sur tactile, :hover reste collé après un tap). */
|
||||
@media (hover: hover) {
|
||||
.field-group :deep(.chip--secondaire.chip--active:hover) {
|
||||
background: rgba(var(--nav-primary-raw), 0.08) !important;
|
||||
border-color: var(--nav-primary-solid) !important;
|
||||
color: var(--nav-text) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,90 @@
|
||||
# Déploiement aep-worker (B5-M2)
|
||||
|
||||
Ce dossier n'a **rien été exécuté ni copié sur le VPS** par la session B5 — code
|
||||
seulement, comme demandé. Étapes exactes pour la session qui déploiera.
|
||||
|
||||
## 0. Pré-requis avant de commencer
|
||||
|
||||
- [ ] **Backup NocoDB < 24 h** avant le premier passage du worker (il écrit en base).
|
||||
- [ ] Vérifier que `/opt/aep/.env` contient déjà : `NOCODB_URL`, `NOCODB_TOKEN`,
|
||||
`NOCODB_BASE`, `NOCODB_TABLE_ORGAS`, `NOCODB_TABLE_STATS`, `BIFROST_URL`,
|
||||
`BIFROST_VK`, `NTFY_TOPIC` (et optionnellement `WORKER_MODEL`,
|
||||
`WORKER_LIMIT`, `BUDGET_MAX_EUR`). Ne PAS créer de nouveau fichier .env —
|
||||
celui-ci existe déjà en prod (mission dit de le lire, pas de le chercher
|
||||
dans ce repo).
|
||||
- [ ] Confirmer que `/opt/nav-carte` (l'ancien worker) est bien absent — il l'était
|
||||
au 27/09 selon B0. Sinon, l'arrêter/purger avant d'activer celui-ci pour
|
||||
éviter un double traitement des mêmes fiches pending.
|
||||
|
||||
## 1. Copier le code
|
||||
|
||||
```bash
|
||||
ssh vps
|
||||
mkdir -p /opt/aep-worker
|
||||
exit
|
||||
# depuis le poste de dev, après avoir buildé/vérifié la branche :
|
||||
scp -r worker/enrich.js worker/package.json worker/fixtures vps:/opt/aep-worker/
|
||||
ssh vps "cd /opt/aep-worker && npm install --omit=dev"
|
||||
```
|
||||
|
||||
(`npm install` : le worker n'a plus de dépendance à `dotenv` côté prod puisque
|
||||
`EnvironmentFile=` du service systemd charge déjà `/opt/aep/.env` — le
|
||||
`package.json` du worker peut être simplifié à cette occasion, ou laissé tel
|
||||
quel si `dotenv` sert encore pour un lancement manuel en local.)
|
||||
|
||||
## 2. Poser les unités systemd
|
||||
|
||||
```bash
|
||||
scp deploy/aep-worker/aep-worker.service deploy/aep-worker/aep-worker.timer \
|
||||
vps:/tmp/
|
||||
ssh vps
|
||||
sudo mv /tmp/aep-worker.service /tmp/aep-worker.timer /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now aep-worker.timer
|
||||
```
|
||||
|
||||
## 3. Vérifier
|
||||
|
||||
```bash
|
||||
systemctl status aep-worker.timer
|
||||
systemctl list-timers | grep aep-worker
|
||||
|
||||
# Lancer un passage manuel (sans attendre les 15 min) :
|
||||
sudo systemctl start aep-worker.service
|
||||
journalctl -u aep-worker.service -n 100 --no-pager
|
||||
```
|
||||
|
||||
Logs attendus : `=== Worker AEP enrichissement démarré ===`, un compte de
|
||||
fiches pending, puis soit `Rien à traiter.` soit le détail par fiche
|
||||
(scrape/Bifrost/ntfy).
|
||||
|
||||
## 4. Test bout-en-bout (M3, session suivante)
|
||||
|
||||
1. Soumettre une vraie ressource de test via `/proposer` (formulaire assoupli).
|
||||
2. Vérifier le 201 + le record `pending`/`ai_processed=false` dans NocoDB.
|
||||
3. Vérifier la notif ntfy de soumission (topic `NTFY_TOPIC`).
|
||||
4. Lancer le worker (`systemctl start aep-worker.service` ou attendre le
|
||||
timer) → vérifier `moderation_status=ai_processed`, `description_enrichie`,
|
||||
`tags_fonction`, `ai_raw_output` remplis, et la notif ntfy « Fiche enrichie ».
|
||||
5. Modérer dans NocoDB UI (`ai_processed` → `approved`) → vérifier l'affichage
|
||||
sur le front.
|
||||
6. **Supprimer les records de test** dans NocoDB (orgas + stats_usage) une
|
||||
fois la preuve faite.
|
||||
7. Mettre à jour `PIPE-IA-DOC.md`, `Onglets/Proposer.md`, `PILOTE - AEP.md`
|
||||
dans le vault avec le résultat réel (pas seulement ce README).
|
||||
|
||||
## Anti-chevauchement
|
||||
|
||||
Lock fichier PID : `/tmp/aep-worker.lock` (configurable via `WORKER_LOCK_FILE`
|
||||
dans `.env` si besoin). Nettoyé en fin de run normal, et automatiquement si le
|
||||
PID contenu dans le lock n'est plus vivant (voir `acquireLock()` dans
|
||||
`enrich.js`) — pas d'intervention manuelle attendue, sauf lock resté après un
|
||||
crash dur du process (`kill -9` externe) : dans ce cas `rm /tmp/aep-worker.lock`
|
||||
suffit.
|
||||
|
||||
## Écarts par rapport à la version NAV V2 (PIPE-IA-DOC.md)
|
||||
|
||||
Voir l'en-tête de commentaire de `worker/enrich.js` et `PIPE-IA-DOC.md` §11 —
|
||||
LLM Bifrost au lieu de Mistral direct, scrape fetch natif au lieu de crawl4ai,
|
||||
notification ntfy au lieu de Resend, écriture `nom`/`submission_type` par le
|
||||
worker seulement quand le formulaire assoupli a laissé un placeholder.
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=AEP — Worker enrichissement IA (scrape léger + Bifrost)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
WorkingDirectory=/opt/aep-worker
|
||||
EnvironmentFile=/opt/aep/.env
|
||||
ExecStart=/usr/bin/node enrich.js
|
||||
TimeoutStartSec=300
|
||||
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Timer 15 min — worker enrichissement IA AEP
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*:0/15
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
|
||||
+133
-549
@@ -1,453 +1,94 @@
|
||||
<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
|
||||
@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 +108,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 +132,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 « vertes » : ce sont celles et ceux qui assument des positions, refusent des projets, expérimentent des modèles de gouvernance, mettent leurs ressources et leurs savoirs en commun.
|
||||
</p>
|
||||
<p class="mission-text">
|
||||
Six familles structurent la cartographie : militants, agences engagées, collectifs de production, ressources communes, recherche, formations alternatives. Filtre par hashtag, ouvre la fiche d'une structure, navigue le graphe (3<sup>e</sup> onglet) pour voir les affinités. Si tu animes ou connais un réseau qui devrait y être : <NuxtLink to="/contribuer" @click.stop>propose-le</NuxtLink>.
|
||||
Six familles structurent la cartographie : militants, agences engagées, collectifs de production, ressources communes, recherche, formations alternatives. Filtre par hashtag, ouvre la fiche d'une structure, navigue le graphe (« Vue graphique » dans le sélecteur de carte) pour voir les affinités. Si tu animes ou connais un réseau qui devrait y être : <NuxtLink to="/contribuer" @click.stop>propose-le</NuxtLink>.
|
||||
</p>
|
||||
</MissionPopup>
|
||||
|
||||
@@ -499,73 +141,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 +160,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 +276,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
@@ -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
@@ -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>
|
||||
|
||||
+70
-19
@@ -6,8 +6,8 @@
|
||||
<div class="proposer-header">
|
||||
<h1>Proposer une ressource</h1>
|
||||
<p class="proposer-subtitle">
|
||||
Tu connais quelque chose d'utile qui n'est pas encore référencé sur AEP ?
|
||||
Choisis la catégorie ci-dessous et soumets-le — on valide sous 7 jours.
|
||||
Colle un lien (ou plusieurs), dis en quelques mots pourquoi c'est utile — on s'occupe du reste.
|
||||
On valide manuellement avant publication.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -20,23 +20,42 @@
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="tabs-bar">
|
||||
<button
|
||||
v-for="tab in TABS"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
class="tab-btn"
|
||||
:class="{ 'tab-btn--active': activeTab === tab.id }"
|
||||
@click="activeTab = tab.id"
|
||||
>{{ tab.label }}</button>
|
||||
</div>
|
||||
<FormLibre :preset-type="presetType" @success="onSuccess" />
|
||||
|
||||
<div class="tab-content">
|
||||
<FormEcosysteme v-if="activeTab === 'ecosysteme'" @success="onSuccess" />
|
||||
<FormReseau v-else-if="activeTab === 'reseau'" @success="onSuccess" />
|
||||
<FormJobs v-else-if="activeTab === 'jobs'" @success="onSuccess" />
|
||||
<FormOutils v-else-if="activeTab === 'outils'" @success="onSuccess" />
|
||||
<FormReferences v-else-if="activeTab === 'references'" @success="onSuccess" />
|
||||
<div class="mode-detaille">
|
||||
<button
|
||||
type="button"
|
||||
class="mode-detaille-toggle"
|
||||
:aria-expanded="detailleOuvert"
|
||||
aria-controls="mode-detaille-panel"
|
||||
@click="detailleOuvert = !detailleOuvert"
|
||||
>
|
||||
{{ detailleOuvert ? '− Fermer le mode détaillé' : '+ Mode détaillé (formulaires par catégorie)' }}
|
||||
</button>
|
||||
|
||||
<div v-if="detailleOuvert" id="mode-detaille-panel" class="mode-detaille-panel">
|
||||
<p class="mode-detaille-hint">
|
||||
Pour aller plus loin qu'un lien + quelques mots : échelle, fonctions, ville... un formulaire par catégorie.
|
||||
</p>
|
||||
<div class="tabs-bar">
|
||||
<button
|
||||
v-for="tab in TABS"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
class="tab-btn"
|
||||
:class="{ 'tab-btn--active': activeTab === tab.id }"
|
||||
@click="activeTab = tab.id"
|
||||
>{{ tab.label }}</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<FormEcosysteme v-if="activeTab === 'ecosysteme'" @success="onSuccess" />
|
||||
<FormReseau v-else-if="activeTab === 'reseau'" @success="onSuccess" />
|
||||
<FormJobs v-else-if="activeTab === 'jobs'" @success="onSuccess" />
|
||||
<FormOutils v-else-if="activeTab === 'outils'" @success="onSuccess" />
|
||||
<FormReferences v-else-if="activeTab === 'references'" @success="onSuccess" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -44,6 +63,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TYPES_SUGGERES, type TypeSuggere } from '~/utils/submitLibre'
|
||||
|
||||
const TABS = [
|
||||
{ id: 'ecosysteme', label: 'Écosystème / Entraide' },
|
||||
{ id: 'reseau', label: 'Réseau AEP' },
|
||||
@@ -52,10 +73,15 @@ const TABS = [
|
||||
{ id: 'references', label: 'Références (bibliothèque)' },
|
||||
] as const
|
||||
|
||||
// ?categorie=outils (lien « Proposer une ressource » des têtes d'échelle) présélectionne l'onglet
|
||||
// ?categorie=outils (lien « Proposer une ressource » des têtes d'échelle) présélectionne
|
||||
// le chip du formulaire assoupli ET l'onglet du mode détaillé si l'un ou l'autre est ouvert.
|
||||
const route = useRoute()
|
||||
const categorie = String(route.query.categorie ?? '')
|
||||
const presetType = computed<TypeSuggere | undefined>(() =>
|
||||
(TYPES_SUGGERES as readonly string[]).includes(categorie) ? (categorie as TypeSuggere) : undefined,
|
||||
)
|
||||
const activeTab = ref<string>(TABS.some(t => t.id === categorie) ? categorie : 'ecosysteme')
|
||||
const detailleOuvert = ref(false)
|
||||
const success = ref(false)
|
||||
|
||||
function onSuccess() {
|
||||
@@ -65,6 +91,7 @@ function onSuccess() {
|
||||
function reset() {
|
||||
success.value = false
|
||||
activeTab.value = 'ecosysteme'
|
||||
detailleOuvert.value = false
|
||||
}
|
||||
|
||||
useHead({ title: 'Proposer une ressource — AEP' })
|
||||
@@ -106,6 +133,30 @@ useHead({ title: 'Proposer une ressource — AEP' })
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mode-detaille {
|
||||
margin-top: 2rem;
|
||||
border-top: 1px solid var(--nav-bg-alt);
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
.mode-detaille-toggle {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--nav-text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
padding: 0.25rem 0;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.mode-detaille-toggle:hover { color: var(--nav-text); }
|
||||
.mode-detaille-panel { margin-top: 1rem; }
|
||||
.mode-detaille-hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--nav-text-muted);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.tabs-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
"note_doublons_en_fr": "3 livres avec version EN aussi indexee dans le RAG pour cross-language queries : carson-mer-autour-de-nous-fr/EN, graeber-wengrow-aurore-fr/EN, saito-capital-anthropocene/EN. JSON conserve la version FR.",
|
||||
"note_v2_1": "Phase 6 refonte Bonpote-aligned : 11 ecoles (fusion Marxismes-ecologiques -> Ecosocialisme, Marx+Saito migres), palette pastel, positions x_hint/y_hint Bonpote-aligned, labels affiches renommes (eco-anarchisme -> Eco-anarchisme, technocritique, ethique-env singulier)",
|
||||
"updated": "2026-05-14",
|
||||
"auteurs_ingeres_count": 80,
|
||||
"auteurs_ingeres_count": 91,
|
||||
"note_v3_0": "Phase 8.A sync corpus unifie : ~140 auteurs Bonpote integres, flag ingere:true/false selon LightRAG VPS. Auteurs non-ingeres = entrees minimales (bio provisoire, livres_rag vide), a enrichir lors de PRG-4/PRG-5.",
|
||||
"note_v3_1": "Phase 8.D transposition 1:1 Bonpote V2 : positions+couleurs Bonpote-aligned via OCR vision Sonnet, blur Voronoi, grisage auteurs non-ingeres",
|
||||
"note_v4_0": "Phase 8.G J+8 : corpus 226 docs / 62 auteurs ingeres. Noeuds-ecoles, popup info RAG, lien Bonpote, chatbots Nebius DeepSeek-V3.2.",
|
||||
"note_v4_1": "Phase 8.G J+8 fin batch : corpus 277 docs / 99 auteurs LightRAG. Mise a jour livres_rag depuis mapping heuristique ID -> slug. Modele ingestion : Qwen3-235B."
|
||||
"note_v4_1": "Phase 8.G J+8 fin batch : corpus 277 docs / 99 auteurs LightRAG. Mise a jour livres_rag depuis mapping heuristique ID -> slug. Modele ingestion : Qwen3-235B.",
|
||||
"note_2026_09_28": "Correction attribution : harvey-brief-history-neoliberalism était dupliqué (heritage du mapping heuristique v4.1) entre hal-harvey (capitalisme-vert, non ingéré) et david-harvey (eco-anarchisme, poster Bonpote). Doublon retiré chez hal-harvey, livre conservé uniquement chez david-harvey (ecoles inchangées, alignées sur le poster Bonpote). auteurs_ingeres_count recalculé par script depuis le contenu JSON (flags ingere:true sur les 171 auteurs) : 91 - remplace la valeur 80 ci-dessus, déjà obsolète avant cette correction (note_v4_1 annonçait 99 côté VPS ; écart à réconcilier séparément, hors périmètre). corpus_ingere (277) et livres_count (64) non recalculés : ils référencent l'état LightRAG VPS, non reproductibles depuis ce JSON seul (mapping heuristique partiel vers livres_rag, cf note_v4_1)."
|
||||
},
|
||||
"ecoles": [
|
||||
{
|
||||
@@ -408,8 +409,13 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"theses_cles_attendues": [],
|
||||
"bio_courte_provisoire": "Théoricien·ne présent·e sur le poster Bonpote (eco-anarchisme), non ingéré·e dans le RAG ATIS.",
|
||||
"theses_cles": [
|
||||
"Accumulation par dépossession",
|
||||
"Le néolibéralisme comme restauration du pouvoir de classe",
|
||||
"Droit à la ville et production capitaliste de l'espace"
|
||||
],
|
||||
"bio_courte": "Géographe marxiste britannique. A Brief History of Neoliberalism (2005) analyse le tournant néolibéral comme projet politique de restauration du pouvoir de la classe possédante, plus que comme doctrine économique neutre.",
|
||||
"note_correction": "Attribution corrigée le 2026-09-28 : le livre était associé par erreur à Hal Harvey (capitalisme-vert, poster Bonpote non ingéré) au lieu de David Harvey.",
|
||||
"ingere": true
|
||||
},
|
||||
{
|
||||
@@ -2124,19 +2130,11 @@
|
||||
"capitalisme-vert"
|
||||
],
|
||||
"ecole_principale": "capitalisme-vert",
|
||||
"livres_rag": [
|
||||
{
|
||||
"slug": "harvey-brief-history-neoliberalism",
|
||||
"titre": "brief history neoliberalism",
|
||||
"annee": 0,
|
||||
"couches": [
|
||||
"fond"
|
||||
]
|
||||
}
|
||||
],
|
||||
"livres_rag": [],
|
||||
"theses_cles_attendues": [],
|
||||
"bio_courte_provisoire": "Théoricien·ne présent·e sur le poster Bonpote (capitalisme-vert), non ingéré·e dans le RAG ATIS.",
|
||||
"ingere": true
|
||||
"note_correction": "Le livre harvey-brief-history-neoliberalism, attribué par erreur, a été retiré le 2026-09-28 (voir entrée david-harvey).",
|
||||
"ingere": false
|
||||
},
|
||||
{
|
||||
"id": "laurence-tubiana",
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Test du circuit breaker budget (server/utils/circuitBreaker.ts), AF5.
|
||||
* Charge le VRAI module TS via jiti (comme test-submit-libre.mjs).
|
||||
*
|
||||
* Le faux NocoDB ci-dessous se comporte comme le vrai sur le point qui a cassé :
|
||||
* toute colonne nommée dans `where`, `fields` ou `sort` et absente du schéma
|
||||
* renvoie un 422. Réintroduire un filtre sur un champ absent fait donc échouer
|
||||
* le cas « schéma sans timestamp ».
|
||||
* Usage : node scripts/test-circuit-breaker.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 { checkBudget, BUDGET_MAX_EUR } = await jiti.import(
|
||||
join(__dirname, '..', 'server', 'utils', 'circuitBreaker.ts'),
|
||||
)
|
||||
|
||||
const NOW = new Date('2026-09-29T10:00:00Z')
|
||||
|
||||
/** Colonnes référencées par une requête NocoDB v2 (where, fields, sort). */
|
||||
function colonnesReferencees(query) {
|
||||
const cols = []
|
||||
if (query.where) {
|
||||
for (const m of String(query.where).matchAll(/\(([^,()]+),/g)) cols.push(m[1].trim())
|
||||
}
|
||||
if (query.fields) cols.push(...String(query.fields).split(',').map((s) => s.trim()))
|
||||
if (query.sort) cols.push(...String(query.sort).split(',').map((s) => s.trim().replace(/^-/, '')))
|
||||
return cols.filter(Boolean)
|
||||
}
|
||||
|
||||
/** Faux NocoDB : schéma de colonnes + lignes ; 422 sur colonne inconnue ; pagination limit/offset. */
|
||||
function fauxNocodb({ schema, rows, panne = false }) {
|
||||
const appels = []
|
||||
const fetchJson = async (url, { headers, query }) => {
|
||||
appels.push({ url, query })
|
||||
if (panne) throw Object.assign(new Error('[GET] 500 Internal Server Error'), { statusCode: 500 })
|
||||
if (headers['xc-token'] !== 'tok') throw Object.assign(new Error('401'), { statusCode: 401 })
|
||||
const inconnues = colonnesReferencees(query).filter((c) => !schema.includes(c))
|
||||
if (inconnues.length) {
|
||||
throw Object.assign(new Error(`[GET] 422 Field '${inconnues[0]}' not found`), { statusCode: 422 })
|
||||
}
|
||||
const limit = Number(query.limit ?? 25)
|
||||
const offset = Number(query.offset ?? 0)
|
||||
const list = rows.slice(offset, offset + limit)
|
||||
return { list, pageInfo: { isLastPage: offset + limit >= rows.length } }
|
||||
}
|
||||
return { fetchJson, appels }
|
||||
}
|
||||
|
||||
const base = { nocodbUrl: 'http://noco', nocodbToken: 'tok', statsTableId: 'mbbq7n47ixy19mc', now: NOW }
|
||||
|
||||
// Schéma supposé de la prod : pas de colonne `timestamp`, date système `CreatedAt`.
|
||||
const SCHEMA_SANS_TS = ['Id', 'model', 'endpoint', 'tokens_in', 'tokens_out', 'cout_eur', 'orga_id', 'CreatedAt', 'UpdatedAt']
|
||||
const SCHEMA_AVEC_TS = [...SCHEMA_SANS_TS, 'timestamp']
|
||||
|
||||
const results = []
|
||||
function check(name, cond, detail = '') {
|
||||
results.push({ name, ok: !!cond, detail })
|
||||
}
|
||||
|
||||
// Journal capturé pour vérifier la ligne de budget
|
||||
const logs = []
|
||||
const origInfo = console.info
|
||||
const origWarn = console.warn
|
||||
console.info = (...a) => logs.push(['info', a.join(' ')])
|
||||
console.warn = (...a) => logs.push(['warn', a.join(' ')])
|
||||
|
||||
try {
|
||||
// 0. Le faux NocoDB rejette bien l'ancienne requête (sinon le test ne prouverait rien)
|
||||
{
|
||||
const { fetchJson } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows: [] })
|
||||
let status = null
|
||||
try {
|
||||
await fetchJson('x', {
|
||||
headers: { 'xc-token': 'tok' },
|
||||
query: { where: '(timestamp,gte,2026-09-01T00:00:00.000Z)', limit: 1000, fields: 'cout_eur,timestamp' },
|
||||
})
|
||||
} catch (e) {
|
||||
status = e.statusCode
|
||||
}
|
||||
check('faux NocoDB : ancienne requête (where/fields sur timestamp) → 422', status === 422, `statut ${status}`)
|
||||
}
|
||||
|
||||
// 1. Schéma sans timestamp : lu, mois courant seul, aucune colonne nommée
|
||||
{
|
||||
const rows = [
|
||||
{ Id: 1, cout_eur: 0.5, CreatedAt: '2026-09-03T08:00:00.000Z' },
|
||||
{ Id: 2, cout_eur: '1.25', CreatedAt: '2026-09-28 22:10:00+00:00' },
|
||||
{ Id: 3, cout_eur: 7, CreatedAt: '2026-08-31T23:59:00.000Z' }, // mois précédent
|
||||
{ Id: 4, cout_eur: null, CreatedAt: '2026-09-10T00:00:00.000Z' },
|
||||
]
|
||||
const { fetchJson, appels } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows })
|
||||
logs.length = 0
|
||||
const s = await checkBudget({ ...base, fetchJson })
|
||||
check('sans timestamp : budget vérifié (pas de 422)', s.verified === true, JSON.stringify(s))
|
||||
check('sans timestamp : cumul = 1,75 € (août exclu)', Math.abs(s.cumulEur - 1.75) < 1e-9, `cumul ${s.cumulEur}`)
|
||||
check('sans timestamp : 3 lignes du mois sur 4', s.lignesMois === 3 && s.lignesLues === 4)
|
||||
check('aucune colonne nommée dans la requête', appels.every((a) => colonnesReferencees(a.query).length === 0),
|
||||
JSON.stringify(appels.map((a) => a.query)))
|
||||
const ligne = logs.find(([niv, t]) => niv === 'info' && t.includes('[circuitBreaker] budget lu'))
|
||||
check('journal : ligne « budget lu » avec le cumul', ligne && ligne[1].includes('1.7500 €'), ligne?.[1] ?? 'absente')
|
||||
}
|
||||
|
||||
// 2. Schéma avec timestamp : timestamp prioritaire sur CreatedAt (ordre du worker)
|
||||
{
|
||||
const rows = [
|
||||
{ Id: 1, cout_eur: 2, timestamp: '2026-09-15T12:00:00.000Z', CreatedAt: '2026-08-15T12:00:00.000Z' },
|
||||
{ Id: 2, cout_eur: 3, timestamp: '2026-08-15T12:00:00.000Z', CreatedAt: '2026-09-15T12:00:00.000Z' },
|
||||
]
|
||||
const { fetchJson } = fauxNocodb({ schema: SCHEMA_AVEC_TS, rows })
|
||||
const s = await checkBudget({ ...base, fetchJson })
|
||||
check('avec timestamp : timestamp prioritaire (cumul 2 €)', s.verified && s.cumulEur === 2, `cumul ${s.cumulEur}`)
|
||||
}
|
||||
|
||||
// 3. Lignes sans aucune date : ignorées et comptées
|
||||
{
|
||||
const rows = [{ Id: 1, cout_eur: 9 }, { Id: 2, cout_eur: 1, CreatedAt: '2026-09-02T00:00:00Z' }]
|
||||
const { fetchJson } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows })
|
||||
logs.length = 0
|
||||
const s = await checkBudget({ ...base, fetchJson })
|
||||
check('sans date : ignorée, comptée', s.cumulEur === 1 && s.lignesSansDate === 1)
|
||||
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' }))
|
||||
const { fetchJson, appels } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows })
|
||||
logs.length = 0
|
||||
const s = await checkBudget({ ...base, fetchJson })
|
||||
check('pagination : 2 500 lignes lues en 3 pages', s.lignesLues === 2500 && appels.length === 3, `${s.lignesLues} lignes, ${appels.length} appels`)
|
||||
check(`seuil : 25 € ≥ ${BUDGET_MAX_EUR} € → blocked`, s.blocked === true && s.warning === true)
|
||||
check('journal : BLOQUÉ', logs.some(([, t]) => t.includes('BLOQUÉ')))
|
||||
}
|
||||
|
||||
// 5. Alerte entre 18 et 20 €
|
||||
{
|
||||
const rows = [{ Id: 1, cout_eur: 18.5, CreatedAt: '2026-09-20T00:00:00Z' }]
|
||||
const { fetchJson } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows })
|
||||
const s = await checkBudget({ ...base, fetchJson })
|
||||
check('18,5 € → warning sans blocage', s.warning === true && s.blocked === false)
|
||||
}
|
||||
|
||||
// 6. Panne NocoDB : fail-open, mais verified=false et avertissement au journal
|
||||
{
|
||||
const { fetchJson } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows: [], panne: true })
|
||||
logs.length = 0
|
||||
const s = await checkBudget({ ...base, fetchJson })
|
||||
check('panne : fail-open, verified=false', s.blocked === false && s.verified === false)
|
||||
check('panne : avertissement « NON vérifié »', logs.some(([niv, t]) => niv === 'warn' && t.includes('NON vérifié')))
|
||||
}
|
||||
} finally {
|
||||
console.info = origInfo
|
||||
console.warn = origWarn
|
||||
}
|
||||
|
||||
let failed = 0
|
||||
for (const r of results) {
|
||||
if (!r.ok) failed++
|
||||
console.log(`${r.ok ? 'ok ' : 'FAIL'} ${r.name}${r.ok ? '' : ` → ${r.detail}`}`)
|
||||
}
|
||||
console.log(failed ? `\nÉCHEC — ${failed}/${results.length} tests en échec` : `\nPASS — ${results.length}/${results.length} tests passés`)
|
||||
process.exit(failed ? 1 : 0)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Test du schéma Zod du formulaire assoupli (B5-M1).
|
||||
* Charge le VRAI schéma TS (utils/submitLibre.ts) via jiti (déjà une dépendance
|
||||
* transitive de Nuxt, pas de nouveau paquet) — pas de réimplémentation parallèle.
|
||||
* Usage : node scripts/test-submit-libre.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 { LibreSubmitSchema, toOrgaPayload, toReferencePayload } = await jiti.import(
|
||||
join(__dirname, '..', 'utils', 'submitLibre.ts'),
|
||||
)
|
||||
|
||||
function baseInput(overrides) {
|
||||
return { liens: [], texte: '', site_web: '', ...overrides }
|
||||
}
|
||||
|
||||
const cases = [
|
||||
{
|
||||
name: 'vide (aucun lien, aucun texte) → refus',
|
||||
input: baseInput({}),
|
||||
expectOk: false,
|
||||
},
|
||||
{
|
||||
name: '1 lien seul → ok',
|
||||
input: baseInput({ liens: ['https://exemple.fr'] }),
|
||||
expectOk: true,
|
||||
},
|
||||
{
|
||||
name: 'texte seul → ok',
|
||||
input: baseInput({ texte: "Ça m'a beaucoup aidé pour ma reconversion." }),
|
||||
expectOk: true,
|
||||
},
|
||||
{
|
||||
name: '11 liens → refus',
|
||||
input: baseInput({ liens: Array.from({ length: 11 }, (_, i) => `https://exemple${i}.fr`) }),
|
||||
expectOk: false,
|
||||
},
|
||||
{
|
||||
name: 'URL invalide (pas de schéma) → refus',
|
||||
input: baseInput({ liens: ['exemple.fr'] }),
|
||||
expectOk: false,
|
||||
},
|
||||
{
|
||||
name: 'ftp:// → refus',
|
||||
input: baseInput({ liens: ['ftp://exemple.fr/fichier.pdf'] }),
|
||||
expectOk: false,
|
||||
},
|
||||
{
|
||||
name: 'double type valide (jobs + outils) → ok',
|
||||
input: baseInput({ liens: ['https://exemple.fr'], type_suggere: 'jobs', type_secondaire: 'outils' }),
|
||||
expectOk: true,
|
||||
},
|
||||
{
|
||||
name: 'type_secondaire sans type_suggere → refus',
|
||||
input: baseInput({ liens: ['https://exemple.fr'], type_secondaire: 'outils' }),
|
||||
expectOk: false,
|
||||
},
|
||||
{
|
||||
name: 'type_secondaire = type_suggere → refus',
|
||||
input: baseInput({ liens: ['https://exemple.fr'], type_suggere: 'jobs', type_secondaire: 'jobs' }),
|
||||
expectOk: false,
|
||||
},
|
||||
{
|
||||
name: 'type inconnu → refus',
|
||||
input: baseInput({ liens: ['https://exemple.fr'], type_suggere: 'inconnu' }),
|
||||
expectOk: false,
|
||||
},
|
||||
]
|
||||
|
||||
let failures = 0
|
||||
|
||||
for (const c of cases) {
|
||||
const result = LibreSubmitSchema.safeParse(c.input)
|
||||
const pass = result.success === c.expectOk
|
||||
const detail = result.success ? '' : ` (${JSON.stringify(result.error.flatten().fieldErrors)})`
|
||||
console.log(`${pass ? 'PASS' : 'FAIL'} — ${c.name}${pass ? '' : detail}`)
|
||||
if (!pass) failures++
|
||||
}
|
||||
|
||||
// ── Mappings (toOrgaPayload / toReferencePayload) ──────────────────────────
|
||||
function checkMapping(name, ok) {
|
||||
console.log(`${ok ? 'PASS' : 'FAIL'} — ${name}`)
|
||||
if (!ok) failures++
|
||||
}
|
||||
|
||||
{
|
||||
const data = LibreSubmitSchema.parse(baseInput({ liens: ['https://exemple.fr'] }))
|
||||
const payload = toOrgaPayload(data)
|
||||
checkMapping(
|
||||
'aucun type → description_user commence par « Type : non précisé »',
|
||||
payload.description_user.startsWith('Type : non précisé'),
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
const data = LibreSubmitSchema.parse(baseInput({ liens: ['https://exemple.fr'], type_suggere: 'jobs' }))
|
||||
const payload = toOrgaPayload(data)
|
||||
checkMapping(
|
||||
'un type → pas de marqueur ni de ligne « Type suggéré »',
|
||||
!payload.description_user.startsWith('Type : non précisé') && !payload.description_user.includes('Type suggéré'),
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
const data = LibreSubmitSchema.parse(
|
||||
baseInput({ liens: ['https://exemple.fr'], type_suggere: 'jobs', type_secondaire: 'outils' }),
|
||||
)
|
||||
const payload = toOrgaPayload(data)
|
||||
checkMapping(
|
||||
'double type → description_user commence par « Type suggéré : Jobs ou Outils »',
|
||||
payload.description_user.startsWith('Type suggéré : Jobs ou Outils'),
|
||||
)
|
||||
checkMapping('double type → submission_type = le premier (job)', payload.submission_type === 'job')
|
||||
checkMapping(
|
||||
'double type → ne commence PAS par « Type : non précisé »',
|
||||
!payload.description_user.startsWith('Type : non précisé'),
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
const data = LibreSubmitSchema.parse(
|
||||
baseInput({ liens: ['https://exemple.fr'], type_suggere: 'references', type_secondaire: 'outils' }),
|
||||
)
|
||||
const payload = toReferencePayload(data)
|
||||
checkMapping(
|
||||
'premier = Références + second → description commence par « Type suggéré : Références ou Outils »',
|
||||
payload.description.startsWith('Type suggéré : Références ou Outils'),
|
||||
)
|
||||
}
|
||||
|
||||
const totalMappingChecks = 6
|
||||
console.log(`\n${failures === 0 ? 'PASS' : 'FAIL'} — ${cases.length + totalMappingChecks - failures}/${cases.length + totalMappingChecks} tests passés`)
|
||||
process.exit(failures > 0 ? 1 : 0)
|
||||
@@ -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)
|
||||
@@ -89,7 +89,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const query: string = body.query.trim()
|
||||
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
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* POST /api/chatbot-reseaux
|
||||
* Chatbot Réseaux AEP — Carte 2 "Réseaux de bifurcation"
|
||||
* Keyword search sur reseaux-bifurcation.json + Mistral Small.
|
||||
* Keyword search sur reseaux-bifurcation.json + LLM via Bifrost (server/utils/bifrost.ts).
|
||||
*/
|
||||
// @ts-ignore — JSON import résolu par Rollup
|
||||
import reseauxData from '../../public/data/reseaux-bifurcation.json'
|
||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||
import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost'
|
||||
import { pickBifrostTier, bifrostContent, REASONING_MARGIN_TOKENS, type BifrostChatResponse } from '~/server/utils/bifrost'
|
||||
|
||||
interface Structure {
|
||||
id: string
|
||||
@@ -99,7 +99,7 @@ export default defineEventHandler(async (event) => {
|
||||
model: tier.model,
|
||||
fallbacks: tier.fallbacks,
|
||||
temperature: 0.3,
|
||||
max_tokens: 700,
|
||||
max_tokens: 700 + REASONING_MARGIN_TOKENS,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
@@ -108,7 +108,7 @@ export default defineEventHandler(async (event) => {
|
||||
}),
|
||||
}
|
||||
)
|
||||
mistralRaw = res.choices?.[0]?.message?.content ?? '{}'
|
||||
mistralRaw = bifrostContent(res, 'chatbot-reseaux')
|
||||
} catch {
|
||||
throw createError({ statusCode: 502, message: 'Erreur IA — réessaie dans quelques instants.' })
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* POST /api/chatbot-taff
|
||||
* Chatbot d'aiguillage — Carte 3 "Trouver du taf"
|
||||
* Lit plateformes-taff.json, appelle Mistral Small, retourne recommandations.
|
||||
* Lit plateformes-taff.json, appelle un LLM via Bifrost (server/utils/bifrost.ts), retourne recommandations.
|
||||
*/
|
||||
|
||||
// @ts-ignore — JSON import résolu par Vite/Rollup
|
||||
import taffData from '../../public/data/plateformes-taff.json'
|
||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||
import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost'
|
||||
import { pickBifrostTier, bifrostContent, REASONING_MARGIN_TOKENS, type BifrostChatResponse } from '~/server/utils/bifrost'
|
||||
|
||||
interface PlateformeMinimal {
|
||||
id: string
|
||||
@@ -110,7 +110,7 @@ export default defineEventHandler(async (event) => {
|
||||
model: tier.model,
|
||||
fallbacks: tier.fallbacks,
|
||||
temperature: 0.3,
|
||||
max_tokens: 700,
|
||||
max_tokens: 700 + REASONING_MARGIN_TOKENS,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
@@ -119,7 +119,7 @@ export default defineEventHandler(async (event) => {
|
||||
}),
|
||||
}
|
||||
)
|
||||
mistralRaw = res.choices?.[0]?.message?.content ?? '{}'
|
||||
mistralRaw = bifrostContent(res, 'chatbot-taff')
|
||||
} catch {
|
||||
throw createError({ statusCode: 502, statusMessage: 'Erreur IA — réessaie dans quelques instants.' })
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* POST /api/chatbot
|
||||
*
|
||||
* Chatbot recherche sémantique — Mistral Small
|
||||
* Chatbot recherche sémantique — LLM via Bifrost (tiers et replis : server/utils/bifrost.ts)
|
||||
* Spec : F §7 (endpoint), F §8 (rate limit), E-spec §6 (détails chatbot)
|
||||
*
|
||||
* Flow :
|
||||
* 1. Rate limit : 10 req/IP/jour (JSON fichier, SHA-256)
|
||||
* 2. Circuit breaker : budget 20€/mois
|
||||
* 3. Fetch top-N fiches (keyword match sur nom+description+fonctions)
|
||||
* 4. Appel Mistral Small avec contexte JSON compact
|
||||
* 4. Appel Bifrost (tier RAPIDE par défaut) avec contexte JSON compact
|
||||
* 5. Parse JSON → { reponse_texte, fiches_recommandees }
|
||||
* 6. Log stats_usage
|
||||
*
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
|
||||
import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost'
|
||||
import { pickBifrostTier, bifrostContent, REASONING_MARGIN_TOKENS, type BifrostChatResponse } from '~/server/utils/bifrost'
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -277,7 +277,7 @@ export default defineEventHandler(async (event) => {
|
||||
model: tier.model,
|
||||
fallbacks: tier.fallbacks,
|
||||
temperature: 0.3,
|
||||
max_tokens: 600,
|
||||
max_tokens: 600 + REASONING_MARGIN_TOKENS,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
@@ -286,7 +286,7 @@ export default defineEventHandler(async (event) => {
|
||||
}),
|
||||
})
|
||||
|
||||
mistralRaw = bifrostRes.choices?.[0]?.message?.content ?? '{}'
|
||||
mistralRaw = bifrostContent(bifrostRes, 'chatbot')
|
||||
tokensIn = bifrostRes.usage?.prompt_tokens ?? 0
|
||||
tokensOut = bifrostRes.usage?.completion_tokens ?? 0
|
||||
realModel =
|
||||
|
||||
+134
-37
@@ -14,6 +14,8 @@
|
||||
|
||||
import { z } from 'zod'
|
||||
import { checkRateLimit } from '~/server/utils/rateLimit'
|
||||
import { notifyNtfy, buildSubmissionNotif } from '~/server/utils/notify'
|
||||
import { LibreSubmitSchema, toOrgaPayload, toReferencePayload, type LibreSubmitInput } from '~/utils/submitLibre'
|
||||
|
||||
const SUBMISSION_TYPES = ['ecosysteme', 'reseau', 'job', 'outil'] as const
|
||||
|
||||
@@ -103,6 +105,15 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
// 3. Lire et valider le body
|
||||
const body = await readBody(event)
|
||||
|
||||
// Formulaire assoupli (B5-M1) — liens + texte libre, type suggéré optionnel.
|
||||
if (body?.mode === 'libre') {
|
||||
return handleLibreSubmit(body)
|
||||
}
|
||||
|
||||
// (rate limit déjà vérifié ci-dessus pour les deux modes)
|
||||
|
||||
// Mode détaillé (5 formulaires typés, conservés derrière un lien discret)
|
||||
const parsed = SubmitSchema.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
@@ -188,10 +199,17 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const submissionId = insertedRecord?.Id ?? insertedRecord?.id ?? null
|
||||
|
||||
// 6. Notification email Jules (fire-and-forget — n'impacte pas la réponse)
|
||||
notifyJules(config, data, submissionId).catch((e) =>
|
||||
console.warn('[submit] Email notification échouée:', e?.message ?? e),
|
||||
)
|
||||
// 6. Notification ntfy à Jules (fire-and-forget — n'impacte pas la réponse)
|
||||
notifyNtfy(
|
||||
process.env.NTFY_TOPIC,
|
||||
buildSubmissionNotif({
|
||||
submissionId,
|
||||
nomSuggere: data.nom,
|
||||
typeSuggere: data.submission_type,
|
||||
nbLiens: data.url ? 1 : 0,
|
||||
nocodbAdminUrl: NOCODB_ADMIN_URL,
|
||||
}),
|
||||
).catch((e) => console.warn('[submit] Notification ntfy échouée:', e?.message ?? e))
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
@@ -203,40 +221,119 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
})
|
||||
|
||||
async function notifyJules(
|
||||
config: ReturnType<typeof useRuntimeConfig>,
|
||||
data: SubmitInput,
|
||||
submissionId: number | null,
|
||||
): Promise<void> {
|
||||
if (!config.resendApiKey) return
|
||||
// ── Formulaire assoupli (B5-M1) ─────────────────────────────────────────────
|
||||
|
||||
const nocoAdminUrl = `http://localhost:8070`
|
||||
const body = {
|
||||
from: 'AEP Carte <contact@trans-former.fr>',
|
||||
to: [config.emailJules],
|
||||
subject: `[AEP] Nouvelle fiche soumise : ${data.nom}`,
|
||||
html: `
|
||||
<p><strong>Nouvelle fiche en attente de modération.</strong></p>
|
||||
<ul>
|
||||
<li><strong>Nom :</strong> ${data.nom}</li>
|
||||
<li><strong>URL :</strong> ${data.url ?? '—'}</li>
|
||||
<li><strong>Échelle :</strong> ${data.echelle}</li>
|
||||
<li><strong>Territoire :</strong> ${data.territoire}</li>
|
||||
<li><strong>Fonctions :</strong> ${data.fonctions.join(', ')}</li>
|
||||
<li><strong>Description :</strong> ${data.description_user}</li>
|
||||
${data.submitted_by_email ? `<li><strong>Email soumetteur :</strong> ${data.submitted_by_email}</li>` : ''}
|
||||
${submissionId ? `<li><strong>ID NocoDB :</strong> ${submissionId}</li>` : ''}
|
||||
</ul>
|
||||
<p><a href="${nocoAdminUrl}">Ouvrir NocoDB pour valider</a></p>
|
||||
`,
|
||||
async function handleLibreSubmit(body: unknown) {
|
||||
const parsed = LibreSubmitSchema.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
throw createError({
|
||||
statusCode: 422,
|
||||
statusMessage: 'Validation échouée',
|
||||
data: parsed.error.flatten().fieldErrors,
|
||||
})
|
||||
}
|
||||
|
||||
await $fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.resendApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = parsed.data
|
||||
|
||||
// Honeypot : un bot remplit ce champ caché — on répond succès sans rien écrire.
|
||||
if (data.site_web) {
|
||||
return {
|
||||
status: 201,
|
||||
submissionId: null,
|
||||
message: 'Ta fiche est en cours de traitement.',
|
||||
trackingUrl: null,
|
||||
}
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
// Chip « Références » → table ressources_references, pas la table orgas.
|
||||
if (data.type_suggere === 'references') {
|
||||
return insertReferenceLibre(config, data)
|
||||
}
|
||||
|
||||
const payload = toOrgaPayload(data)
|
||||
const insertUrl = `${config.nocodbUrl}/api/v1/db/data/noco/${config.nocodbBase}/${config.orgTableId}`
|
||||
|
||||
let insertedRecord: any
|
||||
try {
|
||||
insertedRecord = await $fetch(insertUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'xc-token': config.nocodbToken, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
} catch (e: any) {
|
||||
console.error('[submit] Erreur NocoDB insert (libre):', e?.message ?? e)
|
||||
throw createError({ statusCode: 502, statusMessage: 'Erreur serveur — réessaie dans quelques instants.' })
|
||||
}
|
||||
|
||||
const submissionId = insertedRecord?.Id ?? insertedRecord?.id ?? null
|
||||
|
||||
notifyNtfy(
|
||||
process.env.NTFY_TOPIC,
|
||||
buildSubmissionNotif({
|
||||
submissionId,
|
||||
nomSuggere: nomPourNotif(payload.nom, data),
|
||||
typeSuggere: payload.submission_type,
|
||||
nbLiens: data.liens.length,
|
||||
nocodbAdminUrl: NOCODB_ADMIN_URL,
|
||||
}),
|
||||
).catch((e) => console.warn('[submit] Notification ntfy échouée (libre):', e?.message ?? e))
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
submissionId,
|
||||
message: 'Ta fiche est en cours de traitement.',
|
||||
trackingUrl: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** URL de modération cliquable depuis la notification (téléphone) — jamais localhost. */
|
||||
const NOCODB_ADMIN_URL = 'https://sheets.trans-former.fr'
|
||||
|
||||
/**
|
||||
* Sans lien, `nomSuggere()` retombe sur les 60 premiers caractères du texte libre :
|
||||
* ce texte ne doit jamais partir vers ntfy (topic public par URL). On ne transmet
|
||||
* le nom que lorsqu'il est dérivé d'un hostname.
|
||||
*/
|
||||
function nomPourNotif(nom: string, data: LibreSubmitInput): string {
|
||||
return data.liens.length > 0 ? nom : '[à qualifier] texte seul (voir NocoDB)'
|
||||
}
|
||||
|
||||
async function insertReferenceLibre(config: ReturnType<typeof useRuntimeConfig>, data: LibreSubmitInput) {
|
||||
const payload = toReferencePayload(data)
|
||||
const insertUrl = `${config.nocodbUrl}/api/v1/db/data/noco/${config.nocodbBase}/${config.referencesTableId}`
|
||||
|
||||
let insertedRecord: any
|
||||
try {
|
||||
insertedRecord = await $fetch(insertUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'xc-token': config.nocodbToken, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
} catch (e: any) {
|
||||
console.error('[submit] Erreur NocoDB insert (référence libre):', e?.message ?? e)
|
||||
throw createError({ statusCode: 502, statusMessage: 'Erreur serveur — réessaie dans quelques instants.' })
|
||||
}
|
||||
|
||||
const referenceId = insertedRecord?.Id ?? insertedRecord?.id ?? null
|
||||
|
||||
notifyNtfy(
|
||||
process.env.NTFY_TOPIC,
|
||||
buildSubmissionNotif({
|
||||
submissionId: referenceId,
|
||||
nomSuggere: nomPourNotif(payload.titre, data),
|
||||
typeSuggere: 'references',
|
||||
nbLiens: data.liens.length,
|
||||
nocodbAdminUrl: NOCODB_ADMIN_URL,
|
||||
}),
|
||||
).catch((e) => console.warn('[submit] Notification ntfy échouée (référence libre):', e?.message ?? e))
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
submissionId: referenceId,
|
||||
message: 'Ta référence est en attente de modération.',
|
||||
trackingUrl: null,
|
||||
}
|
||||
}
|
||||
|
||||
+39
-4
@@ -3,10 +3,24 @@
|
||||
* Endpoint OpenAI-compatible : POST {bifrostUrl}/v1/chat/completions
|
||||
* Auth : header x-bf-vk
|
||||
*
|
||||
* 2 tiers validés (Mission M3, build Bifrost) :
|
||||
* 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)
|
||||
*
|
||||
* ⚠ 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
|
||||
* chatbot tombait sur le premier repli Gemini (stats_usage). Primaires passés sur
|
||||
* groq/openai/gpt-oss-20b et groq/openai/gpt-oss-120b ; replis inchangés. Le worker
|
||||
* /opt/aep-worker tourne sur gpt-oss-20b en prod depuis le 28/09 (WORKER_MODEL, json_object) :
|
||||
* Bifrost accepte ce nom de modèle à slash côté Groq.
|
||||
* ⚠ gpt-oss est un modèle à raisonnement :
|
||||
* - la réponse porte un champ message.reasoning à côté de message.content. Les routes
|
||||
* chatbot ne lisent que content (JSON), usage et extra_fields : le champ est ignoré ;
|
||||
* - les tokens de raisonnement sont décomptés de max_tokens. Sans marge, le JSON de la
|
||||
* réponse peut être tronqué ; rendu en HTTP 200, Bifrost ne bascule pas, JSON.parse
|
||||
* échoue → « Je n'ai pas pu analyser ta demande ». Déjà vu le 15/07 : gpt-oss-120b (via Cerebras), 3 appels
|
||||
* corrects sur 4, « tronque parfois à max_tokens 700 ». D'où REASONING_MARGIN_TOKENS, ajouté par
|
||||
* chaque route à son budget de contenu.
|
||||
* ⚠ openrouter-oai exclu (bug Bifrost confirmé — 404 HTML sur modèles avec slash)
|
||||
* ⚠ gemini-oai exige le préfixe "models/" (sinon 403 silencieux)
|
||||
* ⚠ cerebras/gemma-4-31b RETIRÉ du tier RAPIDE (M4, 15/07) : en JSON mode avec un contexte
|
||||
@@ -19,7 +33,7 @@
|
||||
*/
|
||||
|
||||
export const BIFROST_TIER_RAPIDE = {
|
||||
model: 'groq/llama-3.1-8b-instant',
|
||||
model: 'groq/openai/gpt-oss-20b',
|
||||
fallbacks: [
|
||||
'gemini-oai/models/gemini-2.5-flash-lite',
|
||||
'cohere/command-r-08-2024',
|
||||
@@ -27,7 +41,7 @@ export const BIFROST_TIER_RAPIDE = {
|
||||
}
|
||||
|
||||
export const BIFROST_TIER_APPROFONDI = {
|
||||
model: 'groq/llama-3.3-70b-versatile',
|
||||
model: 'groq/openai/gpt-oss-120b',
|
||||
fallbacks: [
|
||||
'gemini-oai/models/gemini-2.5-flash',
|
||||
'mistral/mistral-large-latest',
|
||||
@@ -35,13 +49,34 @@ export const BIFROST_TIER_APPROFONDI = {
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokens ajoutés au budget de contenu de chaque route (max_tokens = contenu + marge) pour
|
||||
* absorber le raisonnement de gpt-oss. Plafond seulement : les replis sans raisonnement
|
||||
* n'écrivent pas plus long, les prompts bornent la réponse (200-250 mots). Rester modeste :
|
||||
* la limite de tokens par minute d'une clé Groq peut compter max_tokens avec le prompt.
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
export interface BifrostChatResponse {
|
||||
choices: { message: { content: string } }[]
|
||||
/** reasoning : présent avec gpt-oss, jamais lu (la réponse utile est dans content). */
|
||||
choices: { message: { content: string; reasoning?: string }; finish_reason?: string }[]
|
||||
usage?: { prompt_tokens: number; completion_tokens: number }
|
||||
extra_fields?: { provider?: string; resolved_model_used?: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function bifrostContent(res: BifrostChatResponse, route: string): string {
|
||||
const choice = res.choices?.[0]
|
||||
if (choice?.finish_reason === 'length') {
|
||||
console.warn(`[${route}] réponse tronquée (finish_reason=length) : relever REASONING_MARGIN_TOKENS`)
|
||||
}
|
||||
return choice?.message?.content ?? '{}'
|
||||
}
|
||||
|
||||
+100
-27
@@ -19,53 +19,126 @@ export interface BudgetStatus {
|
||||
cumulEur: number
|
||||
blocked: boolean
|
||||
warning: boolean
|
||||
/** false si stats_usage n'a pas pu être lu : le budget n'est alors PAS vérifié (fail-open). */
|
||||
verified: boolean
|
||||
/** Lignes lues dans stats_usage (toutes dates). */
|
||||
lignesLues: number
|
||||
/** Lignes retenues pour le mois courant. */
|
||||
lignesMois: number
|
||||
/** Lignes sans aucun champ date reconnu (ignorées, comme dans le worker). */
|
||||
lignesSansDate: number
|
||||
}
|
||||
|
||||
/** Page NocoDB v2 : seuls les champs lus ici sont typés. */
|
||||
interface StatsPage {
|
||||
list?: Record<string, unknown>[]
|
||||
pageInfo?: { isLastPage?: boolean }
|
||||
}
|
||||
|
||||
type FetchJson = (
|
||||
url: string,
|
||||
opts: { headers: Record<string, string>; query: Record<string, string | number> },
|
||||
) => Promise<StatsPage>
|
||||
|
||||
const PAGE_SIZE = 1000
|
||||
const MAX_PAGES = 20 // 20 000 lignes : garde-fou contre une boucle, pas une limite métier
|
||||
|
||||
/**
|
||||
* Date d'une ligne de stats_usage, dans l'ordre du worker (`worker/enrich.js`,
|
||||
* getBudgetMoisCourant) : `timestamp` écrit par les routes et le worker, sinon
|
||||
* la colonne système de NocoDB (`CreatedAt`, ou `created_at` selon la version).
|
||||
*/
|
||||
export function dateLigne(row: Record<string, unknown>): Date | null {
|
||||
const brut = row.timestamp ?? row.CreatedAt ?? row.created_at
|
||||
if (brut === null || brut === undefined || brut === '') return null
|
||||
const d = new Date(brut as string)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule le cumul de dépenses IA du mois courant depuis stats_usage NocoDB.
|
||||
* Retourne blocked=true si le budget est atteint.
|
||||
*
|
||||
* AF5 (29/09) : la requête ne nomme plus AUCUNE colonne (ni `where`, ni `fields`,
|
||||
* ni `sort`). L'ancienne version filtrait sur `timestamp` côté NocoDB et recevait
|
||||
* un 422 à chaque appel : budget jamais vérifié côté chatbot, en silence
|
||||
* (fail-open). On lit la table page par page et on filtre le mois en JS,
|
||||
* comme le worker, qui lit la même table sans erreur.
|
||||
*/
|
||||
export async function checkBudget(config: {
|
||||
nocodbUrl: string
|
||||
nocodbToken: string
|
||||
statsTableId: string
|
||||
/** Injecté par les tests ; `$fetch` de Nitro sinon. */
|
||||
fetchJson?: FetchJson
|
||||
/** Injecté par les tests ; maintenant sinon. */
|
||||
now?: Date
|
||||
}): Promise<BudgetStatus> {
|
||||
const { nocodbUrl, nocodbToken, statsTableId } = config
|
||||
const fetchJson: FetchJson = config.fetchJson ?? ((url, opts) => $fetch<StatsPage>(url, opts))
|
||||
const now = config.now ?? new Date()
|
||||
const annee = now.getUTCFullYear()
|
||||
const mois = now.getUTCMonth()
|
||||
|
||||
// Premier du mois courant à minuit UTC
|
||||
const now = new Date()
|
||||
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))
|
||||
const monthStartIso = monthStart.toISOString()
|
||||
const url = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
|
||||
let cumulEur = 0
|
||||
let lignesLues = 0
|
||||
let lignesMois = 0
|
||||
let lignesSansDate = 0
|
||||
|
||||
try {
|
||||
// Fetch toutes les entrées du mois courant (NocoDB v2)
|
||||
const url = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
|
||||
|
||||
const res = await $fetch<{ list: { cout_eur: number | null; timestamp: string }[] }>(
|
||||
url,
|
||||
{
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
const res = await fetchJson(url, {
|
||||
headers: { 'xc-token': nocodbToken },
|
||||
query: {
|
||||
where: `(timestamp,gte,${monthStartIso})`,
|
||||
limit: 1000,
|
||||
fields: 'cout_eur,timestamp',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const rows = res?.list ?? []
|
||||
const cumulEur = rows.reduce((sum, row) => sum + (Number(row.cout_eur) || 0), 0)
|
||||
|
||||
return {
|
||||
cumulEur,
|
||||
blocked: cumulEur >= BUDGET_MAX_EUR,
|
||||
warning: cumulEur >= BUDGET_WARN_EUR,
|
||||
query: { limit: PAGE_SIZE, offset: page * PAGE_SIZE },
|
||||
})
|
||||
const rows = res?.list ?? []
|
||||
for (const row of rows) {
|
||||
lignesLues++
|
||||
const d = dateLigne(row)
|
||||
if (!d) {
|
||||
lignesSansDate++
|
||||
continue
|
||||
}
|
||||
if (d.getUTCFullYear() === annee && d.getUTCMonth() === mois) {
|
||||
lignesMois++
|
||||
cumulEur += Number(row.cout_eur) || 0
|
||||
}
|
||||
}
|
||||
if (rows.length < PAGE_SIZE || res?.pageInfo?.isLastPage) break
|
||||
}
|
||||
} catch (e) {
|
||||
// En cas d'erreur de lecture, on ne bloque PAS pour ne pas pénaliser les utilisateurs
|
||||
console.warn('[circuitBreaker] Erreur lecture stats_usage — budget non vérifié:', (e as Error).message)
|
||||
return { cumulEur: 0, blocked: false, warning: false }
|
||||
console.warn('[circuitBreaker] Erreur lecture stats_usage — budget NON vérifié:', (e as Error).message)
|
||||
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,
|
||||
warning: cumulEur >= BUDGET_WARN_EUR,
|
||||
verified: true,
|
||||
lignesLues,
|
||||
lignesMois,
|
||||
lignesSansDate,
|
||||
}
|
||||
const mm = String(mois + 1).padStart(2, '0')
|
||||
const suffixe = status.blocked ? ' — BLOQUÉ' : status.warning ? ' — alerte' : ''
|
||||
const sansDate = lignesSansDate ? `, ${lignesSansDate} sans date ignorées` : ''
|
||||
console.info(
|
||||
`[circuitBreaker] budget lu : ${cumulEur.toFixed(4)} € / ${BUDGET_MAX_EUR} € ` +
|
||||
`(${lignesMois} lignes ${annee}-${mm} sur ${lignesLues} lues${sansDate})${suffixe}`,
|
||||
)
|
||||
return status
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Notification ntfy.sh — remplace Resend (abandonné le 15/07, domaine non vérifié).
|
||||
* POST https://ntfy.sh/${NTFY_TOPIC}
|
||||
*
|
||||
* RÈGLE : un topic ntfy est une URL, pas un coffre — le message ne contient
|
||||
* JAMAIS l'email du contributeur ni le texte libre soumis. Seulement des
|
||||
* métadonnées de modération (id NocoDB, nom suggéré, type, nb de liens).
|
||||
*
|
||||
* Si NTFY_TOPIC est absent : log et continue (pas d'erreur bloquante).
|
||||
*/
|
||||
|
||||
export interface NtfyNotifyInput {
|
||||
title: string
|
||||
message: string
|
||||
/** Priorité ntfy 1-5 (3 = défaut) */
|
||||
priority?: number
|
||||
tags?: string[]
|
||||
/** Lien cliqué depuis la notif (ex : NocoDB) */
|
||||
clickUrl?: string
|
||||
}
|
||||
|
||||
export async function notifyNtfy(topic: string | undefined, input: NtfyNotifyInput): Promise<void> {
|
||||
if (!topic) {
|
||||
console.warn('[notify] NTFY_TOPIC absent — notification skippée:', input.title)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
Title: encodeHeaderValue(input.title),
|
||||
Priority: String(input.priority ?? 3),
|
||||
}
|
||||
if (input.tags?.length) headers.Tags = input.tags.join(',')
|
||||
if (input.clickUrl) headers.Click = input.clickUrl
|
||||
|
||||
const res = await fetch(`https://ntfy.sh/${topic}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: input.message,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn('[notify] ntfy erreur HTTP:', res.status, await res.text())
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('[notify] ntfy exception:', e?.message ?? e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le message de notification pour une nouvelle soumission.
|
||||
* N'inclut jamais l'email ni le texte libre du contributeur.
|
||||
*/
|
||||
export function buildSubmissionNotif(input: {
|
||||
submissionId: number | string | null
|
||||
nomSuggere: string
|
||||
typeSuggere: string
|
||||
nbLiens: number
|
||||
nocodbAdminUrl?: string
|
||||
}): NtfyNotifyInput {
|
||||
const { submissionId, nomSuggere, typeSuggere, nbLiens, nocodbAdminUrl } = input
|
||||
return {
|
||||
title: '[AEP] Nouvelle fiche soumise',
|
||||
message: [
|
||||
`Id NocoDB : ${submissionId ?? '—'}`,
|
||||
`Nom suggéré : ${nomSuggere}`,
|
||||
`Type suggéré : ${typeSuggere}`,
|
||||
`Liens : ${nbLiens}`,
|
||||
].join('\n'),
|
||||
tags: ['inbox_tray'],
|
||||
clickUrl: nocodbAdminUrl,
|
||||
}
|
||||
}
|
||||
|
||||
function encodeHeaderValue(v: string): string {
|
||||
// ntfy accepte l'UTF-8 dans les headers mais certains proxies sont capricieux
|
||||
// avec les accents en header brut — on reste simple, ntfy gère nativement.
|
||||
return v
|
||||
}
|
||||
+50
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* B5-M1 — Schéma + mapping du formulaire « proposer » assoupli.
|
||||
*
|
||||
* LE PRINCIPE (décision Jules) : le contributeur colle un lien (ou une liste
|
||||
* de liens) + explique en texte libre pourquoi c'est pertinent. Pas de
|
||||
* description obligatoire, pas de limite de mots, pas de template imposé.
|
||||
* Le typage devient une suggestion optionnelle (chips).
|
||||
*
|
||||
* Partagé client (formulaire, validation immédiate) et serveur (validation
|
||||
* finale + mapping vers les colonnes NocoDB existantes — aucun changement de
|
||||
* schéma, cf. B5-proposer-pipe.md écart #4).
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
/** Chips optionnelles — mêmes 5 catégories que les onglets historiques de /proposer. */
|
||||
export const TYPES_SUGGERES = ['ecosysteme', 'reseau', 'jobs', 'outils', 'references'] as const
|
||||
export type TypeSuggere = (typeof TYPES_SUGGERES)[number]
|
||||
|
||||
export const TYPES_SUGGERES_LABELS: Record<TypeSuggere, string> = {
|
||||
ecosysteme: 'Écosystème / Entraide',
|
||||
reseau: 'Réseau AEP',
|
||||
jobs: 'Jobs',
|
||||
outils: 'Outils',
|
||||
references: 'Références',
|
||||
}
|
||||
|
||||
/**
|
||||
* Légende courte sous les chips — définitions tirées des textes existants du site :
|
||||
* ecosysteme/jobs/outils/references reprennent la tête d'onglet (`TABS`, pages/proposer.vue)
|
||||
* et/ou la phrase de l'échelle qui pointe vers ce type (utils/echelles.ts) ; reseau reprend
|
||||
* la tête d'onglet « Réseau AEP » et les champs projet/lien de FormReseau.vue.
|
||||
*/
|
||||
export const TYPES_SUGGERES_HINTS: Record<TypeSuggere, string> = {
|
||||
ecosysteme: 'entraide, mutualisation',
|
||||
reseau: 'acteurs et projets AEP',
|
||||
jobs: 'offres, plateformes d\'emploi',
|
||||
outils: 'ressources, méthodes',
|
||||
references: 'bibliothèque, ouvrages',
|
||||
}
|
||||
|
||||
/** URL http(s) uniquement — un `ftp://` ou autre schéma est invalide. */
|
||||
const httpUrl = z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((v) => /^https?:\/\/.+/i.test(v), { message: 'URL invalide (doit commencer par http:// ou https://)' })
|
||||
|
||||
export const LibreSubmitSchema = z
|
||||
.object({
|
||||
liens: z.array(httpUrl).max(10, 'Maximum 10 liens').default([]),
|
||||
texte: z.string().max(8000, 'Maximum 8000 caractères').default(''),
|
||||
submitted_by_email: z
|
||||
.string()
|
||||
.email('Email invalide')
|
||||
.optional()
|
||||
.or(z.literal(''))
|
||||
.transform((v) => v || undefined),
|
||||
type_suggere: z.enum(TYPES_SUGGERES).optional().or(z.literal('')).transform((v) => (v ? v : undefined)),
|
||||
// Deuxième chip optionnelle (B5-M2b) — indécis entre deux types.
|
||||
type_secondaire: z.enum(TYPES_SUGGERES).optional().or(z.literal('')).transform((v) => (v ? v : undefined)),
|
||||
// Honeypot anti-spam : champ caché, doit rester vide pour un humain.
|
||||
site_web: z.string().optional().or(z.literal('')),
|
||||
})
|
||||
.refine((data) => data.liens.length > 0 || data.texte.trim().length > 0, {
|
||||
message: 'Ajoute au moins un lien ou quelques mots.',
|
||||
path: ['texte'],
|
||||
})
|
||||
.refine((data) => !data.type_secondaire || !!data.type_suggere, {
|
||||
message: 'Choisis d\'abord un type principal.',
|
||||
path: ['type_secondaire'],
|
||||
})
|
||||
.refine((data) => !data.type_secondaire || data.type_secondaire !== data.type_suggere, {
|
||||
message: 'Le second type doit être différent du premier.',
|
||||
path: ['type_secondaire'],
|
||||
})
|
||||
|
||||
export type LibreSubmitInput = z.infer<typeof LibreSubmitSchema>
|
||||
|
||||
/** Chip 'jobs'/'outils' → valeur `submission_type` en base (table orgas). */
|
||||
const TYPE_TO_SUBMISSION_TYPE: Record<Exclude<TypeSuggere, 'references'>, 'ecosysteme' | 'reseau' | 'job' | 'outil'> = {
|
||||
ecosysteme: 'ecosysteme',
|
||||
reseau: 'reseau',
|
||||
jobs: 'job',
|
||||
outils: 'outil',
|
||||
}
|
||||
|
||||
function hostnameOf(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function nomSuggere(data: LibreSubmitInput): string {
|
||||
const premierLien = data.liens[0]
|
||||
const base = premierLien ? hostnameOf(premierLien) : null
|
||||
const fallback = data.texte.trim().slice(0, 60)
|
||||
return `[à qualifier] ${base || fallback || 'sans titre'}`
|
||||
}
|
||||
|
||||
/**
|
||||
* `prefixLine` est soit le marqueur worker « Type : non précisé » (aucun type choisi),
|
||||
* soit la ligne « Type suggéré : X ou Y » (double type) — jamais les deux à la fois,
|
||||
* et toujours en toute première position quand présent (contrat worker/enrich.js:562).
|
||||
*/
|
||||
function descriptionAvecLiens(texte: string, liens: string[], prefixLine?: string): string {
|
||||
const parts: string[] = []
|
||||
if (prefixLine) parts.push(prefixLine)
|
||||
if (texte.trim()) parts.push(texte.trim())
|
||||
if (liens.length) parts.push(`Liens :\n${liens.join('\n')}`)
|
||||
return parts.join('\n\n')
|
||||
}
|
||||
|
||||
function ligneTypeSuggere(data: LibreSubmitInput): string | undefined {
|
||||
if (!data.type_secondaire) return undefined
|
||||
// Le schéma garantit type_suggere défini dès que type_secondaire l'est.
|
||||
return `Type suggéré : ${TYPES_SUGGERES_LABELS[data.type_suggere!]} ou ${TYPES_SUGGERES_LABELS[data.type_secondaire]}`
|
||||
}
|
||||
|
||||
/** Mapping vers la table orgas existante (submission_type ecosysteme/reseau/job/outil). */
|
||||
export function toOrgaPayload(data: LibreSubmitInput) {
|
||||
const hasSecondaire = !!data.type_secondaire
|
||||
// Marqueur worker posé uniquement quand aucun type exploitable n'a été choisi du tout —
|
||||
// un double type ne le pose jamais (contrat worker/enrich.js:562).
|
||||
const typeNonPrecise = !hasSecondaire && (!data.type_suggere || data.type_suggere === 'references')
|
||||
const submissionType = data.type_suggere && data.type_suggere !== 'references'
|
||||
? TYPE_TO_SUBMISSION_TYPE[data.type_suggere]
|
||||
: 'ecosysteme'
|
||||
const prefixLine = typeNonPrecise ? 'Type : non précisé' : ligneTypeSuggere(data)
|
||||
|
||||
return {
|
||||
submission_type: submissionType,
|
||||
nom: nomSuggere(data),
|
||||
url: data.liens[0] || null,
|
||||
description_user: descriptionAvecLiens(data.texte, data.liens, prefixLine),
|
||||
submitted_by_email: data.submitted_by_email || null,
|
||||
moderation_status: 'pending' as const,
|
||||
ai_processed: false,
|
||||
submitted_at: new Date().toISOString(),
|
||||
scrape_status: data.liens[0] ? 'pending' : 'no_link',
|
||||
}
|
||||
}
|
||||
|
||||
/** Mapping vers la table ressources_references (chip « Références »). */
|
||||
export function toReferencePayload(data: LibreSubmitInput) {
|
||||
return {
|
||||
titre: nomSuggere(data),
|
||||
auteur: 'non précisé',
|
||||
url: data.liens[0] || null,
|
||||
description: descriptionAvecLiens(data.texte, data.liens, ligneTypeSuggere(data)),
|
||||
pertinence_rag: null,
|
||||
hashtags: null,
|
||||
submitted_by_email: data.submitted_by_email || null,
|
||||
moderation_status: 'pending' as const,
|
||||
submitted_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
@@ -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('&')
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// worker/daily-digest.js — Email récap quotidien nouvelles fiches AEP
|
||||
// Cron : 0 8 * * * (tous les jours à 8h)
|
||||
// Usage : node worker/daily-digest.js
|
||||
|
||||
import 'dotenv/config'
|
||||
|
||||
// ─── CONFIG DEPUIS .env ───────────────────────────────────────────────────────
|
||||
const NOCODB_URL = process.env.NOCODB_URL || 'http://localhost:8070';
|
||||
const NOCODB_TOKEN = process.env.NOCODB_TOKEN;
|
||||
const NOCODB_BASE = process.env.NOCODB_BASE;
|
||||
const NOCODB_TABLE_ORGAS = process.env.NOCODB_TABLE_ORGAS;
|
||||
const RESEND_API_KEY = process.env.RESEND_API_KEY;
|
||||
const RESEND_FROM = process.env.RESEND_FROM || 'AEP Digest <noreply@trans-former.fr>';
|
||||
const EMAIL_DEST = process.env.EMAIL_JULES || 'transformationsresilientes@gmail.com';
|
||||
const NOCODB_ADMIN_URL = process.env.NOCODB_ADMIN_URL || 'http://localhost:8070';
|
||||
|
||||
// ─── UTILITAIRES LOG ─────────────────────────────────────────────────────────
|
||||
function log(...args) {
|
||||
const ts = new Date().toISOString();
|
||||
console.log(`[${ts}]`, ...args);
|
||||
}
|
||||
|
||||
// ─── NOCODB — FETCH FICHES DERNIÈRES 24H ─────────────────────────────────────
|
||||
async function fetchNewFiches() {
|
||||
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
// NocoDB ne supporte pas bien le filtre datetime sur CreatedAt — on récupère
|
||||
// les 200 dernières fiches triées par date décroissante et on filtre en JS
|
||||
// (même pattern que getBudgetMoisCourant dans enrich.js)
|
||||
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE}/${NOCODB_TABLE_ORGAS}?limit=200&sort=-CreatedAt`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: { 'xc-token': NOCODB_TOKEN }
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`NocoDB GET fiches → ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const rows = data.list || [];
|
||||
|
||||
// Filtre JS sur les 24 dernières heures
|
||||
const recent = rows.filter(row => {
|
||||
const created = new Date(row.CreatedAt || row.created_at || 0);
|
||||
return created >= new Date(since);
|
||||
});
|
||||
|
||||
return recent;
|
||||
}
|
||||
|
||||
// ─── CONSTRUCTION EMAIL HTML ─────────────────────────────────────────────────
|
||||
function buildEmailHtml(fiches) {
|
||||
const count = fiches.length;
|
||||
const date = new Date().toLocaleDateString('fr-FR', {
|
||||
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric'
|
||||
});
|
||||
|
||||
const lignes = fiches.map(f => {
|
||||
const nom = f.nom || '(sans nom)';
|
||||
const url = f.url || null;
|
||||
const echelle = f.echelle || '—';
|
||||
const statut = f.moderation_status || 'pending';
|
||||
const ficheUrl = `https://aep.trans-former.fr/fiche/${f.Id}`;
|
||||
|
||||
const nomHtml = url
|
||||
? `<a href="${url}" style="color:#1a56db;text-decoration:none;">${escHtml(nom)}</a>`
|
||||
: escHtml(nom);
|
||||
|
||||
const statutColor = statut === 'approved' ? '#057a55'
|
||||
: statut === 'rejected' ? '#c81e1e'
|
||||
: statut === 'ai_processed' ? '#1a56db'
|
||||
: '#9ca3af'; // pending / autre
|
||||
|
||||
return `
|
||||
<tr style="border-bottom:1px solid #f3f4f6;">
|
||||
<td style="padding:10px 12px;font-size:14px;">${nomHtml}</td>
|
||||
<td style="padding:10px 12px;font-size:13px;color:#6b7280;">${escHtml(echelle)}</td>
|
||||
<td style="padding:10px 12px;">
|
||||
<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:12px;
|
||||
font-weight:600;background:#f3f4f6;color:${statutColor};">
|
||||
${escHtml(statut)}
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding:10px 12px;font-size:12px;">
|
||||
<a href="${ficheUrl}" style="color:#6b7280;">fiche #${f.Id}</a>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||
<body style="margin:0;padding:0;background:#f9fafb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f9fafb;padding:32px 0;">
|
||||
<tr><td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.1);">
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background:#1a56db;padding:24px 32px;">
|
||||
<p style="margin:0;font-size:12px;color:#bfdbfe;text-transform:uppercase;letter-spacing:1px;">AEP — Agences En Pratique</p>
|
||||
<h1 style="margin:4px 0 0;font-size:20px;color:#ffffff;font-weight:700;">
|
||||
${count} nouvelle${count > 1 ? 's' : ''} fiche${count > 1 ? 's' : ''} soumise${count > 1 ? 's' : ''}
|
||||
</h1>
|
||||
<p style="margin:4px 0 0;font-size:13px;color:#bfdbfe;">${date}</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Corps -->
|
||||
<tr>
|
||||
<td style="padding:24px 32px;">
|
||||
<p style="margin:0 0 16px;font-size:14px;color:#374151;">
|
||||
${count} fiche${count > 1 ? 's ont été soumises' : ' a été soumise'} dans les dernières 24 heures.
|
||||
</p>
|
||||
|
||||
<!-- Tableau fiches -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;border:1px solid #e5e7eb;border-radius:6px;overflow:hidden;">
|
||||
<thead>
|
||||
<tr style="background:#f9fafb;">
|
||||
<th style="padding:10px 12px;text-align:left;font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;">Organisation</th>
|
||||
<th style="padding:10px 12px;text-align:left;font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;">Échelle</th>
|
||||
<th style="padding:10px 12px;text-align:left;font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;">Statut</th>
|
||||
<th style="padding:10px 12px;text-align:left;font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;">Lien</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${lignes}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- CTA modération -->
|
||||
<div style="margin-top:24px;text-align:center;">
|
||||
<a href="${NOCODB_ADMIN_URL}"
|
||||
style="display:inline-block;padding:10px 20px;background:#1a56db;color:#ffffff;
|
||||
border-radius:6px;font-size:14px;font-weight:600;text-decoration:none;">
|
||||
Ouvrir NocoDB pour modérer
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="padding:16px 32px;border-top:1px solid #f3f4f6;">
|
||||
<p style="margin:0;font-size:11px;color:#9ca3af;text-align:center;">
|
||||
Digest automatique AEP — envoyé chaque matin à 8h
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// Échappement HTML minimal
|
||||
function escHtml(str) {
|
||||
return String(str ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ─── ENVOI VIA RESEND ─────────────────────────────────────────────────────────
|
||||
async function sendDigest(fiches) {
|
||||
const count = fiches.length;
|
||||
const subject = `AEP — ${count} nouvelle${count > 1 ? 's' : ''} fiche${count > 1 ? 's' : ''} soumise${count > 1 ? 's' : ''}`;
|
||||
const html = buildEmailHtml(fiches);
|
||||
|
||||
const res = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${RESEND_API_KEY}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: RESEND_FROM,
|
||||
to: [EMAIL_DEST],
|
||||
subject,
|
||||
html
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Resend API ${res.status}: ${err}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
log(`Email digest envoyé → ${EMAIL_DEST} | id: ${data.id} | sujet: "${subject}"`);
|
||||
}
|
||||
|
||||
// ─── MAIN ─────────────────────────────────────────────────────────────────────
|
||||
async function run() {
|
||||
log('=== daily-digest démarré ===');
|
||||
|
||||
// Vérification variables obligatoires
|
||||
if (!NOCODB_TOKEN || !NOCODB_BASE || !NOCODB_TABLE_ORGAS) {
|
||||
log('ERREUR : Variables NocoDB manquantes (NOCODB_TOKEN, NOCODB_BASE, NOCODB_TABLE_ORGAS)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!RESEND_API_KEY) {
|
||||
log('ERREUR : RESEND_API_KEY manquante');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch fiches des 24 dernières heures
|
||||
const fiches = await fetchNewFiches();
|
||||
log(`${fiches.length} fiche(s) trouvée(s) dans les dernières 24h`);
|
||||
|
||||
if (fiches.length === 0) {
|
||||
log('Aucune nouvelle fiche — digest non envoyé.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Envoi email
|
||||
await sendDigest(fiches);
|
||||
log('=== daily-digest terminé avec succès ===');
|
||||
|
||||
} catch (e) {
|
||||
log('ERREUR daily-digest:', e.message);
|
||||
console.error(e.stack);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
+286
-184
@@ -1,14 +1,28 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* NAV V2 — Worker enrichissement IA
|
||||
* Lancé via systemd timer toutes les 5 minutes
|
||||
* Pipeline : fetch pending → scrape crawl4ai → Mistral Nemo → update NocoDB → log stats
|
||||
* AEP — Worker enrichissement IA (B5-M2)
|
||||
* Lancé via systemd timer toutes les 15 minutes (deploy/aep-worker/aep-worker.timer)
|
||||
* Pipeline : fetch pending → scrape léger (fetch natif) → Bifrost (LLM) → update NocoDB → ntfy
|
||||
*
|
||||
* Écarts à la version NAV V2 d'origine (PIPE-IA-DOC.md, fusionné pas réécrit) :
|
||||
* - LLM : Bifrost (passerelle OpenAI-compatible en prod) au lieu de Mistral direct.
|
||||
* - Scrape : fetch natif Node 22 (timeout 8s, 500 Ko max, texte tronqué 4000 car.)
|
||||
* au lieu de crawl4ai/Python (disque VPS tendu, décision MOE 27/09).
|
||||
* - Notification : ntfy (https://ntfy.sh/$NTFY_TOPIC) au lieu de Resend (abandonné 15/07).
|
||||
* Le message ne contient JAMAIS l'email ni le texte libre du contributeur.
|
||||
* - Le formulaire assoupli (B5-M1) écrit un `nom` placeholder ("[à qualifier] host") et
|
||||
* éventuellement "Type : non précisé" en tête de description_user. Le worker propose un
|
||||
* vrai nom et un type — voir §Sortie JSON ci-dessous et PIPE-IA-DOC.md.
|
||||
* - Seuil « email à 5 fiches pending » PAS réactivé (décision MOE : volume faible, une
|
||||
* notif par fiche traitée suffit — cf. B5-proposer-pipe.md §Points d'attention).
|
||||
*/
|
||||
|
||||
import { spawnSync, execSync } from 'child_process';
|
||||
import { existsSync, writeFileSync, unlinkSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { existsSync, writeFileSync, unlinkSync, readFileSync, mkdirSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// ─── CONFIG DEPUIS .env ───────────────────────────────────────────────────────
|
||||
const NOCODB_URL = process.env.NOCODB_URL || 'http://localhost:8070';
|
||||
@@ -16,25 +30,44 @@ const NOCODB_TOKEN = process.env.NOCODB_TOKEN;
|
||||
const NOCODB_BASE = process.env.NOCODB_BASE;
|
||||
const NOCODB_TABLE_ORGAS = process.env.NOCODB_TABLE_ORGAS;
|
||||
const NOCODB_TABLE_STATS = process.env.NOCODB_TABLE_STATS;
|
||||
const MISTRAL_API_KEY = process.env.MISTRAL_API_KEY;
|
||||
const RESEND_API_KEY = process.env.RESEND_API_KEY;
|
||||
const RESEND_FROM = process.env.RESEND_FROM || 'contact@trans-former.fr';
|
||||
const EMAIL_JULES = process.env.EMAIL_JULES || 'jules@trans-former.fr';
|
||||
|
||||
const BIFROST_URL = process.env.BIFROST_URL || 'http://127.0.0.1:8080';
|
||||
const BIFROST_VK = process.env.BIFROST_VK;
|
||||
const WORKER_MODEL = process.env.WORKER_MODEL || 'groq/openai/gpt-oss-20b'; // llama-3.1-8b-instant retiré par Groq (28/09)
|
||||
|
||||
const NTFY_TOPIC = process.env.NTFY_TOPIC;
|
||||
|
||||
const BUDGET_MAX_EUR = parseFloat(process.env.BUDGET_MAX_EUR || '20');
|
||||
const WORKER_LIMIT = parseInt(process.env.WORKER_LIMIT || '5');
|
||||
const LOCK_FILE = '/tmp/nav-worker.lock';
|
||||
const LOCK_FILE = process.env.WORKER_LOCK_FILE || '/tmp/aep-worker.lock';
|
||||
|
||||
// ─── PRIX MISTRAL NEMO (USD → EUR) ───────────────────────────────────────────
|
||||
const NEMO_PRICE_IN = 0.02 / 1_000_000; // $0.02 / 1M tokens input
|
||||
const NEMO_PRICE_OUT = 0.04 / 1_000_000; // $0.04 / 1M tokens output
|
||||
// Prix par 1M tokens (USD) — par défaut 0 (Groq gratuit dans le tier RAPIDE Bifrost).
|
||||
// Réglable si le worker bascule un jour sur un modèle payant.
|
||||
const WORKER_PRICE_IN = parseFloat(process.env.WORKER_PRICE_IN_USD_PER_M || '0') / 1_000_000;
|
||||
const WORKER_PRICE_OUT = parseFloat(process.env.WORKER_PRICE_OUT_USD_PER_M || '0') / 1_000_000;
|
||||
const USD_TO_EUR = 0.93;
|
||||
|
||||
// Scrape léger
|
||||
const SCRAPE_TIMEOUT_MS = 8_000;
|
||||
const SCRAPE_MAX_BYTES = 500_000;
|
||||
const SCRAPE_TEXT_MAX_CHARS = 4_000;
|
||||
const SCRAPE_USER_AGENT = 'AEP/2.0 contact@trans-former.fr';
|
||||
|
||||
// ─── CLI ──────────────────────────────────────────────────────────────────────
|
||||
const DRY_RUN = process.argv.includes('--dry-run');
|
||||
// DRY_RUN_LIVE=1 : en dry-run, tente quand même de vrais appels réseau (scrape + Bifrost)
|
||||
// pour un test manuel avec accès réseau — ne touche JAMAIS NocoDB en dry-run, dans tous les cas.
|
||||
const DRY_RUN_LIVE = process.env.DRY_RUN_LIVE === '1';
|
||||
const DRY_RUN_FIXTURE = process.env.DRY_RUN_FIXTURE || join(__dirname, 'fixtures', 'dry-run-rows.json');
|
||||
|
||||
// ─── TAXONOMIE VALIDE (apostrophe typographique U+2019 comme NocoDB) ─────────
|
||||
const VALID_FONCTIONS = [
|
||||
'Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier',
|
||||
'Comptabilité', 'Développement', 'Formation', 'Gestion d\u2019agence', 'Santé mentale'
|
||||
'Comptabilité', 'Développement', 'Formation', 'Gestion d’agence', 'Santé mentale'
|
||||
];
|
||||
|
||||
const VALID_SUBMISSION_TYPES = ['ecosysteme', 'reseau', 'job', 'outil'];
|
||||
|
||||
// ─── MAPPING NORMALISATION TAGS ───────────────────────────────────────────────
|
||||
const TAG_MAP = [
|
||||
[['juridique', 'droit', 'litige', 'contrat', 'déontologie', 'décennale', 'médiation', 'pi ', 'propriété intellectuelle', 'ccag', 'marchés publics droit'], 'Juridique'],
|
||||
@@ -45,16 +78,14 @@ const TAG_MAP = [
|
||||
[['comptabilité', 'fiscal', 'tva', 'bnc', 'bic', 'expert-comptable', 'bilan', 'trésorerie', 'transmission agence', 'création agence', 'micro'], 'Comptabilité'],
|
||||
[['développement', 'prospection', 'commercial', 'client', 'réseau', 'candidature', 'consultation', 'acquisition', 'marketing', 'notoriété', 'ao '], 'Développement'],
|
||||
[['formation', 'école', 'mooc', 'organisme', 'formation continue', 'cpf', 'dpc', 'cfaa'], 'Formation'],
|
||||
[['gestion d\u2019agence', 'gestion d\'agence', 'rh', 'recrutement', 'emploi', 'salaire', 'ccn', 'convention collective', 'idcc', 'temps de travail', 'management'], 'Gestion d\u2019agence'],
|
||||
[['gestion d’agence', 'gestion d\'agence', 'rh', 'recrutement', 'emploi', 'salaire', 'ccn', 'convention collective', 'idcc', 'temps de travail', 'management'], 'Gestion d’agence'],
|
||||
[['santé mentale', 'burn-out', 'épuisement', 'souffrance', 'bien-être', 'harcèlement', 'stress', 'psychologique', 'équilibre'], 'Santé mentale'],
|
||||
];
|
||||
|
||||
function normalizeTag(raw) {
|
||||
const t = raw.toLowerCase().trim();
|
||||
// D'abord chercher correspondance exacte dans les valeurs valides
|
||||
const t = String(raw).toLowerCase().trim();
|
||||
const exact = VALID_FONCTIONS.find(v => v.toLowerCase() === t);
|
||||
if (exact) return exact;
|
||||
// Sinon chercher par mots-clés
|
||||
for (const [patterns, normalized] of TAG_MAP) {
|
||||
if (patterns.some(p => t.includes(p))) return normalized;
|
||||
}
|
||||
@@ -117,8 +148,18 @@ async function nocodbPost(tableId, data) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Écrit une mise à jour de fiche — no-op loggé en dry-run (jamais de PATCH réel). */
|
||||
async function patchRow(rowId, data) {
|
||||
if (DRY_RUN) {
|
||||
log(`[dry-run] PATCH fiche ${rowId} :`, JSON.stringify(data));
|
||||
return;
|
||||
}
|
||||
return nocodbPatch(NOCODB_TABLE_ORGAS, rowId, data);
|
||||
}
|
||||
|
||||
// ─── BUDGET CIRCUIT BREAKER ──────────────────────────────────────────────────
|
||||
async function getBudgetMoisCourant() {
|
||||
if (DRY_RUN) return 0;
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth(); // 0-indexed
|
||||
@@ -142,7 +183,12 @@ async function getBudgetMoisCourant() {
|
||||
async function logUsage(usage, model, endpoint, orgaId) {
|
||||
const tokensIn = usage?.prompt_tokens || 0;
|
||||
const tokensOut = usage?.completion_tokens || 0;
|
||||
const coutEur = ((tokensIn * NEMO_PRICE_IN) + (tokensOut * NEMO_PRICE_OUT)) * USD_TO_EUR;
|
||||
const coutEur = ((tokensIn * WORKER_PRICE_IN) + (tokensOut * WORKER_PRICE_OUT)) * USD_TO_EUR;
|
||||
|
||||
if (DRY_RUN) {
|
||||
log(`[dry-run] Usage (non loggé) : ${tokensIn}in + ${tokensOut}out = €${coutEur.toFixed(6)} (${model})`);
|
||||
return coutEur;
|
||||
}
|
||||
|
||||
await nocodbPost(NOCODB_TABLE_STATS, {
|
||||
model,
|
||||
@@ -166,111 +212,168 @@ async function fetchPendingRows() {
|
||||
return data.list || [];
|
||||
}
|
||||
|
||||
// ─── SCRAPING CRAWL4AI (mode HTTP statique, sans Playwright) ─────────────────
|
||||
async function scrapeWithCrawl4ai(url) {
|
||||
log(`Scraping: ${url}`);
|
||||
function loadFixtureRows() {
|
||||
if (!existsSync(DRY_RUN_FIXTURE)) {
|
||||
throw new Error(`Fixture dry-run introuvable : ${DRY_RUN_FIXTURE}`);
|
||||
}
|
||||
const raw = JSON.parse(readFileSync(DRY_RUN_FIXTURE, 'utf-8'));
|
||||
return Array.isArray(raw) ? raw : [raw];
|
||||
}
|
||||
|
||||
// Script Python temporaire pour crawl4ai
|
||||
const urlSafe = url.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||
const script = `
|
||||
import asyncio, sys
|
||||
from crawl4ai import AsyncWebCrawler, CrawlerRunConfig
|
||||
from crawl4ai.async_crawler_strategy import AsyncHTTPCrawlerStrategy
|
||||
|
||||
async def scrape():
|
||||
strategy = AsyncHTTPCrawlerStrategy()
|
||||
run_cfg = CrawlerRunConfig(
|
||||
word_count_threshold=20,
|
||||
excluded_tags=['nav', 'footer', 'script', 'style', 'head'],
|
||||
remove_overlay_elements=True
|
||||
)
|
||||
async with AsyncWebCrawler(crawler_strategy=strategy, verbose=False) as crawler:
|
||||
result = await crawler.arun(url='${urlSafe}', config=run_cfg)
|
||||
if result.success and result.markdown:
|
||||
content = result.markdown[:16000]
|
||||
sys.stdout.buffer.write(content.encode('utf-8'))
|
||||
else:
|
||||
sys.stderr.write(f"Scrape failed: success={result.success}\\n")
|
||||
sys.exit(1)
|
||||
|
||||
asyncio.run(scrape())
|
||||
`;
|
||||
|
||||
const scriptPath = join(tmpdir(), `nav-scrape-${Date.now()}.py`);
|
||||
writeFileSync(scriptPath, script, 'utf-8');
|
||||
// ─── LIENS — extraits de url + description_user (format B5-M1 "Liens :\n...") ─
|
||||
function extractLinks(row) {
|
||||
const text = `${row.url || ''}\n${row.description_user || row.description || ''}`;
|
||||
const matches = text.match(/https?:\/\/[^\s)"'<>]+/g) || [];
|
||||
const cleaned = matches.map(u => u.replace(/[.,;:!?]+$/, ''));
|
||||
return [...new Set(cleaned)];
|
||||
}
|
||||
|
||||
// ─── SCRAPE LÉGER (fetch natif, pas de crawl4ai) ─────────────────────────────
|
||||
async function fetchCapped(url) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), SCRAPE_TIMEOUT_MS);
|
||||
try {
|
||||
const result = spawnSync('python3', [scriptPath], {
|
||||
timeout: 180_000,
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1', PYTHONIOENCODING: 'utf-8' }
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
redirect: 'follow',
|
||||
headers: { 'User-Agent': SCRAPE_USER_AGENT },
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
if (result.error) throw result.error;
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) return await res.text();
|
||||
|
||||
const stdout = result.stdout?.toString('utf-8') || '';
|
||||
const stderr = result.stderr?.toString('utf-8') || '';
|
||||
|
||||
if (result.status === 0 && stdout.length > 50) {
|
||||
log(`Scrape OK: ${stdout.length} chars`);
|
||||
return stdout.trim();
|
||||
} else {
|
||||
throw new Error(`Scrape failed (code ${result.status}): ${stderr.slice(0, 300)}`);
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
total += value.length;
|
||||
if (total >= SCRAPE_MAX_BYTES) {
|
||||
await reader.cancel().catch(() => {});
|
||||
break;
|
||||
}
|
||||
}
|
||||
const buf = Buffer.concat(chunks.map(c => Buffer.from(c)));
|
||||
return buf.subarray(0, SCRAPE_MAX_BYTES).toString('utf-8');
|
||||
} finally {
|
||||
try { unlinkSync(scriptPath); } catch {}
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── APPEL MISTRAL NEMO ───────────────────────────────────────────────────────
|
||||
const SYSTEM_PROMPT = `Tu es un assistant spécialisé dans l'écosystème professionnel de l'architecture en France. Tu reçois des informations sur une organisation ou ressource liée au secteur de l'architecture, et tu dois les enrichir pour alimenter une cartographie collaborative.
|
||||
function extractMeta(html, key) {
|
||||
const re1 = new RegExp(`<meta[^>]*(?:name|property)=["']${key}["'][^>]*content=["']([^"']*)["']`, 'i');
|
||||
const re2 = new RegExp(`<meta[^>]*content=["']([^"']*)["'][^>]*(?:name|property)=["']${key}["']`, 'i');
|
||||
return (html.match(re1) || html.match(re2))?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
function extractOgTags(html) {
|
||||
const og = {};
|
||||
const re1 = /<meta[^>]*property=["']og:([a-zA-Z:_-]+)["'][^>]*content=["']([^"']*)["']/gi;
|
||||
const re2 = /<meta[^>]*content=["']([^"']*)["'][^>]*property=["']og:([a-zA-Z:_-]+)["']/gi;
|
||||
let m;
|
||||
while ((m = re1.exec(html))) og[m[1]] = m[2];
|
||||
while ((m = re2.exec(html))) if (!(m[2] in og)) og[m[2]] = m[1];
|
||||
return og;
|
||||
}
|
||||
|
||||
function decodeEntities(s) {
|
||||
return s
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/�?39;/g, "'");
|
||||
}
|
||||
|
||||
function extractVisibleText(html) {
|
||||
let s = html
|
||||
.replace(/<!--[\s\S]*?-->/g, ' ')
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<nav[\s\S]*?<\/nav>/gi, ' ')
|
||||
.replace(/<footer[\s\S]*?<\/footer>/gi, ' ')
|
||||
.replace(/<head[\s\S]*?<\/head>/gi, ' ');
|
||||
s = s.replace(/<[^>]+>/g, ' ');
|
||||
s = decodeEntities(s);
|
||||
s = s.replace(/\s+/g, ' ').trim();
|
||||
return s.slice(0, SCRAPE_TEXT_MAX_CHARS);
|
||||
}
|
||||
|
||||
function extractTitle(html) {
|
||||
return html.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
async function scrapeLight(url) {
|
||||
log(`Scraping léger: ${url}`);
|
||||
const html = await fetchCapped(url);
|
||||
return {
|
||||
title: extractTitle(html),
|
||||
metaDescription: extractMeta(html, 'description'),
|
||||
og: extractOgTags(html),
|
||||
text: extractVisibleText(html),
|
||||
};
|
||||
}
|
||||
|
||||
const MOCK_SCRAPE_RESULT = {
|
||||
title: '[mock dry-run] Titre de la page',
|
||||
metaDescription: '[mock dry-run] Meta description factice, aucun réseau contacté.',
|
||||
og: { site_name: '[mock dry-run]' },
|
||||
text: '[mock dry-run] Extrait de texte factice utilisé quand DRY_RUN_LIVE n’est pas activé.',
|
||||
};
|
||||
|
||||
// ─── APPEL LLM VIA BIFROST ────────────────────────────────────────────────────
|
||||
const SYSTEM_PROMPT = `Tu es un assistant qui aide à qualifier des ressources soumises pour une cartographie collaborative de l'écosystème professionnel de l'architecture en France (AEP).
|
||||
|
||||
RÈGLES ABSOLUES :
|
||||
1. Tu ne dois JAMAIS inventer d'informations non présentes dans les sources fournies.
|
||||
2. Si une information est absente ou incertaine, retourne \`null\` pour ce champ.
|
||||
3. Tu dois retourner UNIQUEMENT un objet JSON valide, sans texte avant ou après.
|
||||
4. La description_enrichie doit être neutre, factuelle, en français, sans jugement de valeur.
|
||||
5. Les points_cles sont des phrases courtes (max 12 mots chacune), actionnables pour un architecte.
|
||||
6. Pour les tags_fonction, ne propose que des valeurs parmi la liste autorisée.
|
||||
4. "description" : neutre, factuelle, en français, max 300 caractères, sans jugement de valeur.
|
||||
5. "nom" : un nom de fiche court et identifiable, sans le préfixe "[à qualifier]".
|
||||
6. "tags" : 1 à 5 valeurs, uniquement parmi la liste autorisée.
|
||||
|
||||
TAXONOMIE AUTORISÉE :
|
||||
- Échelle (une seule valeur) : "National" | "Régional" | "Départemental" | "Local"
|
||||
- Territoire (une seule valeur) : "Métropole" | "Guadeloupe" | "Martinique" | "Guyane" | "Réunion" | "Mayotte" | null
|
||||
- Tags fonction (1 à 5 valeurs) : "Juridique" | "Technique" | "Économique" | "Administratif" | "Chantier" | "Comptabilité" | "Développement" | "Formation" | "Gestion d\u2019agence" | "Santé mentale"
|
||||
TAGS AUTORISÉS : "Juridique" | "Technique" | "Économique" | "Administratif" | "Chantier" | "Comptabilité" | "Développement" | "Formation" | "Gestion d’agence" | "Santé mentale"
|
||||
TYPE_SUGGERE AUTORISÉ (une seule valeur ou null) : "ecosysteme" | "reseau" | "job" | "outil"
|
||||
|
||||
FORMAT DE SORTIE JSON :
|
||||
{
|
||||
"description_enrichie": "string (max 300 chars, français, neutre, factuel)",
|
||||
"points_cles": ["string", "string", "string"],
|
||||
"tags_fonction": ["Valeur1", "Valeur2"],
|
||||
"echelle": "National" | "Régional" | "Départemental" | "Local" | null,
|
||||
"territoire": "Métropole" | ... | null,
|
||||
"localisation_ville": "string" | null,
|
||||
"nom": "string | null",
|
||||
"description": "string (max 300 chars, français, neutre, factuel) | null",
|
||||
"type_suggere": "ecosysteme" | "reseau" | "job" | "outil" | null,
|
||||
"ville": "string | null",
|
||||
"tags": ["string", "..."],
|
||||
"confiance": "haute" | "moyenne" | "faible"
|
||||
}
|
||||
|
||||
Le champ "confiance" reflète ta certitude globale sur l'enrichissement :
|
||||
- "haute" : URL scrapée avec contenu riche, informations claires
|
||||
- "moyenne" : URL scrapée mais contenu partiel, ou description_user seule suffisante
|
||||
- "faible" : URL non disponible et description_user vague, inférences importantes`;
|
||||
Le champ "confiance" reflète ta certitude globale :
|
||||
- "haute" : contenu scrapé riche, informations claires.
|
||||
- "moyenne" : contenu partiel, ou texte du contributeur seul mais clair.
|
||||
- "faible" : aucun contenu scrapé et texte du contributeur vague, inférences importantes.`;
|
||||
|
||||
function buildUserPrompt(row, scrapeContent) {
|
||||
return `ORGANISATION À ENRICHIR :
|
||||
function buildUserPrompt(row, scrapeData, autresLiens) {
|
||||
return `RESSOURCE À QUALIFIER :
|
||||
|
||||
Nom : ${row.nom}
|
||||
URL : ${row.url || 'non fournie'}
|
||||
Description soumise par l'utilisateur : ${row.description_user || row.description || 'non fournie'}
|
||||
Nom actuel (placeholder à remplacer) : ${row.nom}
|
||||
Lien principal : ${row.url || 'non fourni'}
|
||||
${autresLiens.length ? `Autres liens mentionnés par le contributeur : ${autresLiens.join(', ')}` : ''}
|
||||
Texte du contributeur (pourquoi c'est pertinent pour lui) : ${row.description_user || row.description || 'non fourni'}
|
||||
|
||||
CONTENU DU SITE WEB (extrait par scraping) :
|
||||
${scrapeContent || 'Site non accessible ou URL non fournie.'}
|
||||
CONTENU EXTRAIT DU SITE (scraping léger) :
|
||||
Titre : ${scrapeData?.title || 'non disponible'}
|
||||
Meta description : ${scrapeData?.metaDescription || 'non disponible'}
|
||||
Open Graph : ${scrapeData && Object.keys(scrapeData.og || {}).length ? JSON.stringify(scrapeData.og) : 'non disponible'}
|
||||
Texte visible (extrait) : ${scrapeData?.text || 'Site non accessible ou lien non fourni.'}
|
||||
|
||||
---
|
||||
|
||||
Enrichis cette fiche selon les règles du system prompt. Retourne uniquement le JSON.`;
|
||||
Qualifie cette ressource selon les règles du system prompt. Retourne uniquement le JSON.`;
|
||||
}
|
||||
|
||||
async function callMistralWithRetry(row, scrapeContent, maxRetries = 2) {
|
||||
const userPrompt = buildUserPrompt(row, scrapeContent);
|
||||
async function callBifrostWithRetry(row, scrapeData, autresLiens, maxRetries = 2) {
|
||||
const userPrompt = buildUserPrompt(row, scrapeData, autresLiens);
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
if (attempt > 0) {
|
||||
@@ -279,14 +382,14 @@ async function callMistralWithRetry(row, scrapeContent, maxRetries = 2) {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('https://api.mistral.ai/v1/chat/completions', {
|
||||
const res = await fetch(`${BIFROST_URL}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${MISTRAL_API_KEY}`,
|
||||
'x-bf-vk': BIFROST_VK,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'open-mistral-nemo',
|
||||
model: WORKER_MODEL,
|
||||
temperature: 0.2,
|
||||
max_tokens: 800,
|
||||
response_format: { type: 'json_object' },
|
||||
@@ -300,101 +403,88 @@ async function callMistralWithRetry(row, scrapeContent, maxRetries = 2) {
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Mistral API ${res.status}: ${err}`);
|
||||
throw new Error(`Bifrost API ${res.status}: ${err}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
if (!content) throw new Error('Réponse Mistral vide');
|
||||
if (!content) throw new Error('Réponse Bifrost vide');
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
// Attacher usage pour logging
|
||||
parsed._usage = data.usage;
|
||||
parsed._raw = content;
|
||||
return parsed;
|
||||
|
||||
} catch (e) {
|
||||
log(`Erreur Mistral (tentative ${attempt + 1}): ${e.message}`);
|
||||
log(`Erreur Bifrost (tentative ${attempt + 1}): ${e.message}`);
|
||||
if (attempt === maxRetries) return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── EMAIL JULES VIA RESEND ───────────────────────────────────────────────────
|
||||
async function sendEmailJules(subject, body) {
|
||||
if (!RESEND_API_KEY) {
|
||||
log('RESEND_API_KEY absent, email skippé');
|
||||
const MOCK_BIFROST_RESULT = {
|
||||
nom: '[mock dry-run] Nom suggéré',
|
||||
description: '[mock dry-run] Description factice générée sans appel réseau.',
|
||||
type_suggere: 'ecosysteme',
|
||||
ville: null,
|
||||
tags: ['Développement'],
|
||||
confiance: 'faible',
|
||||
_usage: { prompt_tokens: 0, completion_tokens: 0 },
|
||||
_raw: '{"mock":"dry-run"}',
|
||||
};
|
||||
|
||||
// ─── NOTIFICATION NTFY (jamais l'email ni le texte du contributeur) ──────────
|
||||
async function notifyNtfy(title, message) {
|
||||
if (DRY_RUN) {
|
||||
log(`[dry-run] ntfy (non envoyé) — ${title} :`, message.replace(/\n/g, ' | '));
|
||||
return;
|
||||
}
|
||||
if (!NTFY_TOPIC) {
|
||||
log('NTFY_TOPIC absent, notification skippée');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('https://api.resend.com/emails', {
|
||||
const res = await fetch(`https://ntfy.sh/${NTFY_TOPIC}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${RESEND_API_KEY}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: RESEND_FROM,
|
||||
to: [EMAIL_JULES],
|
||||
subject,
|
||||
text: body
|
||||
})
|
||||
headers: { Title: title, Priority: '3', Tags: 'inbox_tray' },
|
||||
body: message,
|
||||
});
|
||||
if (!res.ok) log('Email error:', await res.text());
|
||||
else log('Email envoyé à Jules:', subject);
|
||||
if (!res.ok) log('ntfy erreur:', await res.text());
|
||||
else log('ntfy envoyé:', title);
|
||||
} catch (e) {
|
||||
log('Email exception:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── VÉRIFICATION SEUIL 5 FICHES PENDING MODÉRATION ─────────────────────────
|
||||
async function checkModerationQueue() {
|
||||
const data = await nocodbGet(
|
||||
`${NOCODB_TABLE_ORGAS}?where=(moderation_status,eq,ai_processed)&limit=100`
|
||||
);
|
||||
const count = data.pageInfo?.totalRows || 0;
|
||||
if (count >= 5) {
|
||||
log(`Seuil modération atteint: ${count} fiches en attente`);
|
||||
await sendEmailJules(
|
||||
`NAV — ${count} fiches à modérer`,
|
||||
`Bonjour Jules,\n\n${count} fiches ont été enrichies par l'IA et attendent ta validation dans NocoDB.\n\nLien NocoDB : http://localhost:8070\nFiltre : moderation_status = ai_processed\n\nBonne modération !`
|
||||
);
|
||||
log('ntfy exception:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MAIN ─────────────────────────────────────────────────────────────────────
|
||||
async function run() {
|
||||
if (!acquireLock()) {
|
||||
if (!DRY_RUN && !acquireLock()) {
|
||||
log('Worker déjà en cours, skip');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
log('=== Worker NAV enrichissement démarré ===');
|
||||
log(`=== Worker AEP enrichissement démarré${DRY_RUN ? ' (--dry-run)' : ''} ===`);
|
||||
|
||||
try {
|
||||
// Vérification clés obligatoires
|
||||
if (!NOCODB_TOKEN || !NOCODB_BASE || !NOCODB_TABLE_ORGAS || !MISTRAL_API_KEY) {
|
||||
throw new Error('Variables .env manquantes (NOCODB_TOKEN, NOCODB_BASE, NOCODB_TABLE_ORGAS, MISTRAL_API_KEY)');
|
||||
if (!DRY_RUN) {
|
||||
if (!NOCODB_TOKEN || !NOCODB_BASE || !NOCODB_TABLE_ORGAS || !BIFROST_VK) {
|
||||
throw new Error('Variables .env manquantes (NOCODB_TOKEN, NOCODB_BASE, NOCODB_TABLE_ORGAS, BIFROST_VK)');
|
||||
}
|
||||
}
|
||||
|
||||
// Check budget global
|
||||
const budgetMois = await getBudgetMoisCourant();
|
||||
log(`Budget mois courant: €${budgetMois.toFixed(4)} / €${BUDGET_MAX_EUR}`);
|
||||
|
||||
if (budgetMois >= BUDGET_MAX_EUR) {
|
||||
log('Budget épuisé pour ce mois. Worker en pause.');
|
||||
await sendEmailJules(
|
||||
'NAV — Budget IA épuisé ce mois',
|
||||
`Le budget IA de ${BUDGET_MAX_EUR}€ a été atteint. Le worker est en pause jusqu'au 1er du mois prochain.\n\nConsommation actuelle : €${budgetMois.toFixed(4)}`
|
||||
);
|
||||
await notifyNtfy('[AEP] Budget IA épuisé', `Le budget de ${BUDGET_MAX_EUR}€ a été atteint. Worker en pause jusqu'au 1er du mois prochain.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch fiches pending
|
||||
const rows = await fetchPendingRows();
|
||||
log(`${rows.length} fiche(s) à traiter`);
|
||||
const rows = DRY_RUN ? loadFixtureRows() : await fetchPendingRows();
|
||||
log(`${rows.length} fiche(s) à traiter${DRY_RUN ? ' (fixture)' : ''}`);
|
||||
|
||||
if (rows.length === 0) {
|
||||
log('Rien à traiter.');
|
||||
@@ -407,71 +497,86 @@ async function run() {
|
||||
const rowStart = Date.now();
|
||||
log(`--- Traitement fiche ${row.Id}: ${row.nom} ---`);
|
||||
|
||||
// Re-check budget avant chaque fiche
|
||||
const budgetCheck = await getBudgetMoisCourant();
|
||||
if (budgetCheck >= BUDGET_MAX_EUR) {
|
||||
log('Budget atteint mid-pipeline, arrêt.');
|
||||
break;
|
||||
}
|
||||
|
||||
// Scraping
|
||||
let scrapeContent = null;
|
||||
const hasUrl = row.url && row.url.trim().length > 0;
|
||||
const shouldScrape = hasUrl && (row.scrape_status === 'pending' || !row.scrape_status);
|
||||
const liens = extractLinks(row);
|
||||
const primaryUrl = row.url && row.url.trim() ? row.url.trim() : null;
|
||||
const autresLiens = liens.filter(l => l !== primaryUrl);
|
||||
|
||||
// Scraping (uniquement le lien principal — un seul champ scrape_content en base)
|
||||
let scrapeData = null;
|
||||
const shouldScrape = primaryUrl && (row.scrape_status === 'pending' || !row.scrape_status);
|
||||
const useNetwork = !DRY_RUN || DRY_RUN_LIVE;
|
||||
|
||||
if (shouldScrape) {
|
||||
try {
|
||||
scrapeContent = await scrapeWithCrawl4ai(row.url);
|
||||
await nocodbPatch(NOCODB_TABLE_ORGAS, row.Id, {
|
||||
scrapeData = useNetwork ? await scrapeLight(primaryUrl) : MOCK_SCRAPE_RESULT;
|
||||
await patchRow(row.Id, {
|
||||
scrape_status: 'scraped',
|
||||
scrape_content: scrapeContent
|
||||
scrape_content: JSON.stringify(scrapeData),
|
||||
});
|
||||
} catch (e) {
|
||||
log(`Scrape échoué: ${e.message}`);
|
||||
await nocodbPatch(NOCODB_TABLE_ORGAS, row.Id, { scrape_status: 'failed' });
|
||||
// Continue avec l'IA sans contenu scrape
|
||||
await patchRow(row.Id, { scrape_status: 'failed' });
|
||||
}
|
||||
} else if (!hasUrl) {
|
||||
await nocodbPatch(NOCODB_TABLE_ORGAS, row.Id, { scrape_status: 'no_link' });
|
||||
} else if (!primaryUrl) {
|
||||
await patchRow(row.Id, { scrape_status: 'no_link' });
|
||||
}
|
||||
|
||||
// Appel Mistral Nemo
|
||||
const enriched = await callMistralWithRetry(row, scrapeContent);
|
||||
// Appel LLM (Bifrost)
|
||||
const enriched = useNetwork
|
||||
? await callBifrostWithRetry(row, scrapeData, autresLiens)
|
||||
: MOCK_BIFROST_RESULT;
|
||||
|
||||
if (!enriched) {
|
||||
log(`Échec Mistral sur fiche ${row.Id}, flag ai_error`);
|
||||
await nocodbPatch(NOCODB_TABLE_ORGAS, row.Id, {
|
||||
moderation_status: 'ai_error',
|
||||
ai_processed: true
|
||||
});
|
||||
log(`Échec Bifrost sur fiche ${row.Id}, flag ai_error`);
|
||||
await patchRow(row.Id, { moderation_status: 'ai_error', ai_processed: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalisation tags
|
||||
const rawTags = enriched.tags_fonction || [];
|
||||
const rawTags = enriched.tags || enriched.tags_fonction || [];
|
||||
const normalizedTags = [...new Set(rawTags.map(normalizeTag).filter(Boolean))];
|
||||
|
||||
// Update NocoDB
|
||||
const updateData = {
|
||||
description_enrichie: enriched.description_enrichie || null,
|
||||
points_cles: enriched.points_cles ? JSON.stringify(enriched.points_cles) : null,
|
||||
description_enrichie: enriched.description || null,
|
||||
tags_fonction: normalizedTags.join(','),
|
||||
moderation_status: 'ai_processed',
|
||||
ai_processed: true,
|
||||
ai_raw_output: JSON.stringify({ output: enriched, confiance: enriched.confiance })
|
||||
ai_raw_output: JSON.stringify({ output: enriched, confiance: enriched.confiance }),
|
||||
};
|
||||
|
||||
// Conserver echelle/territoire/localisation si l'IA les a enrichis
|
||||
if (enriched.echelle && !row.echelle) updateData.echelle = enriched.echelle;
|
||||
if (enriched.territoire && !row.territoire) updateData.territoire = enriched.territoire;
|
||||
if (enriched.localisation_ville && !row.localisation_ville) {
|
||||
updateData.localisation_ville = enriched.localisation_ville;
|
||||
// Nom : ne remplacer que le placeholder posé par le formulaire assoupli (B5-M1).
|
||||
if (enriched.nom && typeof row.nom === 'string' && row.nom.startsWith('[à qualifier]')) {
|
||||
updateData.nom = enriched.nom;
|
||||
}
|
||||
if (enriched.ville && !row.localisation_ville) {
|
||||
updateData.localisation_ville = enriched.ville;
|
||||
}
|
||||
// submission_type : ne réajuster que si l'utilisateur n'avait pas choisi de chip
|
||||
// (marqueur posé par utils/submitLibre.ts::toOrgaPayload — "Type : non précisé").
|
||||
const typeNonPrecise = typeof row.description_user === 'string'
|
||||
&& row.description_user.startsWith('Type : non précisé');
|
||||
if (typeNonPrecise && VALID_SUBMISSION_TYPES.includes(enriched.type_suggere)) {
|
||||
updateData.submission_type = enriched.type_suggere;
|
||||
}
|
||||
|
||||
await nocodbPatch(NOCODB_TABLE_ORGAS, row.Id, updateData);
|
||||
await patchRow(row.Id, updateData);
|
||||
await logUsage(enriched._usage, WORKER_MODEL, 'enrichissement', row.Id);
|
||||
|
||||
// Log usage tokens
|
||||
await logUsage(enriched._usage, 'open-mistral-nemo', 'enrichissement', row.Id);
|
||||
await notifyNtfy(
|
||||
'[AEP] Fiche enrichie',
|
||||
[
|
||||
`Id NocoDB : ${row.Id}`,
|
||||
`Nom suggéré : ${updateData.nom || row.nom}`,
|
||||
`Type : ${updateData.submission_type || row.submission_type || 'nc'}`,
|
||||
`Confiance : ${enriched.confiance || 'nc'}`,
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const elapsed = ((Date.now() - rowStart) / 1000).toFixed(1);
|
||||
log(`Fiche ${row.Id} traitée en ${elapsed}s — confiance: ${enriched.confiance || 'nc'}`);
|
||||
@@ -480,15 +585,12 @@ async function run() {
|
||||
|
||||
log(`=== Run terminé: ${processedCount}/${rows.length} fiches traitées en ${((Date.now() - startTime) / 1000).toFixed(1)}s ===`);
|
||||
|
||||
// Check seuil modération
|
||||
await checkModerationQueue();
|
||||
|
||||
} catch (e) {
|
||||
log('ERREUR WORKER:', e.message);
|
||||
console.error(e.stack);
|
||||
process.exit(1);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
releaseLock();
|
||||
if (!DRY_RUN) releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"Id": 9001,
|
||||
"nom": "[à qualifier] exemple.org",
|
||||
"url": "https://exemple.org",
|
||||
"description_user": "Ça m'a beaucoup aidé à sortir de l'isolement quand j'ai monté mon agence.\n\nLiens :\nhttps://exemple.org\nhttps://exemple.org/guide-installation",
|
||||
"submitted_by_email": null,
|
||||
"submission_type": "ecosysteme",
|
||||
"moderation_status": "pending",
|
||||
"ai_processed": false,
|
||||
"scrape_status": "pending",
|
||||
"localisation_ville": null
|
||||
},
|
||||
{
|
||||
"Id": 9002,
|
||||
"nom": "[à qualifier] sans titre",
|
||||
"url": null,
|
||||
"description_user": "Type : non précisé\n\nUn super groupe d'entraide entre archis installés en rural, très actif sur les questions de reconversion.",
|
||||
"submitted_by_email": "test@exemple.fr",
|
||||
"submission_type": "ecosysteme",
|
||||
"moderation_status": "pending",
|
||||
"ai_processed": false,
|
||||
"scrape_status": "no_link",
|
||||
"localisation_ville": null
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user