Compare commits

..
Author SHA1 Message Date
Jules NenyandClaude Sonnet 4.6 a1c47002d5 fix(media): centre gravite auteurs + bouton vue partagee + poignee draggable barre
- CartePensees.vue : pre-positionner auteurs sur ecole.x_hint/y_hint + jitter 80px pour eviter le rush initial vers la droite au chargement
- media.vue : bouton Vue partagee repositionne entre Carte plein ecran et Chatbot plein ecran, style homogene avec les autres boutons
- media.vue : poignee draggable sur barre separation carte/chatbot en mode split - ratio clamp 20/80, localStorage media-split-ratio, triggerResize D3 au mouseup, desactivee sur mobile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 17:18:52 +02:00
Jules Neny c14a1ee01f feat(media): iteration 2 carte - Voronoi bg + collapso fusion + lisibilite + liens influence 2026-05-12 12:12:05 +02:00
Jules Neny 1b1e373bea feat(media): refonte carte Bonpote-aligned 11 ecoles pastel + header RAG->MEDIA
- auteurs-pensees.json: 11 ecoles (suppression marxismes-ecologiques, fusion Marx+Saito->ecosocialisme), palette pastel, positions x_hint/y_hint Bonpote-aligned
- CartePensees.vue: texte ecole blanc->#1a1a1a, background #f5f3f0, linkDistance 130, charge -50, forceX/forceY ajoutescode pour ancrer auteurs pres de leur ecole principale
- app.vue: onglet desktop RAG->MEDIA sans badge, menu mobile to=/rag->to=/media avec active state conditionnel
2026-05-12 11:27:16 +02:00
Jules Neny c6295ea228 fix: chatbot corpus onMounted + CSS auteurs lisibilite + remove /rag placeholder
- ChatbotPensees: deplace lecture localStorage dans onMounted (fix bug hydratation SSR/CSR, corpus 'both' garanti au render initial)
- CartePensees: opacity 1, stroke-width 2px, font-weight 600 (auteurs lisibles sur fond pastel)
- pages/rag.vue: supprime la page placeholder /rag (route disparait, Nuxt retourne 404)
2026-05-12 01:00:03 +02:00
Jules NenyandClaude Sonnet 4.6 cd2d225e91 feat(media): split layout 2/3 carte + 1/3 chatbot + toggle plein ecran
- Chatbot passe d'overlay flottant a inline (1/3 hauteur permanent)
- Bouton [Carte plein ecran] / [Chatbot plein ecran] / [Vue partagee]
- Transition CSS douce 0.3s ease sur height/flex-basis/opacity
- Restart D3 simulation alpha(0.3) apres transition (350ms delay)
- localStorage persistance du mode (cle media-layout-mode)
- Responsive mobile <768px : stack vertical carte 60vh + chatbot 40vh
- CartePensees expose triggerResize() via defineExpose
- ChatbotPensees : prop inline booleen, 2 modes rendu (overlay/inline)

V2 Phase 4

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 00:06:51 +02:00
Jules Neny 11732a6a4b feat(media): rename /pensees-ecologiques → /media + corpus réel + 12 écoles FRACAS Bonpote
- Page pages/pensees-ecologiques.vue → pages/media.vue (titre "ATIS Média")
- Labels onglet/menu "Pensées" → "Média" (app.vue, agences, index, filters)
- auteurs-pensees.json reconciled avec 141 docs LightRAG (était 27)
  · 28 auteurs (était 18), 64 livres, slugs corrigés (ex: bookchin-ecologie-liberte)
  · 12 écoles: 8 familles FRACAS Bonpote + 4 extensions ATIS
  · Labels alignés Bonpote: Écologies libertaires (ex eco-anarchisme),
    Écologies anti-industrielles (ex technocritique)
  · Familles Bonpote ajoutées: Capitalisme vert + Écofascismes
    (corpus_status: non_ingere — fidélité carte, critique éditoriale assumée)

V2 Phase 2.3 — corpus réel reflété, alignement Bonpote initial
2026-05-11 23:21:49 +02:00
Jules Neny 538c9a1214 feat(chatbot): add corpus toggle UI (pensees/projets/both) + refs filtering
- 3 toggle buttons in chatbot header, default Croise
- Pass corpus param to /api/chatbot-pensees
- Filter references UI side based on corpus (no FRACAS leak in projets mode)
- localStorage persistence with key chatbot-pensees-corpus

V2 Phase 2.2 -- frontend toggle, paired with B.1 backend (commit 8d673482)
2026-05-11 19:28:01 +02:00
Jules NenyandClaude Sonnet 4.6 8d673482b6 feat(chatbot): add corpus param (pensees/projets/both) with adaptive preface
- New corpus param defaults to 'both' (projet-centered crossing)
- 3 preface modes for LightRAG query orientation
- Smoke tested via SSH direct LightRAG VPS -- pondération validée

V2 Phase 2.1 -- backend only, frontend toggle pending B.2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 19:23:23 +02:00
Jules NenyandClaude Sonnet 4.6 586742d90e fix(rag-pe): supprimer toggle inutile + chatbot global tous onglets
- pensees-ecologiques.vue : supprime toggle Familiale/Graphe, une seule CartePensees
- agences.vue : ChatbotPensees accessible depuis tous les onglets (desktop)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:01:45 +02:00
Jules NenyandClaude Sonnet 4.6 668ae5caff feat(rag-pe): PRG-5 + PRG-6 frontend pensees ecologiques
- server/api/chatbot-pensees.post.ts : endpoint LightRAG VPS (hybrid mode, preface militante, rate limit 20/jour, health guard)
- nuxt.config.ts : ragPeUrl runtimeConfig (NUXT_RAG_PE_URL)
- public/data/auteurs-pensees.json : 18 auteurs FRACAS, 8 ecoles, theses, livres RAG
- components/CartePensees.vue : D3 force-directed (8 ecoles fixes + auteurs gravitants)
- components/FicheAuteur.vue : modal auteur (bio + theses + livres RAG + bouton RAG)
- components/ChatbotPensees.vue : overlay chatbot bottom-right (sources expansibles)
- pages/pensees-ecologiques.vue : page dedicee /pensees-ecologiques (toggle Familiale/Graphe)
- pages/agences.vue : 4e onglet "Pensees" (desktop + mobile) -> /pensees-ecologiques

Branche : feat/aep-rag-pensees-ecologiques
Checkpoint Jules requis avant merge main.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:07:42 +02:00
104 changed files with 5814 additions and 31368 deletions
-1
View File
@@ -7,4 +7,3 @@ dist
*.log
*.tmp.*
.firecrawl/
-143
View File
@@ -1,143 +0,0 @@
# Design system nav-carte (AEP)
Ce fichier existe pour que B6 à B9 n'aient pas à relire les récaps de B3 (`1 PROJETS/DEV/AEP/Cascade-Refonte/recaps/B3-*.md` dans le vault Dropbox) pour comprendre ce qui a été posé. Si tu es sur B6+ et que tu touches au visuel de nav-carte, lis ceci d'abord.
## D'où viennent les tokens
Les couleurs, espacements, rayons, ombres, etc. ne sont **jamais** des valeurs en dur dans les composants nav-carte. Ils viennent de deux fichiers, chargés dans cet ordre dans `nuxt.config.ts` :
1. `@transformer/ui/tokens.css` — package npm partagé (`git+https://git.trans-former.fr/jules/design-system.git`), le schéma canonique de tokens par **rôle** (`--bg`, `--text`, `--primary`, `--focus-ring`, `--space-*`, `--radius-*`, `--fs-*`, etc.). Jamais cloné en local, jamais modifié depuis ce repo.
2. `@transformer/ui/theme/aep.css` — la palette AEP spécifique (« Sobre institutionnel », validée par Jules), qui surcharge les rôles du point 1 avec les vraies couleurs : bleu nuit `#1a2238`, accent safran `#f5b342`, fond crème `#f8f6f1`. C'est ce fichier qui rend `--primary`, `--accent`, `--bg` etc. concrets pour AEP.
`assets/css/main.css` définit ensuite des variables `--nav-*` (héritées de l'ancienne palette V1, avant l'arrivée du DS). Elles sont **alias** des rôles ci-dessus : `--nav-bg: var(--bg)`, `--nav-text: var(--text)`, etc. Elles existent uniquement parce que tout le code existant (avant B3) les consomme — ne pas les supprimer sans grep préalable, ne pas en créer de nouvelles : pour du code neuf, consommer directement les tokens `--bg`/`--text`/`--primary`/... du DS.
**Trois variables restent volontairement non-aliasées**, propres à nav-carte :
- `--nav-primary-raw` — triplet RGB (`26, 34, 56`) utilisé dans des `rgba(var(--nav-primary-raw), x)`. Le DS n'expose pas de triplet RGB séparé, seulement des couleurs finales.
- `--nav-primary-solid` — bleu nuit plein (`#1a2238` clair, `#c8d2f0` sombre). Différent de `--primary` du DS, qui est le bleu nuit à 60 % d'opacité (utilisé pour les surfaces translucides). `--nav-primary-solid` sert au texte et aux éléments qui doivent rester lisibles en plein (logo, soulignement d'onglet actif).
- `--nav-text-on-primary` — texte de contraste, gardé en valeur littérale (`#f8f6f1` clair / `#111520` sombre) plutôt qu'aliasé sur `--on-primary` du DS. **Raison** : cette variable sert de texte de contraste sur DEUX fonds différents dans le code existant — `--nav-primary` (translucide) ET `--nav-primary-solid` (opaque) — qui ont une légèreté **inversée** en mode sombre (le translucide rend sombre une fois mélangé au fond, le solid `#c8d2f0` est un lavande clair). Le rôle `--on-primary` du DS ne colle qu'au premier cas ; l'aliaser cassait le contraste à une dizaine d'endroits (logo « AEP », boutons, bandeaux) en sombre. Si tu ajoutes un nouvel usage de cette variable, vérifie d'abord sur QUEL fond (translucide ou solid) le texte doit être lisible.
Dark mode : classe `.dark` posée sur `<html>` (`document.documentElement.classList.add('dark')`, persisté dans `localStorage.aep_theme`). `theme/aep.css` définit ses valeurs sombres sous le sélecteur `:root.dark, :root[data-theme="dark"]` — les deux formes sont acceptées, mais nav-carte n'utilise que `.dark`. Pas de bascule automatique par `prefers-color-scheme` (décision explicite : le site n'en a jamais eu, ça changerait ce que voient les visiteurs par défaut).
## Grammaire des chips par dimension
Trois dimensions, trois traitements visuels — posés en B3-M1, pensés pour rester stables jusqu'à B4+ (nouvelles dimensions à ajouter, pas à réinventer) :
| Dimension | Variant | Rendu inactif | Rendu actif |
|---|---|---|---|
| Échelle (national/régional/local) | `echelle` | outline, fond transparent | fond `--primary` plein, texte `--on-primary` |
| Fonction / hashtag | `fonction` (défaut) | fond `--bg-alt` | fond `--primary` plein, texte `--on-primary` |
| Posture (préparée pour B4, pas encore consommée) | `posture` | outline safran (`--accent`) | fond `--accent` plein, texte `--on-accent` **sombre** — jamais blanc sur safran (voir règle de contraste plus bas) |
Composants : `components/Chip.vue` (une chip, `<button>` ou `<span>` selon `as`) et `components/ChipGroup.vue` (conteneur `role="group"`, `flex-wrap`, gap). Toujours les deux ensemble — jamais une chip nue hors groupe (accessibilité : le groupe porte l'`aria-label` qui dit ce qu'on filtre).
```vue
<ChipGroup aria-label="Filtrer par fonction">
<Chip
v-for="fn in fonctions"
:key="fn"
:label="fn"
variant="fonction"
:active="selected.includes(fn)"
@toggle="toggleFonction(fn)"
/>
</ChipGroup>
```
Pour une chip non cliquable (affichage seul, ex. liste de résultats) : `as="span"` — pas de `type="button"`, pas d'`aria-pressed`, pas de handler `@toggle` nécessaire.
## FicheMiniCard + useFicheAdapter — brancher une nouvelle source
`composables/useFicheAdapter.ts` expose `toMini(source): MiniFiche`, où `MiniFiche = { id, nom, resume, chips, echelle?, geoloc, kind }`. Aujourd'hui il distingue `Org` (NocoDB, page `/`) et `StructureV2` (JSON statique `reseaux-bifurcation.json`, page `/agences`) via `'famille_principale' in source` (seul champ propre à `StructureV2`).
**Pour brancher une 3e source** (B6+, ex. un nouveau type de fiche) :
1. Ajoute son type à l'union `Org | StructureV2 | TonNouveauType` dans la signature de `toMini`.
2. Ajoute une garde de type (comme `isStructure`) basée sur un champ qui n'existe QUE dans ton nouveau type — ne réutilise pas `famille_principale` ou un champ ambigu.
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` (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.
## Vue fiches (B11 02/10/2026, détail B12 03/10/2026) — `VueFiches` + `useVueFiches`
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).
**Trois fichiers, trois rôles.**
| 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 |
**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.
**É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 `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)
Le favicon (`public/favicon.svg`, décliné en `favicon-32.png`, `favicon-16.png`, `apple-touch-icon.png`) dessine un petit graphe : un nœud central relié à cinq nœuds périphériques par des arêtes, sur fond bleu nuit avec des nœuds/arêtes safran. C'est un **motif à décliner**, pas une icône isolée — il préfigure les liens de graphe et les connexions entre fiches attendus en B4 (postures), B8 et B9 (structures liées, graphe de réseau déjà présent dans `FicheModalV2` via `structuresVoisines`). Si tu dessines un composant de graphe ou de connexions entre fiches dans ces batchs, reprends ce vocabulaire visuel (nœuds pleins, arêtes fines, un nœud central mis en avant) plutôt que d'en inventer un nouveau.
## Deux règles de contraste
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.
+50
View File
@@ -11,6 +11,56 @@ Journal technique de la V2. Décisions, anomalies, points bloquants, TODOs.
---
## 2026-05-08 — Fix mobile + chatbot prod (cause racine résolue)
**Commits :** session loggée sur main (pushé sur gitea)
**Pattern :** pilote direct, 2 batches successifs, ~3h, 11 fichiers
### Cause racine bug "chatbot Carte 1 == Carte 2"
`/api/chatbot-reseaux` était **404 en prod** (jamais déployé) — explique pourquoi 5 cycles de fix précédents (ChatbotReseaux.vue prop endpoint, useRoute fallback, useMarkdown direct, etc.) n'ont rien donné : le code source était correct depuis le début. Le rebuild + redeploy de cette session résout le bug.
**Verif :** `curl -s -X POST https://aep.trans-former.fr/api/chatbot-reseaux` → 200 + réponse distincte de `/api/chatbot`.
### Batch 1 — fixes mobile principaux
- Hamburger app.vue : ajout Jobs + Manifeste + Soutenir, ré-ordonnancement (Manifeste dans 2e groupe avec À propos/Soutenir/Signaler)
- BandeauBas.vue : FAB cœur jaune mobile retiré (Soutenir migré dans hamburger via lien Liberapay direct)
- agences.vue mobile : 3e onglet "Graphe" ajouté + masquage MobileSheet en mode graphe (canvas fullscreen)
- a-propos.vue : section 1 "Mission" retirée (devient pop-up Carte 1) + `overflow-x: hidden` sur `.apropos-page` + retrait `white-space: nowrap` problématique sur `.badge-detail`
- pages/manifeste.vue : nouvelle page (texte version `manifeste-page-carto-V1.md`, sans le diagramme ASCII pour V1 web)
- components/MissionPopup.vue : nouveau composant générique (props `title`, `ctaLabel`, `storageKey`, slot pour contenu, `:slotted()` pour styles)
- index.vue : intégration MissionPopup + bouton (i) `position:fixed` bottom-left + auto-show 1ère visite via `localStorage.aep_mission_seen`
- trouver-du-taf.vue : toggle "Filtres [N] [chevron]" mobile-only (`@media max-width: 767px`) avec `taff-filters-collapsible` max-height transition
- FicheModal.vue + FicheModalV2.vue : sur mobile `top: 76px` + `max-height: calc(100dvh - 92px)` au lieu de `top: 50% translate(-50%, -50%)` + `max-height: 90vh` qui mordait sur le header
### Batch 2 — pop-up Carte 2, logo, intro Jobs, labels graphe
- agences.vue : pop-up Réseaux AEP avec MissionPopup (storageKey `aep_reseaux_seen`, ctaLabel "Explorer les 120 réseaux") + bouton (i) flottant
- app.vue logo header : badge AEP + 2 spans `logo-line-1` ("Architecture") / `logo-line-2` ("d'Écologie Politique") avec font-size responsive (0.7rem mobile → 0.85rem ≥1024)
- trouver-du-taf.vue : `<details class="taff-pedago" open>` avec 3 blocs (deux onglets, trois étiquettes, cinq axes) + onglet "Plateformes B2C" → "Pour archi indépendants"
- GraphView.vue : `d3NodeSelection.filter(type==='structure').append('text')` avec class `graph-struct-label`, `dy: -(d.r + 5)`, font-size 9.5px, halo via `paint-order: stroke; stroke: var(--nav-bg)` (style global non-scoped pour piercer D3)
### Bug d'opération à retenir
Lors du 1er déploiement batch 2, `bash deploy.sh` semblait OK (HTTP 200) mais le HTML en prod ne contenait pas les modifs. **Cause** : Dropbox sync a effacé `.output/` entre `npm run build` et le tar SCP — le tar a uploadé un `.output` quasi-vide. Solution : 2e cycle clean (`Remove-Item .nuxt/dist + .output`) + rebuild + redeploy avec `yes y |` (skip confirm interactif `.env diff`).
**Réflexe à intégrer** : après build, vérifier `grep -o "<un-fragment-de-modif>" .output/public/_nuxt/*.js | head` AVANT le deploy. Si 0 match → ne pas deploy, rebuild.
### Bug de communication à retenir
Jules a signalé "le logo n'a pas marché", "B2C pas renommé", "hamburger pas modifié" alors que le HTML en prod contenait bien les modifs (vérifié curl avec `?nc=$(date +%s)`). **Cause** : cache navigateur / service worker Nuxt. Réflexe à mettre en place pour /done de toute session web : si Jules dit "ça n'apparaît pas", vérifier curl en bypass cache AVANT de chercher un bug. Si match curl → demander hard refresh (Ctrl+Shift+R).
### Reste à faire (batch 3)
Voir `0 INBOX/PROMPTS/cascade-megaboum/REPRISE-aep-carto-fix-batch3.md` :
- Bouton "+" → sélecteur 3 cartes (Entraide/Réseaux/Jobs)
- Pop-up explication 5 axes Jobs (paragraphe par axe)
- Pop-up Carte 1 visibilité (option à clarifier avec Jules)
- GraphView Carte 1 (centres = hashtags, couche échelle activable) — gros chantier session dédiée
---
## 2026-04-27 — Session V3 : Finition mobile + Blog Liberapay + 3 deploys
**Commit :** `a02a555` — feat(mobile): accordéon outremer, hamburger nav, logo AEP, fiches cliquables, chatbot fullscreen
-90
View File
@@ -204,8 +204,6 @@ 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
@@ -332,91 +330,3 @@ 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`.
+285 -152
View File
@@ -1,12 +1,12 @@
<template>
<div class="flex flex-col h-screen overflow-hidden" :class="{ dark: isDark }" style="background: var(--nav-bg);">
<!-- ═══════════════════════════════════════ TOP NAV GLOBAL (B10 : échelles S / M / XL) -->
<!-- ═══════════════════════════════════════ TOP NAV GLOBAL -->
<header
class="app-header flex items-center px-4 shrink-0 relative z-[9999] shadow-sm gap-3"
class="flex items-center justify-between px-4 py-2.5 shrink-0 relative z-[9999] shadow-sm gap-3"
style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);"
>
<!-- Logo : texte complet sauf 768-1023 et 1280-1439 (place des onglets) -->
<!-- Logo -->
<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,41 +20,139 @@
</div>
</a>
<!-- Échelles : trois groupes ≥ 1280, groupe courant 768-1279, rien < 768 (fil d'échelle) -->
<NavEchelles />
<div class="flex-1 md:hidden" aria-hidden="true" />
<!-- ── 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="/"
class="nav-tab"
:class="{ 'nav-tab--active': route.path === '/' }"
>
Écosystème Entraide Architecture
</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 === '/media' }"
>
MEDIA
</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>
<!-- Actions droite -->
<div class="flex items-center gap-2 shrink-0">
<NuxtLink
to="/manifeste"
class="header-lien hidden md:inline-flex"
:aria-current="route.path === '/manifeste' ? 'page' : undefined"
>
Manifeste
</NuxtLink>
<NuxtLink
to="/a-propos"
class="header-lien header-xl"
:aria-current="route.path === '/a-propos' ? 'page' : undefined"
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);"
>
À propos
</NuxtLink>
<a
:href="SOUTENIR_URL"
target="_blank"
rel="noopener noreferrer"
class="header-soutenir header-xl"
>
Soutenir
</a>
<!-- Proposer une ressource -->
<NuxtLink
to="/proposer"
class="header-proposer hidden md:inline-flex"
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);"
>
+ Proposer
Signaler
</NuxtLink>
<!-- Proposer — popover 3 choix -->
<div class="hidden sm:block relative" ref="proposerAnchor" data-proposer-popover>
<button
@click="proposerOpen = !proposerOpen"
class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 inline-flex items-center gap-1"
style="background: var(--nav-accent); color: var(--nav-text);"
aria-label="Proposer une contribution"
>
+ Proposer
</button>
<div
v-if="proposerOpen"
class="absolute right-0 top-full mt-1 rounded-lg shadow-lg min-w-[240px] py-1"
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
>
<NuxtLink
to="/contribuer"
class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70"
style="color: var(--nav-text);"
@click="proposerOpen = false"
>
<span>Fiche Entraide <span style="color: var(--nav-text-muted); font-weight: 400; font-size: 0.7rem; display: block;">Carte 1 — Écosystème archi</span></span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
</NuxtLink>
<div style="height: 1px; background: var(--nav-bg-alt); margin: 2px 0;"></div>
<NuxtLink
to="/contribuer-reseau"
class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70"
style="color: var(--nav-text);"
@click="proposerOpen = false"
>
<span>Réseau / collectif <span style="color: var(--nav-text-muted); font-weight: 400; font-size: 0.7rem; display: block;">Carte 2 — Réseaux AEP</span></span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
</NuxtLink>
<div style="height: 1px; background: var(--nav-bg-alt); margin: 2px 0;"></div>
<NuxtLink
to="/contribuer-job"
class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70"
style="color: var(--nav-text);"
@click="proposerOpen = false"
>
<span>Plateforme jobs <span style="color: var(--nav-text-muted); font-weight: 400; font-size: 0.7rem; display: block;">Carte 3 — Jobs archi</span></span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
</NuxtLink>
</div>
</div>
<!-- Toggle dark mode -->
<button
@@ -76,54 +174,120 @@
</svg>
</button>
<!-- Mobile : proposer icône -->
<NuxtLink
to="/proposer"
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" aria-hidden="true">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</NuxtLink>
<!-- Mobile : contribuer icône → popover -->
<div class="sm:hidden relative" data-proposer-popover>
<button
@click="proposerOpen = !proposerOpen"
class="p-2 rounded-lg"
style="background: var(--nav-accent); color: var(--nav-text);"
title="Contribuer"
aria-label="Contribuer"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
<div
v-if="proposerOpen"
class="absolute right-0 top-full mt-1 rounded-lg shadow-lg min-w-[220px] py-1"
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
>
<NuxtLink to="/contribuer" class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" style="color: var(--nav-text);" @click="proposerOpen = false">
<span>Fiche Entraide</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
</NuxtLink>
<div style="height: 1px; background: var(--nav-bg-alt); margin: 2px 0;"></div>
<NuxtLink to="/contribuer-reseau" class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" style="color: var(--nav-text);" @click="proposerOpen = false">
<span>Réseau / collectif</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
</NuxtLink>
<div style="height: 1px; background: var(--nav-bg-alt); margin: 2px 0;"></div>
<NuxtLink to="/contribuer-job" class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" style="color: var(--nav-text);" @click="proposerOpen = false">
<span>Plateforme jobs</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
</NuxtLink>
</div>
</div>
<!-- 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>
<!-- Hamburger mobile (lg:hidden) — toujours en dernier à droite -->
<div class="lg:hidden 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="/" class="block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Écosystème Entraide Architecture</NuxtLink>
<NuxtLink to="/agences" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/agences' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Réseaux AEP</NuxtLink>
<NuxtLink to="/trouver-du-taf" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/trouver-du-taf' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Jobs</NuxtLink>
<NuxtLink to="/media" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/media' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">MEDIA</NuxtLink>
<NuxtLink to="/codev" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/codev') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Codev</NuxtLink>
<div style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div>
<NuxtLink to="/manifeste" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/manifeste' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text-muted);'">Manifeste</NuxtLink>
<NuxtLink to="/a-propos" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">À propos</NuxtLink>
<a href="https://liberapay.com/trans-former.fr/donate" target="_blank" rel="noopener noreferrer" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">Soutenir →</a>
<NuxtLink to="/signaler" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">Signaler</NuxtLink>
</div>
</div>
</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 min-h-0" :class="route.path === '/' ? 'overflow-hidden' : 'overflow-y-auto'">
<div class="flex-1" :class="route.path === '/' ? 'overflow-hidden' : 'overflow-y-auto'">
<NuxtPage />
</div>
<NavTiroir :open="menuOpen" @close="menuOpen = false" />
<!-- Bandeau bas — transparence IA + soutien + compteurs semaine -->
<BandeauBas />
</div>
</template>
<script setup lang="ts">
import { SOUTENIR_URL } from '~/utils/liens'
const router = useRouter()
const route = useRoute()
const menuOpen = ref(false)
const hamburgerOpen = ref(false)
watch(() => route.path, () => { hamburgerOpen.value = false })
// ── Popover "+ Proposer" ─────────────────────────────────────────────────
const proposerOpen = ref(false)
const proposerAnchor = ref<HTMLElement | null>(null)
function onClickOutsideProposer(e: MouseEvent) {
// Ferme si le clic est hors de tout élément portant data-proposer-popover
const target = e.target as HTMLElement
if (!target.closest('[data-proposer-popover]')) {
proposerOpen.value = false
}
}
watch(proposerOpen, (open) => {
if (open) {
// Délai court pour ne pas attraper le clic d'ouverture lui-même
setTimeout(() => document.addEventListener('click', onClickOutsideProposer, true), 10)
} else {
document.removeEventListener('click', onClickOutsideProposer, true)
}
})
onUnmounted(() => {
document.removeEventListener('click', onClickOutsideProposer, true)
})
// ── Dark mode ─────────────────────────────────────────────────────────────
const isDark = ref(false)
@@ -146,14 +310,40 @@ 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;
@@ -168,96 +358,39 @@ function toggleDark() {
@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; }
}
/* ── Liens et boutons de droite ───────────────────────────────────────── */
.header-lien {
align-items: center;
padding: 6px 8px;
border-radius: var(--radius-md);
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
white-space: nowrap;
text-decoration: none;
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);
}
.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);
}
.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;
}
.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 {
/* ── Onglets header desktop ───────────────────────────────────────────── */
.nav-tab {
position: relative;
display: inline-flex;
flex-direction: column;
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;
padding: 6px 16px 8px;
font-size: 0.8rem;
font-weight: 500;
color: var(--nav-text-muted);
text-decoration: none;
transition: color 0.15s;
border-bottom: 2px solid transparent;
gap: 2px;
}
.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; }
.nav-tab:hover {
color: var(--nav-text);
}
@media (min-width: 1280px) {
.header-menu { display: none; }
.nav-tab--active {
color: var(--nav-text);
border-bottom-color: var(--nav-primary-solid);
font-weight: 600;
}
.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;
}
</style>
+16 -32
View File
@@ -3,44 +3,28 @@
@tailwind utilities;
/* ── Palette NAV V2 — Sobre institutionnel ─────────────────────────────── */
/* --nav-* sont désormais des ALIAS vers les rôles @transformer/ui (tokens.css
+ theme/aep.css, chargés avant ce fichier dans nuxt.config.ts).
--nav-primary-raw, --nav-primary-solid et --nav-text-on-primary restent des
valeurs PROPRES à nav-carte (non aliasées) :
- le DS n'a pas de triplet RGB (--nav-primary-raw) ;
- le --text du DS en sombre (#e8ecf5) ne correspond pas à la teinte bleu
nuit voulue ici pour --nav-primary-solid (#c8d2f0) ;
- --nav-text-on-primary sert de texte de contraste à la fois sur
--nav-primary (translucide, alias DS) ET sur --nav-primary-solid (opaque,
propre à nav-carte) — deux fonds dont la légèreté s'inverse en sombre
(translucide ≈ fond sombre, solid = lavande claire). Le rôle --on-primary
du DS ne colle qu'au premier cas ; l'aliaser cassait le contraste du AEP
logo, des boutons trouver-du-taf, BandeauBas, SimulateurFeature,
FicheDetail (texte clair sur --primary-solid clair en sombre — testé au
pixel le 25/09, illisible). Gardé littéral = identique à la prod avant B3.
Idem --nav-accent-soft, sans équivalent DS. (B3-M0, 2026-09-25) */
:root {
/* Fonds */
--nav-bg: var(--bg);
--nav-bg-alt: var(--bg-alt);
--nav-surface: var(--surface);
--nav-bg: #f8f6f1;
--nav-bg-alt: #eee9df;
--nav-surface: #ffffff;
/* Bleu nuit — 60% d'opacité sur les surfaces */
--nav-primary-raw: 26, 34, 56;
--nav-primary: var(--primary);
--nav-primary: rgba(26, 34, 56, 0.6);
--nav-primary-solid: #1a2238;
/* Accent safran */
--nav-accent: var(--accent);
--nav-accent: #f5b342;
--nav-accent-soft: rgba(245, 179, 66, 0.85);
/* Texte */
--nav-text: var(--text);
--nav-text-muted: var(--text-muted);
--nav-text: #1a2238;
--nav-text-muted: rgba(26, 34, 56, 0.55);
--nav-text-on-primary: #f8f6f1;
/* Typographie */
--nav-font: var(--font-body);
--nav-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
@layer base {
@@ -74,25 +58,25 @@
/* Focus ring accessibilité */
*:focus-visible {
outline: 2px solid var(--focus-ring);
outline: 2px solid var(--nav-accent);
outline-offset: 2px;
}
/* ── Dark mode ──────────────────────────────────────────────────────────── */
.dark {
--nav-bg: var(--bg);
--nav-bg-alt: var(--bg-alt);
--nav-surface: var(--surface);
--nav-bg: #111520;
--nav-bg-alt: #1e2538;
--nav-surface: #1a2238;
--nav-primary-raw: 200, 210, 240;
--nav-primary: var(--primary);
--nav-primary: rgba(200, 210, 240, 0.15);
--nav-primary-solid: #c8d2f0;
--nav-accent: var(--accent);
--nav-accent: #f5b342;
--nav-accent-soft: rgba(245, 179, 66, 0.85);
--nav-text: var(--text);
--nav-text-muted: var(--text-muted);
--nav-text: #e8ecf5;
--nav-text-muted: rgba(200, 210, 240, 0.55);
--nav-text-on-primary: #111520;
}
+488
View File
@@ -0,0 +1,488 @@
<template>
<!-- ═══════════════════════════════════════ BANDEAU BAS ═══════════════════ -->
<!-- DESKTOP uniquement (≥1024px) — mobile a le FAB séparé -->
<footer
v-if="!isMobile"
ref="bandeauEl"
class="bandeau-bas shrink-0"
:class="{ 'bandeau-collapsed': isCollapsed }"
aria-label="Informations projet AEP"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
>
<!-- Contenu plein -->
<div class="bandeau-inner" :class="{ 'bandeau-inner--hidden': isCollapsed }">
<!-- ── GAUCHE : Transparence IA ──────────────────────────────────────── -->
<div class="bandeau-col">
<p class="bandeau-label">Transparence IA</p>
<template v-if="stats">
<p class="bandeau-value">
Coût IA ce mois : <strong>{{ stats.cout_mois_eur.toFixed(2) }} €</strong>
&nbsp;·&nbsp;
Tokens : <strong>{{ stats.tokens_mois.toLocaleString('fr-FR') }}</strong>
</p>
<!-- Jauge -->
<div class="jauge-track" aria-label="Budget IA consommé" role="progressbar" :aria-valuenow="jaugePct" aria-valuemin="0" aria-valuemax="100">
<div class="jauge-fill" :style="{ width: jaugePct + '%' }" />
</div>
<p class="bandeau-sub">
{{ stats.requetes_mois }} requête{{ stats.requetes_mois !== 1 ? 's' : '' }} ce mois
</p>
</template>
<template v-else-if="loading">
<p class="bandeau-sub">Chargement…</p>
</template>
<template v-else>
<p class="bandeau-sub">Données indisponibles</p>
</template>
</div>
<!-- ── MILIEU : CTA Soutien ──────────────────────────────────────────── -->
<div class="bandeau-col bandeau-col--center">
<div class="soutenir-wrap">
<button
class="btn-soutenir"
type="button"
@click="modalOpen = true"
@mouseenter="tooltipVisible = true"
@mouseleave="tooltipVisible = false"
@focus="tooltipVisible = true"
@blur="tooltipVisible = false"
aria-label="Soutenir le projet AEP sur Liberapay"
>
Soutenir le projet
</button>
<!-- Tooltip au hover -->
<div v-if="tooltipVisible" class="soutenir-tooltip" role="tooltip">
1 € = 30 fiches mises en ligne
</div>
</div>
</div>
<!-- ── DROITE : Compteurs semaine ────────────────────────────────────── -->
<div class="bandeau-col bandeau-col--right">
<p class="bandeau-label">Cette semaine</p>
<template v-if="stats">
<p class="bandeau-value">
{{ stats.fiches_semaine }} fiche{{ stats.fiches_semaine !== 1 ? 's' : '' }} ajoutée{{ stats.fiches_semaine !== 1 ? 's' : '' }}
</p>
<p class="bandeau-sub">
{{ stats.requetes_chatbot_semaine }} requête{{ stats.requetes_chatbot_semaine !== 1 ? 's' : '' }} chatbot
</p>
</template>
<template v-else>
<p class="bandeau-sub">—</p>
</template>
</div>
</div>
<!-- Barre fine (état collapsed) -->
<div class="bandeau-thin" :class="{ 'bandeau-thin--visible': isCollapsed }">
<span class="bandeau-thin-label">AEP · Transparence IA</span>
</div>
<!-- ── MODAL Liberapay ───────────────────────────────────────────────── -->
<Teleport to="body">
<Transition name="backdrop">
<div
v-if="modalOpen"
class="modal-backdrop"
@click="modalOpen = false"
aria-hidden="true"
/>
</Transition>
<Transition name="modal">
<div
v-if="modalOpen"
class="modal-box"
role="dialog"
aria-modal="true"
aria-label="Soutenir AEP sur Liberapay"
>
<button
class="modal-close"
type="button"
@click="modalOpen = false"
aria-label="Fermer"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
<h2 class="modal-title">Soutenir AEP</h2>
<p class="modal-desc">
AEP est un outil libre, sans publicité, financé par les dons.
1 € finance environ 30 fiches mises en ligne.
</p>
<div class="modal-widget">
<iframe
src="https://liberapay.com/trans-former.fr/widgets/button.html"
width="95"
height="22"
style="border: 0;"
title="Faire un don sur Liberapay"
/>
</div>
<a
href="https://liberapay.com/trans-former.fr/donate"
target="_blank"
rel="noopener noreferrer"
class="modal-link"
>
Faire un don sur Liberapay →
</a>
</div>
</Transition>
</Teleport>
</footer>
<!-- Mobile (< 1024px) : pas de FAB — Soutenir est dans le menu hamburger -->
</template>
<script setup lang="ts">
interface Stats {
cout_mois_eur: number
budget_mois: number
tokens_mois: number
co2_kg: number
requetes_mois: number
fiches_semaine: number
requetes_chatbot_semaine: number
}
const stats = ref<Stats | null>(null)
const loading = ref(true)
const modalOpen = ref(false)
const tooltipVisible = ref(false)
// Desktop — replié par défaut, déploie au hover, replie immédiatement à la sortie
const bandeauEl = ref<HTMLElement | null>(null)
const isCollapsed = ref(true) // replié par défaut
const REFRESH_MS = 5 * 60 * 1000 // 5 minutes
// Détection mobile côté client
const isMobile = ref(false)
onMounted(() => {
isMobile.value = window.innerWidth < 1024
const handleResize = () => {
isMobile.value = window.innerWidth < 1024
}
window.addEventListener('resize', handleResize)
onUnmounted(() => window.removeEventListener('resize', handleResize))
fetchStats()
const interval = setInterval(fetchStats, REFRESH_MS)
onUnmounted(() => clearInterval(interval))
})
function onMouseEnter() {
isCollapsed.value = false
}
function onMouseLeave() {
// Repli immédiat — pas de timer
isCollapsed.value = true
}
async function fetchStats() {
try {
const res = await $fetch<Stats>('/api/stats')
stats.value = res
} catch {
stats.value = null
} finally {
loading.value = false
}
}
const jaugePct = computed(() => {
if (!stats.value) return 0
return Math.min(100, Math.round((stats.value.cout_mois_eur / stats.value.budget_mois) * 100))
})
</script>
<style scoped>
/* ── Bandeau bas ─────────────────────────────────────────────────────────── */
.bandeau-bas {
background: rgba(26, 34, 56, 0.7); /* opacité 70% */
color: var(--nav-text-on-primary);
font-family: var(--nav-font, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif);
border-top: 1px solid rgba(255, 255, 255, 0.08);
transition: min-height 0.25s ease;
position: relative;
overflow: hidden;
}
.bandeau-collapsed {
min-height: 32px !important;
cursor: pointer;
}
.bandeau-inner {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 10px 20px;
max-width: 1400px;
margin: 0 auto;
min-height: 64px;
transition: opacity 0.2s ease, max-height 0.3s ease;
max-height: 200px;
opacity: 1;
}
.bandeau-inner--hidden {
opacity: 0;
max-height: 0;
overflow: hidden;
padding-top: 0;
padding-bottom: 0;
min-height: 0;
}
/* Barre fine (collapsed) */
.bandeau-thin {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.bandeau-thin--visible {
opacity: 1;
}
.bandeau-thin-label {
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
opacity: 0.7;
}
/* ── Colonnes ────────────────────────────────────────────────────────────── */
.bandeau-col {
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.bandeau-col--center {
align-items: center;
justify-content: center;
flex: 0 0 auto;
padding-top: 8px; /* décaler légèrement vers le bas pour mieux centrer dans la hauteur */
}
.bandeau-col--right {
align-items: flex-end;
}
/* ── Typo ────────────────────────────────────────────────────────────────── */
.bandeau-label {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
opacity: 0.65;
margin: 0;
line-height: 1.3;
}
.bandeau-value {
font-size: 0.775rem;
font-weight: 500;
color: var(--nav-text-on-primary);
margin: 0;
line-height: 1.4;
}
.bandeau-value strong {
font-weight: 700;
}
.bandeau-sub {
font-size: 0.68rem;
opacity: 0.7;
margin: 0;
line-height: 1.4;
}
/* ── Jauge budget ────────────────────────────────────────────────────────── */
.jauge-track {
width: 100%;
max-width: 180px;
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.18);
overflow: hidden;
margin: 3px 0;
}
.jauge-fill {
height: 100%;
border-radius: 2px;
background: var(--nav-accent);
transition: width 0.6s ease;
}
/* ── Bouton soutenir + tooltip ───────────────────────────────────────────── */
.soutenir-wrap {
position: relative;
display: inline-flex;
flex-direction: column;
align-items: center;
}
.btn-soutenir {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 7px 16px;
border-radius: 8px;
border: none;
background: var(--nav-accent);
color: var(--nav-text);
font-size: 0.8rem;
font-weight: 700;
font-family: inherit;
cursor: pointer;
white-space: nowrap;
transition: opacity 0.15s, transform 0.1s;
flex-shrink: 0;
}
.btn-soutenir:hover { opacity: 0.88; transform: translateY(-1px); }
.btn-soutenir:active { opacity: 1; transform: translateY(0); }
.soutenir-tooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--nav-primary-solid, #1a2238);
color: var(--nav-text-on-primary);
font-size: 0.72rem;
font-weight: 600;
padding: 5px 10px;
border-radius: 6px;
white-space: nowrap;
pointer-events: none;
z-index: 10;
}
.soutenir-tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--nav-primary-solid, #1a2238);
}
/* ── Modal ───────────────────────────────────────────────────────────────── */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(26, 34, 56, 0.55);
}
.modal-box {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 2001;
background: var(--nav-surface, #ffffff);
border-radius: 16px;
padding: 28px 24px 24px;
width: min(380px, 90vw);
box-shadow: 0 8px 40px rgba(26, 34, 56, 0.22);
display: flex;
flex-direction: column;
gap: 12px;
}
.modal-close {
position: absolute;
top: 14px;
right: 14px;
width: 30px;
height: 30px;
border-radius: 8px;
border: none;
background: var(--nav-bg-alt, #eee9df);
color: var(--nav-text-muted, rgba(26,34,56,0.55));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.modal-close:hover { background: var(--nav-bg, #f8f6f1); }
.modal-title {
font-size: 1.05rem;
font-weight: 700;
color: var(--nav-text, #1a2238);
margin: 0;
}
.modal-desc {
font-size: 0.85rem;
color: var(--nav-text-muted, rgba(26,34,56,0.55));
line-height: 1.6;
margin: 0;
}
.modal-widget {
display: flex;
justify-content: center;
padding: 12px 0;
}
.modal-link {
display: block;
text-align: center;
font-size: 0.8rem;
color: var(--nav-primary-solid, #1a2238);
text-decoration: underline;
transition: opacity 0.15s;
}
.modal-link:hover { opacity: 0.7; }
/* ── Transitions ─────────────────────────────────────────────────────────── */
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
.modal-enter-active, .modal-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; transform: translate(-50%, -48%); }
.sheet-enter-active, .sheet-leave-active { transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); }
.sheet-enter-from, .sheet-leave-to { transform: translateY(100%); }
@media (prefers-reduced-motion: reduce) {
.btn-soutenir { transition: none; }
.jauge-fill { transition: none; }
.modal-enter-active, .modal-leave-active { transition: none; }
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
.sheet-enter-active, .sheet-leave-active { transition: none; }
.bandeau-bas { transition: none; }
.bandeau-inner { transition: none; }
}
</style>
+98 -126
View File
@@ -14,7 +14,7 @@
<script setup lang="ts">
interface EcoleData { id: string; label: string; description: string; color: string; x_hint: number; y_hint: number }
interface LivreRag { slug: string; titre: string; annee: number; couches: string[] }
interface AuteurData { id: string; nom: string; dates: string; ecoles: string[]; ecole_principale: string; ingere: boolean; livres_rag: LivreRag[]; theses_cles: string[]; bio_courte: string; bio_courte_provisoire?: string }
interface AuteurData { id: string; nom: string; dates: string; ecoles: string[]; ecole_principale: string; livres_rag: LivreRag[]; theses_cles: string[]; bio_courte: string }
interface PenseesData { ecoles: EcoleData[]; auteurs: AuteurData[] }
// Liens d'influence inter-ecoles (Phase 7 - matrice de filiation)
@@ -30,7 +30,7 @@ const LINKS_INFLUENCE = [
{ source: 'decroissance', target: 'pensees-vivant', auteurs_passerelle: ['Servigne', 'Despret'], type: 'filiation' },
{ source: 'pensees-vivant', target: 'ethiques-environnementales', auteurs_passerelle: ['Naess', 'Latour'], type: 'filiation' },
{ source: 'ecosocialisme', target: 'eco-anarchisme', auteurs_passerelle: ['Gorz', 'Graeber'], type: 'filiation' },
// liens de critique
// liens de critique (toutes les ecoles progressistes vs cap-vert / ecofascismes)
{ source: 'ecosocialisme', target: 'capitalisme-vert', auteurs_passerelle: ['Klein', 'Malm'], type: 'critique' },
{ source: 'decroissance', target: 'capitalisme-vert', auteurs_passerelle: ['Latouche', 'Meadows'], type: 'critique' },
{ source: 'eco-anarchisme', target: 'capitalisme-vert', auteurs_passerelle: ['Bookchin'], type: 'critique' },
@@ -39,7 +39,7 @@ const LINKS_INFLUENCE = [
]
const props = defineProps<{ data: PenseesData | null; active?: boolean }>()
const emit = defineEmits<{ 'select-auteur': [id: string]; 'select-ecole': [id: string] }>()
const emit = defineEmits<{ 'select-auteur': [id: string] }>()
const svgRef = ref<SVGElement | null>(null)
const tooltipRef = ref<HTMLElement | null>(null)
@@ -52,6 +52,7 @@ let d3EdgeLabelSel: any = null
async function initGraph() {
if (!svgRef.value || !props.data) return
const d3 = await import('d3')
const { Delaunay } = await import('d3-delaunay')
const svgEl = svgRef.value
const W = svgEl.clientWidth || 900
@@ -65,12 +66,70 @@ async function initGraph() {
const ecoleMap = new Map<string, EcoleData>(props.data.ecoles.map(e => [e.id, e]))
// Positions fixes des ecoles (base pour forces D3)
// Positions fixes des ecoles (base pour Voronoi)
const ecolePositions = new Map<string, { tx: number; ty: number }>()
props.data.ecoles.forEach(e => {
ecolePositions.set(e.id, { tx: W * e.x_hint, ty: H * e.y_hint })
})
// ---- VORONOI BACKGROUND (couche 1) ----
const ecolesArr = props.data.ecoles
const points: [number, number][] = ecolesArr.map(e => [W * e.x_hint, H * e.y_hint])
const delaunay = Delaunay.from(points)
const voronoi = delaunay.voronoi([0, 0, W, H])
// Groupe Voronoi (fond, couche 1)
const gVoronoi = g.append('g').attr('class', 'voronoi-bg')
ecolesArr.forEach((ecole, i) => {
const cellPath = voronoi.renderCell(i)
const poly = voronoi.cellPolygon(i)
gVoronoi.append('path')
.attr('d', cellPath)
.attr('fill', ecole.color)
.attr('fill-opacity', 0.48)
.attr('class', 'voronoi-cell')
.attr('data-ecole', ecole.id)
.on('mouseenter', (e: any) => {
if (!tooltipRef.value) return
tooltipRef.value.innerHTML = `<strong>${ecole.label}</strong><br><span style="opacity:0.75;font-size:0.72rem;">${ecole.description}</span>`
tooltipRef.value.style.opacity = '1'
})
.on('mousemove', (e: any) => {
if (!tooltipRef.value || !svgEl) return
const rect = (svgEl as HTMLElement).getBoundingClientRect()
tooltipRef.value.style.left = (e.clientX - rect.left + 14) + 'px'
tooltipRef.value.style.top = (e.clientY - rect.top - 10) + 'px'
})
.on('mouseleave', () => { if (tooltipRef.value) tooltipRef.value.style.opacity = '0' })
// Label ecole dans la cellule (centroid du polygone)
if (poly && poly.length > 0) {
const centroid = d3.polygonCentroid(poly as [number, number][])
if (centroid && !isNaN(centroid[0]) && !isNaN(centroid[1])) {
const words = ecole.label.split(' ')
const labelEl = gVoronoi.append('text')
.attr('class', 'voronoi-cell-label')
.attr('x', centroid[0])
.attr('y', centroid[1])
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.style('pointer-events', 'none')
.style('user-select', 'none')
if (words.length <= 2) {
labelEl.text(ecole.label)
} else {
const mid = Math.ceil(words.length / 2)
labelEl.append('tspan').attr('x', centroid[0]).attr('dy', '-0.55em').text(words.slice(0, mid).join(' '))
labelEl.append('tspan').attr('x', centroid[0]).attr('dy', '1.1em').text(words.slice(mid).join(' '))
}
}
}
})
// ---- LIENS D'INFLUENCE INTER-ECOLES (couche 3) ----
const gInfluence = g.append('g').attr('class', 'links-influence')
@@ -107,14 +166,12 @@ async function initGraph() {
})
// ---- SIMULATION D3 (auteurs) ----
// Pre-positionner chaque auteur pres de son ecole + jitter aleatoire pour eviter le rush initial vers la droite
const auteurNodes: any[] = props.data.auteurs.map(a => {
const ecole = ecoleMap.get(a.ecole_principale)
const jitter = () => (Math.random() - 0.5) * 80
return {
id: a.id, type: 'auteur', nom: a.nom, dates: a.dates,
bio_courte: a.bio_courte,
bio_provisoire: a.bio_courte_provisoire ?? '',
ingere: a.ingere,
id: a.id, type: 'auteur', nom: a.nom, dates: a.dates, bio_courte: a.bio_courte,
ecole_principale: a.ecole_principale,
color: ecole?.color ?? '#888', r: 11,
x: W * (ecole?.x_hint ?? 0.5) + jitter(),
@@ -138,101 +195,28 @@ async function initGraph() {
fx: W * e.x_hint, fy: H * e.y_hint,
}))
// Rayon proportionnel au nombre d'auteurs de l'ecole
const ecoleAuteurCounts = new Map<string, number>()
props.data.ecoles.forEach(e => ecoleAuteurCounts.set(e.id, 0))
props.data.auteurs.forEach(a => ecoleAuteurCounts.set(a.ecole_principale, (ecoleAuteurCounts.get(a.ecole_principale) ?? 0) + 1))
const ecoleRadius = (count: number) => Math.max(16, Math.min(36, 13 + count * 1.5))
const allNodes = [...ecoleFixedNodes, ...auteurNodes]
if (simulation) simulation.stop()
simulation = d3.forceSimulation(allNodes)
.force('link', d3.forceLink(links).id((d: any) => d.id).distance(120).strength((d: any) => d.strength ?? 0.5))
.force('charge', d3.forceManyBody().strength(-70))
.force('link', d3.forceLink(links).id((d: any) => d.id).distance(110).strength((d: any) => d.strength ?? 0.5))
.force('charge', d3.forceManyBody().strength(-45))
.force('center', d3.forceCenter(W / 2, H / 2).strength(0.02))
.force('collision', d3.forceCollide().radius((d: any) => d.type === 'ecole-fixed' ? ecoleRadius(ecoleAuteurCounts.get(d.ecoleId) ?? 0) + 4 : 12))
.force('collision', d3.forceCollide().radius((d: any) => d.type === 'auteur' ? 14 : 0))
.force('forceX', d3.forceX<any>((d: any) => {
if (d.type === 'auteur') {
const pos = ecolePositions.get(d.ecole_principale)
return pos ? pos.tx : W / 2
}
return W / 2
}).strength(0.15))
}).strength(0.12))
.force('forceY', d3.forceY<any>((d: any) => {
if (d.type === 'auteur') {
const pos = ecolePositions.get(d.ecole_principale)
return pos ? pos.ty : H / 2
}
return H / 2
}).strength(0.15))
// ---- NOEUDS ECOLES visibles (couche 3.5) ----
const gEcoles = g.append('g').attr('class', 'ecoles-nodes')
ecoleFixedNodes.forEach(eNode => {
const ecole = ecoleMap.get(eNode.ecoleId)
if (!ecole) return
const count = ecoleAuteurCounts.get(eNode.ecoleId) ?? 0
const r = ecoleRadius(count)
gEcoles.append('circle')
.attr('cx', eNode.fx).attr('cy', eNode.fy).attr('r', r)
.attr('fill', ecole.color).attr('fill-opacity', 0.82).attr('stroke', ecole.color).attr('stroke-width', 2)
.attr('class', 'ecole-node').style('cursor', 'pointer')
.on('mouseenter', (e: any) => {
if (!tooltipRef.value) return
tooltipRef.value.innerHTML = `<strong>${ecole.label}</strong> <span style="opacity:0.6;font-size:0.7rem;">${count} auteur${count > 1 ? 's' : ''}</span><br><span style="opacity:0.75;font-size:0.72rem;">${ecole.description}</span>`
tooltipRef.value.style.opacity = '1'
})
.on('mousemove', (e: any) => {
if (!tooltipRef.value || !svgEl) return
const rect = (svgEl as HTMLElement).getBoundingClientRect()
tooltipRef.value.style.left = (e.clientX - rect.left + 14) + 'px'
tooltipRef.value.style.top = (e.clientY - rect.top - 10) + 'px'
})
.on('mouseleave', () => { if (tooltipRef.value) tooltipRef.value.style.opacity = '0' })
.on('click', (e: any) => { e.stopPropagation(); emit('select-ecole', eNode.ecoleId) })
// ---- TITRES ECOLES visibles en permanence ----
const labelText = ecole.label
const words = labelText.split(' ')
const fontSize = Math.max(12, r * 0.45)
if (words.length > 2 || labelText.length > 12) {
const mid = Math.ceil(words.length / 2)
const line1 = words.slice(0, mid).join(' ')
const line2 = words.slice(mid).join(' ')
const textEl = gEcoles.append('text')
.attr('x', eNode.fx)
.attr('y', eNode.fy)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.style('pointer-events', 'none')
.style('font-weight', '700')
.style('font-size', `${fontSize}px`)
.style('fill', '#ffffff')
.style('text-shadow', '0 1px 3px rgba(0,0,0,0.5)')
.style('user-select', 'none')
textEl.append('tspan')
.attr('x', eNode.fx)
.attr('dy', `-${fontSize * 0.6}px`)
.text(line1)
textEl.append('tspan')
.attr('x', eNode.fx)
.attr('dy', `${fontSize * 1.2}px`)
.text(line2)
} else {
gEcoles.append('text')
.attr('x', eNode.fx)
.attr('y', eNode.fy)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.style('pointer-events', 'none')
.style('font-weight', '700')
.style('font-size', `${fontSize}px`)
.style('fill', '#ffffff')
.style('user-select', 'none')
.text(labelText)
}
})
}).strength(0.12))
// ---- LIENS APPARTENANCE (couche 4) ----
const gLinks = g.append('g').attr('class', 'links-appartenance')
@@ -240,6 +224,7 @@ async function initGraph() {
.attr('stroke', 'rgba(150,150,150,0.28)').attr('stroke-width', 1.2)
// ---- EDGE LABELS - sous-courants (couche 4b) ----
// Afficher label "decroissance" sur lien Servigne (sous-courant specifique - option C)
const subcourantLinks = links.filter((l: any) => l.isSubcourant)
d3EdgeLabelSel = gLinks.selectAll('text.pensees-edge-label')
.data(subcourantLinks)
@@ -249,47 +234,32 @@ async function initGraph() {
// ---- NODES AUTEURS (couche 5) ----
const gAuteurs = g.append('g').attr('class', 'auteurs')
d3NodeSel = gAuteurs.selectAll('g').data(auteurNodes).join('g')
.style('cursor', (d: any) => d.ingere ? 'pointer' : 'default')
.style('cursor', 'pointer')
.call(d3.drag<any, any>()
.on('start', (e: any, d: any) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y })
.on('drag', (e: any, d: any) => { d.fx = e.x; d.fy = e.y })
.on('end', (e: any, d: any) => { if (!e.active) simulation.alphaTarget(0); d.fx = null; d.fy = null }))
.on('click', (e: any, d: any) => {
if (!d.ingere) return
e.stopPropagation()
emit('select-auteur', d.id)
})
.on('click', (e: any, d: any) => { e.stopPropagation(); emit('select-auteur', d.id) })
// Phase 8.D : grisage conditionnel auteurs non-ingeres
d3NodeSel.append('circle')
.attr('r', (d: any) => d.r)
.attr('fill', (d: any) => d.ingere ? (d.color + 'cc') : '#bbbbbb')
.attr('stroke', (d: any) => d.ingere ? d.color : '#999999')
.attr('fill', (d: any) => d.color + 'cc')
.attr('stroke', (d: any) => d.color)
.attr('stroke-width', 1.5)
.attr('opacity', (d: any) => d.ingere ? 1 : 0.35)
// ---- LABELS AUTEURS (couche 6 - drop-shadow blanc) ----
// ---- LABELS AUTEURS (couche 6 - fix 7.1 : drop-shadow blanc) ----
d3NodeSel.append('text')
.attr('class', 'pensees-auteur-label')
.text((d: any) => d.nom.split(' ').pop() ?? d.nom)
.attr('text-anchor', 'middle')
.attr('dy', (d: any) => -(d.r + 4))
.style('pointer-events', 'none')
.style('opacity', (d: any) => d.ingere ? 1 : 0.3)
.style('fill', (d: any) => d.ingere ? '#1a1a1a' : '#777777')
d3NodeSel
.on('mouseenter', (e: any, d: any) => {
if (!tooltipRef.value) return
let tooltipHtml = ''
if (d.ingere) {
const rawBio = d.bio_courte || ''
const bio = rawBio.length > 90 ? rawBio.slice(0, 87) + '...' : rawBio
tooltipHtml = `<strong>${d.nom}</strong> <span style="opacity:0.6;font-size:0.7rem;">${d.dates}</span><br><span style="opacity:0.75;font-size:0.72rem;">${bio || 'Dans le RAG ATIS.'}</span>`
} else {
tooltipHtml = `<strong>${d.nom}</strong> <span style="opacity:0.6;font-size:0.7rem;">${d.dates}</span><br><span style="opacity:0.65;font-size:0.72rem;font-style:italic;">Présent dans Bonpote, pas encore ingéré dans le RAG ATIS.</span>`
}
tooltipRef.value.innerHTML = tooltipHtml
const bio = d.bio_courte.length > 90 ? d.bio_courte.slice(0, 87) + '...' : d.bio_courte
tooltipRef.value.innerHTML = `<strong>${d.nom}</strong> <span style="opacity:0.6;font-size:0.7rem;">${d.dates}</span><br><span style="opacity:0.75;font-size:0.72rem;">${bio}</span>`
tooltipRef.value.style.opacity = '1'
})
.on('mousemove', (e: any) => {
@@ -305,6 +275,7 @@ async function initGraph() {
.attr('x1', (d: any) => d.source.x).attr('y1', (d: any) => d.source.y)
.attr('x2', (d: any) => d.target.x).attr('y2', (d: any) => d.target.y)
// Edge labels positions (milieu du lien)
d3EdgeLabelSel
.attr('x', (d: any) => (d.source.x + d.target.x) / 2)
.attr('y', (d: any) => (d.source.y + d.target.y) / 2)
@@ -317,20 +288,9 @@ async function initGraph() {
})
}
watch(() => props.active, (val) => {
if (val && import.meta.client && props.data)
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
})
watch(() => props.data, (val) => {
if (val && props.active && import.meta.client)
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
})
onMounted(async () => {
if (import.meta.client && props.data && props.active) {
await nextTick()
initGraph()
}
})
watch(() => props.active, (val) => { if (val && import.meta.client && props.data) requestAnimationFrame(() => requestAnimationFrame(() => initGraph())) })
watch(() => props.data, (val) => { if (val && props.active && import.meta.client) requestAnimationFrame(() => requestAnimationFrame(() => initGraph())) })
onMounted(async () => { if (import.meta.client && props.data && props.active) { await nextTick(); initGraph() } })
onUnmounted(() => { if (simulation) simulation.stop() })
function triggerResize() {
@@ -344,6 +304,7 @@ defineExpose({ triggerResize })
</script>
<style>
/* ---- Labels auteurs : fix 7.1 drop-shadow blanc pour lisibilite sur pastel ---- */
.pensees-auteur-label {
fill: #1a1a1a;
font-weight: 600;
@@ -352,6 +313,7 @@ defineExpose({ triggerResize })
user-select: none;
}
/* ---- Labels edge sous-courants (option C : seulement les liens secondaires) ---- */
.pensees-edge-label {
fill: #555;
font-size: 8.5px;
@@ -363,10 +325,20 @@ defineExpose({ triggerResize })
pointer-events: none;
}
.ecole-node {
transition: opacity 0.15s, r 0.15s;
/* ---- Voronoi cellules ---- */
.voronoi-cell {
stroke: rgba(255,255,255,0.4);
stroke-width: 1.5px;
cursor: default;
}
.ecole-node:hover {
opacity: 0.75;
/* ---- Labels ecoles dans cellules Voronoi ---- */
.voronoi-cell-label {
fill: rgba(40,40,40,0.52);
font-size: 17px;
font-weight: 700;
letter-spacing: 0.3px;
pointer-events: none;
user-select: none;
}
</style>
+242 -394
View File
@@ -1,448 +1,296 @@
<template>
<div class="chatbot-pensees" :class="{ inline: inline }">
<!-- Header compact -->
<div class="chatbot-header">
<div class="chatbot-header-left">
<div class="chatbot-icon">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<!-- Mode overlay : bouton flottant bottom-right (legacy) -->
<template v-if="!inline">
<button v-if="!open" @click="open = true"
class="fixed bottom-6 right-6 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
style="height:48px;background:var(--nav-primary);color:var(--nav-text-on-primary);font-size:0.875rem;font-weight:600;"
aria-label="Chatbot Pensees Ecologiques">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<span>Pensees ?</span>
</button>
<Transition name="cpanel">
<div v-if="open" class="fixed bottom-6 right-6 z-[1000] flex flex-col"
style="width:min(360px,calc(100vw - 24px));max-height:60vh;background:var(--nav-surface);border-radius:14px;box-shadow:0 8px 32px rgba(26,34,56,0.22);overflow:hidden;border:1px solid var(--nav-bg-alt);"
role="dialog" aria-modal="true" aria-label="RAG Pensees Ecologiques">
<!-- Header overlay -->
<div class="flex items-center justify-between px-4 py-3 shrink-0" style="border-bottom:1px solid var(--nav-bg-alt);background:var(--nav-bg);">
<div>
<p class="text-sm font-bold" style="color:var(--nav-text);">RAG Pensees Ecologiques</p>
<p class="text-xs" style="color:var(--nav-text-muted);">{{ corpusCount }} auteurs ingeres</p>
</div>
<button @click="open = false" class="flex items-center justify-center w-7 h-7 rounded-full hover:opacity-70"
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);" aria-label="Fermer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<span class="chatbot-title">
<template v-if="auteurContext">{{ auteurContext }}</template>
<template v-else>Bibliothèque des pensées</template>
</span>
<span v-if="auteurContext" class="chatbot-corpus-badge">auteur</span>
<span v-else class="chatbot-corpus-badge">FRACAS</span>
<!-- Corpus toggle overlay -->
<div class="shrink-0 px-3 pt-2 pb-1" style="background:var(--nav-bg);border-bottom:1px solid var(--nav-bg-alt);">
<div class="flex gap-1" role="group" aria-label="Choisir le corpus">
<button v-for="opt in corpusOptions" :key="opt.value" @click="setCorpus(opt.value)" :title="opt.tooltip"
class="flex-1 px-2 py-1 rounded text-xs font-medium transition-colors"
:style="corpus === opt.value ? 'background:var(--nav-primary);color:var(--nav-text-on-primary);' : 'background:var(--nav-bg-alt);color:var(--nav-text-muted);'"
:aria-pressed="corpus === opt.value">{{ opt.label }}</button>
</div>
</div>
<!-- Messages overlay -->
<div ref="msgElOverlay" class="flex-1 overflow-y-auto px-4 py-3 flex flex-col gap-3" style="min-height:0;">
<div v-if="messages.length === 0" style="font-size:0.8rem;color:var(--nav-text-muted);line-height:1.5;">
<template v-if="corpus === 'pensees'">Pose une question sur les pensees ecologiques...</template>
<template v-else-if="corpus === 'projets'">Pose une question sur les projets d'architecture de Jules...</template>
<template v-else>Pose une question sur les pensees ecologiques ancrees dans les projets archi de Jules.</template>
</div>
<template v-for="(msg, i) in messages" :key="i">
<div v-if="msg.role === 'user'" class="self-end max-w-[85%] px-3 py-2 rounded-xl text-sm"
style="background:var(--nav-primary);color:var(--nav-text-on-primary);font-weight:500;">{{ msg.content }}</div>
<div v-else class="self-start max-w-full">
<div class="px-3 py-2 rounded-xl text-sm leading-relaxed" style="background:var(--nav-bg-alt);color:var(--nav-text);"
v-html="renderMd(stripSrc(msg.content))" />
<div v-if="filteredSources(msg.content).length" class="mt-1.5">
<button @click="toggled[i] = !toggled[i]" class="flex items-center gap-1 text-xs hover:opacity-70" style="color:var(--nav-text-muted);">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
:style="`transform:rotate(${toggled[i] ? 90 : 0}deg);transition:transform 0.15s`"><polyline points="9 18 15 12 9 6"/></svg>
Sources ({{ filteredSources(msg.content).length }})
</button>
<div v-if="toggled[i]" class="mt-1 flex flex-col gap-1">
<div v-for="(s, si) in filteredSources(msg.content)" :key="si" class="px-2 py-1 rounded text-xs"
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);border-left:2px solid var(--nav-primary-solid);">
<span style="font-weight:600;color:var(--nav-text);">[{{ si + 1 }}]</span> {{ s }}
</div>
</div>
</div>
</div>
</template>
<div v-if="loading" class="self-start px-3 py-2 rounded-xl" style="background:var(--nav-bg-alt);">
<span class="dots"><span/><span style="animation-delay:150ms"/><span style="animation-delay:300ms"/></span>
</div>
<div v-if="err" class="text-xs px-3 py-2 rounded-xl" style="background:#fee;color:#c0392b;">{{ err }}</div>
</div>
<!-- Input overlay -->
<div class="shrink-0 px-3 py-3" style="border-top:1px solid var(--nav-bg-alt);">
<div class="flex items-center gap-2">
<input ref="inputElOverlay" v-model="q" type="text" placeholder="Ta question..." maxlength="500"
class="flex-1 px-3 py-2 rounded-lg text-sm outline-none"
style="background:var(--nav-bg);color:var(--nav-text);border:1px solid var(--nav-bg-alt);"
@keydown.enter.prevent="send" />
<button @click="send" :disabled="loading || !q.trim()"
class="flex items-center justify-center w-9 h-9 rounded-lg"
:style="loading||!q.trim() ? 'background:var(--nav-bg-alt);opacity:0.5;cursor:not-allowed;' : 'background:var(--nav-primary);cursor:pointer;'"
aria-label="Envoyer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color:white;">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
</div>
</div>
<div class="chatbot-header-right">
<button
v-if="messages.length > 0"
class="chatbot-clear-btn"
title="Effacer la conversation"
@click="clearMessages"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/>
</svg>
</button>
<select v-model="corpusMode" class="chatbot-corpus-select" title="Corpus interrogé">
<option value="pensees">Pensées éco.</option>
<option value="both">Pensées + Projets</option>
<option value="projets">Projets</option>
</select>
</Transition>
</template>
<!-- Mode inline : remplit 100% de son parent slot -->
<div v-else
class="flex flex-col w-full h-full"
style="background:var(--nav-surface);overflow:hidden;"
role="region" aria-label="RAG Pensees Ecologiques">
<!-- Header inline -->
<div class="flex items-center justify-between px-4 py-2 shrink-0" style="border-bottom:1px solid var(--nav-bg-alt);background:var(--nav-bg);">
<div>
<p class="text-sm font-bold" style="color:var(--nav-text);">RAG Pensees Ecologiques</p>
<p class="text-xs" style="color:var(--nav-text-muted);">{{ corpusCount }} auteurs ingeres</p>
</div>
</div>
<!-- Zone messages -->
<div ref="messagesContainer" class="chatbot-messages">
<!-- Onboarding -->
<div v-if="messages.length === 0" class="chatbot-onboarding">
<template v-if="auteurContext">
<p>Interroge le corpus de <strong>{{ auteurContext }}</strong> — ses livres, ses thèses, ses arguments.</p>
<p class="chatbot-onboarding-hint">Ex : "Quelle est sa critique du capitalisme vert ?"</p>
</template>
<template v-else>
<p>Bibliothèque des pensées écologiques — <strong>~140 auteurs FRACAS</strong>, de l'écosocialisme à l'éthique environnementale.</p>
<p class="chatbot-onboarding-hint">Ex : "Quelles différences entre décroissance et doughnut économics ?"</p>
</template>
<!-- Corpus toggle inline -->
<div class="shrink-0 px-3 pt-2 pb-1" style="background:var(--nav-bg);border-bottom:1px solid var(--nav-bg-alt);">
<div class="flex gap-1" role="group" aria-label="Choisir le corpus">
<button v-for="opt in corpusOptions" :key="opt.value" @click="setCorpus(opt.value)" :title="opt.tooltip"
class="flex-1 px-2 py-1 rounded text-xs font-medium transition-colors"
:style="corpus === opt.value ? 'background:var(--nav-primary);color:var(--nav-text-on-primary);' : 'background:var(--nav-bg-alt);color:var(--nav-text-muted);'"
:aria-pressed="corpus === opt.value">{{ opt.label }}</button>
</div>
</div>
<!-- Messages -->
<!-- Messages inline -->
<div ref="msgElInline" class="flex-1 overflow-y-auto px-4 py-3 flex flex-col gap-3" style="min-height:0;">
<div v-if="messages.length === 0" style="font-size:0.8rem;color:var(--nav-text-muted);line-height:1.5;">
<template v-if="corpus === 'pensees'">Pose une question sur les pensees ecologiques : ecosocialisme, decroissance, ecofeminismes, technocritique, deep ecology...</template>
<template v-else-if="corpus === 'projets'">Pose une question sur les projets d'architecture de Jules : Butte Pinson, strategie thermique, partis pris constructifs...</template>
<template v-else>Pose une question sur les pensees ecologiques ancrees dans les projets archi de Jules (corpus croise, defaut).</template>
</div>
<template v-for="(msg, i) in messages" :key="i">
<div v-if="msg.role === 'user'" class="chatbot-bubble user">{{ msg.content }}</div>
<div v-else class="chatbot-bubble assistant">
<div class="chatbot-md" v-html="renderMd(msg.content)" />
<div v-if="msg.auteur" class="chatbot-auteur-tag">
<span>{{ msg.auteur.nom }}</span>
<div v-if="msg.role === 'user'" class="self-end max-w-[85%] px-3 py-2 rounded-xl text-sm"
style="background:var(--nav-primary);color:var(--nav-text-on-primary);font-weight:500;">{{ msg.content }}</div>
<div v-else class="self-start max-w-full">
<div class="px-3 py-2 rounded-xl text-sm leading-relaxed" style="background:var(--nav-bg-alt);color:var(--nav-text);"
v-html="renderMd(stripSrc(msg.content))" />
<div v-if="filteredSources(msg.content).length" class="mt-1.5">
<button @click="toggled[i] = !toggled[i]" class="flex items-center gap-1 text-xs hover:opacity-70" style="color:var(--nav-text-muted);">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
:style="`transform:rotate(${toggled[i] ? 90 : 0}deg);transition:transform 0.15s`"><polyline points="9 18 15 12 9 6"/></svg>
Sources ({{ filteredSources(msg.content).length }})
</button>
<div v-if="toggled[i]" class="mt-1 flex flex-col gap-1">
<div v-for="(s, si) in filteredSources(msg.content)" :key="si" class="px-2 py-1 rounded text-xs"
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);border-left:2px solid var(--nav-primary-solid);">
<span style="font-weight:600;color:var(--nav-text);">[{{ si + 1 }}]</span> {{ s }}
</div>
</div>
</div>
</div>
</template>
<!-- Chargement -->
<div v-if="loading" class="chatbot-bubble assistant loading">
<span class="dot" /><span class="dot" /><span class="dot" />
<div v-if="loading" class="self-start px-3 py-2 rounded-xl" style="background:var(--nav-bg-alt);">
<span class="dots"><span/><span style="animation-delay:150ms"/><span style="animation-delay:300ms"/></span>
</div>
<!-- Erreur -->
<div v-if="errorMsg" class="chatbot-error">{{ errorMsg }}</div>
<div v-if="err" class="text-xs px-3 py-2 rounded-xl" style="background:#fee;color:#c0392b;">{{ err }}</div>
</div>
<!-- Input -->
<div class="chatbot-input-row">
<input
v-model="inputText"
type="text"
:disabled="loading"
:placeholder="auteurContext ? `Interroger ${auteurContext}…` : 'Pose ta question sur les pensées éco…'"
class="chatbot-input"
@keydown.enter.prevent="sendMessage"
/>
<button
:disabled="loading || !inputText.trim()"
class="chatbot-send-btn"
aria-label="Envoyer"
@click="sendMessage"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
<!-- Input inline -->
<div class="shrink-0 px-3 py-3" style="border-top:1px solid var(--nav-bg-alt);">
<div class="flex items-center gap-2">
<input ref="inputElInline" v-model="q" type="text" placeholder="Ta question..." maxlength="500"
class="flex-1 px-3 py-2 rounded-lg text-sm outline-none"
style="background:var(--nav-bg);color:var(--nav-text);border:1px solid var(--nav-bg-alt);"
@keydown.enter.prevent="send" />
<button @click="send" :disabled="loading || !q.trim()"
class="flex items-center justify-center w-9 h-9 rounded-lg"
:style="loading||!q.trim() ? 'background:var(--nav-bg-alt);opacity:0.5;cursor:not-allowed;' : 'background:var(--nav-primary);cursor:pointer;'"
aria-label="Envoyer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color:white;">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useMarkdown } from '~/composables/useMarkdown'
interface Message { role: 'user' | 'assistant'; content: string }
const { render: renderMd } = useMarkdown()
type CorpusMode = 'pensees' | 'projets' | 'both'
interface ChatMessage {
role: 'user' | 'assistant'
content: string
auteur?: { slug: string; nom: string } | null
const CORPUS_STORAGE_KEY = 'chatbot-pensees-corpus'
const PROJECT_SOURCE_PATTERNS = [/butte.?pinson/i, /butte_pinson/i]
function isProjectSource(s: string): boolean {
return PROJECT_SOURCE_PATTERNS.some(p => p.test(s))
}
const corpusOptions: { value: CorpusMode; label: string; tooltip: string }[] = [
{ value: 'pensees', label: 'Pensees', tooltip: 'Corpus FRACAS uniquement (auteurs ecologie politique)' },
{ value: 'projets', label: 'Projets', tooltip: 'Projets archi de Jules uniquement' },
{ value: 'both', label: 'Croise*', tooltip: 'Projets ancres + pensees en eclairage (defaut)' },
]
const props = defineProps<{
auteurContext: string | null
auteurContext?: string | null
inline?: boolean
}>()
const messages = ref<ChatMessage[]>([])
const inputText = ref('')
const open = ref(false)
const q = ref('')
const messages = ref<Message[]>([])
const loading = ref(false)
const errorMsg = ref('')
const corpusMode = ref<'pensees' | 'projets' | 'both'>('pensees')
const messagesContainer = ref<HTMLElement | null>(null)
const err = ref('')
const toggled = ref<Record<number, boolean>>({})
const msgElOverlay = ref<HTMLElement | null>(null)
const msgElInline = ref<HTMLElement | null>(null)
const inputElOverlay = ref<HTMLInputElement | null>(null)
const inputElInline = ref<HTMLInputElement | null>(null)
const corpusCount = 18
watch(() => props.auteurContext, () => {
messages.value = []
errorMsg.value = ''
const corpus = ref<CorpusMode>('both')
onMounted(() => {
const saved = window.localStorage.getItem(CORPUS_STORAGE_KEY) as CorpusMode | null
if (saved && ['pensees', 'projets', 'both'].includes(saved)) {
corpus.value = saved
}
})
function clearMessages() {
messages.value = []
errorMsg.value = ''
function setCorpus(val: CorpusMode) {
corpus.value = val
window.localStorage.setItem(CORPUS_STORAGE_KEY, val)
}
function scrollToBottom() {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
watch(open, (val) => {
if (!val) return
nextTick(() => inputElOverlay.value?.focus())
if (props.auteurContext && messages.value.length === 0)
q.value = `Quelles sont les theses centrales de ${props.auteurContext} ?`
})
watch(() => props.auteurContext, (ctx) => {
if (!ctx) return
if (!props.inline && !open.value) open.value = true
if (messages.value.length === 0) q.value = `Quelles sont les theses centrales de ${ctx} ?`
})
async function sendMessage() {
const query = inputText.value.trim()
async function send() {
const query = q.value.trim()
if (!query || loading.value) return
inputText.value = ''
errorMsg.value = ''
err.value = ''
messages.value.push({ role: 'user', content: query })
q.value = ''
loading.value = true
await nextTick()
scrollToBottom()
scrollBottom()
try {
const res = await $fetch<{
response: string
auteur: { slug: string; nom: string } | null
}>('/api/chatbot-pensees', {
const res = await $fetch<{ response: string }>('/api/chatbot-pensees', {
method: 'POST',
body: {
query,
corpus: corpusMode.value,
auteur_slug: props.auteurContext ? slugify(props.auteurContext) : undefined,
},
})
messages.value.push({
role: 'assistant',
content: res.response,
auteur: res.auteur ?? null,
body: { query, mode: 'hybrid', corpus: corpus.value },
})
messages.value.push({ role: 'assistant', content: res.response ?? '' })
} catch (e: any) {
const status = e?.statusCode ?? e?.status
if (status === 429) {
errorMsg.value = 'Limite de 20 questions par jour atteinte.'
} else if (status === 503) {
errorMsg.value = 'Le RAG est indisponible pour l\'instant — réessaie dans quelques minutes.'
} else if (status === 504) {
errorMsg.value = 'Le RAG met du temps à répondre — réessaie dans quelques secondes.'
} else {
errorMsg.value = 'Une erreur est survenue. Réessaie dans quelques instants.'
}
const s = e?.response?.status ?? e?.statusCode
err.value = s === 429 ? 'Limite atteinte.' : s === 503 ? 'RAG indisponible.' : 'Erreur - reessaie.'
} finally {
loading.value = false
await nextTick()
scrollToBottom()
scrollBottom()
}
}
function slugify(str: string): string {
return str.toLowerCase()
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
function scrollBottom() {
const el = props.inline ? msgElInline.value : msgElOverlay.value
if (el) el.scrollTop = el.scrollHeight
}
function renderMd(t: string) {
return '<p>' + t.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/\*(.+?)\*/g, '<em>$1</em>').replace(/\n\n/g, '</p><p>').replace(/\n/g, '<br>') + '</p>'
}
function stripSrc(t: string) { return t.replace(/\n*(?:Sources?|References?)\s*:[\s\S]*$/i, '').trim() }
function parseSrc(t: string): string[] {
const bloc = t.match(/\n*(?:Sources?|References?)\s*:\n?([\s\S]+?)$/i)
if (bloc) return bloc[1].split('\n').map(l => l.replace(/^[-*\d.[\]]+\s*/, '').trim()).filter(l => l.length > 3)
return [...new Set([...t.matchAll(/\[([^\]]{5,80})\]/g)].filter(m => m[1].includes(' - ')).map(m => m[1]))]
}
function filteredSources(t: string): string[] {
const all = parseSrc(t)
if (corpus.value === 'both') return all
if (corpus.value === 'projets') return all.filter(s => isProjectSource(s))
return all.filter(s => !isProjectSource(s))
}
</script>
<style scoped>
.chatbot-pensees {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--nav-bg);
}
/* Header */
.chatbot-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 12px;
background: var(--nav-surface);
border-bottom: 1px solid rgba(180, 170, 160, 0.22);
min-height: 36px;
}
.chatbot-header-left {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
overflow: hidden;
}
.chatbot-icon {
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--nav-primary);
display: flex;
align-items: center;
justify-content: center;
color: var(--nav-text-on-primary);
}
.chatbot-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--nav-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
}
.chatbot-corpus-badge {
flex-shrink: 0;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--nav-text-muted);
background: var(--nav-bg-alt);
border-radius: 4px;
padding: 1px 5px;
}
.chatbot-header-right {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.chatbot-clear-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 4px;
background: transparent;
color: var(--nav-text-muted);
border: none;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.chatbot-clear-btn:hover {
background: var(--nav-bg-alt);
color: var(--nav-text);
}
.chatbot-corpus-select {
font-size: 0.7rem;
padding: 2px 4px;
border-radius: 4px;
border: 1px solid rgba(180, 170, 160, 0.3);
background: var(--nav-bg-alt);
color: var(--nav-text-muted);
cursor: pointer;
max-width: 110px;
}
/* Messages */
.chatbot-messages {
flex: 1;
overflow-y: auto;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.chatbot-onboarding {
font-size: 0.8rem;
line-height: 1.55;
color: var(--nav-text-muted);
background: var(--nav-bg-alt);
border-radius: 8px;
padding: 10px 12px;
}
.chatbot-onboarding p { margin: 0 0 6px; }
.chatbot-onboarding p:last-child { margin-bottom: 0; }
.chatbot-onboarding strong { color: var(--nav-text); font-weight: 600; }
.chatbot-onboarding-hint { font-style: italic; font-size: 0.75rem; opacity: 0.75; }
/* Bulles */
.chatbot-bubble {
max-width: 88%;
font-size: 0.825rem;
line-height: 1.55;
border-radius: 10px;
padding: 8px 11px;
}
.chatbot-bubble.user {
align-self: flex-end;
background: var(--nav-primary);
color: var(--nav-text-on-primary);
border-radius: 10px 10px 3px 10px;
}
.chatbot-bubble.assistant {
align-self: flex-start;
background: var(--nav-surface);
border: 1px solid rgba(180, 170, 160, 0.22);
color: var(--nav-text);
border-radius: 10px 10px 10px 3px;
}
/* Markdown dans les bulles */
:deep(.chatbot-md) { font-size: inherit; line-height: 1.55; }
:deep(.chatbot-md p) { margin: 0 0 0.35em; }
:deep(.chatbot-md p:last-child) { margin-bottom: 0; }
:deep(.chatbot-md strong) { font-weight: 700; }
:deep(.chatbot-md em) { font-style: italic; }
:deep(.chatbot-md ul) { margin: 0.25em 0 0.25em 1em; list-style: disc; padding: 0; }
:deep(.chatbot-md li) { margin-bottom: 0.1em; }
.chatbot-auteur-tag {
margin-top: 6px;
font-size: 0.7rem;
color: var(--nav-text-muted);
font-style: italic;
}
/* Loading */
.chatbot-bubble.loading {
display: flex;
gap: 4px;
align-items: center;
padding: 10px 12px;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--nav-text-muted);
animation: blink 1.2s infinite ease-in-out;
}
.dot:nth-child(2) { animation-delay: 0.2s; }
.dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes blink {
0%, 80%, 100% { opacity: 0.25; }
40% { opacity: 1; }
}
/* Erreur */
.chatbot-error {
align-self: flex-start;
font-size: 0.775rem;
color: #a85d3e;
background: rgba(168, 93, 62, 0.08);
border-radius: 8px;
padding: 7px 10px;
max-width: 90%;
}
/* Input */
.chatbot-input-row {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-top: 1px solid rgba(180, 170, 160, 0.22);
background: var(--nav-surface);
}
.chatbot-input {
flex: 1;
padding: 6px 10px;
border-radius: 8px;
border: 1px solid rgba(180, 170, 160, 0.3);
background: var(--nav-bg);
color: var(--nav-text);
font-family: var(--nav-font);
font-size: 0.8rem;
min-width: 0;
transition: border-color 0.15s;
}
.chatbot-input:focus {
outline: none;
border-color: var(--nav-primary);
}
.chatbot-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.chatbot-send-btn {
flex-shrink: 0;
width: 30px;
height: 30px;
border-radius: 7px;
border: none;
background: var(--nav-primary);
color: var(--nav-text-on-primary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: opacity 0.15s;
}
.chatbot-send-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.chatbot-send-btn:not(:disabled):hover {
opacity: 0.85;
}
.cpanel-enter-active { transition: opacity 0.2s, transform 0.22s cubic-bezier(0.34,1.56,0.64,1); }
.cpanel-leave-active { transition: opacity 0.18s, transform 0.15s ease-in; }
.cpanel-enter-from { opacity: 0; transform: translateY(12px) scale(0.95); }
.cpanel-leave-to { opacity: 0; transform: translateY(8px) scale(0.97); }
.dots span { display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--nav-text-muted);margin:0 2px;animation:bounce 1s infinite; }
@keyframes bounce { 0%,80%,100%{transform:translateY(0)}40%{transform:translateY(-5px)} }
</style>
+4 -2
View File
@@ -52,8 +52,10 @@
<div class="chatbot-body-inner" ref="messagesContainer">
<!-- Onboarding -->
<div v-if="messages.length === 0" class="onboarding-bubble">
<p>Explore les 120 structures de la carte par la conversation. Je peux t'aider à trouver des collectifs, agences ou réseaux selon ta situation, ta pratique ou tes inspirations du moment.</p>
<p class="example">Exemple : "Je cherche des acteurs de la rénovation de maisons individuelles en France, plutôt en milieu rural, avec des approches biosourcées ou low-tech."</p>
<p>Je connais les structures d'entraide pour architectes référencées sur cette carte — appui juridique, technique, économique, formation, santé mentale, gestion d'agence…</p>
<p>Décris ta situation, je te propose les fiches les plus pertinentes.</p>
<p class="example">Exemple : "Architecte salarié, litige avec mon employeur, besoin d'un appui juridique droit du travail, Île-de-France."</p>
<p style="margin-top: 8px; font-size: 0.72rem; opacity: 0.6;">Propulsé par Mistral FR — serveur européen souverain, zéro rétention.</p>
</div>
<!-- Messages -->
+1
View File
@@ -69,6 +69,7 @@
<div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
<!-- Message onboarding (avant la première question) -->
<div v-if="messages.length === 0" class="onboarding-bubble">
<p>Ce chatbot fonctionne sur un serveur européen souverain (Mistral FR, zéro rétention), conçu sobre en énergie.</p>
<p>Pour m'aider à te répondre efficacement, formule ta requête ainsi :</p>
<ul>
<li>• Besoin : [ce que tu cherches]</li>
-177
View File
@@ -1,177 +0,0 @@
<template>
<component
:is="as"
:type="as === 'button' ? 'button' : undefined"
class="chip"
:class="[`chip--${variant}`, { 'chip--active': active }]"
:aria-pressed="as === 'button' ? active : undefined"
@click="as === 'button' && emit('toggle')"
>
<span class="chip__label">{{ label }}</span>
<span v-if="count !== undefined" class="chip__count">{{ count }}</span>
<button
v-if="removable"
type="button"
class="chip__remove"
:aria-label="`Retirer ${label}`"
@click.stop="emit('remove')"
>
<svg width="10" height="10" 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>
</component>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
label: string
variant?: 'echelle' | 'fonction' | 'posture'
active?: boolean
count?: number
as?: 'button' | 'span'
removable?: boolean
}>(),
{
variant: 'fonction',
active: false,
count: undefined,
as: 'button',
removable: false,
}
)
const emit = defineEmits<{
toggle: []
remove: []
}>()
</script>
<style scoped>
.chip {
display: inline-flex;
align-items: center;
gap: var(--space-2xs);
padding: var(--space-2xs) var(--space-sm);
border-radius: var(--radius-full);
font-size: var(--fs-xs);
line-height: var(--lh-tight);
font-weight: var(--fw-medium);
border: 1.5px solid transparent;
cursor: pointer;
user-select: none;
min-height: 32px;
box-sizing: border-box;
background: none;
transition: background-color var(--dur-fast) var(--ease-out),
color var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out),
transform var(--dur-fast) var(--ease-out);
}
@media (prefers-reduced-motion: reduce) {
.chip {
transition: none;
}
}
/* 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 {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
/* ── Échelle : outline bleu, fond transparent, actif = plein ────────────── */
.chip--echelle {
border-color: var(--primary);
color: var(--text);
background: transparent;
}
@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(--nav-primary-solid);
border-color: var(--nav-primary-solid);
color: var(--nav-text-on-primary);
}
/* ── Fonction/hashtag : pleine bleu, inactif = bg-alt ────────────────────── */
.chip--fonction {
border-color: transparent;
background: var(--bg-alt);
color: var(--text);
}
@media (hover: hover) {
.chip--fonction:hover {
background: var(--primary-hover);
color: var(--on-primary);
}
}
.chip--fonction.chip--active {
background: var(--nav-primary-solid);
color: var(--nav-text-on-primary);
}
/* ── Posture (préparée pour plus tard) : outline safran ──────────────────── */
.chip--posture {
border-color: var(--accent);
color: var(--text);
background: transparent;
}
@media (hover: hover) {
.chip--posture:hover {
background: var(--accent);
color: var(--on-accent);
}
}
.chip--posture.chip--active {
background: var(--accent);
border-color: var(--accent);
color: var(--on-accent);
}
.chip__count {
font-size: 0.85em;
opacity: 0.75;
}
.chip__remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
padding: 0;
border: none;
background: transparent;
color: inherit;
opacity: 0.7;
cursor: pointer;
border-radius: var(--radius-full);
}
.chip__remove:hover {
opacity: 1;
}
.chip__remove:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 1px;
}
</style>
-19
View File
@@ -1,19 +0,0 @@
<template>
<div class="chip-group" role="group" :aria-label="ariaLabel">
<slot />
</div>
</template>
<script setup lang="ts">
defineProps<{
ariaLabel: string
}>()
</script>
<style scoped>
.chip-group {
display: flex;
flex-wrap: wrap;
gap: var(--space-2xs) var(--space-xs);
}
</style>
+62
View File
@@ -0,0 +1,62 @@
<template>
<div class="space-y-1">
<p class="filter-label">ÉCHELLE</p>
<div class="chips-row">
<span
v-for="option in ECHELLES"
:key="option"
class="chip"
:style="isSelected(option)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggle(option)"
>{{ option }}</span>
</div>
</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>
<style scoped>
.filter-label {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--nav-text-muted);
display: block;
margin-bottom: 4px;
text-transform: uppercase;
}
.chips-row { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }
.chip {
cursor: pointer;
padding: 3px 10px;
border-radius: 9999px;
font-size: 0.75rem;
transition: all 0.15s;
user-select: none;
}
</style>
-121
View File
@@ -1,121 +0,0 @@
<template>
<section class="echelle-tete" :class="{ 'echelle-tete--compact': compact }" :aria-label="`Échelle ${e.id} : ${e.titre}`">
<p class="echelle-tete__kicker">
<span class="echelle-tete__badge" aria-hidden="true">{{ e.id }}</span>
<span class="echelle-tete__titre">{{ e.titre }}<span v-if="e.sousTitre" class="echelle-tete__sous-titre">{{ ' ' + e.sousTitre }}</span></span>
</p>
<ul class="echelle-tete__mots">
<li v-for="m in e.mots" :key="m">{{ m }}</li>
</ul>
<p class="echelle-tete__phrase">{{ e.phrase }}</p>
<slot />
<p v-if="contribuer" class="echelle-tete__contribuer">
<span>{{ ECHELLES_CADRE_COURT }}</span>
<NuxtLink :to="`/proposer?categorie=${e.categorieProposer}`" @click="$emit('navigate')">Proposer une ressource →</NuxtLink>
</p>
</section>
</template>
<script setup lang="ts">
import { ECHELLES, type EchelleId } from '~/utils/echelles'
const props = withDefaults(defineProps<{
echelle: EchelleId
/** Variante resserrée, pour une popup ou un en-tête centré. */
compact?: boolean
/** Affiche la ligne « on pose l'infrastructure, le collectif nourrit ». */
contribuer?: boolean
}>(), { compact: false, contribuer: true })
defineEmits<{ navigate: [] }>()
const e = computed(() => ECHELLES[props.echelle])
const ECHELLES_CADRE_COURT = 'Chaque section est ouverte aux contributions.'
</script>
<style scoped>
.echelle-tete {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin: 0 0 var(--space-md);
}
.echelle-tete__kicker {
display: flex;
align-items: baseline;
gap: var(--space-sm);
margin: 0;
}
.echelle-tete__badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
padding: 0.1rem 0.5rem;
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--on-accent);
font-weight: var(--fw-bold);
font-size: var(--fs-sm);
letter-spacing: 0.04em;
line-height: 1.4;
}
.echelle-tete__titre {
font-size: var(--fs-lg);
font-weight: var(--fw-bold);
color: var(--text);
line-height: var(--lh-tight);
}
.echelle-tete__sous-titre {
font-weight: var(--fw-regular);
color: var(--text-muted);
}
.echelle-tete__mots {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.15rem 0;
font-size: var(--fs-sm);
color: var(--text);
line-height: var(--lh-base);
}
.echelle-tete__mots li:not(:last-child)::after {
content: '·';
margin: 0 0.5rem;
color: var(--accent);
font-weight: var(--fw-bold);
}
.echelle-tete__phrase {
margin: 0;
font-size: var(--fs-sm);
font-style: italic;
color: var(--text-muted);
line-height: var(--lh-base);
}
.echelle-tete__contribuer {
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.75rem;
font-size: var(--fs-xs);
color: var(--text-muted);
}
.echelle-tete__contribuer a {
color: var(--link);
text-decoration: underline;
text-underline-offset: 2px;
}
.echelle-tete__contribuer a:hover { color: var(--link-hover); }
.echelle-tete--compact { text-align: left; }
.echelle-tete--compact .echelle-tete__titre { font-size: var(--fs-base); }
</style>
+98
View File
@@ -0,0 +1,98 @@
<template>
<Teleport to="body">
<Transition name="backdrop">
<div v-if="open && auteur" class="fixed inset-0 z-[1500]" style="background: rgba(26,34,56,0.55);" @click="emit('close')" aria-hidden="true" />
</Transition>
<Transition name="modal">
<div v-if="open && auteur" class="fixed z-[1501] left-1/2 flex flex-col"
style="top:50%;transform:translate(-50%,-50%);width:min(520px,94vw);max-height:85vh;background:var(--nav-bg);border-radius:14px;box-shadow:0 16px 64px rgba(26,34,56,0.28);overflow:hidden;"
role="dialog" aria-modal="true">
<!-- Header -->
<div class="flex items-start justify-between px-5 py-4 shrink-0"
:style="`border-bottom: 3px solid ${ecoleColor}; background: var(--nav-surface);`">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="px-2 py-0.5 rounded-full text-xs font-semibold" :style="`background:${ecoleColor}22;color:${ecoleColor};`">{{ ecoleLabel }}</span>
<span v-for="eid in auteur.ecoles.filter(e => e !== auteur.ecole_principale)" :key="eid"
class="px-2 py-0.5 rounded-full text-xs" :style="`background:${getEcoleColor(eid)}22;color:${getEcoleColor(eid)};`">{{ getEcoleLabel(eid) }}</span>
</div>
<h2 class="mt-2 font-bold text-lg leading-tight" style="color:var(--nav-text);">{{ auteur.nom }}</h2>
<p class="text-sm" style="color:var(--nav-text-muted);">{{ auteur.dates }}</p>
</div>
<button @click="emit('close')" class="ml-3 shrink-0 flex items-center justify-center w-8 h-8 rounded-full hover:opacity-70"
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);" aria-label="Fermer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<!-- Body -->
<div class="flex-1 overflow-y-auto px-5 py-4 flex flex-col gap-4">
<p class="text-sm leading-relaxed" style="color:var(--nav-text);">{{ auteur.bio_courte }}</p>
<div v-if="auteur.theses_cles.length">
<p class="text-xs font-bold uppercase tracking-widest mb-2" style="color:var(--nav-text-muted);">Theses cles</p>
<ul class="flex flex-col gap-1.5">
<li v-for="t in auteur.theses_cles" :key="t" class="flex items-start gap-2 text-sm" style="color:var(--nav-text);">
<span class="mt-1.5 w-1.5 h-1.5 rounded-full shrink-0" :style="`background:${ecoleColor};`"></span>
<span>{{ t }}</span>
</li>
</ul>
</div>
<div v-if="auteur.livres_rag.length">
<p class="text-xs font-bold uppercase tracking-widest mb-2" style="color:var(--nav-text-muted);">Livres dans le RAG</p>
<div class="flex flex-col gap-2">
<div v-for="l in auteur.livres_rag" :key="l.slug" class="flex items-start gap-3 p-3 rounded-lg" style="background:var(--nav-bg-alt);">
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold leading-snug" style="color:var(--nav-text);">{{ l.titre }}</p>
<p class="text-xs mt-0.5" style="color:var(--nav-text-muted);">{{ l.annee }}</p>
</div>
<div class="flex gap-1 shrink-0">
<span v-for="c in l.couches" :key="c" class="px-1.5 py-0.5 rounded text-xs" style="background:var(--nav-surface);color:var(--nav-text-muted);">{{ c }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="shrink-0 px-5 py-3 border-t" style="border-color:var(--nav-bg-alt);">
<button @click="emit('interroger-rag', auteurId!)" class="w-full py-2.5 rounded-lg text-sm font-semibold hover:opacity-80"
:style="`background:${ecoleColor};color:white;`">
Interroger le RAG sur {{ auteur.nom.split(' ').pop() }}
</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
interface LivreRag { slug: string; titre: string; annee: number; couches: string[] }
interface AuteurData { id: string; nom: string; dates: string; ecoles: string[]; ecole_principale: string; livres_rag: LivreRag[]; theses_cles: string[]; bio_courte: string }
interface EcoleData { id: string; label: string; color: string }
interface PenseesData { ecoles: EcoleData[]; auteurs: AuteurData[] }
const props = defineProps<{ open: boolean; auteurId: string | null; data: PenseesData | null }>()
const emit = defineEmits<{ close: []; 'interroger-rag': [auteurId: string] }>()
const auteur = computed<AuteurData | null>(() => {
if (!props.auteurId || !props.data) return null
return props.data.auteurs.find(a => a.id === props.auteurId) ?? null
})
const ecoleColor = computed(() => props.data?.ecoles.find(e => e.id === auteur.value?.ecole_principale)?.color ?? '#888')
const ecoleLabel = computed(() => props.data?.ecoles.find(e => e.id === auteur.value?.ecole_principale)?.label ?? '')
function getEcoleColor(id: string) { return props.data?.ecoles.find(e => e.id === id)?.color ?? '#888' }
function getEcoleLabel(id: string) { return props.data?.ecoles.find(e => e.id === id)?.label ?? id }
function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && props.open) emit('close') }
onMounted(() => window.addEventListener('keydown', onKey))
onUnmounted(() => window.removeEventListener('keydown', onKey))
</script>
<style scoped>
.backdrop-enter-active,.backdrop-leave-active { transition: opacity 0.2s; }
.backdrop-enter-from,.backdrop-leave-to { opacity: 0; }
.modal-enter-active { transition: opacity 0.2s, transform 0.22s cubic-bezier(0.34,1.56,0.64,1); }
.modal-leave-active { transition: opacity 0.18s, transform 0.18s ease-in; }
.modal-enter-from { opacity: 0; transform: translate(-50%,-48%) scale(0.94); }
.modal-leave-to { opacity: 0; transform: translate(-50%,-48%) scale(0.96); }
</style>
+27 -59
View File
@@ -6,14 +6,12 @@
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);"
>
<!-- Bandeau titre -->
<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'"
<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"
style="color: var(--nav-text);"
>{{ org.nom }}</component>
>{{ org.nom }}</h1>
<a
v-if="org.url"
@@ -35,28 +33,18 @@
</div>
<!-- Méta : échelle + ville -->
<div class="flex flex-wrap items-center gap-2 text-sm mb-3" style="color: color-mix(in srgb, var(--nav-text) 78%, transparent);">
<div class="flex flex-wrap items-center gap-2 text-sm mb-3" style="color: var(--nav-text-muted);">
<span
v-if="org.echelle"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: var(--nav-primary-solid); color: var(--nav-text-on-primary);"
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
>{{ org.echelle }}</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>
<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>
</div>
<!-- Tags fonction -->
<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">
<div v-if="fonctionTags.length" class="flex flex-wrap gap-1.5">
<span
v-for="tag in fonctionTags"
:key="tag"
@@ -67,13 +55,13 @@
</div>
<!-- Corps : description + mini-carte -->
<div class="flex flex-col gap-6" :class="compact ? 'p-4' : 'p-6 lg:flex-row'">
<div class="p-6 flex flex-col lg:flex-row gap-6">
<!-- 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: color-mix(in srgb, var(--nav-text) 78%, transparent);">Description communauté</p>
<p class="text-sm leading-relaxed" style="color: var(--nav-text-muted);">Description communauté</p>
<p class="mt-1 leading-relaxed" style="color: var(--nav-text);">{{ descriptionUser }}</p>
</div>
@@ -82,7 +70,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: color-mix(in srgb, var(--nav-text) 78%, transparent);">
<p class="text-sm leading-relaxed flex items-center gap-1.5" style="color: var(--nav-text-muted);">
<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"/>
@@ -100,7 +88,7 @@
<!-- Points clés -->
<div v-if="pointsCles.length" class="mt-4">
<p class="text-sm font-medium mb-2" style="color: color-mix(in srgb, var(--nav-text) 78%, transparent);">Points clés</p>
<p class="text-sm font-medium mb-2" style="color: var(--nav-text-muted);">Points clés</p>
<ul class="space-y-1">
<li
v-for="(point, i) in pointsCles"
@@ -118,16 +106,15 @@
<!-- Mini-carte Leaflet -->
<div
v-if="hasCoords"
class="shrink-0 rounded-xl overflow-hidden"
:class="compact ? '' : 'lg:w-56 xl:w-64'"
class="shrink-0 lg:w-56 xl:w-64 rounded-xl overflow-hidden"
style="height: 180px; border: 1px solid var(--nav-bg-alt);"
>
<ClientOnly>
<div ref="mapContainer" class="w-full h-full" role="img" :aria-label="`Carte : ${org.nom}${org.localisation_ville ? `, ${org.localisation_ville}` : ''}`"></div>
<div ref="mapContainer" class="w-full h-full"></div>
<template #fallback>
<div
class="w-full h-full flex items-center justify-center text-xs"
style="background: var(--nav-bg-alt); color: color-mix(in srgb, var(--nav-text) 78%, transparent);"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>Carte…</div>
</template>
</ClientOnly>
@@ -153,7 +140,7 @@
<Transition name="report-form">
<div v-if="reportOpen" class="report-panel">
<p class="text-xs mb-3" style="color: color-mix(in srgb, var(--nav-text) 78%, transparent);">
<p class="text-xs mb-3" style="color: var(--nav-text-muted);">
Tes suggestions seront transmises à l'équipe AEP par email.
</p>
<div class="flex flex-col gap-3">
@@ -184,7 +171,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: color-mix(in srgb, var(--nav-text) 78%, transparent); opacity: 0.6;">
<p class="text-xs" style="color: var(--nav-text-muted); opacity: 0.6;">
{{ reportMessage.length }}/500
</p>
</div>
@@ -196,18 +183,9 @@
</template>
<script setup lang="ts">
const baseTiles = useBaseTiles()
import type { Org } from '~/types/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] }>()
const props = defineProps<{ org: Org }>()
// ── Champs ──────────────────────────────────────────────────────────────
const descriptionUser = computed(() =>
@@ -285,24 +263,15 @@ async function submitReport() {
// ── Mini-carte Leaflet ────────────────────────────────────────────────
const mapContainer = ref<HTMLElement | null>(null)
// 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
onMounted(async () => {
if (!hasCoords.value || !mapContainer.value) return
const L = (await import('leaflet')).default
const lat = props.org.latitude as number
const lng = props.org.longitude as number
const map = L.map(el, {
const map = L.map(mapContainer.value, {
center: [lat, lng],
zoom: 10,
zoomControl: false,
@@ -314,7 +283,7 @@ async function initMap(el: HTMLElement) {
attributionControl: false,
})
L.tileLayer(baseTiles.url(document.documentElement.classList.contains('dark')), {
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 18,
}).addTo(map)
@@ -331,9 +300,8 @@ async function initMap(el: HTMLElement) {
iconAnchor: [7, 7],
})
// É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)
}
L.marker([lat, lng], { icon }).addTo(map)
})
</script>
<style scoped>
@@ -343,7 +311,7 @@ async function initMap(el: HTMLElement) {
align-items: center;
gap: 6px;
font-size: 0.78rem;
color: color-mix(in srgb, var(--nav-text) 78%, transparent);
color: var(--nav-text-muted);
background: none;
border: none;
cursor: pointer;
-170
View File
@@ -1,170 +0,0 @@
<!--
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>
<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"
:aria-label="`Étiquettes de ${fiche.nom}`"
class="fiche-mini-card__chips"
>
<Chip v-if="fiche.echelle" as="span" variant="echelle" :label="fiche.echelle" />
<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>
</article>
</template>
<script setup lang="ts">
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;
box-sizing: border-box;
transition: background-color 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) {
.fiche-mini-card {
transition: none;
}
}
.fiche-mini-card:hover {
background: var(--surface-2);
box-shadow: var(--shadow-sm);
}
/* 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);
font-weight: var(--fw-semibold);
line-height: var(--lh-tight);
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fiche-mini-card__resume {
width: 100%;
margin-top: var(--space-2xs);
font-size: var(--fs-xs);
line-height: var(--lh-base);
/* --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;
overflow: hidden;
}
.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: color-mix(in srgb, var(--text) 78%, transparent);
}
</style>
+183
View File
@@ -0,0 +1,183 @@
<template>
<Teleport to="body">
<!-- Backdrop -->
<Transition name="backdrop">
<div
v-if="modelValue && orgId != null"
class="fixed inset-0 z-[1500]"
style="background: rgba(26,34,56,0.55);"
@click="close"
aria-hidden="true"
/>
</Transition>
<!-- Modal centré -->
<Transition name="modal">
<div
v-if="modelValue && orgId != null"
class="fiche-modal fixed z-[1501] left-1/2 -translate-x-1/2 flex flex-col"
style="
width: min(768px, 92vw);
background: var(--nav-bg);
border-radius: 16px;
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
overflow: hidden;
"
role="dialog"
aria-modal="true"
:aria-label="org?.nom ?? 'Fiche organisation'"
@keydown.esc="close"
>
<!-- Header modal -->
<div
class="flex items-center justify-between px-5 py-3 shrink-0 border-b"
style="background: var(--nav-surface); border-color: var(--nav-bg-alt);"
>
<span class="text-sm font-semibold" style="color: var(--nav-text-muted);">Fiche détaillée</span>
<div class="flex items-center gap-2">
<!-- Lien fiche complète -->
<a
v-if="orgId"
:href="`/fiche/${orgId}`"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-opacity hover:opacity-70"
style="background: var(--nav-bg-alt); color: var(--nav-text);"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
Ouvrir
</a>
<!-- Fermer -->
<button
@click="close"
class="w-8 h-8 rounded-lg flex items-center justify-center transition-colors"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
aria-label="Fermer"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</div>
<!-- Contenu scrollable -->
<div class="flex-1 overflow-y-auto px-5 py-5">
<!-- Chargement -->
<div v-if="pending" class="py-12 text-center text-sm" style="color: var(--nav-text-muted);">
Chargement de la fiche…
</div>
<!-- Erreur -->
<div v-else-if="error" class="py-12 text-center">
<p class="text-base font-semibold mb-2" style="color: var(--nav-text);">Fiche introuvable</p>
<p class="text-sm" style="color: var(--nav-text-muted);">L'organisation demandée n'existe pas ou a été supprimée.</p>
</div>
<!-- Contenu -->
<template v-else-if="org">
<FicheDetail :org="org" />
<div class="mb-5" style="height: 1px; background: var(--nav-bg-alt);"></div>
<CommentSection :org-id="org.Id" :refresh="commentRefreshTick" />
<CommentForm :org-id="org.Id" @submitted="onCommentSubmitted" />
</template>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { Org } from '~/types/org'
const props = defineProps<{
modelValue: boolean
orgId: number | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
function close() {
emit('update:modelValue', false)
}
// Fermeture Esc globale
onMounted(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.modelValue) close()
}
window.addEventListener('keydown', handler)
onUnmounted(() => window.removeEventListener('keydown', handler))
})
// Fetch fiche quand orgId change
const org = ref<Org | null>(null)
const pending = ref(false)
const error = ref(false)
watch(() => props.orgId, async (id) => {
if (id == null) return
pending.value = true
error.value = false
org.value = null
try {
org.value = await $fetch<Org>(`/api/fiche/${id}`)
} catch {
error.value = true
} finally {
pending.value = false
}
}, { immediate: true })
const commentRefreshTick = ref(0)
function onCommentSubmitted() {
commentRefreshTick.value++
}
</script>
<style scoped>
/* Modal positionnement : centré desktop, descendu sous le header sur mobile */
.fiche-modal {
top: 50%;
transform: translate(-50%, -50%);
max-height: 90vh;
}
@media (max-width: 1023px) {
.fiche-modal {
top: 76px;
transform: translateX(-50%);
max-height: calc(100dvh - 92px);
}
}
/* Backdrop */
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
/* Modal */
.modal-enter-active, .modal-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.modal-enter-from, .modal-leave-to {
opacity: 0;
transform: translate(-50%, -52%);
}
@media (max-width: 1023px) {
.modal-enter-from, .modal-leave-to {
transform: translate(-50%, calc(-2% + 76px));
}
}
@media (prefers-reduced-motion: reduce) {
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
.modal-enter-active, .modal-leave-active { transition: none; }
}
</style>
+360
View File
@@ -0,0 +1,360 @@
<template>
<Teleport to="body">
<!-- Backdrop -->
<Transition name="backdrop">
<div
v-if="modelValue && structureId != null"
class="fixed inset-0 z-[1500]"
style="background: rgba(26,34,56,0.55);"
@click="close"
aria-hidden="true"
/>
</Transition>
<!-- Modal -->
<Transition name="modal">
<div
v-if="modelValue && structureId != null && structure"
class="fiche-modal-v2 fixed z-[1501] left-1/2 -translate-x-1/2 flex flex-col"
style="
width: min(780px, 94vw);
background: var(--nav-bg);
border-radius: 16px;
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
overflow: hidden;
"
role="dialog"
aria-modal="true"
:aria-label="structure?.nom ?? 'Fiche structure'"
@keydown.esc="close"
>
<!-- Header modal -->
<div
class="flex items-center justify-between px-5 py-3 shrink-0"
:style="`border-bottom: 3px solid ${familleColor}; background: var(--nav-surface);`"
>
<div class="flex items-center gap-3">
<!-- Pastille famille -->
<div
class="w-3 h-3 rounded-full shrink-0"
:style="`background: ${familleColor};`"
/>
<span class="text-sm font-semibold" style="color: var(--nav-text-muted);">
{{ familleLabel }}
</span>
<!-- Badges -->
<div class="flex gap-1.5">
<span
v-if="structure.badges.centre_ressources"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: #2d8a6b22; color: #2d8a6b;"
>Centre ressources</span>
<span
v-if="structure.badges.mouvement_manifeste"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: #c44a2f22; color: #c44a2f;"
>Manifeste</span>
<span
v-if="structure.badges.contre_pouvoir_spatial"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: #1a3a6b22; color: #1a3a6b;"
>Contre-pouvoir</span>
<span
v-if="structure.badges.f6_recherche_politique"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: #6b3fa022; color: #6b3fa0;"
>Recherche pol.</span>
</div>
</div>
<div class="flex items-center gap-2">
<a
v-if="structure.url"
:href="structure.url"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-opacity hover:opacity-70"
style="background: var(--nav-bg-alt); color: var(--nav-text);"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
Site web
</a>
<button
@click="close"
class="w-8 h-8 rounded-lg flex items-center justify-center transition-colors"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
aria-label="Fermer"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</div>
<!-- Contenu scrollable -->
<div class="flex-1 overflow-y-auto px-5 py-5">
<!-- Nom + meta -->
<div class="mb-4">
<h2 class="text-xl font-bold mb-1" style="color: var(--nav-text);">{{ structure.nom }}</h2>
<div class="flex flex-wrap gap-2 text-sm" style="color: var(--nav-text-muted);">
<span>{{ structure.type_principal }}</span>
<span>·</span>
<span>{{ structure.ville }}, {{ structure.pays }}</span>
</div>
</div>
<!-- Hashtags -->
<div v-if="structure.hashtags.length" class="flex flex-wrap gap-1.5 mb-4">
<span
v-for="tag in structure.hashtags"
:key="tag"
class="px-2 py-0.5 rounded-full text-xs"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ tag }}</span>
</div>
<!-- Description courte -->
<p class="text-sm leading-relaxed mb-4" style="color: var(--nav-text);">{{ structure.description_courte }}</p>
<!-- Description longue (expandable) -->
<div v-if="structure.description_longue" class="mb-4">
<div
class="text-sm leading-relaxed"
style="color: var(--nav-text); white-space: pre-wrap;"
:style="showFullDesc ? '' : 'max-height: 120px; overflow: hidden;'"
>{{ structure.description_longue }}</div>
<button
@click="showFullDesc = !showFullDesc"
class="mt-2 text-xs underline"
style="color: var(--nav-text-muted);"
>{{ showFullDesc ? 'Réduire' : 'Lire la suite…' }}</button>
</div>
<!-- Pensées rattachées -->
<div v-if="structure.pensees && structure.pensees.length" class="mb-4">
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Pensées rattachées</h3>
<div class="flex flex-wrap gap-1.5">
<span
v-for="pensee in structure.pensees"
:key="pensee.id"
class="px-2 py-0.5 rounded text-xs"
:style="pensee.confiance === 'ia_suggested'
? 'background: var(--nav-bg-alt); color: var(--nav-text-muted); border: 1px dashed var(--nav-bg-alt);'
: 'background: var(--nav-bg-alt); color: var(--nav-text);'"
:title="pensee.confiance === 'ia_suggested' ? 'IA suggéré' : ''"
>
{{ pensee.label }}<span v-if="pensee.confiance === 'ia_suggested'" class="ml-1 opacity-60">~</span>
</span>
</div>
</div>
<!-- Projets emblématiques -->
<div v-if="projetsStructure.length" class="mb-4">
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Projets emblématiques</h3>
<div class="space-y-2">
<div
v-for="projet in projetsStructure.slice(0, 5)"
:key="projet.id"
class="rounded-lg p-3"
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);"
>
<div class="flex items-start justify-between gap-2">
<span class="font-medium text-sm" style="color: var(--nav-text);">{{ projet.nom }}</span>
<span v-if="projet.annee" class="text-xs shrink-0" style="color: var(--nav-text-muted);">{{ projet.annee }}</span>
</div>
<div v-if="projet.lieu" class="text-xs mt-0.5" style="color: var(--nav-text-muted);">{{ projet.lieu }}</div>
<p class="text-xs mt-1 leading-relaxed" style="color: var(--nav-text-muted);">{{ projet.description.slice(0, 120) }}{{ projet.description.length > 120 ? '…' : '' }}</p>
<a
v-if="projet.url"
:href="projet.url"
target="_blank"
rel="noopener noreferrer"
class="text-xs mt-1 inline-block"
style="color: var(--nav-text-muted); text-decoration: underline;"
>En savoir plus →</a>
</div>
</div>
</div>
<!-- Structures voisines (graphe) -->
<div v-if="structuresVoisines.length" class="mb-4">
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Structures liées</h3>
<div class="flex flex-wrap gap-2">
<button
v-for="voisine in structuresVoisines.slice(0, 6)"
:key="voisine.id"
class="px-2 py-1 rounded text-xs transition-colors hover:opacity-70"
style="background: var(--nav-bg-alt); color: var(--nav-text); border: 1px solid transparent;"
@click="emit('update:structureId', voisine.id)"
>{{ voisine.nom }}</button>
</div>
</div>
<!-- Sources -->
<div v-if="structure.sources && structure.sources.length" class="mb-4">
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Sources</h3>
<div class="space-y-1">
<div v-for="(source, i) in structure.sources" :key="i" class="flex items-center gap-2">
<span
class="px-1.5 py-0.5 rounded text-xs shrink-0"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ source.type }}</span>
<a
v-if="source.url"
:href="source.url"
target="_blank"
rel="noopener noreferrer"
class="text-xs underline truncate"
style="color: var(--nav-text);"
>{{ source.titre }}</a>
<span v-else class="text-xs" style="color: var(--nav-text);">{{ source.titre }}</span>
</div>
</div>
</div>
<!-- CTAs -->
<div class="flex gap-3 pt-2" style="border-top: 1px solid var(--nav-bg-alt);">
<a
href="/contribuer"
class="text-xs underline"
style="color: var(--nav-text-muted);"
>Signaler une erreur</a>
<a
href="/contribuer"
class="text-xs underline"
style="color: var(--nav-text-muted);"
>Réclamer cette fiche</a>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { ReseauxBifurcationData, StructureV2, ProjetEmblematique } from '~/types/structure-v2'
const FAMILLE_COLORS: Record<number, string> = {
1: '#a85d3e',
2: '#c4a472',
3: '#d4a017',
4: '#5a7a4a',
5: '#3d6a8c',
}
const FAMILLE_LABELS: Record<number, string> = {
1: 'Réemploi & filières',
2: 'Frugalité & low-tech',
3: 'Architecture sociale',
4: 'Collectifs & AMO',
5: 'Urbanisme de transition',
}
const props = defineProps<{
modelValue: boolean
structureId: string | null
data: ReseauxBifurcationData | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'update:structureId': [id: string]
}>()
const showFullDesc = ref(false)
function close() {
emit('update:modelValue', false)
showFullDesc.value = false
}
// Fermeture Esc globale
onMounted(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.modelValue) close()
}
window.addEventListener('keydown', handler)
onUnmounted(() => window.removeEventListener('keydown', handler))
})
// Remettre showFullDesc a false a chaque changement de fiche
watch(() => props.structureId, () => {
showFullDesc.value = false
})
const structure = computed<StructureV2 | null>(() => {
if (!props.data || !props.structureId) return null
return props.data.structures.find(s => s.id === props.structureId) ?? null
})
const familleColor = computed(() =>
FAMILLE_COLORS[structure.value?.famille_principale ?? 1] ?? '#888'
)
const familleLabel = computed(() =>
FAMILLE_LABELS[structure.value?.famille_principale ?? 1] ?? ''
)
const projetsStructure = computed<ProjetEmblematique[]>(() => {
if (!props.data || !props.structureId) return []
return props.data.projets?.filter(p => p.structure_parent === props.structureId) ?? []
})
const structuresVoisines = computed<StructureV2[]>(() => {
if (!props.data || !props.structureId) return []
const edges = props.data.graphe?.edges ?? []
const voisineIds = edges
.filter(e => e.source === props.structureId || e.target === props.structureId)
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
.map(e => e.source === props.structureId ? e.target : e.source)
.slice(0, 8)
return voisineIds
.map(id => props.data!.structures.find(s => s.id === id))
.filter(Boolean) as StructureV2[]
})
</script>
<style scoped>
/* Backdrop */
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
/* Modal positionnement : centré desktop, descendu sous le header sur mobile */
.fiche-modal-v2 {
top: 50%;
transform: translate(-50%, -50%);
max-height: 90vh;
}
@media (max-width: 1023px) {
.fiche-modal-v2 {
top: 76px;
transform: translateX(-50%);
max-height: calc(100dvh - 92px);
}
}
/* Modal */
.modal-enter-active, .modal-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.modal-enter-from, .modal-leave-to {
opacity: 0;
transform: translate(-50%, -52%);
}
@media (max-width: 1023px) {
.modal-enter-from, .modal-leave-to {
transform: translate(-50%, calc(-2% + 76px));
}
}
@media (prefers-reduced-motion: reduce) {
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
.modal-enter-active, .modal-leave-active { transition: none; }
}
</style>
-291
View File
@@ -1,291 +0,0 @@
<!--
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>
-99
View File
@@ -1,99 +0,0 @@
<!--
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>
+104
View File
@@ -0,0 +1,104 @@
<template>
<div class="space-y-1">
<!-- Label + toggle collapse -->
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
<p class="filter-label" style="margin-bottom: 0;">
FONCTION
<span v-if="modelValue.length" style="font-weight: 400; text-transform: none; letter-spacing: 0; font-size: 0.65rem; margin-left: 4px;">({{ modelValue.length }} active{{ modelValue.length > 1 ? 's' : '' }})</span>
</p>
<button
@click="toggleCollapse"
style="font-size: 0.7rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0; white-space: nowrap;"
>{{ isOpen ? 'Replier' : 'Fonctions (' + FONCTIONS.length + ')' }}</button>
</div>
<!-- Chips (visible si ouvert ou si des fonctions sont actives) -->
<div v-if="isOpen" class="chips-row">
<span
v-for="fn in FONCTIONS"
:key="fn"
class="chip"
:style="modelValue.includes(fn)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggle(fn)"
>{{ fn }}</span>
</div>
<!-- Effacer (visible même replié si filtres actifs) -->
<p v-if="modelValue.length" class="text-xs pt-0.5" style="color: var(--nav-text-muted);">
<button @click="emit('update:modelValue', [])" class="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[]]
}>()
// Replié par défaut, ouvre automatiquement quand des filtres sont actifs
const manuallyOpen = ref(false)
const isOpen = computed(() => {
return manuallyOpen.value || props.modelValue.length > 0
})
function toggleCollapse() {
// Si des filtres actifs forcent l'ouverture, on doit gérer le cas « forcer fermer »
if (isOpen.value) {
manuallyOpen.value = false
// Si des fonctions sont actives, le computed va les réouvrir — on les efface
// Non : on laisse le choix à l'utilisateur. On toggle juste manuallyOpen.
// Quand replié avec filtres actifs, l'indicateur "(N actives)" reste visible.
} else {
manuallyOpen.value = true
}
}
function toggle(fn: string) {
if (props.modelValue.includes(fn)) {
emit('update:modelValue', props.modelValue.filter(f => f !== fn))
} else {
emit('update:modelValue', [...props.modelValue, fn])
}
}
</script>
<style scoped>
.filter-label {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--nav-text-muted);
display: block;
text-transform: uppercase;
}
.chips-row { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }
.chip {
cursor: pointer;
padding: 3px 10px;
border-radius: 9999px;
font-size: 0.75rem;
transition: all 0.15s;
user-select: none;
}
</style>
-184
View File
@@ -1,184 +0,0 @@
<template>
<form @submit.prevent="submit" class="proposer-form" novalidate>
<div class="field-group" :class="{ 'field-error': errors.nom }">
<label for="eco-nom">Nom de l'organisation <span class="required">*</span></label>
<input id="eco-nom" v-model="form.nom" type="text" placeholder="Ex : UNSFA, Maison de l'Architecture..." autocomplete="organization" @blur="validateField('nom')" />
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.url }">
<label for="eco-url">Site web <span class="label-hint">(optionnel — recommandé pour l'enrichissement IA)</span></label>
<input id="eco-url" v-model="form.url" type="url" placeholder="https://..." @blur="validateField('url')" />
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.description_user }">
<label for="eco-desc">Description courte <span class="required">*</span> <span class="label-hint">(50 à 500 caractères)</span></label>
<textarea id="eco-desc" v-model="form.description_user" rows="4" placeholder="Présente l'organisation en quelques mots : ses missions, son public, ce qu'elle apporte..." @blur="validateField('description_user')" />
<div class="field-meta">
<span v-if="errors.description_user" class="error-msg" role="alert">{{ errors.description_user }}</span>
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">{{ form.description_user.length }}/500</span>
</div>
</div>
<div class="field-group" :class="{ 'field-error': errors.echelle }">
<fieldset>
<legend>Échelle <span class="required">*</span> <span class="label-hint">(une seule)</span></legend>
<div class="radio-group">
<label v-for="opt in ECHELLES" :key="opt" class="radio-label" :class="{ active: form.echelle === opt }">
<input type="radio" :value="opt" v-model="form.echelle" name="eco-echelle" @change="validateField('echelle')" />{{ opt }}
</label>
</div>
</fieldset>
<span v-if="errors.echelle" class="error-msg" role="alert">{{ errors.echelle }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.fonctions }">
<fieldset>
<legend>Fonctions <span class="required">*</span> <span class="label-hint">(1 à 5 — l'ordre de clic = priorité)</span></legend>
<div class="checkbox-grid">
<label v-for="fn in FONCTIONS" :key="fn" class="checkbox-label" :class="{ active: form.fonctions.includes(fn), disabled: !form.fonctions.includes(fn) && form.fonctions.length >= 5 }">
<input type="checkbox" :value="fn" :checked="form.fonctions.includes(fn)" :disabled="!form.fonctions.includes(fn) && form.fonctions.length >= 5" @change="toggleFonction(fn)" />
<span class="fn-order" v-if="form.fonctions.includes(fn)">{{ form.fonctions.indexOf(fn) + 1 }}</span>{{ fn }}
</label>
</div>
</fieldset>
<span v-if="errors.fonctions" class="error-msg" role="alert">{{ errors.fonctions }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.territoire }">
<fieldset>
<legend>Territoire <span class="required">*</span></legend>
<div class="radio-group">
<label v-for="t in TERRITOIRES" :key="t" class="radio-label" :class="{ active: form.territoire === t }">
<input type="radio" :value="t" v-model="form.territoire" name="eco-territoire" @change="validateField('territoire')" />{{ t }}
</label>
</div>
</fieldset>
<span v-if="errors.territoire" class="error-msg" role="alert">{{ errors.territoire }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.localisation_ville }">
<label for="eco-ville">Ville principale <span class="label-hint">(optionnel — pour la géolocalisation sur la carte)</span></label>
<input id="eco-ville" v-model="form.localisation_ville" type="text" placeholder="Ex : Paris, Lyon, Bordeaux..." />
<span v-if="errors.localisation_ville" class="error-msg" role="alert">{{ errors.localisation_ville }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
<label for="eco-email">Ton email <span class="label-hint">(optionnel — pour le suivi de modération)</span></label>
<input id="eco-email" v-model="form.submitted_by_email" type="email" placeholder="ton@email.fr" autocomplete="email" @blur="validateField('submitted_by_email')" />
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">{{ errors.submitted_by_email }}</span>
</div>
<div v-if="serverError" class="server-error" role="alert"><strong>Erreur :</strong> {{ serverError }}</div>
<div class="form-actions">
<button type="submit" class="btn-primary" :disabled="submitting">{{ submitting ? 'Envoi en cours...' : 'Proposer la fiche →' }}</button>
</div>
<p class="form-note">Ta fiche sera examinée par l'équipe avant publication.</p>
</form>
</template>
<script setup lang="ts">
import { z } from 'zod'
const ECHELLES = ['National', 'Régional', 'Local'] as const
const TERRITOIRES = ['Métropole', 'Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte'] as const
const FONCTIONS = ['Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier', 'Comptabilité', 'Développement', 'Formation', 'Gestion d\'agence', 'Santé mentale'] as const
const Schema = z.object({
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
url: z.string().url('URL invalide (commencer par https://)').optional().or(z.literal('')),
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
echelle: z.enum(ECHELLES, { errorMap: () => ({ message: 'Sélectionne une échelle' }) }),
fonctions: z.array(z.string()).min(1, 'Sélectionne au moins une fonction').max(5, 'Maximum 5 fonctions'),
territoire: z.enum(TERRITOIRES, { errorMap: () => ({ message: 'Sélectionne un territoire' }) }),
localisation_ville: z.string().max(100).optional(),
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
})
const emit = defineEmits<{ success: [] }>()
const form = reactive({
nom: '', url: '', description_user: '', echelle: '' as string,
fonctions: [] as string[], territoire: '' as string,
localisation_ville: '', submitted_by_email: '',
})
const errors = reactive<Record<string, string>>({})
const submitting = ref(false)
const serverError = ref('')
function validateField(field: string) {
const partial = Schema.partial()
const result = partial.safeParse({ [field]: (form as any)[field] })
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors
errors[field] = fieldErrors[field]?.[0] ?? ''
} else {
delete errors[field]
}
}
function validateAll(): boolean {
const result = Schema.safeParse(form)
if (!result.success) {
const flat = result.error.flatten().fieldErrors
Object.assign(errors, Object.fromEntries(Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])))
return false
}
Object.keys(errors).forEach(k => delete errors[k])
return true
}
function toggleFonction(fn: string) {
const idx = form.fonctions.indexOf(fn)
if (idx >= 0) form.fonctions.splice(idx, 1)
else if (form.fonctions.length < 5) form.fonctions.push(fn)
validateField('fonctions')
}
async function submit() {
serverError.value = ''
if (!validateAll()) {
await nextTick()
const firstError = document.querySelector('.field-error')
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
return
}
submitting.value = true
try {
await $fetch('/api/submit', {
method: 'POST',
body: {
submission_type: 'ecosysteme',
nom: form.nom,
url: form.url || undefined,
description_user: form.description_user,
echelle: form.echelle,
fonctions: form.fonctions,
territoire: form.territoire,
localisation_ville: form.localisation_ville || undefined,
submitted_by_email: form.submitted_by_email || undefined,
},
})
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) {
const fieldErrors = e.data
Object.entries(fieldErrors).forEach(([k, v]) => { errors[k] = Array.isArray(v) ? v[0] : String(v) })
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
} else {
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
}
} finally {
submitting.value = false
}
}
</script>
<style scoped>
@import './form-styles.css';
</style>
-134
View File
@@ -1,134 +0,0 @@
<template>
<form @submit.prevent="submit" class="proposer-form" novalidate>
<div class="field-group" :class="{ 'field-error': errors.nom }">
<label for="job-nom">Nom de la plateforme / job board <span class="required">*</span></label>
<input id="job-nom" v-model="form.nom" type="text" placeholder="Ex : Houzz, Architoo, BOAMP..." @blur="validateField('nom')" />
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
</div>
<div class="field-group">
<label for="job-type">Type de plateforme <span class="label-hint">(optionnel)</span></label>
<div class="radio-group">
<label v-for="t in TYPES" :key="t" class="radio-label" :class="{ active: form.type_plateforme === t }">
<input type="radio" :value="t" v-model="form.type_plateforme" name="job-type" />{{ LABELS[t] }}
</label>
</div>
</div>
<div class="field-group" :class="{ 'field-error': errors.url }">
<label for="job-url">Site web <span class="required">*</span></label>
<input id="job-url" v-model="form.url" type="url" placeholder="https://..." @blur="validateField('url')" />
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.description_user }">
<label for="job-desc">Description courte <span class="required">*</span> <span class="label-hint">(50 à 500 caractères)</span></label>
<textarea id="job-desc" v-model="form.description_user" rows="4" placeholder="Décris la plateforme : qui la gère, quel type de missions, quel modèle économique..." @blur="validateField('description_user')" />
<div class="field-meta">
<span v-if="errors.description_user" class="error-msg" role="alert">{{ errors.description_user }}</span>
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">{{ form.description_user.length }}/500</span>
</div>
</div>
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
<label for="job-email">Ton email <span class="label-hint">(optionnel — pour le suivi)</span></label>
<input id="job-email" v-model="form.submitted_by_email" type="email" placeholder="ton@email.fr" autocomplete="email" @blur="validateField('submitted_by_email')" />
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">{{ errors.submitted_by_email }}</span>
</div>
<div v-if="serverError" class="server-error" role="alert"><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 { z } from 'zod'
const TYPES = ['mise-en-relation', 'appel-offre-public', 'communaute-pro'] as const
const LABELS: Record<string, string> = { 'mise-en-relation': 'Mise en relation (B2C)', 'appel-offre-public': 'Appels d\'offres publics', 'communaute-pro': 'Communauté pro' }
const Schema = z.object({
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
url: z.string().url('URL invalide (commencer par https://)'),
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
type_plateforme: z.string().optional(),
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
})
const emit = defineEmits<{ success: [] }>()
const form = reactive({
nom: '', url: '', description_user: '', type_plateforme: '', submitted_by_email: '',
})
const errors = reactive<Record<string, string>>({})
const submitting = ref(false)
const serverError = ref('')
function validateField(field: string) {
const partial = Schema.partial()
const result = partial.safeParse({ [field]: (form as any)[field] })
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors
errors[field] = fieldErrors[field]?.[0] ?? ''
} else {
delete errors[field]
}
}
function validateAll(): boolean {
const result = Schema.safeParse(form)
if (!result.success) {
const flat = result.error.flatten().fieldErrors
Object.assign(errors, Object.fromEntries(Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])))
return false
}
Object.keys(errors).forEach(k => delete errors[k])
return true
}
async function submit() {
serverError.value = ''
if (!validateAll()) {
await nextTick()
const firstError = document.querySelector('.field-error')
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
return
}
submitting.value = true
try {
await $fetch('/api/submit', {
method: 'POST',
body: {
submission_type: 'job',
nom: form.nom,
url: form.url,
description_user: form.description_user,
type_plateforme: form.type_plateforme || undefined,
submitted_by_email: form.submitted_by_email || undefined,
},
})
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) {
const fieldErrors = e.data
Object.entries(fieldErrors).forEach(([k, v]) => { errors[k] = Array.isArray(v) ? v[0] : String(v) })
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
} else {
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
}
} finally {
submitting.value = false
}
}
</script>
<style scoped>
@import './form-styles.css';
</style>
-310
View File
@@ -1,310 +0,0 @@
<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>
-134
View File
@@ -1,134 +0,0 @@
<template>
<form @submit.prevent="submit" class="proposer-form" novalidate>
<div class="field-group" :class="{ 'field-error': errors.nom }">
<label for="out-nom">Nom de l'outil <span class="required">*</span></label>
<input id="out-nom" v-model="form.nom" type="text" placeholder="Ex : Simulateur Autonomie, FreeCAD..." @blur="validateField('nom')" />
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
</div>
<div class="field-group">
<label>Catégorie <span class="label-hint">(optionnelle)</span></label>
<div class="radio-group">
<label v-for="c in CATEGORIES" :key="c" class="radio-label" :class="{ active: form.categorie === c }">
<input type="radio" :value="c" v-model="form.categorie" name="out-categorie" />{{ LABELS[c] }}
</label>
</div>
</div>
<div class="field-group" :class="{ 'field-error': errors.url }">
<label for="out-url">Site web / lien <span class="label-hint">(optionnel — si pas de lien direct)</span></label>
<input id="out-url" v-model="form.url" type="url" placeholder="https://..." @blur="validateField('url')" />
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.description_user }">
<label for="out-desc">Description courte <span class="required">*</span> <span class="label-hint">(50 à 500 caractères)</span></label>
<textarea id="out-desc" v-model="form.description_user" rows="4" placeholder="Décris l'outil : à quoi il sert, pour quel usage, ce qui le rend utile..." @blur="validateField('description_user')" />
<div class="field-meta">
<span v-if="errors.description_user" class="error-msg" role="alert">{{ errors.description_user }}</span>
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">{{ form.description_user.length }}/500</span>
</div>
</div>
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
<label for="out-email">Ton email <span class="label-hint">(optionnel — pour le suivi)</span></label>
<input id="out-email" v-model="form.submitted_by_email" type="email" placeholder="ton@email.fr" autocomplete="email" @blur="validateField('submitted_by_email')" />
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">{{ errors.submitted_by_email }}</span>
</div>
<div v-if="serverError" class="server-error" role="alert"><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 { z } from 'zod'
const CATEGORIES = ['simulateur', 'logiciel', 'guide', 'autre'] as const
const LABELS: Record<string, string> = { simulateur: 'Simulateur', logiciel: 'Logiciel / app', guide: 'Guide / ressource', autre: 'Autre' }
const Schema = z.object({
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
url: z.string().url('URL invalide').optional().or(z.literal('')),
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
categorie: z.string().optional(),
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
})
const emit = defineEmits<{ success: [] }>()
const form = reactive({
nom: '', url: '', description_user: '', categorie: '', submitted_by_email: '',
})
const errors = reactive<Record<string, string>>({})
const submitting = ref(false)
const serverError = ref('')
function validateField(field: string) {
const partial = Schema.partial()
const result = partial.safeParse({ [field]: (form as any)[field] })
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors
errors[field] = fieldErrors[field]?.[0] ?? ''
} else {
delete errors[field]
}
}
function validateAll(): boolean {
const result = Schema.safeParse(form)
if (!result.success) {
const flat = result.error.flatten().fieldErrors
Object.assign(errors, Object.fromEntries(Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])))
return false
}
Object.keys(errors).forEach(k => delete errors[k])
return true
}
async function submit() {
serverError.value = ''
if (!validateAll()) {
await nextTick()
const firstError = document.querySelector('.field-error')
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
return
}
submitting.value = true
try {
await $fetch('/api/submit', {
method: 'POST',
body: {
submission_type: 'outil',
nom: form.nom,
url: form.url || undefined,
description_user: form.description_user,
categorie: form.categorie || undefined,
submitted_by_email: form.submitted_by_email || undefined,
},
})
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) {
const fieldErrors = e.data
Object.entries(fieldErrors).forEach(([k, v]) => { errors[k] = Array.isArray(v) ? v[0] : String(v) })
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
} else {
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
}
} finally {
submitting.value = false
}
}
</script>
<style scoped>
@import './form-styles.css';
</style>
-142
View File
@@ -1,142 +0,0 @@
<template>
<form @submit.prevent="submit" class="proposer-form" novalidate>
<div class="field-group" :class="{ 'field-error': errors.titre }">
<label for="ref-titre">Titre du livre / document <span class="required">*</span></label>
<input id="ref-titre" v-model="form.titre" type="text" placeholder="Ex : Repenser l'architecture..." @blur="validateField('titre')" />
<span v-if="errors.titre" class="error-msg" role="alert">{{ errors.titre }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.auteur }">
<label for="ref-auteur">Auteur·ice(s) <span class="required">*</span></label>
<input id="ref-auteur" v-model="form.auteur" type="text" placeholder="Ex : Philippe Rahm, Doina Petrescu..." @blur="validateField('auteur')" />
<span v-if="errors.auteur" class="error-msg" role="alert">{{ errors.auteur }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.url }">
<label for="ref-url">Lien <span class="label-hint">(optionnel — éditeur, статье, PDF…)</span></label>
<input id="ref-url" v-model="form.url" type="url" placeholder="https://..." @blur="validateField('url')" />
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.description }">
<label for="ref-desc">Description de l'ouvrage <span class="required">*</span> <span class="label-hint">(50 à 1000 caractères)</span></label>
<textarea id="ref-desc" v-model="form.description" rows="4" placeholder="Présente l'ouvrage : thèse principale, approche, pourquoi c'est important..." @blur="validateField('description')" />
<div class="field-meta">
<span v-if="errors.description" class="error-msg" role="alert">{{ errors.description }}</span>
<span v-else class="char-count" :class="{ 'char-warn': form.description.length > 900 }">{{ form.description.length }}/1000</span>
</div>
</div>
<div class="field-group">
<label for="ref-pertinence">Pertinence pour le RAG <span class="label-hint">(optionnel — en quoi cette référence est utile ?)</span></label>
<textarea id="ref-pertinence" v-model="form.pertinence_rag" rows="2" placeholder="Ex : Excellente source sur l'architecture participative..." maxlength="500" />
</div>
<div class="field-group">
<label for="ref-hashtags">Hashtags <span class="label-hint">(optionnel — séparés par des virgules)</span></label>
<input id="ref-hashtags" v-model="form.hashtags" type="text" placeholder="Ex : écologie, participation, matériaux bio..." />
</div>
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
<label for="ref-email">Ton email <span class="label-hint">(optionnel — pour le suivi)</span></label>
<input id="ref-email" v-model="form.submitted_by_email" type="email" placeholder="ton@email.fr" autocomplete="email" @blur="validateField('submitted_by_email')" />
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">{{ errors.submitted_by_email }}</span>
</div>
<div v-if="serverError" class="server-error" role="alert"><strong>Erreur :</strong> {{ serverError }}</div>
<div class="form-actions">
<button type="submit" class="btn-primary" :disabled="submitting">{{ submitting ? 'Envoi en cours...' : 'Proposer la référence →' }}</button>
</div>
<p class="form-note">Ta référence sera examinée avant d'être intégrée au fonds documentaire RAG.</p>
</form>
</template>
<script setup lang="ts">
import { z } from 'zod'
const Schema = z.object({
titre: z.string().min(3, 'Minimum 3 caractères').max(200, 'Maximum 200 caractères').trim(),
auteur: z.string().min(2, 'Minimum 2 caractères').max(200, 'Maximum 200 caractères').trim(),
url: z.string().url('URL invalide').optional().or(z.literal('')),
description: z.string().min(50, 'Minimum 50 caractères').max(1000, 'Maximum 1000 caractères').trim(),
pertinence_rag: z.string().max(500).optional(),
hashtags: z.string().max(300).optional(),
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
})
const emit = defineEmits<{ success: [] }>()
const form = reactive({
titre: '', auteur: '', url: '', description: '',
pertinence_rag: '', hashtags: '', submitted_by_email: '',
})
const errors = reactive<Record<string, string>>({})
const submitting = ref(false)
const serverError = ref('')
function validateField(field: string) {
const partial = Schema.partial()
const result = partial.safeParse({ [field]: (form as any)[field] })
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors
errors[field] = fieldErrors[field]?.[0] ?? ''
} else {
delete errors[field]
}
}
function validateAll(): boolean {
const result = Schema.safeParse(form)
if (!result.success) {
const flat = result.error.flatten().fieldErrors
Object.assign(errors, Object.fromEntries(Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])))
return false
}
Object.keys(errors).forEach(k => delete errors[k])
return true
}
async function submit() {
serverError.value = ''
if (!validateAll()) {
await nextTick()
const firstError = document.querySelector('.field-error')
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
return
}
submitting.value = true
try {
await $fetch('/api/submit/reference', {
method: 'POST',
body: {
titre: form.titre,
auteur: form.auteur,
url: form.url || undefined,
description: form.description,
pertinence_rag: form.pertinence_rag || undefined,
hashtags: form.hashtags || undefined,
submitted_by_email: form.submitted_by_email || undefined,
},
})
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) {
const fieldErrors = e.data
Object.entries(fieldErrors).forEach(([k, v]) => { errors[k] = Array.isArray(v) ? v[0] : String(v) })
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
} else {
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
}
} finally {
submitting.value = false
}
}
</script>
<style scoped>
@import './form-styles.css';
</style>
-158
View File
@@ -1,158 +0,0 @@
<template>
<form @submit.prevent="submit" class="proposer-form" novalidate>
<div class="field-group" :class="{ 'field-error': errors.nom }">
<label for="res-nom">Nom du réseau / collectif / ressource <span class="required">*</span></label>
<input id="res-nom" v-model="form.nom" type="text" placeholder="Ex : Collectif des Arpenteurs, Archi'Résilience..." @blur="validateField('nom')" />
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.type_principal }">
<fieldset>
<legend>Type <span class="label-hint">(optionnel)</span></legend>
<div class="radio-group">
<label v-for="t in TYPES" :key="t" class="radio-label" :class="{ active: form.type_principal === t }">
<input type="radio" :value="t" v-model="form.type_principal" name="res-type" />{{ LABELS[t] }}
</label>
</div>
</fieldset>
</div>
<div class="field-group" :class="{ 'field-error': errors.url }">
<label for="res-url">Site web <span class="label-hint">(optionnel)</span></label>
<input id="res-url" v-model="form.url" type="url" placeholder="https://..." @blur="validateField('url')" />
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.description_user }">
<label for="res-desc">Description courte <span class="required">*</span> <span class="label-hint">(50 à 500 caractères)</span></label>
<textarea id="res-desc" v-model="form.description_user" rows="4" placeholder="Présente ce réseau/collectif : son objet, son échelle, ce qui le distingue..." @blur="validateField('description_user')" />
<div class="field-meta">
<span v-if="errors.description_user" class="error-msg" role="alert">{{ errors.description_user }}</span>
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">{{ form.description_user.length }}/500</span>
</div>
</div>
<div class="field-group">
<label for="res-ville">Ville principale <span class="label-hint">(optionnel)</span></label>
<input id="res-ville" v-model="form.localisation_ville" type="text" placeholder="Ex : Paris, Lyon..." />
</div>
<div class="field-group">
<label for="res-projet-lien">Lien vers un projet / réalisation <span class="label-hint">(optionnel)</span></label>
<input id="res-projet-lien" v-model="form.projet_lien" type="url" placeholder="https://..." />
</div>
<div class="field-group">
<label for="res-projet-adresse">Adresse du projet <span class="label-hint">(optionnel)</span></label>
<input id="res-projet-adresse" v-model="form.projet_adresse" type="text" placeholder="Ex : 12 rue des Lilas, 75000 Paris" />
</div>
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
<label for="res-email">Ton email <span class="label-hint">(optionnel — pour le suivi)</span></label>
<input id="res-email" v-model="form.submitted_by_email" type="email" placeholder="ton@email.fr" autocomplete="email" @blur="validateField('submitted_by_email')" />
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">{{ errors.submitted_by_email }}</span>
</div>
<div v-if="serverError" class="server-error" role="alert"><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 { z } from 'zod'
const TYPES = ['agence', 'collectif', 'reseau', 'asso', 'ressource'] as const
const LABELS: Record<string, string> = { agence: 'Agence', collectif: 'Collectif', reseau: 'Réseau', asso: 'Association', ressource: 'Ressource' }
const Schema = z.object({
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
type_principal: z.string().optional(),
url: z.string().url('URL invalide').optional().or(z.literal('')),
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
localisation_ville: z.string().max(100).optional(),
projet_lien: z.string().url('URL invalide').optional().or(z.literal('')),
projet_adresse: z.string().max(200).optional(),
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
})
const emit = defineEmits<{ success: [] }>()
const form = reactive({
nom: '', type_principal: '', url: '', description_user: '',
localisation_ville: '', projet_lien: '', projet_adresse: '', submitted_by_email: '',
})
const errors = reactive<Record<string, string>>({})
const submitting = ref(false)
const serverError = ref('')
function validateField(field: string) {
const partial = Schema.partial()
const result = partial.safeParse({ [field]: (form as any)[field] })
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors
errors[field] = fieldErrors[field]?.[0] ?? ''
} else {
delete errors[field]
}
}
function validateAll(): boolean {
const result = Schema.safeParse(form)
if (!result.success) {
const flat = result.error.flatten().fieldErrors
Object.assign(errors, Object.fromEntries(Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])))
return false
}
Object.keys(errors).forEach(k => delete errors[k])
return true
}
async function submit() {
serverError.value = ''
if (!validateAll()) {
await nextTick()
const firstError = document.querySelector('.field-error')
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
return
}
submitting.value = true
try {
await $fetch('/api/submit', {
method: 'POST',
body: {
submission_type: 'reseau',
nom: form.nom,
type_principal: form.type_principal || undefined,
url: form.url || undefined,
description_user: form.description_user,
localisation_ville: form.localisation_ville || undefined,
projet_lien: form.projet_lien || undefined,
projet_adresse: form.projet_adresse || undefined,
submitted_by_email: form.submitted_by_email || undefined,
},
})
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) {
const fieldErrors = e.data
Object.entries(fieldErrors).forEach(([k, v]) => { errors[k] = Array.isArray(v) ? v[0] : String(v) })
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
} else {
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
}
} finally {
submitting.value = false
}
}
</script>
<style scoped>
@import './form-styles.css';
</style>
+1
View File
@@ -877,6 +877,7 @@ onUnmounted(() => {
/* Labels des structures dans le graphe (D3 injecte les <text>, donc style global) */
.graph-view .graph-struct-label {
fill: var(--nav-text);
opacity: 0.7;
paint-order: stroke;
stroke: var(--nav-bg);
stroke-width: 3px;
+97
View File
@@ -0,0 +1,97 @@
<template>
<div class="hashtag-filter" style="padding: 8px 12px; background: var(--nav-surface);">
<!-- Filtres famille -->
<div style="margin-bottom: 6px;">
<span class="filter-label">FAMILLES</span>
<div class="chips-row">
<span
v-for="fam in FAMILLES"
:key="fam.id"
class="chip"
:style="selectedFamille === fam.id
? `background: ${fam.color}; color: white; font-weight: 600; border: 2px solid ${fam.color};`
: `background: var(--nav-bg-alt); color: ${fam.color}; border: 2px solid ${fam.color}; font-weight: 600;`"
@click="toggleFamille(fam.id)"
>{{ fam.shortLabel }}</span>
</div>
</div>
<!-- Filtres hashtags avec toggle -->
<div>
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
<span class="filter-label">HASHTAGS</span>
<button
@click="hashtagsVisible = !hashtagsVisible"
style="font-size: 0.7rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0;"
>{{ hashtagsVisible ? 'Replier' : 'Afficher (' + props.allHashtags.length + ')' }}</button>
</div>
<div v-if="hashtagsVisible" class="chips-row">
<span
v-for="tag in props.allHashtags"
:key="tag"
class="chip chip-small"
:style="selectedHashtags.includes(tag)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggleHashtag(tag)"
>{{ tag }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const FAMILLES = [
{ id: 1, shortLabel: 'Réemploi', color: '#a85d3e' },
{ id: 2, shortLabel: 'Frugalité', color: '#c4a472' },
{ id: 3, shortLabel: 'Social', color: '#d4a017' },
{ id: 4, shortLabel: 'Collectifs', color: '#5a7a4a' },
{ id: 5, shortLabel: 'Urbanisme', color: '#3d6a8c' },
{ id: 6, shortLabel: 'Recherche', color: '#6b3fa0' },
]
const props = defineProps<{
allHashtags: string[]
selectedHashtags: string[]
selectedFamille: number | null
}>()
const emit = defineEmits<{
'update:selectedHashtags': [v: string[]]
'update:selectedFamille': [v: number | null]
}>()
const hashtagsVisible = ref(false)
function toggleFamille(id: number) {
emit('update:selectedFamille', props.selectedFamille === id ? null : id)
}
function toggleHashtag(tag: string) {
const current = props.selectedHashtags
emit('update:selectedHashtags',
current.includes(tag) ? current.filter(t => t !== tag) : [...current, tag]
)
}
</script>
<style scoped>
.filter-label {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--nav-text-muted);
display: block;
margin-bottom: 4px;
}
.chips-row { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }
.chip {
cursor: pointer;
padding: 3px 10px;
border-radius: 9999px;
font-size: 0.75rem;
transition: all 0.15s;
user-select: none;
}
.chip-small { font-size: 0.7rem; padding: 2px 8px; }
</style>
+1 -1
View File
@@ -31,7 +31,7 @@
<p style="font-size: 0.875rem; line-height: 1.6; opacity: 0.75; margin: 0 0 24px 0;">
Elle ne prétend pas à l'exhaustivité. Elle est un geste politique :
rendre visible ce qui se transforme, comment, par qui, où.
Six familles et des hashtags vous permettent d'explorer.
5 familles et des hashtags vous permettent d'explorer.
</p>
<button
@click="dismiss"
-19
View File
@@ -1,19 +0,0 @@
<template>
<div class="media-tab-backend" style="padding: 2rem; overflow-y: auto;">
<div style="max-width: 640px;">
<h2 style="font-weight: 700; font-size: 1.1rem; margin-bottom: 0.75rem; color: var(--nav-text);">LightRAG backend</h2>
<p style="font-size: 0.9rem; line-height: 1.6; color: var(--nav-text); margin-bottom: 0.5rem;">
Voici l'interface brute du <strong>LightRAG</strong> qui alimente la carte des pensées écologiques.
C'est la "cuisine" du RAG : ingestion de documents, extraction d'entités, relations, requêtes.
</p>
</div>
<iframe
src="https://lightrag.trans-former.fr/"
style="width: 100%; height: 70vh; border: 1px solid var(--nav-bg-alt, #ddd); border-radius: 8px; margin-top: 1.5rem;"
title="LightRAG backend AEP — lecture seule"
sandbox="allow-same-origin allow-scripts"
loading="lazy"
/>
</div>
</template>
-67
View File
@@ -1,67 +0,0 @@
<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);">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.
</p>
</div>
<div class="projets-grid">
<article v-for="p in projets" :key="p.id" class="projet-card">
<img v-if="p.thumb" :src="p.thumb" :alt="p.titre" class="projet-thumb" loading="lazy" />
<div v-else class="projet-thumb projet-thumb--placeholder">📐</div>
<h3 style="font-weight: 600; font-size: 0.95rem; margin: 0.5rem 0 0.25rem; color: var(--nav-text);">{{ p.titre }}</h3>
<p style="font-size: 0.8rem; color: var(--nav-text-muted); margin-bottom: 0.5rem;">
{{ (p.auteurs || []).filter((a: string) => a !== 'Inconnu').join(', ') }}
<template v-if="p.ecole && p.ecole !== 'Inconnu'"> · {{ p.ecole }}</template>
<template v-if="p.annee && p.annee !== 'Inconnu'"> · {{ p.annee }}</template>
</p>
<p style="font-size: 0.875rem; line-height: 1.5; color: var(--nav-text); flex: 1; margin-bottom: 0.75rem;">{{ p.description }}</p>
<a v-if="p.url" :href="p.url" target="_blank" rel="noopener" style="color: var(--nav-primary-solid, #3b6ea5); font-weight: 600; font-size: 0.875rem; text-decoration: none;">
Découvrir →
</a>
<span v-if="p.link_status === 'broken'" style="color: #e67e22; font-size: 0.8rem; display: block; margin-top: 0.25rem;">⚠ Lien d'origine cassé</span>
</article>
</div>
<p style="margin-top: 2rem; font-size: 0.875rem; color: var(--nav-text-muted);">
Tu as un PFE engagé à partager ? <a href="mailto:contact@trans-former.fr" style="color: var(--nav-primary-solid);">Écris-moi</a>.
</p>
</div>
</template>
<script setup lang="ts">
const { data: pfeData } = await useFetch<{ projets: any[] }>('/data/pfe-engages.json')
const projets = computed(() => pfeData.value?.projets ?? [])
</script>
<style scoped>
.projets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.25rem;
}
.projet-card {
border: 1px solid var(--nav-bg-alt, #eee);
border-radius: 10px;
padding: 1rem;
display: flex;
flex-direction: column;
background: var(--nav-surface);
}
.projet-thumb {
width: 100%;
height: 140px;
object-fit: cover;
border-radius: 6px;
background: var(--nav-bg-alt, #f5f5f5);
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
}
</style>
-651
View File
@@ -1,651 +0,0 @@
<template>
<div class="media-visuel">
<!-- Conteneur principal : carte → toolbar → chatbot (ou bonpote/rag-backend) -->
<div class="layout-container">
<!-- SLOT CARTE D3 (mode carte uniquement) -->
<div
v-if="contentView === 'carte'"
class="carte-slot"
:class="[
layoutMode === 'split' ? 'carte-split' : '',
layoutMode === 'carte-full' ? 'carte-full' : '',
layoutMode === 'chatbot-full' ? 'carte-hidden' : '',
]"
:style="layoutMode === 'split' ? { flexBasis: carteFlexBasis } : {}"
style="position: relative;"
>
<ClientOnly>
<CartePensees
ref="cartePenseesRef"
:data="penseesData"
:active="true"
@select-auteur="onSelectAuteur"
@select-ecole="onSelectEcole"
/>
<template #fallback>
<div class="w-full h-full flex items-center justify-center" style="color: var(--nav-text-muted);">
Chargement de la carte...
</div>
</template>
</ClientOnly>
<!-- Overlay PDF FRACAS -->
<div
v-if="showFracasPdf"
class="fracas-overlay"
:style="{ opacity: fracasOpacity / 100 }"
>
<embed
src="/cartes/carte-fracas-bonpote-v2.pdf"
type="application/pdf"
style="width: 100%; height: 100%;"
/>
</div>
</div>
<!-- BARRE DE TOGGLE (entre carte et chatbot, toujours visible) -->
<div class="layout-toggle-bar shrink-0">
<!-- Gauche : contrôles layout (seulement en mode carte) -->
<template v-if="contentView === 'carte'">
<button
@click="layoutMode = 'carte-full'"
:class="{ active: layoutMode === 'carte-full' }"
class="toggle-btn"
title="Carte en plein ecran"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
</svg>
Carte plein ecran
</button>
<button
v-if="layoutMode !== 'split'"
@click="layoutMode = 'split'"
class="toggle-btn"
title="Vue partagee"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="12" x2="21" y2="12"/>
</svg>
Vue partagee
</button>
<button
@click="layoutMode = 'chatbot-full'"
:class="{ active: layoutMode === 'chatbot-full' }"
class="toggle-btn"
title="Chatbot plein ecran"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
Chatbot plein ecran
</button>
</template>
<!-- Droite : contrôles contenu (toujours, indépendants du layoutMode) -->
<div style="margin-left: auto; display: flex; align-items: center; gap: 4px; flex-wrap: wrap;">
<!-- Slider opacité PDF -->
<input
v-if="showFracasPdf && contentView === 'carte'"
type="range"
min="0"
max="100"
v-model.number="fracasOpacity"
class="opacity-slider"
:title="`Opacité ${fracasOpacity}%`"
/>
<!-- CARTE PRINCIPALE -->
<button
@click="showCarte"
:class="{ active: contentView === 'carte' }"
class="toggle-btn"
title="Vue principale : carte D3 + chatbot"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="2"/><path d="M12 2a10 10 0 0 0-7.07 17.07M12 2a10 10 0 0 1 7.07 17.07M3.34 7h17.32M3.34 17h17.32"/>
</svg>
CARTE PRINCIPALE
</button>
<!-- Tickbox PDF + carte des pensées -->
<div class="carte-pensees-ctrl">
<input
type="checkbox"
v-model="showFracasPdf"
class="fracas-check"
title="Superposer la carte FRACAS en PDF"
/>
<button
@click="contentView = 'bonpote'"
:class="{ active: contentView === 'bonpote' }"
class="toggle-btn carte-pensees-btn"
title="Carte des pensées écologiques — référence FRACAS Bonpote V2"
>
📗 carte des pensées écologiques
</button>
</div>
<!-- RAG backend -->
<button
@click="contentView = 'rag-backend'"
:class="{ active: contentView === 'rag-backend' }"
class="toggle-btn"
title="Interface LightRAG backend"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
</svg>
RAG backend
</button>
</div>
</div>
<!-- POIGNEE DRAGGABLE (split uniquement) -->
<div
v-if="contentView === 'carte' && layoutMode === 'split'"
class="split-handle"
@mousedown.prevent="onHandleMousedown"
title="Redimensionner"
>
<span class="split-handle-grip"></span>
</div>
<!-- SLOT CHATBOT (mode carte uniquement) -->
<div
v-if="contentView === 'carte'"
class="chatbot-slot"
:class="[
layoutMode === 'split' ? 'chatbot-split' : '',
layoutMode === 'chatbot-full' ? 'chatbot-full-mode' : '',
layoutMode === 'carte-full' ? 'chatbot-hidden' : '',
]"
:style="layoutMode === 'split' ? { flexBasis: chatbotFlexBasis } : {}"
>
<ClientOnly>
<ChatbotPensees :auteurContext="chatbotAuteur" :inline="true" />
</ClientOnly>
</div>
<!-- VUE BONPOTE -->
<div
v-if="contentView === 'bonpote'"
class="flex-1 overflow-y-auto px-6 py-8"
style="max-width: 680px; margin: 0 auto;"
>
<div class="mb-6">
<p class="text-xs font-bold uppercase tracking-widest mb-2" style="color: var(--nav-text-muted);">Reference editoriale</p>
<h2 class="text-xl font-bold mb-3" style="color: var(--nav-text);">Carte FRACAS des pensees ecologiques</h2>
<p class="text-sm leading-relaxed mb-4" style="color: var(--nav-text);">
FRACAS (Familles, Racines et Arpentages des Courants et Alternatives Solidaires) est une carte des ecoles de pensee ecologique publiee par Bonpote en octobre 2024. Elle reference ~140 auteurs et autrices reparti-es en 10 ecoles de pensee, depuis l'ecosocialisme jusqu'a l'ethique environnementale.
</p>
<p class="text-sm leading-relaxed mb-6" style="color: var(--nav-text);">
Le RAG ATIS est construit sur cette reference : chaque auteur ingere dans la bibliotheque correspond a une entree de la carte FRACAS. Les ecoles de pensee, les positions et les couleurs de notre carte sont transposees 1:1 depuis Bonpote V2.
</p>
<div class="flex flex-col gap-3">
<a href="https://bonpote.com/la-carte-des-pensees-ecologiques/"
target="_blank" rel="noopener"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:opacity-80 transition-opacity"
style="background: var(--nav-primary, #3b6ea5); color: white; font-size: 0.875rem; font-weight: 600; text-decoration: none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Lire l'article Bonpote + carte interactive
</a>
<a href="https://bonpote.com/wp-content/uploads/2024/10/FRACAS_BONPOTE_CARTE_VERSO_V2-OCT2024.pdf"
target="_blank" rel="noopener"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:opacity-80 transition-opacity"
style="background: var(--nav-bg-alt); color: var(--nav-text); font-size: 0.875rem; font-weight: 500; text-decoration: none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Telecharger le poster PDF (recto/verso)
</a>
<button
@click="showCarte"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:opacity-80 transition-opacity text-left"
style="background: var(--nav-bg-alt); color: var(--nav-text); font-size: 0.875rem; font-weight: 500; border: none; cursor: pointer;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
Interroger le RAG ATIS sur ces pensees
</button>
</div>
</div>
<div>
<p class="text-xs font-bold uppercase tracking-widest mb-3" style="color: var(--nav-text-muted);">Les 10 ecoles de pensee (FRACAS V2)</p>
<div class="flex flex-col gap-2">
<div v-for="ecole in (penseesData?.ecoles ?? [])" :key="ecole.id"
class="flex items-start gap-3 px-3 py-2 rounded-lg"
style="background: var(--nav-bg-alt);">
<span class="w-3 h-3 rounded-full shrink-0 mt-1" :style="`background:${ecole.color};`"></span>
<div>
<p class="text-sm font-semibold" style="color: var(--nav-text);">{{ ecole.label }}</p>
<p class="text-xs mt-0.5 leading-relaxed" style="color: var(--nav-text-muted);">{{ ecole.description }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- VUE RAG BACKEND -->
<div
v-if="contentView === 'rag-backend'"
style="flex: 1; overflow: hidden; display: flex; flex-direction: column;"
>
<MediaTabBackend />
</div>
</div>
<!-- Fiche auteur modal -->
<FicheAuteur
:open="ficheOpen"
:auteurId="ficheAuteurId"
:data="penseesData"
@close="ficheOpen = false"
@interroger-rag="onInterrogerRag"
/>
<!-- Fiche ecole modal -->
<FicheEcole
:open="ficheEcoleOpen"
:ecoleId="ficheEcoleId"
:data="penseesData"
@close="ficheEcoleOpen = false"
@select-auteur="onSelectAuteurFromEcole"
@interroger-ecole="onInterrogerEcole"
/>
<!-- Modal info RAG -->
<Teleport to="body">
<Transition name="backdrop">
<div v-if="ragInfoOpen" class="fixed inset-0 z-[2000]" style="background:rgba(26,34,56,0.55);" @click="ragInfoOpen = false" aria-hidden="true" />
</Transition>
<Transition name="modal">
<div v-if="ragInfoOpen" class="fixed z-[2001] left-1/2 flex flex-col"
style="top:50%;transform:translate(-50%,-50%);width:min(580px,94vw);max-height:85vh;background:var(--nav-bg);border-radius:14px;box-shadow:0 16px 64px rgba(26,34,56,0.28);overflow:hidden;"
role="dialog" aria-modal="true" aria-label="À propos du RAG FRACAS">
<div class="flex items-center justify-between px-5 py-4 shrink-0"
style="border-bottom:2px solid var(--nav-bg-alt);background:var(--nav-surface);">
<h2 class="font-bold text-base" style="color:var(--nav-text);">FRACAS - Bibliothèque des pensées écologiques</h2>
<button @click="ragInfoOpen = false" class="ml-3 shrink-0 flex items-center justify-center w-8 h-8 rounded-full hover:opacity-70"
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);" aria-label="Fermer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto px-5 py-4" style="color:var(--nav-text);font-size:0.875rem;line-height:1.6;">
<EchelleTete echelle="XL" compact @navigate="ragInfoOpen = false" />
<p class="mb-3">Une bibliothèque parlante des pensées écologiques, organisée pour aider à créer une pensée complexe et nuancée, à soutenir l'écriture de nouveaux récits, des alternatives concrètes et des projets collectifs.</p>
<p class="mb-4" style="color:var(--nav-text-muted);font-size:0.8rem;">Projet open source, ouvert à toutes et à tous - <a href="https://bonpote.com/la-carte-des-pensees-ecologiques/" target="_blank" rel="noopener" style="text-decoration:underline;">article + carte FRACAS Bonpote V2</a>.</p>
<div class="flex flex-col gap-3">
<div class="p-3 rounded-lg" style="background:var(--nav-bg-alt);">
<p class="font-semibold mb-1" style="font-size:0.8rem;color:var(--nav-text-muted);text-transform:uppercase;letter-spacing:0.05em;">Ce qu'est un RAG</p>
<p>Les textes sont vectorises dans un espace de 662 dimensions - chaque livre devient un nuage de points semantiques. La proximite entre les points capture la proximite entre les idees, pas les mots.</p>
</div>
<div class="p-3 rounded-lg" style="background:var(--nav-bg-alt);">
<p class="font-semibold mb-1" style="font-size:0.8rem;color:var(--nav-text-muted);text-transform:uppercase;letter-spacing:0.05em;">Chunking intelligent</p>
<p>Lors de l'ingestion, nous selectionnons les entites cles (concepts, auteurs, relations entre idees) plutot que de decouper mecaniquement les textes.</p>
</div>
<div class="p-3 rounded-lg" style="background:var(--nav-bg-alt);">
<p class="font-semibold mb-2" style="font-size:0.8rem;color:var(--nav-text-muted);text-transform:uppercase;letter-spacing:0.05em;">Trois couches d'analyse</p>
<div class="flex flex-col gap-1.5">
<div class="flex gap-2"><span class="font-semibold" style="min-width:70px;">Fond</span><span>Les idees, les theses, les arguments - ce qu'on interroge directement.</span></div>
<div class="flex gap-2"><span class="font-semibold" style="min-width:70px;">Forme</span><span>Les modeles narratifs, la rhetorique, la construction argumentative.</span></div>
<div class="flex gap-2"><span class="font-semibold" style="min-width:70px;">Structure</span><span>L'architecture des livres - comment les auteurs construisent leur pensee.</span></div>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
interface EcoleData { id: string; label: string; description: string; color: string; x_hint: number; y_hint: number }
interface LivreRag { slug: string; titre: string; annee: number; couches: string[] }
interface AuteurData { id: string; nom: string; dates: string; ecoles: string[]; ecole_principale: string; livres_rag: LivreRag[]; theses_cles: string[]; bio_courte: string }
interface PenseesData { meta: any; ecoles: EcoleData[]; auteurs: AuteurData[] }
type LayoutMode = 'split' | 'carte-full' | 'chatbot-full'
type ContentView = 'carte' | 'bonpote' | 'rag-backend'
const LAYOUT_KEY = 'media-layout-mode'
const CONTENT_KEY = 'media-content-view'
const SPLIT_RATIO_KEY = 'media-split-ratio'
const DEFAULT_SPLIT_RATIO = 0.66
const ficheOpen = ref(false)
const ficheAuteurId = ref<string | null>(null)
const ficheEcoleOpen = ref(false)
const ficheEcoleId = ref<string | null>(null)
const ragInfoOpen = ref(false)
const chatbotAuteur = ref<string | null>(null)
const layoutMode = ref<LayoutMode>('split')
const contentView = ref<ContentView>('carte')
const cartePenseesRef = ref<{ triggerResize: () => void } | null>(null)
const showFracasPdf = ref(false)
const fracasOpacity = ref(60)
const penseesData = ref<PenseesData | null>(null)
const splitRatio = ref(DEFAULT_SPLIT_RATIO)
const carteFlexBasis = computed(() => `${splitRatio.value * 100}%`)
const chatbotFlexBasis = computed(() => `${(1 - splitRatio.value) * 100}%`)
let dragStartY = 0
let dragStartRatio = DEFAULT_SPLIT_RATIO
let containerHeight = 0
function showCarte() {
contentView.value = 'carte'
layoutMode.value = 'split'
if (typeof window !== 'undefined') {
localStorage.setItem(CONTENT_KEY, 'carte')
localStorage.setItem(LAYOUT_KEY, 'split')
}
nextTick(() => cartePenseesRef.value?.triggerResize())
}
function onHandleMousedown(e: MouseEvent) {
dragStartY = e.clientY
dragStartRatio = splitRatio.value
const container = (e.target as HTMLElement)?.closest('.layout-container') as HTMLElement | null
containerHeight = container ? container.clientHeight : window.innerHeight
window.addEventListener('mousemove', onHandleMousemove)
window.addEventListener('mouseup', onHandleMouseup)
}
function onHandleMousemove(e: MouseEvent) {
const delta = e.clientY - dragStartY
const newRatio = dragStartRatio + delta / containerHeight
splitRatio.value = Math.min(0.80, Math.max(0.20, newRatio))
}
function onHandleMouseup() {
window.removeEventListener('mousemove', onHandleMousemove)
window.removeEventListener('mouseup', onHandleMouseup)
if (typeof window !== 'undefined') localStorage.setItem(SPLIT_RATIO_KEY, String(splitRatio.value))
cartePenseesRef.value?.triggerResize()
}
onMounted(async () => {
if (typeof window !== 'undefined') {
const savedLayout = localStorage.getItem(LAYOUT_KEY) as LayoutMode | null
if (savedLayout && (['split', 'carte-full', 'chatbot-full'] as string[]).includes(savedLayout)) {
layoutMode.value = savedLayout
}
const savedContent = localStorage.getItem(CONTENT_KEY) as ContentView | null
if (savedContent && (['carte', 'bonpote', 'rag-backend'] as string[]).includes(savedContent)) {
contentView.value = savedContent
}
const savedRatio = parseFloat(localStorage.getItem(SPLIT_RATIO_KEY) ?? '')
if (!isNaN(savedRatio) && savedRatio >= 0.20 && savedRatio <= 0.80) {
splitRatio.value = savedRatio
}
if (!localStorage.getItem('rag-fracas-info-seen')) {
ragInfoOpen.value = true
localStorage.setItem('rag-fracas-info-seen', '1')
}
}
try {
penseesData.value = await $fetch<PenseesData>('/data/auteurs-pensees.json')
} catch (e) {
console.error('Erreur chargement auteurs-pensees.json', e)
}
})
watch(layoutMode, (v) => {
if (typeof window !== 'undefined') localStorage.setItem(LAYOUT_KEY, v)
if (v === 'split' || v === 'carte-full') {
setTimeout(() => cartePenseesRef.value?.triggerResize(), 350)
}
})
watch(contentView, (v) => {
if (typeof window !== 'undefined') localStorage.setItem(CONTENT_KEY, v)
})
function onSelectAuteur(id: string) {
ficheAuteurId.value = id
ficheOpen.value = true
chatbotAuteur.value = null
}
function onSelectEcole(id: string) {
ficheEcoleId.value = id
ficheEcoleOpen.value = true
}
function onSelectAuteurFromEcole(auteurId: string) {
ficheEcoleOpen.value = false
onSelectAuteur(auteurId)
}
function onInterrogerEcole(ecoleId: string) {
ficheEcoleOpen.value = false
const ecole = penseesData.value?.ecoles.find(e => e.id === ecoleId)
chatbotAuteur.value = ecole?.label ?? null
if (contentView.value !== 'carte') showCarte()
else if (layoutMode.value === 'carte-full') layoutMode.value = 'split'
}
function onInterrogerRag(auteurId: string) {
ficheOpen.value = false
const auteur = penseesData.value?.auteurs.find(a => a.id === auteurId)
chatbotAuteur.value = auteur?.nom ?? null
if (contentView.value !== 'carte') showCarte()
else if (layoutMode.value === 'carte-full') layoutMode.value = 'split'
}
</script>
<style scoped>
.media-visuel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.layout-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
/* --- Slot carte --- */
.carte-slot {
overflow: hidden;
position: relative;
transition: opacity 0.2s ease;
}
.carte-split {
flex: 0 0 66%;
min-height: 0;
opacity: 1;
}
.carte-full {
flex: 1 1 100%;
min-height: 0;
opacity: 1;
}
.carte-hidden {
flex: 0 0 0;
height: 0;
opacity: 0;
overflow: hidden;
}
/* --- Overlay PDF FRACAS --- */
.fracas-overlay {
position: absolute;
inset: 0;
z-index: 50;
pointer-events: none;
}
/* --- Barre de toggle (entre carte et chatbot) --- */
.layout-toggle-bar {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
background: var(--nav-bg);
border-top: 1px solid rgba(180, 170, 160, 0.22);
border-bottom: 1px solid rgba(180, 170, 160, 0.22);
min-height: 38px;
flex-wrap: wrap;
}
.toggle-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
background: var(--nav-bg-alt);
color: var(--nav-text-muted);
border: 1px solid transparent;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.toggle-btn:hover {
background: var(--nav-surface);
color: var(--nav-text);
}
.toggle-btn.active {
background: var(--nav-primary);
color: var(--nav-text-on-primary);
border-color: var(--nav-primary);
}
/* --- Contrôle fusionné carte des pensées --- */
.carte-pensees-ctrl {
display: inline-flex;
align-items: center;
gap: 0;
border-radius: 6px;
overflow: hidden;
border: 1px solid rgba(180, 170, 160, 0.3);
}
.fracas-check {
margin: 0 2px 0 7px;
cursor: pointer;
accent-color: var(--nav-primary, #3b6ea5);
}
.carte-pensees-btn {
border-radius: 0;
border: none;
}
.opacity-slider {
width: 80px;
cursor: pointer;
accent-color: var(--nav-primary, #3b6ea5);
}
/* --- Poignee draggable --- */
.split-handle {
flex-shrink: 0;
height: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: row-resize;
background: transparent;
position: relative;
z-index: 10;
user-select: none;
}
.split-handle:hover {
background: rgba(180, 170, 160, 0.18);
}
.split-handle-grip {
display: block;
width: 32px;
height: 4px;
border-radius: 2px;
background: repeating-linear-gradient(
to bottom,
rgba(160, 150, 140, 0.55) 0px,
rgba(160, 150, 140, 0.55) 1px,
transparent 1px,
transparent 3px
);
}
@media (max-width: 767px) {
.split-handle { display: none; }
}
/* --- Slot chatbot --- */
.chatbot-slot {
overflow: hidden;
position: relative;
transition: opacity 0.2s ease;
border-top: 1px solid rgba(180, 170, 160, 0.28);
}
.chatbot-split {
flex: 1 1 0;
min-height: 120px;
opacity: 1;
}
.chatbot-full-mode {
flex: 1 1 100%;
min-height: 0;
opacity: 1;
}
.chatbot-hidden {
flex: 0 0 0;
height: 0;
opacity: 0;
overflow: hidden;
}
/* --- Transitions modal RAG info --- */
.backdrop-enter-active,.backdrop-leave-active { transition: opacity 0.2s; }
.backdrop-enter-from,.backdrop-leave-to { opacity: 0; }
.modal-enter-active { transition: opacity 0.2s, transform 0.22s cubic-bezier(0.34,1.56,0.64,1); }
.modal-leave-active { transition: opacity 0.18s, transform 0.18s ease-in; }
.modal-enter-from { opacity: 0; transform: translate(-50%,-48%) scale(0.94); }
.modal-leave-to { opacity: 0; transform: translate(-50%,-48%) scale(0.96); }
/* --- Mobile --- */
@media (max-width: 767px) {
.carte-split {
flex: 0 0 60vh;
height: 60vh;
}
.chatbot-split {
flex: 1 1 0;
min-height: 120px;
}
.toggle-btn {
font-size: 0.7rem;
padding: 3px 7px;
}
}
</style>
-5
View File
@@ -32,7 +32,6 @@
<div class="mission-body">
<h2 id="mission-title" class="mission-title">{{ title }}</h2>
<EchelleTete v-if="echelle" :echelle="echelle" compact @navigate="close" />
<slot>
<p class="mission-text">
L'architecture est l'une des professions les plus complexes qui soit ; elle croise droit, technique, esthétique, économie, social, écologie — tout à la fois, tout simultanément, souvent sans filet. Paradoxalement, c'est aussi l'une des moins structurées sur le plan de l'entraide&nbsp;: peu de transmission horizontale, beaucoup d'isolement, une culture du chacun-pour-soi héritée d'une formation qui prépare à la compétition plus qu'à la coopération. On sort de l'école seul·e. On s'installe seul·e. On réinvente ce que d'autres ont déjà traversé.
@@ -53,15 +52,11 @@
</template>
<script setup lang="ts">
import type { EchelleId } from '~/utils/echelles'
const props = withDefaults(defineProps<{
modelValue: boolean
title?: string
ctaLabel?: string
storageKey?: string
/** Tête d'échelle du deck (S / M / XL) affichée sous le titre. Absente sur /agences tant que l'arbitrage n° 1 n'est pas rendu. */
echelle?: EchelleId
}>(), {
title: "L'écosystème d'entraide architecte",
ctaLabel: 'Explorer la carte',
+218
View File
@@ -0,0 +1,218 @@
<!--
MobileSheet — Bottom sheet swipable 3 états (collapsed / half / full)
Pattern Google Maps mobile
États :
- collapsed : juste la poignée + compteur (~56px)
- half : ~50dvh (état initial)
- full : ~92dvh (plein écran)
Déclenché par touch/drag sur la poignée
-->
<template>
<div
class="mobile-sheet"
:class="`mobile-sheet--${state}`"
:style="{ transform: `translateY(${dragOffset}px)` }"
>
<!-- Poignée drag -->
<div
class="sheet-handle-area"
@touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove"
@touchend="onTouchEnd"
>
<div class="sheet-handle-bar" />
</div>
<!-- Compteur compact (toujours visible) -->
<div class="sheet-header" @click="onHeaderClick">
<span class="sheet-counter">
<span v-if="pending">Chargement…</span>
<span v-else>{{ resultCount }} fiche{{ resultCount > 1 ? 's' : '' }}</span>
</span>
<svg
width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round" aria-hidden="true"
class="sheet-chevron"
:class="{ 'sheet-chevron--up': state !== 'collapsed' }"
style="color: var(--nav-text-muted); flex-shrink: 0;"
>
<polyline points="18 15 12 9 6 15"/>
</svg>
</div>
<!-- Contenu scrollable (caché si collapsed) -->
<div
v-show="state !== 'collapsed'"
ref="contentEl"
class="sheet-content"
>
<slot />
</div>
</div>
</template>
<script setup lang="ts">
type SheetState = 'collapsed' | 'half' | 'full'
const props = defineProps<{
resultCount: number
pending?: boolean
}>()
const emit = defineEmits<{
'state-change': [state: SheetState]
}>()
const state = ref<SheetState>('half')
const dragOffset = ref(0)
const contentEl = ref<HTMLElement | null>(null)
let touchStartY = 0
let touchCurrentY = 0
let isDragging = false
// Cycle états au clic header
function onHeaderClick() {
if (state.value === 'collapsed') {
setSheetState('half')
} else if (state.value === 'half') {
setSheetState('full')
} else {
setSheetState('collapsed')
}
}
function setSheetState(next: SheetState) {
state.value = next
dragOffset.value = 0
emit('state-change', next)
}
// Touch handlers
function onTouchStart(e: TouchEvent) {
touchStartY = e.touches[0].clientY
touchCurrentY = touchStartY
isDragging = true
dragOffset.value = 0
}
function onTouchMove(e: TouchEvent) {
if (!isDragging) return
touchCurrentY = e.touches[0].clientY
const delta = touchCurrentY - touchStartY
// Limiter le drag visuellement (résistance)
dragOffset.value = delta * 0.6
}
function onTouchEnd() {
if (!isDragging) return
isDragging = false
const delta = touchCurrentY - touchStartY
const threshold = 60 // px minimum pour changer d'état
if (delta > threshold) {
// Swipe vers le bas → état inférieur
if (state.value === 'full') setSheetState('half')
else if (state.value === 'half') setSheetState('collapsed')
else setSheetState('collapsed')
} else if (delta < -threshold) {
// Swipe vers le haut → état supérieur
if (state.value === 'collapsed') setSheetState('half')
else if (state.value === 'half') setSheetState('full')
else setSheetState('full')
} else {
// Snap back
dragOffset.value = 0
}
// Transition smooth au relâchement
dragOffset.value = 0
}
</script>
<style scoped>
.mobile-sheet {
position: fixed;
inset-x: 0;
bottom: 0;
z-index: 500;
background: var(--nav-surface);
border-radius: 16px 16px 0 0;
box-shadow: 0 -4px 24px rgba(26, 34, 56, 0.14);
display: flex;
flex-direction: column;
transition: height 0.3s cubic-bezier(0.32, 0.72, 0, 1), transform 0.05s linear;
will-change: height, transform;
overflow: hidden;
}
/* ── États hauteur ───────────────────────────────────────────────────── */
.mobile-sheet--collapsed {
height: 56px;
}
.mobile-sheet--half {
height: 50dvh;
min-height: 200px;
}
.mobile-sheet--full {
height: 92dvh;
}
/* ── Poignée ─────────────────────────────────────────────────────────── */
.sheet-handle-area {
display: flex;
justify-content: center;
padding: 10px 0 4px;
cursor: grab;
flex-shrink: 0;
}
.sheet-handle-area:active {
cursor: grabbing;
}
.sheet-handle-bar {
width: 36px;
height: 4px;
border-radius: 2px;
background: var(--nav-bg-alt);
}
/* ── Header compteur ─────────────────────────────────────────────────── */
.sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px 8px;
cursor: pointer;
flex-shrink: 0;
gap: 8px;
}
.sheet-counter {
font-size: 0.8rem;
font-weight: 600;
color: var(--nav-text-muted);
}
.sheet-chevron {
transition: transform 0.2s ease;
}
.sheet-chevron--up {
transform: rotate(180deg);
}
/* ── Contenu scrollable ──────────────────────────────────────────────── */
.sheet-content {
flex: 1;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
</style>
-148
View File
@@ -1,148 +0,0 @@
<!--
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>
+18 -68
View File
@@ -5,7 +5,6 @@
</template>
<script setup lang="ts">
const baseTiles = useBaseTiles()
import type { Map, Marker, DivIcon } from 'leaflet'
interface Org {
@@ -24,16 +23,10 @@ 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)
@@ -41,15 +34,14 @@ 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, isSurvol = false): DivIcon {
function createPinIcon(isPrioritaire: boolean, isSelected = false): DivIcon {
const L = (window as any).L
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'
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'
return L.divIcon({
className: '',
@@ -83,7 +75,6 @@ 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, {
@@ -99,10 +90,12 @@ async function initMap() {
// Fond de carte CartoDB Positron (light ou dark selon theme)
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
const tileUrl = baseTiles.url(isDark)
const tileUrl = isDark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
tileLayerInstance = L.tileLayer(tileUrl, {
attribution: baseTiles.attribution,
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
maxZoom: 19,
})
tileLayerInstance.addTo(mapInstance!)
@@ -133,14 +126,6 @@ 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) {
@@ -157,7 +142,10 @@ function updateMarkers(L?: any) {
)
orgsWithCoords.forEach((org) => {
const marker = leaflet.marker([org.latitude!, org.longitude!], { icon: iconeDe(org), title: org.nom })
const isSelected = org.Id === props.selectedId
const icon = createPinIcon(!!org.prioritaire, isSelected)
const marker = leaflet.marker([org.latitude!, org.longitude!], { icon })
const fonctions = org.tags_fonction
? org.tags_fonction.split(',').map((f: string) => f.trim()).filter(Boolean).slice(0, 2).join(', ')
@@ -176,8 +164,6 @@ 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)
@@ -204,7 +190,7 @@ watch(
const oldMarker = markers.get(oldId)
const oldOrg = props.orgs.find(o => o.Id === oldId)
if (oldMarker && oldOrg) {
oldMarker.setIcon(iconeDe(oldOrg))
oldMarker.setIcon(createPinIcon(!!oldOrg.prioritaire, false))
}
}
// Mettre en avant le nouveau marker
@@ -212,7 +198,7 @@ watch(
const newMarker = markers.get(newId)
const newOrg = props.orgs.find(o => o.Id === newId)
if (newMarker && newOrg) {
newMarker.setIcon(iconeDe(newOrg))
newMarker.setIcon(createPinIcon(!!newOrg.prioritaire, true))
// Centrer si visible
const latLng = newMarker.getLatLng()
mapInstance.panTo(latLng, { animate: true })
@@ -221,33 +207,14 @@ 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
const L = (window as any).L
if (!L) return
const url = baseTiles.url(dark)
const url = dark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
tileLayerInstance.setUrl(url)
}
@@ -271,26 +238,9 @@ 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>
+17 -67
View File
@@ -5,7 +5,6 @@
</template>
<script setup lang="ts">
const baseTiles = useBaseTiles()
import type { Map, Marker, DivIcon } from 'leaflet'
import type { StructureV2 } from '~/types/structure-v2'
@@ -21,16 +20,10 @@ 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)
@@ -38,18 +31,17 @@ 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, isSurvol = false): DivIcon {
function createPinIcon(famille: number, isSelected = false): DivIcon {
const L = (window as any).L
const bg = getFamilleColor(famille)
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'
const size = isSelected ? 20 : 14
const border = isSelected ? 'white' : 'rgba(255,255,255,0.7)'
const shadow = isSelected ? `0 0 0 4px ${bg}55` : 'none'
return L.divIcon({
className: '',
@@ -83,7 +75,6 @@ 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, {
@@ -96,10 +87,12 @@ async function initMap() {
})
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
const tileUrl = baseTiles.url(isDark)
const tileUrl = isDark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
tileLayerInstance = L.tileLayer(tileUrl, {
attribution: baseTiles.attribution,
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
maxZoom: 19,
})
tileLayerInstance.addTo(mapInstance!)
@@ -129,14 +122,6 @@ 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) {
@@ -152,7 +137,10 @@ function updateMarkers(L?: any) {
)
structuresWithCoords.forEach((structure) => {
const marker = leaflet.marker([structure.latitude!, structure.longitude!], { icon: iconeDe(structure), title: structure.nom })
const isSelected = structure.id === props.selectedId
const icon = createPinIcon(structure.famille_principale, isSelected)
const marker = leaflet.marker([structure.latitude!, structure.longitude!], { icon })
const hashtagsHtml = structure.hashtags.slice(0, 2)
.map(h => `<span style="font-size:10px;color:var(--nav-text-muted);">${h}</span>`)
@@ -178,8 +166,6 @@ 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)
@@ -208,14 +194,14 @@ watch(
const oldMarker = markers.get(oldId)
const oldStructure = props.structures.find(s => s.id === oldId)
if (oldMarker && oldStructure) {
oldMarker.setIcon(iconeDe(oldStructure))
oldMarker.setIcon(createPinIcon(oldStructure.famille_principale, false))
}
}
if (newId != null) {
const newMarker = markers.get(newId)
const newStructure = props.structures.find(s => s.id === newId)
if (newMarker && newStructure) {
newMarker.setIcon(iconeDe(newStructure))
newMarker.setIcon(createPinIcon(newStructure.famille_principale, true))
const latLng = newMarker.getLatLng()
mapInstance.panTo(latLng, { animate: true })
}
@@ -223,32 +209,13 @@ 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
if (!L) return
const url = baseTiles.url(dark)
const url = dark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
tileLayerInstance.setUrl(url)
}
@@ -268,26 +235,9 @@ onMounted(() => {
onUnmounted(() => {
document.removeEventListener('nav-v2-select', onNavV2Select as EventListener)
themeObserver?.disconnect()
resizeObserver?.disconnect()
if (mapInstance) {
mapInstance.remove()
mapInstance = null
}
})
// « Voir sur la carte » (B12) : la carte vient peut-être de s'afficher (Fiches → Mixte) ;
// on attend sa taille réelle, puis on sort l'épingle de son cluster.
watch(
() => props.centrage?.n,
() => {
const c = props.centrage
if (!c) return
setTimeout(() => {
if (!mapInstance || !clusterGroup) return
mapInstance.invalidateSize({ pan: false })
const m = markers.get(c.id)
if (m) clusterGroup.zoomToShowLayer(m, () => mapInstance?.panTo(m.getLatLng(), { animate: true }))
}, 260)
},
)
</script>
+257
View File
@@ -0,0 +1,257 @@
<template>
<aside
class="flex flex-col h-full overflow-hidden"
style="background: var(--nav-surface); border-right: 1px solid var(--nav-bg-alt);"
>
<!-- ═══════════════════════════════════ BARRE DE RECHERCHE (tout en haut) -->
<div
class="shrink-0 px-4 pt-4 pb-3 border-b"
style="border-color: var(--nav-bg-alt);"
>
<label class="sidebar-search-label" aria-label="Rechercher une organisation">
<svg
width="15" height="15" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true"
class="sidebar-search-icon"
>
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
ref="searchInputEl"
:value="search"
type="search"
placeholder="Rechercher une organisation…"
class="sidebar-search-input"
autocomplete="off"
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
/>
<button
v-if="search"
type="button"
class="sidebar-search-clear"
aria-label="Effacer la recherche"
@click.stop="emit('update:search', '')"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</label>
</div>
<!-- ═══════════════════════════════════ FILTRES (haut, compact) -->
<div
class="shrink-0 px-4 pt-3 pb-3 space-y-4 border-b"
style="border-color: var(--nav-bg-alt);"
>
<!-- Échelle (checkbox compactes) -->
<EchelleFilter
:modelValue="echelle"
:counts="echelleCount"
@update:modelValue="emit('update:echelle', $event)"
/>
<!-- Fonctions (checkbox compactes) -->
<FonctionFilter
:modelValue="fonctions"
:counts="fonctionCount"
@update:modelValue="emit('update:fonctions', $event)"
/>
</div>
<!-- ═══════════════════════════════════ LISTE FICHES (milieu, scrollable) -->
<div class="flex-1 flex flex-col min-h-0">
<!-- Header liste -->
<div
class="shrink-0 flex items-center justify-between px-4 py-2 border-b"
style="border-color: var(--nav-bg-alt);"
>
<span class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">
{{ resultCount }} résultat{{ resultCount > 1 ? 's' : '' }}
</span>
<button
v-if="hasActiveFilters"
@click="emit('reset-filters')"
class="text-xs underline hover:opacity-70"
style="color: var(--nav-text-muted);"
>
Effacer les filtres
</button>
</div>
<!-- Liste scrollable -->
<div class="flex-1 overflow-y-auto px-3 py-2 space-y-1.5">
<div
v-if="pending"
class="flex items-center justify-center py-8"
style="color: var(--nav-text-muted);"
>
Chargement…
</div>
<div v-else-if="orgs.length === 0" class="text-center py-8">
<p class="text-xs" style="color: var(--nav-text-muted);">Aucun résultat</p>
</div>
<!-- Card fiche compacte -->
<div
v-for="org in orgs"
:key="org.Id"
class="rounded-lg px-3 py-2 cursor-pointer transition-all"
:style="selectedId === org.Id
? 'background: var(--nav-bg-alt); border-left: 3px solid var(--nav-accent); padding-left: 9px;'
: 'background: var(--nav-bg); border-left: 3px solid transparent; padding-left: 9px;'"
@click="emit('select-org', org.Id)"
@mouseenter="emit('hover-org', org.Id)"
@mouseleave="emit('hover-org', null)"
>
<div class="flex items-start justify-between gap-1.5">
<span
class="font-semibold text-sm leading-snug"
style="color: var(--nav-text);"
>{{ org.nom }}</span>
<span
v-if="org.echelle"
class="shrink-0 px-1.5 py-0.5 rounded-full text-xs"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted); margin-top: 1px;"
>{{ org.echelle }}</span>
</div>
<div v-if="orgFonctions(org).length" class="mt-1 flex flex-wrap gap-1">
<span
v-for="fn in orgFonctions(org)"
:key="fn"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted); border: 1px solid var(--nav-bg-alt); letter-spacing: 0.01em;"
>{{ fn }}</span>
</div>
<div v-if="org.localisation_ville" class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">
{{ org.localisation_ville }}
</div>
</div>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
interface Org {
Id: number
nom: string
echelle?: string
tags_fonction?: string
territoire?: string
localisation_ville?: string
latitude?: number | null
longitude?: number | null
prioritaire?: boolean
}
const props = defineProps<{
search: string
modeValue: string // 'metropole' | 'outremer'
echelle: string[]
fonctions: string[]
territoire: string | null
echelleCount: Record<string, number>
fonctionCount: Record<string, number>
territoireCount: Record<string, number>
resultCount: number
orgs: Org[] // fiches filtrées à afficher dans la liste
selectedId: number | null
hasActiveFilters: boolean
pending?: boolean
}>()
const emit = defineEmits<{
'update:search': [value: string]
'update:mode': [value: string]
'update:echelle': [value: string[]]
'update:fonctions': [value: string[]]
'update:territoire': [value: string | null]
'select-org': [id: number]
'hover-org': [id: number | null]
'reset-filters': []
}>()
const searchInputEl = ref<HTMLInputElement | null>(null)
function orgFonctions(org: Org): string[] {
return (org.tags_fonction ?? '').split(',').map(f => f.trim()).filter(Boolean).slice(0, 2)
}
</script>
<style scoped>
.sidebar-search-label {
display: flex;
align-items: center;
gap: 8px;
border: 1.5px solid var(--nav-bg-alt);
border-radius: 10px;
background: var(--nav-bg);
padding: 7px 10px;
cursor: text;
width: 100%;
box-sizing: border-box;
transition: border-color 0.2s;
}
.sidebar-search-label:focus-within {
border-color: var(--nav-primary);
background: var(--nav-surface);
}
.sidebar-search-icon {
color: var(--nav-text-muted);
flex-shrink: 0;
transition: color 0.2s;
}
.sidebar-search-label:focus-within .sidebar-search-icon {
color: var(--nav-primary-solid);
}
.sidebar-search-input {
border: none;
outline: none;
background: transparent;
color: var(--nav-text);
font-size: 13px;
width: 100%;
min-width: 0;
font-family: var(--nav-font);
}
.sidebar-search-input::placeholder {
color: var(--nav-text-muted);
}
.sidebar-search-input::-webkit-search-cancel-button {
display: none;
}
.sidebar-search-clear {
display: flex;
align-items: center;
justify-content: center;
color: var(--nav-text-muted);
flex-shrink: 0;
padding: 2px;
border-radius: 50%;
transition: color 0.15s, background 0.15s;
background: transparent;
border: none;
cursor: pointer;
}
.sidebar-search-clear:hover {
color: var(--nav-text);
background: var(--nav-bg-alt);
}
</style>
-313
View File
@@ -1,313 +0,0 @@
<!--
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>
+45
View File
@@ -0,0 +1,45 @@
<template>
<NuxtLink
:to="`/fiche/${org.Id}`"
class="block bg-white rounded-xl shadow-sm border border-warm-200 hover:shadow-md hover:border-sage-300 transition-all duration-200 p-5"
>
<div class="flex items-start justify-between gap-3 mb-2">
<h2 class="font-semibold text-gray-900 text-base leading-snug">{{ org.nom }}</h2>
<TypeBadge v-if="org.type_org" :type="org.type_org" class="shrink-0 mt-0.5" />
</div>
<p class="text-gray-600 text-sm leading-relaxed mb-3 line-clamp-2">
{{ org.description }}
</p>
<div v-if="tags.length" class="flex flex-wrap gap-1.5">
<TagBadge
v-for="tag in tags"
:key="tag"
:tag="tag"
@click="$emit('filter-tag', tag)"
/>
</div>
</NuxtLink>
</template>
<script setup lang="ts">
const props = defineProps<{
org: {
Id: number
nom: string
type_org?: string
description?: string
tags?: string
lien?: string
}
}>()
defineEmits<{ 'filter-tag': [tag: string] }>()
const tags = computed(() =>
props.org.tags
? props.org.tags.split(',').map((t) => t.trim()).filter(Boolean)
: []
)
</script>
-150
View File
@@ -1,150 +0,0 @@
<template>
<component
:is="url ? 'a' : 'div'"
v-bind="url ? { href: url, target: '_blank', rel: 'noopener noreferrer' } : {}"
class="outil-card"
:class="{ 'outil-card--link': !!url, 'outil-card--disabled': !url }"
>
<div class="outil-card__header">
<span class="outil-card__icon" aria-hidden="true">{{ icon }}</span>
<span :class="['outil-card__badge', `outil-card__badge--${tag}`]">{{ tagLabel }}</span>
</div>
<h3 class="outil-card__titre">{{ titre }}</h3>
<p class="outil-card__desc">{{ description }}</p>
<span v-if="cta && url" class="outil-card__cta">{{ cta }}</span>
<span v-else-if="!url" class="outil-card__cta outil-card__cta--disabled">Bientôt disponible</span>
</component>
</template>
<script setup lang="ts">
const props = defineProps<{
icon?: string
titre: string
url?: string | null
description?: string
cta?: string
tag?: string
}>()
const tagLabels: Record<string, string> = {
'outil-aep': 'Outil AEP',
'inspiration-externe': 'Inspiration',
'disponible': 'Disponible',
'recommande': 'Recommandé',
'a-venir': 'À venir',
}
const tagLabel = computed(() => props.tag ? (tagLabels[props.tag] ?? props.tag) : '')
</script>
<style scoped>
.outil-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem 1.25rem;
border-radius: 10px;
border: 1px solid var(--nav-bg-alt);
background: var(--nav-surface);
text-decoration: none;
color: var(--nav-text);
transition: box-shadow 0.15s, border-color 0.15s;
}
.outil-card--link:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
border-color: var(--nav-primary-solid);
}
.outil-card--disabled {
opacity: 0.65;
}
.outil-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.outil-card__icon {
font-size: 1.3rem;
line-height: 1;
}
.outil-card__badge {
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 7px;
border-radius: 999px;
}
.outil-card__badge--outil-aep {
background: #d1fae5;
color: #065f46;
}
.outil-card__badge--inspiration-externe {
background: #fef3c7;
color: #92400e;
}
.outil-card__badge--disponible {
background: #d1fae5;
color: #065f46;
}
.outil-card__badge--recommande {
background: #dbeafe;
color: #1e40af;
}
.outil-card__badge--a-venir {
background: var(--nav-bg-alt);
color: var(--nav-text-muted);
}
.outil-card__titre {
font-size: 0.9rem;
font-weight: 600;
color: var(--nav-text);
margin: 0;
line-height: 1.35;
}
.outil-card__desc {
font-size: 0.82rem;
color: var(--nav-text-muted);
margin: 0;
line-height: 1.5;
}
.outil-card__cta {
font-size: 0.78rem;
font-weight: 600;
color: var(--nav-primary-solid);
margin-top: 0.25rem;
}
.outil-card__cta--disabled {
color: var(--nav-text-muted);
font-weight: 400;
font-style: italic;
}
/* Dark mode badge overrides */
:global(.dark) .outil-card__badge--outil-aep {
background: #064e3b;
color: #a7f3d0;
}
:global(.dark) .outil-card__badge--inspiration-externe {
background: #78350f;
color: #fde68a;
}
:global(.dark) .outil-card__badge--disponible {
background: #064e3b;
color: #a7f3d0;
}
:global(.dark) .outil-card__badge--recommande {
background: #1e3a5f;
color: #93c5fd;
}
</style>
+4 -3
View File
@@ -38,7 +38,6 @@
</template>
<script setup lang="ts">
const baseTiles = useBaseTiles()
import type { Map as LeafletMap, TileLayer } from 'leaflet'
interface Org {
@@ -112,7 +111,9 @@ function createPinIcon(L: any, isPrioritaire: boolean, isSelected = false) {
}
function getTileUrl(dark: boolean) {
return baseTiles.url(dark)
return dark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
}
async function initSingleMap(domName: string) {
@@ -131,7 +132,7 @@ async function initSingleMap(domName: string) {
touchZoom: true, keyboard: false,
})
const tileLayer = L.tileLayer(getTileUrl(isDark), {
attribution: baseTiles.attribution, maxZoom: 19,
attribution: '© OpenStreetMap contributors © CARTO', maxZoom: 19,
})
tileLayer.addTo(map)
tileLayers[domName] = tileLayer as unknown as TileLayer
-156
View File
@@ -1,156 +0,0 @@
<template>
<component
:is="url ? 'a' : 'div'"
v-bind="url ? { href: url, target: '_blank', rel: 'noopener noreferrer' } : {}"
class="simu-feature"
:class="{ 'simu-feature--link': !!url }"
>
<div class="simu-feature__inner">
<div class="simu-feature__left">
<span class="simu-feature__icon" aria-hidden="true">{{ icon }}</span>
<div class="simu-feature__body">
<div class="simu-feature__header">
<h3 class="simu-feature__titre">{{ titre }}</h3>
<span v-if="tag" :class="['simu-feature__badge', `simu-feature__badge--${tag}`]">{{ tagLabel }}</span>
</div>
<p class="simu-feature__desc">{{ description }}</p>
</div>
</div>
<span v-if="cta && url" class="simu-feature__cta">{{ cta }}</span>
</div>
</component>
</template>
<script setup lang="ts">
const props = defineProps<{
icon?: string
titre: string
url?: string | null
description?: string
cta?: string
tag?: string
}>()
const tagLabels: Record<string, string> = {
'outil-aep': 'Outil AEP',
'inspiration-externe': 'Inspiration externe',
'disponible': 'Disponible',
'recommande': 'Recommandé',
'a-venir': 'À venir',
}
const tagLabel = computed(() => props.tag ? (tagLabels[props.tag] ?? props.tag) : '')
</script>
<style scoped>
.simu-feature {
display: block;
padding: 1.5rem 1.75rem;
border-radius: 14px;
border: 1.5px solid var(--nav-bg-alt);
background: var(--nav-surface);
text-decoration: none;
color: var(--nav-text);
transition: box-shadow 0.2s, border-color 0.2s, transform 0.15s;
}
.simu-feature--link:hover {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
border-color: var(--nav-primary-solid);
transform: translateY(-2px);
}
.simu-feature__inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
flex-wrap: wrap;
}
.simu-feature__left {
display: flex;
align-items: flex-start;
gap: 1rem;
flex: 1;
min-width: 0;
}
.simu-feature__icon {
font-size: 2rem;
line-height: 1;
flex-shrink: 0;
margin-top: 2px;
}
.simu-feature__body {
flex: 1;
min-width: 0;
}
.simu-feature__header {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 0.4rem;
}
.simu-feature__titre {
font-size: 1.05rem;
font-weight: 700;
color: var(--nav-text);
margin: 0;
line-height: 1.3;
}
.simu-feature__badge {
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 8px;
border-radius: 999px;
background: #d1fae5;
color: #065f46;
}
.simu-feature__badge--inspiration-externe {
background: #fef3c7;
color: #92400e;
}
.simu-feature__desc {
font-size: 0.88rem;
color: var(--nav-text-muted);
margin: 0;
line-height: 1.55;
}
.simu-feature__cta {
display: inline-flex;
align-items: center;
padding: 0.6rem 1.25rem;
background: var(--nav-primary-solid);
color: var(--nav-text-on-primary);
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
transition: opacity 0.15s;
flex-shrink: 0;
}
.simu-feature--link:hover .simu-feature__cta {
opacity: 0.88;
}
:global(.dark) .simu-feature__badge {
background: #064e3b;
color: #a7f3d0;
}
:global(.dark) .simu-feature__badge--inspiration-externe {
background: #78350f;
color: #fde68a;
}
</style>
+11
View File
@@ -0,0 +1,11 @@
<template>
<span
class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-sage-100 text-sage-700 border border-sage-200 cursor-pointer hover:bg-sage-200 transition-colors"
@click.prevent="$emit('click', tag)"
>{{ tag }}</span>
</template>
<script setup lang="ts">
defineProps<{ tag: string }>()
defineEmits<{ click: [tag: string] }>()
</script>
-201
View File
@@ -1,201 +0,0 @@
<template>
<ul class="tree-ascii" :class="{ 'tree-ascii--root': depth === 0 }" :style="{ '--depth': depth }">
<li
v-for="(node, i) in tree.children"
:key="i"
class="tree-ascii__node"
>
<!-- Nœud avec enfants : bouton toggle -->
<template v-if="node.children && node.children.length">
<button
class="tree-ascii__branch"
:aria-expanded="!!open[i]"
@click="toggle(i)"
>
<span class="tree-ascii__chevron" aria-hidden="true">{{ open[i] ? '▼' : '▶' }}</span>
<span class="tree-ascii__label">{{ node.name }}</span>
<span class="tree-ascii__count">({{ node.children.length }})</span>
</button>
<TreeASCII
v-if="open[i]"
:tree="node"
:depth="depth + 1"
/>
</template>
<!-- Feuille avec URL : lien cliquable -->
<template v-else-if="node.url">
<a
:href="node.url"
target="_blank"
rel="noopener noreferrer"
class="tree-ascii__leaf tree-ascii__leaf--link"
>
<span class="tree-ascii__prefix" aria-hidden="true">└─</span>
<span class="tree-ascii__label">{{ node.name }}</span>
<span v-if="node.desc" class="tree-ascii__desc"> — {{ node.desc }}</span>
<svg class="tree-ascii__ext" width="10" height="10" 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>
</a>
</template>
<!-- Feuille sans URL -->
<template v-else>
<span class="tree-ascii__leaf">
<span class="tree-ascii__prefix" aria-hidden="true">└─</span>
<span class="tree-ascii__label">{{ node.name }}</span>
<span v-if="node.desc" class="tree-ascii__desc"> — {{ node.desc }}</span>
</span>
</template>
</li>
</ul>
</template>
<script setup lang="ts">
export interface TreeNode {
name: string
url?: string
desc?: string
children?: TreeNode[]
}
export interface TreeData {
name?: string
children?: TreeNode[]
}
const props = withDefaults(defineProps<{
tree: TreeData
depth?: number
}>(), {
depth: 0
})
// Toutes les branches fermées par défaut
const open = ref<Record<number, boolean>>({})
function toggle(i: number) {
open.value[i] = !open.value[i]
}
</script>
<style scoped>
.tree-ascii {
list-style: none;
padding: 0;
margin: 0;
font-family: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
font-size: 0.82rem;
padding-left: calc(var(--depth, 0) * 1.25rem + 0.5rem);
}
.tree-ascii--root {
padding-left: 0;
}
.tree-ascii__node {
margin: 2px 0;
line-height: 1.6;
}
/* Bouton branche (nœud avec enfants) */
.tree-ascii__branch {
display: inline-flex;
align-items: center;
gap: 0.35rem;
background: none;
border: none;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
color: var(--nav-text);
font-family: inherit;
font-size: inherit;
font-weight: 600;
transition: background 0.1s, color 0.1s;
text-align: left;
}
.tree-ascii__branch:hover {
background: var(--nav-bg-alt);
color: var(--nav-primary-solid);
}
.tree-ascii__chevron {
font-size: 0.65rem;
color: var(--nav-text-muted);
width: 12px;
text-align: center;
flex-shrink: 0;
}
.tree-ascii__count {
font-size: 0.7rem;
color: var(--nav-text-muted);
font-weight: 400;
}
/* Feuille */
.tree-ascii__leaf {
display: inline-flex;
align-items: baseline;
gap: 0.25rem;
padding: 1px 6px;
border-radius: 4px;
text-decoration: none;
color: var(--nav-text-muted);
}
.tree-ascii__leaf--link {
color: var(--nav-text);
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.tree-ascii__leaf--link:hover {
background: var(--nav-bg-alt);
color: var(--nav-primary-solid);
text-decoration: underline;
}
.tree-ascii__prefix {
color: var(--nav-text-muted);
opacity: 0.5;
font-size: 0.75rem;
flex-shrink: 0;
}
.tree-ascii__label {
font-weight: 500;
}
.tree-ascii__leaf--link .tree-ascii__label {
font-weight: 400;
}
.tree-ascii__desc {
color: var(--nav-text-muted);
font-size: 0.78rem;
font-style: italic;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 60ch;
}
.tree-ascii__ext {
opacity: 0.4;
flex-shrink: 0;
margin-left: 2px;
vertical-align: middle;
}
@media (max-width: 640px) {
.tree-ascii__desc {
display: none;
}
}
</style>
File diff suppressed because it is too large Load Diff
-118
View File
@@ -1,118 +0,0 @@
<!--
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>
-240
View File
@@ -1,240 +0,0 @@
.proposer-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.field-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.field-group label,
.field-group legend {
font-size: 0.875rem;
font-weight: 600;
color: var(--nav-text);
display: block;
}
.field-group fieldset {
border: none;
padding: 0;
margin: 0;
}
.required { color: #c0392b; }
.label-hint {
font-weight: 400;
color: var(--nav-text-muted);
font-size: 0.8rem;
margin-left: 0.25rem;
}
.field-group input[type="text"],
.field-group input[type="url"],
.field-group input[type="email"],
.field-group textarea {
width: 100%;
padding: 0.625rem 0.875rem;
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 8px;
font-size: 0.9rem;
color: var(--nav-text);
background: var(--nav-surface);
font-family: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
box-sizing: border-box;
}
.field-group input:focus,
.field-group textarea:focus {
outline: none;
border-color: var(--nav-primary-solid);
box-shadow: 0 0 0 2px rgba(245, 179, 66, 0.4);
}
.field-group textarea {
resize: vertical;
min-height: 100px;
}
.field-error input,
.field-error textarea {
border-color: #c0392b !important;
}
.error-msg {
font-size: 0.8rem;
color: #c0392b;
}
.field-meta {
display: flex;
justify-content: flex-end;
}
.char-count {
font-size: 0.75rem;
color: var(--nav-text-muted);
}
.char-warn {
color: #e67e22;
}
.radio-group {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.375rem;
}
.radio-label {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 6px;
font-size: 0.85rem;
color: var(--nav-text);
background: var(--nav-surface);
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.radio-label input[type="radio"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.radio-label:hover {
border-color: var(--nav-primary-solid);
background: var(--nav-bg-alt);
}
.radio-label.active {
background: var(--nav-primary);
border-color: transparent;
color: var(--nav-text-on-primary);
}
.checkbox-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
margin-top: 0.375rem;
}
@media (max-width: 400px) {
.checkbox-grid { grid-template-columns: 1fr; }
}
.checkbox-label {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 6px;
font-size: 0.85rem;
color: var(--nav-text);
background: var(--nav-surface);
cursor: pointer;
transition: all 0.15s;
user-select: none;
position: relative;
}
.checkbox-label input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.checkbox-label:hover:not(.disabled) {
border-color: var(--nav-primary-solid);
background: var(--nav-bg-alt);
}
.checkbox-label.active {
background: var(--nav-primary);
border-color: transparent;
color: var(--nav-text-on-primary);
}
.checkbox-label.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.fn-order {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: var(--nav-accent);
color: var(--nav-text);
border-radius: 50%;
font-size: 0.7rem;
font-weight: 700;
flex-shrink: 0;
}
.server-error {
padding: 0.875rem 1rem;
background: #fdf0ee;
border: 1px solid #e74c3c;
border-radius: 8px;
font-size: 0.875rem;
color: #c0392b;
}
.form-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 0.5rem;
}
.btn-primary {
padding: 0.75rem 1.5rem;
background: var(--nav-primary);
color: var(--nav-text-on-primary);
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: background 0.15s, opacity 0.15s;
}
.btn-primary:hover:not(:disabled) {
background: rgba(26, 34, 56, 0.75);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.form-note {
font-size: 0.75rem;
color: var(--nav-text-muted);
text-align: center;
margin: 0;
}
@media (max-width: 480px) {
.form-actions { flex-direction: column-reverse; }
.btn-primary { width: 100%; justify-content: center; }
}
-22
View File
@@ -1,22 +0,0 @@
// Fond de carte commun aux cartes Leaflet.
// CARTO exige une clé depuis le 23/09/2026 (sans clé : filigrane « API KEY REQUIRED »).
// Clé fournie au runtime par NUXT_PUBLIC_CARTO_KEY ; sans clé, repli sur le Plan IGN (Géoplateforme, sans clé).
const IGN_URL = 'https://data.geopf.fr/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0'
+ '&LAYER=GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2&STYLE=normal&TILEMATRIXSET=PM_0_19&FORMAT=image/png'
+ '&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}'
export function useBaseTiles() {
const key = useRuntimeConfig().public.cartoKey as string
function url(dark: boolean) {
if (!key) return IGN_URL
const style = dark ? 'dark_all' : 'light_all'
return `https://{s}.basemaps.cartocdn.com/${style}/{z}/{x}/{y}{r}.png?key=${encodeURIComponent(key)}`
}
const attribution = key
? '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>'
: '© <a href="https://www.ign.fr/">IGN</a> – Géoplateforme'
return { url, attribution }
}
-139
View File
@@ -1,139 +0,0 @@
/**
* useFicheAdapter — B3-M2, étendu en B11-M2
* Adaptateur léger Org | StructureV2 → MiniFiche, format commun consommé par
* 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'
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). */
function firstSentence(text: string): string {
const match = text.match(/^.*?[.!?](?=\s|$)/)
return (match ? match[0] : text).trim()
}
/** Tronque proprement sur un mot, jamais au milieu. */
function truncateOnWord(text: string, max = RESUME_MAX): string {
if (text.length <= max) return text
const slice = text.slice(0, max)
const lastSpace = slice.lastIndexOf(' ')
const cut = lastSpace > max * 0.4 ? slice.slice(0, lastSpace) : slice
return `${cut.trimEnd()}…`
}
function buildResume(candidates: Array<string | null | undefined>): string {
const source = candidates.find((c) => !!c && c.trim().length > 0)
if (!source) return ''
return truncateOnWord(firstSentence(source.trim()))
}
function isStructure(source: Org | StructureV2): source is StructureV2 {
return 'famille_principale' in source
}
function orgFonctions(org: Org): string[] {
return (org.tags_fonction ?? '')
.split(',')
.map((f) => f.trim())
.filter(Boolean)
}
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,
nom: source.nom,
resume: buildResume([source.description_courte, source.description_longue]),
chips: hashtags.slice(0, 3),
chipsDimension: 'tags',
geoloc: source.latitude != null && source.longitude != null,
kind: 'structure',
etiquettes: {
tags: hashtags.map(sansDiese),
famille: famillesDe(source),
pays: source.pays ? [source.pays] : [],
},
coords: coordsDe(source.latitude, source.longitude),
ville: source.ville,
// Portée de la recherche d'avant B11 sur /agences : description courte et hashtags.
texte: [source.description_courte, ...hashtags].filter(Boolean).join(' '),
}
}
const fonctions = orgFonctions(source)
return {
id: source.Id,
nom: source.nom,
resume: buildResume([source.description_enrichie, source.description]),
chips: fonctions.slice(0, 3),
chipsDimension: 'fonctions',
echelle: source.echelle,
geoloc: source.latitude != null && source.longitude != null,
kind: 'org',
etiquettes: {
fonctions,
echelle: source.echelle ? [source.echelle] : [],
territoire: [source.territoire && DOM_TOM.includes(source.territoire) ? source.territoire : 'Métropole'],
},
coords: coordsDe(source.latitude, source.longitude),
date: source.submitted_at,
ville: source.localisation_ville,
}
}
export function useFicheAdapter() {
return { toMini }
}
-252
View File
@@ -1,252 +0,0 @@
/**
* 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>
-90
View File
@@ -1,90 +0,0 @@
# 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.
-11
View File
@@ -1,11 +0,0 @@
[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
-9
View File
@@ -1,9 +0,0 @@
[Unit]
Description=Timer 15 min — worker enrichissement IA AEP
[Timer]
OnCalendar=*:0/15
Persistent=true
[Install]
WantedBy=timers.target
+2 -22
View File
@@ -1,21 +1,6 @@
export default defineNuxtConfig({
modules: ['@nuxtjs/tailwindcss'],
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' },
{ rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-16.png' },
{ rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' },
],
},
},
css: [
'@transformer/ui/tokens.css',
'@transformer/ui/theme/aep.css',
'~/assets/css/main.css',
'leaflet/dist/leaflet.css',
'leaflet.markercluster/dist/MarkerCluster.css',
@@ -31,8 +16,6 @@ export default defineNuxtConfig({
commentTableId: process.env.COMMENT_TABLE_ID || process.env.AVIS_TABLE_ID,
statsTableId: process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc',
mistralApiKey: process.env.MISTRAL_API_KEY,
bifrostUrl: process.env.BIFROST_URL || 'http://127.0.0.1:8080',
bifrostVk: process.env.BIFROST_VK,
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
resendApiKey: process.env.RESEND_API_KEY,
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
@@ -40,17 +23,14 @@ export default defineNuxtConfig({
codevPassword: 'merci', // NUXT_CODEV_PASSWORD - défaut "merci", overridable
codevBaseId: '', // NUXT_CODEV_BASE_ID - base NocoDB (ex: pipilvsi7dibo80)
codevAdminPassword: 'admin2026', // NUXT_CODEV_ADMIN_PASSWORD
ragPeUrl: process.env.RAG_PE_URL || 'http://localhost:9621',
referencesTableId: process.env.REFERENCES_TABLE_ID || process.env.NUXT_REFERENCES_TABLE_ID || '',
public: {
cartoKey: '', // NUXT_PUBLIC_CARTO_KEY — clé CARTO Basemaps (gratuite), lue au runtime
},
ragPeUrl: process.env.NUXT_RAG_PE_URL || 'http://localhost:9621',
},
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client
ssr: true,
vite: {
cacheDir: 'C:/Users/jules/AppData/Local/nav-carte-vite-cache',
optimizeDeps: {
include: ['leaflet', 'leaflet.markercluster', 'd3'],
},
-6
View File
@@ -8,7 +8,6 @@
"hasInstallScript": true,
"dependencies": {
"@headlessui/vue": "^1.7.23",
"@transformer/ui": "git+https://git.trans-former.fr/jules/design-system.git",
"@types/leaflet": "^1.9.21",
"@types/leaflet.markercluster": "^1.5.6",
"d3": "^7.9.0",
@@ -3626,11 +3625,6 @@
"vue": "^2.7.0 || ^3.0.0"
}
},
"node_modules/@transformer/ui": {
"version": "0.1.0",
"resolved": "git+https://git.trans-former.fr/jules/design-system.git#057de406c804315e09d28fb26f10941d61e461b1",
"license": "UNLICENSED"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
-1
View File
@@ -11,7 +11,6 @@
},
"dependencies": {
"@headlessui/vue": "^1.7.23",
"@transformer/ui": "git+https://git.trans-former.fr/jules/design-system.git",
"@types/leaflet": "^1.9.21",
"@types/leaflet.markercluster": "^1.5.6",
"d3": "^7.9.0",
+66 -83
View File
@@ -13,7 +13,7 @@
<section class="section-mission">
<h1>À propos d'AEP</h1>
<p class="mission-text">
AEP — Architecture d'Écologie Politique — est un commun vivant : une infrastructure d'entraide, de ressources documentées et de cartographies au service d'une profession en mutation. Ce site rassemble les outils, l'écosystème d'entraide, les réseaux AEP, les plateformes de mise en relation, le codev, la bibliothèque des pensées écologiques et les projets, un manifeste, une transparence radicale sur l'IA et le financement, et une gouvernance partagée.
AEP — Architecture d'Écologie Politique — est un commun vivant : une infrastructure d'entraide, de ressources documentées et de cartographies au service d'une profession en mutation. Ce site rassemble trois cartes (entraide, réseaux engagés, plateformes de mise en relation), un manifeste, une transparence radicale sur l'IA et le financement, et une gouvernance partagée.
</p>
</section>
@@ -27,6 +27,14 @@
Ce site tourne sur un VPS - un serveur privé loué en Europe - construit entièrement avec des logiciels open source, quasi gratuits. Pas de services Google, pas d'Amazon, pas de Microsoft dans la chaîne. C'est un choix délibéré : contrôler son infrastructure, c'est contrôler ses données.
</p>
<div class="badges-row">
<div class="badge">
<span class="badge-icon" aria-hidden="true">
<!-- Mistral logo placeholder -->
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>
</span>
<span class="badge-label">IA souveraine</span>
<span class="badge-detail">Mistral AI, Paris - conforme RGPD ; pas de collecte ni de profiling ; aucun transfert hors UE</span>
</div>
<div class="badge">
<span class="badge-icon" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
@@ -95,8 +103,22 @@
L'IA est un outil. La question, c'est au service de quoi. Oui, elle est énergivore - et il faut l'assumer. Mais si c'est au service de l'émancipation, du collectif, de transformer nos infrastructures sociétales, alors on dit oui - en sachant que c'est une étape transitoire. L'infrastructure de l'IA elle-même peut évoluer : devenir plus locale, plus sobre, plus souveraine. C'est dans cette direction qu'on regarde.
</p>
<div class="ia-grid">
<div class="ia-card">
<div class="ia-card-label">Enrichissement des fiches</div>
<div class="ia-card-model">Mistral Nemo</div>
<div class="ia-card-cost">~0,00003 €/fiche</div>
</div>
<div class="ia-card">
<div class="ia-card-label">Chatbot de recherche</div>
<div class="ia-card-model">Mistral Small</div>
<div class="ia-card-cost">~0,003 €/requête</div>
</div>
</div>
<ul class="ia-details">
<li>10 € / mois investis au départ par le collectif - vos dons constituent la réserve pour faire vivre le projet et financer ses recherches</li>
<li>Coût visible en direct dans le bandeau bas de la carte</li>
</ul>
</section>
@@ -168,44 +190,12 @@
</a>
</section>
<!-- ══════════════════════════════════════════════════════════
SECTION 8 - API publique
══════════════════════════════════════════════════════════ -->
<section class="section section-opensource">
<h2>API publique</h2>
<p class="section-text">
Les données de ce site internet sont publiques, toutes consultables via API directe, pour que des agents IA, des développeurs, ou d'autres sites puissent interroger la base sans passer par l'interface graphique.
</p>
<p class="section-text">
<strong>Données accessibles :</strong> organisations d'entraide, pratiques d'architecture d'écologie politique (graphe complet + projets), plateformes d'emploi, RAG et ses auteurs, et toutes autres données à venir.
</p>
<div class="api-copy-box">
<code class="api-url">https://api.trans-former.fr</code>
<button class="btn-copy" @click="copyApiUrl">{{ copied ? 'Copié !' : 'Copier' }}</button>
</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>
<script setup lang="ts">
import { ref } from 'vue'
useHead({ title: 'À propos - AEP' })
const copied = ref(false)
function copyApiUrl() {
navigator.clipboard.writeText('https://api.trans-former.fr')
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
</script>
<style scoped>
@@ -276,18 +266,6 @@ 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;
@@ -394,6 +372,49 @@ function copyApiUrl() {
/* ── IA grid ─────────────────────────────────────────────────────────────────── */
.ia-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin-bottom: 1rem;
}
@media (max-width: 480px) {
.ia-grid {
grid-template-columns: 1fr;
}
}
.ia-card {
background: var(--nav-surface);
border: 1px solid rgba(26, 34, 56, 0.1);
border-radius: 10px;
padding: 1rem 1.125rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.ia-card-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--nav-text-muted);
font-weight: 600;
}
.ia-card-model {
font-size: 1rem;
font-weight: 700;
color: var(--nav-text);
}
.ia-card-cost {
font-size: 0.825rem;
color: var(--nav-text-muted);
font-variant-numeric: tabular-nums;
}
.ia-details {
list-style: none;
padding: 0;
@@ -491,44 +512,6 @@ function copyApiUrl() {
background: var(--nav-bg-alt);
}
/* ── API copy box ────────────────────────────────────────────────────────────── */
.api-copy-box {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
padding: 0.5rem 0.75rem;
background: var(--nav-surface);
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 8px;
}
.api-url {
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: 0.875rem;
color: var(--nav-primary-solid);
user-select: all;
}
.btn-copy {
padding: 0.375rem 0.75rem;
background: var(--nav-bg-alt);
border: 1px solid rgba(26, 34, 56, 0.15);
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
color: var(--nav-text);
transition: background 0.15s;
white-space: nowrap;
}
.btn-copy:hover {
background: var(--nav-primary-solid);
color: #fff;
}
/* ── Responsive général ──────────────────────────────────────────────────────── */
@media (max-width: 480px) {
+479 -139
View File
@@ -1,94 +1,404 @@
<template>
<div class="relative h-full overflow-hidden" style="background: var(--nav-bg);">
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
<!-- IntentionBanner s'auto-affiche via Teleport (overlay plein écran) -->
<IntentionBanner />
<!-- ═══════════════════════════════════════ SIDEBAR DESKTOP (>= 1024px) -->
<div class="hidden lg:block overflow-y-auto" style="width: 320px; min-width: 320px; flex-shrink: 0; border-right: 1px solid var(--nav-bg-alt); height: 100%;">
<!-- ═══════════════════════════════════════ 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"
>
<template #carte>
<!-- Métropole -->
<div v-show="vf.etat.sousVue === 'metropole'" class="relative flex-1 min-h-0">
<!-- 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>
<div v-if="structure.hashtags.length" class="mt-1 flex flex-wrap gap-1">
<span
v-for="tag in structure.hashtags.slice(0, 2)"
:key="tag"
class="text-xs"
style="color: var(--nav-text-muted);"
>{{ tag }}</span>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════ ZONE CENTRALE (carte) -->
<main class="flex-1 flex flex-col overflow-hidden relative">
<!-- ── VUE DESKTOP : Onglets Métro/Outre-mer ── -->
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
<!-- Onglets desktop -->
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'metropole'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'metropole'"
>Métropolitain</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'outremer'"
>Outre-mer</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'graphe'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'graphe'"
>Vue graphique</button>
<NuxtLink
to="/media"
class="px-5 py-2 text-sm font-medium transition-colors"
style="color: var(--nav-text-muted); border-bottom: 2px solid transparent;"
active-class="!color-nav-text"
>Média</NuxtLink>
</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>
<NavMapV2
:structures="metropoleStructures"
:selectedId="selectedId"
:survolId="vf.survolId == null ? null : String(vf.survolId)"
:centrage="centrageCarte"
@select-structure="onSelectStructure"
@survol="(id) => (vf.survolId = id)"
<OutremerMap
:orgs="outremerOrgsLegacy"
:selectedId="selectedIdLegacyNum"
@select-org="() => {}"
/>
<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="flex items-center justify-center h-full text-sm" style="color: var(--nav-text-muted);">
Chargement…
</div>
</template>
</ClientOnly>
</div>
<!-- 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);">
<!-- Vue graphique desktop -->
<div v-show="desktopMapView === 'graphe'" class="flex-1 overflow-hidden flex flex-col">
<div class="flex-1 overflow-hidden relative">
<ClientOnly>
<GraphView
:data="bifurcationData"
:allHashtags="allHashtags"
:active="desktopMapView === 'graphe'"
@select-structure="onSelectStructure"
/>
<template #fallback>
<div class="flex items-center justify-center h-full" style="color: var(--nav-text-muted);">
Chargement du graphe...
</div>
</template>
</ClientOnly>
</div>
<ChatbotPlaceholder
@highlightOrgs="() => {}"
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
/>
</div>
</div>
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer/Graphique + sheet swipable ── -->
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'metropole'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'metropole'"
>Métropolitain</button>
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'outremer'"
>Outre-mer</button>
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'graphe'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'graphe'"
>Graphe</button>
<NuxtLink
to="/media"
class="flex-1 py-2 text-sm font-medium transition-colors text-center"
style="color: var(--nav-text-muted); border-bottom: 2px solid transparent;"
>Média</NuxtLink>
</div>
<div class="lg:hidden flex-1 relative overflow-hidden">
<!-- Carte mobile Métropole -->
<div v-show="mobileMapView === 'metropole'" class="absolute inset-0">
<ClientOnly>
<OutremerMap :orgs="[]" :selectedId="null" @select-org="() => {}" />
<NavMapV2
ref="navMapMobileRef"
:structures="metropoleStructures"
:selectedId="selectedId"
@select-structure="onSelectStructureMobile"
/>
<template #fallback>
<div class="flex items-center justify-center h-48 text-sm" style="color: var(--nav-text-muted);">Chargement…</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>
<!-- Vue graphique -->
<div v-show="vf.etat.sousVue === 'graphe'" class="relative flex-1 min-h-0 overflow-hidden">
<!-- Carte mobile Outre-mer -->
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
<ClientOnly>
<OutremerMap
:orgs="outremerOrgsLegacy"
:selectedId="selectedIdLegacyNum"
@select-org="() => {}"
/>
<template #fallback>
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
Chargement…
</div>
</template>
</ClientOnly>
</div>
<!-- Vue graphique mobile -->
<div v-show="mobileMapView === 'graphe'" class="absolute inset-0 overflow-hidden" style="background: var(--nav-bg);">
<ClientOnly>
<GraphView
:data="bifurcationData"
:allHashtags="allHashtags"
:active="vf.etat.sousVue === 'graphe' && vf.vueAffichee !== 'fiches'"
@select-structure="onSelectStructure"
:active="mobileMapView === 'graphe'"
@select-structure="onSelectStructureMobile"
/>
<template #fallback>
<div class="flex items-center justify-center h-full" style="color: var(--nav-text-muted);">Chargement du graphe…</div>
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
Chargement du graphe…
</div>
</template>
</ClientOnly>
</div>
<!-- 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 pour ne pas occulter le canvas) -->
<ClientOnly v-if="mobileMapView !== 'graphe'">
<MobileSheet :resultCount="filtered.length" :pending="pending">
<!-- Bandeau intention mobile -->
<div class="px-3 py-2" style="background: var(--bifurc-banner-bg, #faf8f5); border-bottom: 1px solid var(--bifurc-banner-border, #e0d8cc);">
<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>
<!-- 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>
<!-- 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"
/>
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
<button
class="lg:hidden fixed right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
:style="`
bottom: ${basFlottants}px;
class="lg:hidden fixed bottom-6 right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
style="
height: 48px;
background: var(--nav-primary-solid);
background: var(--nav-primary);
opacity: 0.92;
color: var(--nav-text-on-primary);
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
font-family: var(--nav-font);
font-size: 0.875rem;
font-weight: 600;
`"
"
aria-label="Ouvrir l'assistant Chatbot"
@click="chatbotOpen = true"
>
@@ -104,11 +414,15 @@
@update:modelValue="chatbotOpen = $event"
/>
<!-- ═══════════════════════════════════════ CHATBOT PENSEES (desktop, tous onglets) -->
<ClientOnly>
<ChatbotPensees />
</ClientOnly>
<!-- ═══════════════════════════════════════ POP-UP MISSION RÉSEAUX AEP -->
<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"
@@ -126,13 +440,12 @@
title="Réseaux AEP — l'architecture qui s'engage"
ctaLabel="Explorer les 120 réseaux"
storageKey="aep_reseaux_seen"
echelle="M"
>
<p class="mission-text">
Cette carte rassemble <strong>120 réseaux, collectifs et agences</strong> qui pratiquent une architecture engagée — écologique, politique, biorégionale. Ce ne sont pas seulement des agences «&nbsp;vertes&nbsp;»&nbsp;: ce sont celles et ceux qui assument des positions, refusent des projets, expérimentent des modèles de gouvernance, mettent leurs ressources et leurs savoirs en commun.
</p>
<p class="mission-text">
Six familles structurent la cartographie&nbsp;: militants, agences engagées, collectifs de production, ressources communes, recherche, formations alternatives. Filtre par hashtag, ouvre la fiche d'une structure, navigue le graphe («&nbsp;Vue graphique&nbsp;» dans le sélecteur de carte) pour voir les affinités. Si tu animes ou connais un réseau qui devrait y être&nbsp;: <NuxtLink to="/contribuer" @click.stop>propose-le</NuxtLink>.
Six familles structurent la cartographie&nbsp;: militants, agences engagées, collectifs de production, ressources communes, recherche, formations alternatives. Filtre par hashtag, ouvre la fiche d'une structure, navigue le graphe (3<sup>e</sup> onglet) pour voir les affinités. Si tu animes ou connais un réseau qui devrait y être&nbsp;: <NuxtLink to="/contribuer" @click.stop>propose-le</NuxtLink>.
</p>
</MissionPopup>
@@ -141,8 +454,47 @@
<script setup lang="ts">
import type { ReseauxBifurcationData, StructureV2 } from '~/types/structure-v2'
import type { ConfigVueFiches, ValeurFiltre } from '~/utils/vueFiches'
import { sansDiese } from '~/composables/useFicheAdapter'
// ── Couleurs familles ──────────────────────────────────────────────────────
const FAMILLE_COLORS: Record<number, string> = {
1: '#a85d3e',
2: '#c4a472',
3: '#d4a017',
4: '#5a7a4a',
5: '#3d6a8c',
6: '#6b3fa0',
}
function familleColor(f: number): string {
return FAMILLE_COLORS[f] ?? '#888'
}
// ── État UI ────────────────────────────────────────────────────────────────
const selectedId = ref<string | null>(null)
const hoveredId = ref<string | null>(null)
const ficheModalOpen = ref(false)
const ficheModalId = ref<string | null>(null)
const chatbotOpen = ref(false)
const mobileMapView = ref<'metropole' | 'outremer' | 'graphe'>('metropole')
const desktopMapView = ref<'metropole' | 'outremer' | 'graphe'>('metropole')
const missionOpen = ref(false)
onMounted(() => {
try {
if (!localStorage.getItem('aep_reseaux_seen')) {
missionOpen.value = true
}
} catch {}
})
// Filtres
const search = ref('')
const selectedFamille = ref<number | null>(null)
const selectedHashtags = ref<string[]>([])
// Refs cartes
const navMapRef = ref<any>(null)
const navMapMobileRef = ref<any>(null)
// ── Données V2 - JSON statique ─────────────────────────────────────────────
const bifurcationData = ref<ReseauxBifurcationData | null>(null)
@@ -160,98 +512,86 @@ onMounted(async () => {
const structures = computed<StructureV2[]>(() => bifurcationData.value?.structures ?? [])
// Tous les hashtags uniques triés (le graphe les attend avec leur « # »)
// Tous les hashtags uniques triés
const allHashtags = computed<string[]>(() => {
const set = new Set<string>()
structures.value.forEach(s => s.hashtags.forEach(h => set.add(h)))
return Array.from(set).sort()
})
// ── 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',
// ── Filtrage ───────────────────────────────────────────────────────────────
const filtered = computed<StructureV2[]>(() => {
let result = structures.value
// Filtre texte
if (search.value.trim()) {
const q = search.value.toLowerCase()
result = result.filter(
s =>
s.nom.toLowerCase().includes(q) ||
s.ville.toLowerCase().includes(q) ||
s.description_courte.toLowerCase().includes(q) ||
s.hashtags.some(h => h.toLowerCase().includes(q))
)
}
// Filtre famille - F6 = badge_f6_recherche_politique, pas famille_principale
if (selectedFamille.value !== null) {
if (selectedFamille.value === 6) {
result = result.filter(s => (s.badges as any)?.f6_recherche_politique === true)
} else {
result = result.filter(
s => s.famille_principale === selectedFamille.value ||
(s.familles_secondaires ?? []).includes(selectedFamille.value!)
)
}
}
// Filtre hashtags (AND logique si plusieurs)
if (selectedHashtags.value.length) {
result = result.filter(
s => selectedHashtags.value.every(h => s.hashtags.includes(h))
)
}
return result
})
const hasActiveFilters = computed(
() => !!search.value || selectedFamille.value !== null || selectedHashtags.value.length > 0
)
function resetFilters() {
search.value = ''
selectedFamille.value = null
selectedHashtags.value = []
}
/** 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) }))
}
// 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)
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' },
],
},
}))
const { toMini } = useFicheAdapter()
const fichesMini = computed(() => structures.value.map((s) => toMini(s)))
const vf = useVueFiches(fichesMini, config)
// 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)
// 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) {
vf.ouvrirFiche(id)
selectedId.value = selectedId.value === id ? null : id
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
ficheModalId.value = id
ficheModalOpen.value = true
}
}
// « 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 }
})
function onSelectStructureMobile(id: string) {
selectedId.value = id
ficheModalId.value = id
ficheModalOpen.value = true
}
// ── 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_reseaux_seen')) missionOpen.value = true
} catch {}
})
// 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')) })
useHead({ title: "AEP - Réseaux de bifurcation architecturale" })
</script>
<style scoped>
@@ -276,6 +616,6 @@ useHead({ title: computed(() => (vf.ficheOuverte ? `${vf.ficheOuverte.nom} — A
.reseaux-info-btn:hover { opacity: 0.85; transform: translateY(-1px); color: var(--nav-text); }
@media (min-width: 1024px) {
.reseaux-info-btn { bottom: 10px; left: auto; right: 52px; }
.reseaux-info-btn { bottom: 16px; left: 340px; }
}
</style>
-8
View File
@@ -5,7 +5,6 @@
<div class="lock-header">
<h1>Co-développement</h1>
<p class="lock-subtitle">Entraide entre pairs</p>
<EchelleTete echelle="M" compact :contribuer="false" class="lock-echelle" />
<p class="lock-intro">Cet espace est un cercle. Pour entrer, il y a un mot.</p>
</div>
@@ -105,13 +104,6 @@ async function submit() {
margin: 0 0 1rem;
}
.lock-echelle {
text-align: left;
padding: var(--space-md);
border-radius: var(--radius-md);
background: var(--bg-alt);
}
.lock-intro {
font-size: 0.9rem;
color: var(--nav-text-muted, #6b7280);
+790 -2
View File
@@ -1,7 +1,795 @@
<template>
<div />
<div class="contribuer-page">
<div class="contribuer-inner">
<!-- Retour -->
<NuxtLink to="/" class="back-link">
← Retour à la carte
</NuxtLink>
<!-- En-tête -->
<div class="contribuer-header">
<h1>Proposer une ressource</h1>
<p class="contribuer-subtitle">
Tu connais une organisation utile aux architectes qui n'est pas encore référencée ?
Soumets-la ici — une IA enrichira la fiche et on validera sous 7 jours.
</p>
<p class="contribuer-hint">
Si tu n'as pas le temps de tout remplir, laisse-nous juste le lien — on extraira les infos du site.
Mais une description de toi, c'est toujours plus vivant et plus précis.
</p>
</div>
<!-- Message succès -->
<div v-if="success" class="success-block" role="status" aria-live="polite">
<div class="success-icon">✓</div>
<h2>Merci !</h2>
<p>Ta fiche est en cours de traitement.</p>
<p class="success-detail">
Une IA va scraper le site et enrichir la description.
Jules (et bientôt une équipe de modération) valide sous 7 jours.
</p>
<button type="button" class="btn-secondary" @click="reset">
Proposer une autre fiche
</button>
</div>
<!-- Formulaire -->
<form v-else @submit.prevent="submit" class="contribuer-form" novalidate>
<!-- Nom -->
<div class="field-group" :class="{ 'field-error': errors.nom }">
<label for="nom">Nom de l'organisation <span class="required">*</span></label>
<input
id="nom"
v-model="form.nom"
type="text"
placeholder="Ex : UNSFA, Maison de l'Architecture..."
autocomplete="organization"
@blur="validateField('nom')"
/>
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
</div>
<!-- URL -->
<div class="field-group" :class="{ 'field-error': errors.url }">
<label for="url">
Site web
<span class="label-hint">(optionnel — recommandé pour l'enrichissement IA)</span>
</label>
<input
id="url"
v-model="form.url"
type="url"
placeholder="https://..."
@blur="validateField('url')"
/>
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
</div>
<!-- Description -->
<div class="field-group" :class="{ 'field-error': errors.description_user }">
<label for="description_user">
Description courte <span class="required">*</span>
<span class="label-hint">(50 à 500 caractères)</span>
</label>
<textarea
id="description_user"
v-model="form.description_user"
rows="4"
placeholder="Présente l'organisation en quelques mots : ses missions, son public, ce qu'elle apporte..."
@blur="validateField('description_user')"
/>
<div class="field-meta">
<span v-if="errors.description_user" class="error-msg" role="alert">
{{ errors.description_user }}
</span>
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">
{{ form.description_user.length }}/500
</span>
</div>
</div>
<!-- Échelle -->
<div class="field-group" :class="{ 'field-error': errors.echelle }">
<fieldset>
<legend>
Échelle <span class="required">*</span>
<span class="label-hint">(une seule)</span>
</legend>
<div class="radio-group">
<label
v-for="opt in ECHELLES"
:key="opt"
class="radio-label"
:class="{ active: form.echelle === opt }"
>
<input
type="radio"
:value="opt"
v-model="form.echelle"
name="echelle"
@change="validateField('echelle')"
/>
{{ opt }}
</label>
</div>
</fieldset>
<span v-if="errors.echelle" class="error-msg" role="alert">{{ errors.echelle }}</span>
</div>
<!-- Fonctions -->
<div class="field-group" :class="{ 'field-error': errors.fonctions }">
<fieldset>
<legend>
Fonctions <span class="required">*</span>
<span class="label-hint">(1 à 5 — l'ordre de clic = priorité)</span>
</legend>
<div class="checkbox-grid">
<label
v-for="fn in FONCTIONS"
:key="fn"
class="checkbox-label"
:class="{
active: form.fonctions.includes(fn),
disabled: !form.fonctions.includes(fn) && form.fonctions.length >= 5,
}"
>
<input
type="checkbox"
:value="fn"
:checked="form.fonctions.includes(fn)"
:disabled="!form.fonctions.includes(fn) && form.fonctions.length >= 5"
@change="toggleFonction(fn)"
/>
<span class="fn-order" v-if="form.fonctions.includes(fn)">
{{ form.fonctions.indexOf(fn) + 1 }}
</span>
{{ fn }}
</label>
</div>
</fieldset>
<span v-if="errors.fonctions" class="error-msg" role="alert">{{ errors.fonctions }}</span>
</div>
<!-- Territoire -->
<div class="field-group" :class="{ 'field-error': errors.territoire }">
<fieldset>
<legend>
Territoire <span class="required">*</span>
</legend>
<div class="radio-group">
<label
v-for="t in TERRITOIRES"
:key="t"
class="radio-label"
:class="{ active: form.territoire === t }"
>
<input
type="radio"
:value="t"
v-model="form.territoire"
name="territoire"
@change="validateField('territoire')"
/>
{{ t }}
</label>
</div>
</fieldset>
<span v-if="errors.territoire" class="error-msg" role="alert">{{ errors.territoire }}</span>
</div>
<!-- Ville -->
<div class="field-group" :class="{ 'field-error': errors.localisation_ville }">
<label for="localisation_ville">
Ville principale
<span class="label-hint">(optionnel — pour la géolocalisation sur la carte)</span>
</label>
<input
id="localisation_ville"
v-model="form.localisation_ville"
type="text"
placeholder="Ex : Paris, Lyon, Bordeaux..."
/>
<span v-if="errors.localisation_ville" class="error-msg" role="alert">
{{ errors.localisation_ville }}
</span>
</div>
<!-- Email -->
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
<label for="submitted_by_email">
Ton email
<span class="label-hint">(optionnel — pour le suivi de modération)</span>
</label>
<input
id="submitted_by_email"
v-model="form.submitted_by_email"
type="email"
placeholder="ton@email.fr"
autocomplete="email"
@blur="validateField('submitted_by_email')"
/>
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">
{{ errors.submitted_by_email }}
</span>
</div>
<!-- Erreur globale -->
<div v-if="serverError" class="server-error" role="alert">
<strong>Erreur :</strong> {{ serverError }}
</div>
<!-- Actions -->
<div class="form-actions">
<NuxtLink to="/" class="btn-secondary">Annuler</NuxtLink>
<button
type="submit"
class="btn-primary"
:disabled="submitting"
>
{{ submitting ? 'Envoi en cours...' : 'Proposer la fiche →' }}
</button>
</div>
<p class="form-note">
Ta fiche sera examinée par l'équipe avant publication.
</p>
</form>
</div>
</div>
</template>
<script setup lang="ts">
navigateTo('/proposer', { redirectCode: 301 })
import { z } from 'zod'
// ── Constantes ────────────────────────────────────────────────────────────────
const ECHELLES = ['National', 'Régional', 'Local'] as const
const TERRITOIRES = ['Métropole', 'Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte'] as const
const FONCTIONS = [
'Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier',
'Comptabilité', 'Développement', 'Formation', 'Gestion d\'agence', 'Santé mentale',
] as const
// ── Schéma Zod (côté client — miroir du serveur) ──────────────────────────────
const SubmitSchema = z.object({
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
url: z.string().url('URL invalide (commencer par https://)').optional().or(z.literal('')),
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
echelle: z.enum(ECHELLES, { errorMap: () => ({ message: 'Sélectionne une échelle' }) }),
fonctions: z.array(z.string()).min(1, 'Sélectionne au moins une fonction').max(5, 'Maximum 5 fonctions'),
territoire: z.enum(TERRITOIRES, { errorMap: () => ({ message: 'Sélectionne un territoire' }) }),
localisation_ville: z.string().max(100).optional(),
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
})
// ── État du formulaire ────────────────────────────────────────────────────────
const form = reactive({
nom: '',
url: '',
description_user: '',
echelle: '' as typeof ECHELLES[number] | '',
fonctions: [] as string[],
territoire: '' as typeof TERRITOIRES[number] | '',
localisation_ville: '',
submitted_by_email: '',
})
const errors = reactive<Record<string, string>>({})
const submitting = ref(false)
const success = ref(false)
const serverError = ref('')
const trackingUrl = ref<string | null>(null)
// ── Validation champ par champ ────────────────────────────────────────────────
function validateField(field: string) {
const partial = SubmitSchema.partial()
const result = partial.safeParse({ [field]: (form as any)[field] })
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors
errors[field] = fieldErrors[field]?.[0] ?? ''
} else {
delete errors[field]
}
}
function validateAll(): boolean {
const result = SubmitSchema.safeParse(form)
if (!result.success) {
const flat = result.error.flatten().fieldErrors
Object.assign(errors, Object.fromEntries(
Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])
))
return false
}
Object.keys(errors).forEach(k => delete errors[k])
return true
}
// ── Gestion fonctions (ordre de clic = priorité) ──────────────────────────────
function toggleFonction(fn: string) {
const idx = form.fonctions.indexOf(fn)
if (idx >= 0) {
form.fonctions.splice(idx, 1)
} else if (form.fonctions.length < 5) {
form.fonctions.push(fn)
}
validateField('fonctions')
}
// ── Soumission ────────────────────────────────────────────────────────────────
async function submit() {
serverError.value = ''
if (!validateAll()) {
// Scroll vers la première erreur
await nextTick()
const firstError = document.querySelector('.field-error')
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
return
}
submitting.value = true
try {
const result: any = await $fetch('/api/submit', {
method: 'POST',
body: {
nom: form.nom,
url: form.url || undefined,
description_user: form.description_user,
echelle: form.echelle,
fonctions: form.fonctions,
territoire: form.territoire,
localisation_ville: form.localisation_ville || undefined,
submitted_by_email: form.submitted_by_email || undefined,
},
})
trackingUrl.value = result.trackingUrl ?? null
success.value = true
} 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) {
// Erreurs Zod serveur → mapper sur le formulaire
const fieldErrors = e.data
Object.entries(fieldErrors).forEach(([k, v]) => {
errors[k] = Array.isArray(v) ? v[0] : String(v)
})
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
} else {
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
}
} finally {
submitting.value = false
}
}
function reset() {
Object.assign(form, {
nom: '', url: '', description_user: '', echelle: '',
fonctions: [], territoire: '', localisation_ville: '', submitted_by_email: '',
})
Object.keys(errors).forEach(k => delete errors[k])
success.value = false
serverError.value = ''
trackingUrl.value = null
}
// ── Meta ──────────────────────────────────────────────────────────────────────
useHead({ title: 'Proposer une ressource — AEP' })
</script>
<style scoped>
/* ── Layout ─────────────────────────────────────────────────────────────────── */
.contribuer-page {
min-height: 100vh;
background: var(--nav-bg);
padding: 1.5rem 1rem 4rem;
}
.contribuer-inner {
max-width: 640px;
margin: 0 auto;
}
/* ── Retour ──────────────────────────────────────────────────────────────────── */
.back-link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--nav-primary-solid);
opacity: 0.7;
text-decoration: none;
margin-bottom: 1.5rem;
transition: opacity 0.15s;
}
.back-link:hover {
opacity: 1;
}
/* ── En-tête ─────────────────────────────────────────────────────────────────── */
.contribuer-header {
margin-bottom: 2rem;
}
.contribuer-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--nav-text);
margin: 0 0 0.5rem;
}
.contribuer-subtitle {
font-size: 0.9rem;
color: var(--nav-text-muted);
line-height: 1.5;
margin: 0 0 0.5rem;
}
.contribuer-hint {
font-size: 0.82rem;
color: var(--nav-text-muted);
opacity: 0.75;
line-height: 1.5;
margin: 0;
}
/* ── Succès ──────────────────────────────────────────────────────────────────── */
.success-block {
background: var(--nav-surface);
border: 1px solid rgba(26, 34, 56, 0.15);
border-radius: 12px;
padding: 2rem 1.5rem;
text-align: center;
}
.success-icon {
width: 48px;
height: 48px;
background: rgba(26, 34, 56, 0.1);
color: var(--nav-text);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
font-weight: 700;
margin: 0 auto 1rem;
}
.success-block h2 {
font-size: 1.25rem;
font-weight: 700;
color: var(--nav-text);
margin: 0 0 0.5rem;
}
.success-block p {
font-size: 0.9rem;
color: var(--nav-text-muted);
margin: 0 0 0.5rem;
line-height: 1.5;
}
.success-detail {
font-size: 0.85rem !important;
}
.success-tracking {
font-size: 0.85rem !important;
margin-top: 1rem !important;
}
.tracking-link {
color: var(--nav-primary-solid);
font-size: 0.8rem;
word-break: break-all;
}
/* ── Formulaire ──────────────────────────────────────────────────────────────── */
.contribuer-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* ── Champ générique ─────────────────────────────────────────────────────────── */
.field-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.field-group label,
.field-group legend {
font-size: 0.875rem;
font-weight: 600;
color: var(--nav-text);
display: block;
}
.field-group fieldset {
border: none;
padding: 0;
margin: 0;
}
.required {
color: #c0392b;
}
.label-hint {
font-weight: 400;
color: var(--nav-text-muted);
font-size: 0.8rem;
margin-left: 0.25rem;
}
.field-group input[type="text"],
.field-group input[type="url"],
.field-group input[type="email"],
.field-group textarea {
width: 100%;
padding: 0.625rem 0.875rem;
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 8px;
font-size: 0.9rem;
color: var(--nav-text);
background: var(--nav-surface);
font-family: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
box-sizing: border-box;
}
.field-group input:focus,
.field-group textarea:focus {
outline: none;
border-color: var(--nav-primary-solid);
box-shadow: 0 0 0 2px rgba(245, 179, 66, 0.4);
}
.field-group textarea {
resize: vertical;
min-height: 100px;
}
/* Erreur champ */
.field-error input,
.field-error textarea {
border-color: #c0392b !important;
}
.error-msg {
font-size: 0.8rem;
color: #c0392b;
}
.field-meta {
display: flex;
justify-content: flex-end;
}
.char-count {
font-size: 0.75rem;
color: var(--nav-text-muted);
}
.char-warn {
color: #e67e22;
}
/* ── Radio (Échelle + Territoire) ────────────────────────────────────────────── */
.radio-group {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.375rem;
}
.radio-label {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 6px;
font-size: 0.85rem;
color: var(--nav-text);
background: var(--nav-surface);
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.radio-label input[type="radio"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.radio-label:hover {
border-color: var(--nav-primary-solid);
background: var(--nav-bg-alt);
}
.radio-label.active {
background: var(--nav-primary);
border-color: transparent;
color: var(--nav-text-on-primary);
}
/* ── Checkboxes (Fonctions) ──────────────────────────────────────────────────── */
.checkbox-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
margin-top: 0.375rem;
}
@media (max-width: 400px) {
.checkbox-grid {
grid-template-columns: 1fr;
}
}
.checkbox-label {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 6px;
font-size: 0.85rem;
color: var(--nav-text);
background: var(--nav-surface);
cursor: pointer;
transition: all 0.15s;
user-select: none;
position: relative;
}
.checkbox-label input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.checkbox-label:hover:not(.disabled) {
border-color: var(--nav-primary-solid);
background: var(--nav-bg-alt);
}
.checkbox-label.active {
background: var(--nav-primary);
border-color: transparent;
color: var(--nav-text-on-primary);
}
.checkbox-label.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.fn-order {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: var(--nav-accent);
color: var(--nav-text);
border-radius: 50%;
font-size: 0.7rem;
font-weight: 700;
flex-shrink: 0;
}
/* ── Erreur serveur ──────────────────────────────────────────────────────────── */
.server-error {
padding: 0.875rem 1rem;
background: #fdf0ee;
border: 1px solid #e74c3c;
border-radius: 8px;
font-size: 0.875rem;
color: #c0392b;
}
/* ── Actions ──────────────────────────────────────────────────────────────────── */
.form-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 0.5rem;
}
.btn-primary {
padding: 0.75rem 1.5rem;
background: var(--nav-primary);
color: var(--nav-text-on-primary);
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: background 0.15s, opacity 0.15s;
}
.btn-primary:hover:not(:disabled) {
background: rgba(26, 34, 56, 0.75);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.75rem 1.25rem;
background: transparent;
color: var(--nav-text-muted);
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 8px;
font-size: 0.9rem;
cursor: pointer;
font-family: inherit;
text-decoration: none;
display: inline-flex;
align-items: center;
transition: border-color 0.15s, color 0.15s;
}
.btn-secondary:hover {
border-color: var(--nav-primary-solid);
color: var(--nav-text);
}
.form-note {
font-size: 0.75rem;
color: var(--nav-text-muted);
text-align: center;
margin: 0;
}
/* ── Responsive ──────────────────────────────────────────────────────────────── */
@media (max-width: 480px) {
.contribuer-page {
padding: 1rem 0.75rem 3rem;
}
.form-actions {
flex-direction: column-reverse;
}
.btn-primary,
.btn-secondary {
width: 100%;
justify-content: center;
}
}
</style>
-150
View File
@@ -1,150 +0,0 @@
<!--
Page de dev — story de test FicheMiniCard (B3-M2)
Sert uniquement à la vérification visuelle. Aucun lien vers cette page
nulle part dans l'app (pas dans app.vue, pas dans les pages) : navigation
directe par URL uniquement (/dev/fiche-mini-card).
-->
<template>
<div class="story">
<h1 class="story__title">FicheMiniCard — story de test (B3-M2)</h1>
<section class="story__section">
<h2 class="story__heading">Org — cas nominal</h2>
<div class="story__grid">
<FicheMiniCard :fiche="orgNominal" @open="log('org-nominal')" />
</div>
</section>
<section class="story__section">
<h2 class="story__heading">Structure — cas nominal</h2>
<div class="story__grid">
<FicheMiniCard :fiche="structureNominal" @open="log('structure-nominal')" />
</div>
</section>
<section class="story__section">
<h2 class="story__heading">Cas limites</h2>
<div class="story__grid">
<FicheMiniCard :fiche="nomLong" @open="log('nom-long')" />
<FicheMiniCard :fiche="sansDescription" @open="log('sans-description')" />
<FicheMiniCard :fiche="sansChips" @open="log('sans-chips')" />
<FicheMiniCard :fiche="sansGeoloc" @open="log('sans-geoloc')" />
</div>
</section>
<section class="story__section">
<h2 class="story__heading">Dernier événement « open »</h2>
<p class="story__log">{{ lastOpen ?? '(aucun clic pour le moment)' }}</p>
</section>
</div>
</template>
<script setup lang="ts">
import type { MiniFiche } from '~/composables/useFicheAdapter'
definePageMeta({ layout: false })
const lastOpen = ref<string | null>(null)
function log(label: string) {
lastOpen.value = label
}
const orgNominal: MiniFiche = {
id: 1,
nom: 'Réseau Bâti Vivant',
resume: 'Un réseau national d\'accompagnement juridique et technique pour les collectifs d\'auto-construction.',
chips: ['Juridique', 'Technique'],
echelle: 'National',
geoloc: true,
kind: 'org',
}
const structureNominal: MiniFiche = {
id: 'f1-rotor',
nom: 'Rotor',
resume: 'Rotor invente en Belgique le protocole de la déconstruction sélective comme geste politique contre la culture du tout-neuf.',
chips: ['#reemploi-structurel', '#plateforme-materiaux'],
geoloc: true,
kind: 'structure',
}
const nomLong: MiniFiche = {
id: 2,
nom: 'Collectif Régional de Réemploi et d\'Accompagnement des Chantiers Participatifs du Grand Ouest',
resume: 'Nom volontairement long pour vérifier l\'ellipse sur une ligne.',
chips: ['Chantier'],
echelle: 'Régional',
geoloc: true,
kind: 'org',
}
const sansDescription: MiniFiche = {
id: 3,
nom: 'Structure sans description',
resume: '',
chips: ['Formation'],
echelle: 'Local',
geoloc: true,
kind: 'org',
}
const sansChips: MiniFiche = {
id: 4,
nom: 'Structure sans chips',
resume: 'Une organisation dont aucune fonction ni hashtag n\'est renseigné dans la source.',
chips: [],
geoloc: true,
kind: 'org',
}
const sansGeoloc: MiniFiche = {
id: 5,
nom: 'Structure sans localisation',
resume: 'Une fiche nationale sans latitude ni longitude — invisible sur la carte, visible ici.',
chips: ['Administratif', 'Comptabilité'],
echelle: 'National',
geoloc: false,
kind: 'org',
}
</script>
<style scoped>
.story {
max-width: 1100px;
margin: 0 auto;
padding: var(--space-lg);
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
}
.story__title {
font-size: var(--fs-xl);
font-weight: var(--fw-bold);
margin-bottom: var(--space-lg);
}
.story__section {
margin-bottom: var(--space-xl);
}
.story__heading {
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--text-muted);
margin-bottom: var(--space-sm);
}
.story__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: var(--space-sm);
}
.story__log {
font-size: var(--fs-sm);
color: var(--text-muted);
}
</style>
+517 -155
View File
@@ -1,91 +1,310 @@
<template>
<div class="relative h-full overflow-hidden" style="background: var(--nav-bg);">
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
<!-- 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
<!-- ═══════════════════════════════════════ 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"
/>
</div>
<!-- ═══════════════════════════════════════ 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">
<!-- ═══════════════════════════════════════ 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 ── -->
<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="desktopMapView = 'metropole'"
>Métropolitain</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'outremer'"
>Outre-mer</button>
</div>
<!-- Carte Métropole desktop -->
<div v-show="desktopMapView === 'metropole'" class="flex-1 flex flex-col overflow-hidden">
<div class="relative flex-1" style="min-height: 200px;">
<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>
</div>
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + carte pleine hauteur + sheet swipable ── -->
<!-- Onglets Métropolitain / Outre-mer -->
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'metropole'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'metropole'"
>Métropolitain</button>
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'outremer'"
>Outre-mer</button>
</div>
<div class="lg:hidden flex-1 relative overflow-hidden">
<!-- Carte Métropole -->
<div v-show="mobileMapView === 'metropole'" class="absolute inset-0">
<ClientOnly>
<NavMap
ref="navMapMobileRef"
:orgs="metropoleOrgs"
:selectedId="selectedId"
:survolId="survolNum"
:centrage="centrageMetropole"
@select-org="onSelectOrg"
@survol="(id) => (vf.survolId = id)"
@select-org="onSelectOrgMobile"
/>
<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>
<!-- Outre-mer -->
<div v-show="vf.etat.sousVue === 'outremer'" class="flex-1 min-h-0 overflow-y-auto" style="background: var(--nav-bg);">
<!-- Carte Outre-mer (scroll vertical, pleine largeur) -->
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
<ClientOnly>
<OutremerMap
:orgs="outremerOrgs"
:selectedId="selectedId"
@select-org="onSelectOrg"
@select-org="onSelectOrgMobile"
/>
<template #fallback>
<div class="flex items-center justify-center h-48 text-sm" style="color: var(--nav-text-muted);">Chargement…</div>
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
Chargement…
</div>
</template>
</ClientOnly>
</div>
<!-- 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) -->
<ClientOnly>
<MobileSheet :resultCount="filtered.length" :pending="pending">
<!-- Barre recherche -->
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
<label class="mobile-search-label" aria-label="Rechercher une 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>
<!-- 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>
<!-- Filtres ÉCHELLE — chips style FONCTION -->
<div class="mt-2">
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">ÉCHELLE</span>
<div class="flex flex-wrap gap-1">
<span
v-for="opt in ECHELLES"
:key="opt"
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
:style="echelle.includes(opt)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggleEchelle(opt)"
>{{ opt }}</span>
</div>
</div>
<!-- Filtres FONCTION — chips flex-wrap + toggle collapse -->
<div class="mt-2">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
<span class="text-xs font-bold uppercase tracking-wide" style="color: var(--nav-text-muted);">
FONCTION
<span v-if="fonctions.length" style="font-weight: 400; text-transform: none; letter-spacing: 0; font-size: 0.65rem; margin-left: 4px;">({{ fonctions.length }} active{{ fonctions.length > 1 ? 's' : '' }})</span>
</span>
<button
@click="mobileFonctionsOpen = !mobileFonctionsOpen"
style="font-size: 0.65rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0; white-space: nowrap;"
>{{ mobileFonctionsOpen || fonctions.length ? (mobileFonctionsOpen ? 'Replier' : 'Afficher') : 'Fonctions (' + FONCTIONS.length + ')' }}</button>
</div>
<div v-if="mobileFonctionsOpen || fonctions.length" class="flex flex-wrap gap-1">
<span
v-for="fn in FONCTIONS"
:key="fn"
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
:style="fonctions.includes(fn)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggleFonction(fn)"
>{{ fn }}</span>
</div>
</div>
<button
v-if="hasActiveFilters"
@click="resetFilters"
class="mt-2 text-xs"
style="color: var(--nav-text-muted); text-decoration: underline;"
>✕ Effacer les filtres</button>
</div>
<!-- Compteur + Liste fiches -->
<div class="px-3 py-2">
<div class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">
{{ filtered.length }} résultat{{ filtered.length > 1 ? 's' : '' }}
</div>
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
Chargement des fiches…
</div>
<div v-else-if="filtered.length === 0" class="text-center py-8">
<p class="text-sm mb-2" style="color: var(--nav-text-muted);">Aucun résultat pour ces filtres.</p>
<button @click="resetFilters" class="text-sm underline" style="color: var(--nav-primary-solid);">
Effacer les filtres
</button>
</div>
<div class="space-y-2">
<div
v-for="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"
/>
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
<button
class="lg:hidden fixed right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
:style="`
bottom: ${basFlottants}px;
class="lg:hidden fixed bottom-6 right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
style="
height: 48px;
background: var(--nav-primary-solid);
background: var(--nav-primary);
opacity: 0.92;
color: var(--nav-text-on-primary);
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
font-family: var(--nav-font);
font-size: 0.875rem;
font-weight: 600;
`"
"
aria-label="Ouvrir l'assistant Chatbot"
@click="chatbotOpen = true"
>
@@ -106,7 +325,6 @@
<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"
@@ -121,7 +339,6 @@
<MissionPopup
:modelValue="missionOpen"
@update:modelValue="missionOpen = $event"
echelle="M"
/>
</div>
@@ -129,128 +346,273 @@
<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()
// ── Données ───────────────────────────────────────────────────────────────
const { data, pending } = await useFetch<{ list: Org[]; source: string }>('/api/organisations')
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'
)
const orgs = computed<Org[]>(() => data.value?.list ?? [])
const dataSource = computed(() => data.value?.source ?? 'nocodb')
const desktopMapView = ref<'metropole' | 'outremer'>('metropole')
const selectedId = ref<number | null>(null)
const chatbotOpen = ref(false)
const ficheModalOpen = ref(false)
const ficheModalId = ref<number | null>(null)
const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
const missionOpen = ref(false)
const mobileFonctionsOpen = ref(false)
// `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
onMounted(() => {
try {
if (!localStorage.getItem('aep_mission_seen')) {
missionOpen.value = true
}
} catch {}
})
// ── 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
// Surlignage temporaire (5 sec) suite à une réponse chatbot
// → sélectionne le premier ID recommandé sur la carte, puis remet à null
let highlightTimer: ReturnType<typeof setTimeout> | null = null
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
surligneId.value = firstId
// Sauvegarde la sélection courante
prevSelectedId.value = selectedId.value
selectedId.value = firstId
if (highlightTimer) clearTimeout(highlightTimer)
highlightTimer = setTimeout(() => {
surligneId.value = null
// Restaure la sélection précédente (ou null)
selectedId.value = prevSelectedId.value
prevSelectedId.value = null
highlightTimer = null
}, 5000)
}
// 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}` })
}
}, { immediate: true })
// 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'
router.replace({ query: Object.keys(q).length ? q : undefined })
}
// ── 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))
// 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)
}
onMounted(() => {
try {
if (!localStorage.getItem('aep_mission_seen')) missionOpen.value = true
} catch {}
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) ?? ''
})
// 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')) })
// ── 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
})
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
}
})
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)
}
useHead({ title: 'AEP — Cartographie de l\'écologie politique architecturale' })
</script>
<style scoped>
@@ -275,6 +637,6 @@ useHead({ title: computed(() => (vf.ficheOuverte ? `${vf.ficheOuverte.nom} — A
.mission-info-btn:hover { opacity: 0.85; transform: translateY(-1px); color: var(--nav-text); }
@media (min-width: 1024px) {
.mission-info-btn { bottom: 10px; left: auto; right: 52px; }
.mission-info-btn { bottom: 16px; left: 340px; }
}
</style>
+400 -10
View File
@@ -1,23 +1,413 @@
<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);">
<MediaTabVisuel v-if="tab === 'visuel'" />
<MediaTabProjets v-else />
<!-- ZONE PRINCIPALE (pleine largeur, pas de sidebar) -->
<main class="media-main">
<!-- Header onglet -->
<div class="shrink-0 px-5 py-3"
style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<h1 class="font-bold text-base" style="color: var(--nav-text);">ATIS Media</h1>
<p class="text-xs mt-0.5" style="color: var(--nav-text-muted);">
{{ corpusCount }} auteurs ingeres dans le RAG - carte FRACAS Bonpote V2
</p>
</div>
<!-- Conteneur split / plein ecran -->
<div class="layout-container">
<!-- Slot carte D3 -->
<div
class="carte-slot"
:class="[
layoutMode === 'split' ? 'carte-split' : '',
layoutMode === 'carte-full' ? 'carte-full' : '',
layoutMode === 'chatbot-full' ? 'carte-hidden' : '',
]"
:style="layoutMode === 'split' ? { flexBasis: carteFlexBasis } : {}"
>
<ClientOnly>
<CartePensees
ref="cartePenseesRef"
:data="penseesData"
:active="true"
@select-auteur="onSelectAuteur"
/>
<template #fallback>
<div class="w-full h-full flex items-center justify-center" style="color: var(--nav-text-muted);">
Chargement de la carte...
</div>
</template>
</ClientOnly>
</div>
<!-- Barre de toggle -->
<div class="layout-toggle-bar shrink-0">
<button
@click="setLayoutMode('carte-full')"
:class="{ active: layoutMode === 'carte-full' }"
class="toggle-btn"
title="Carte en plein ecran"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
</svg>
Carte plein ecran
</button>
<button
v-if="layoutMode !== 'split'"
@click="setLayoutMode('split')"
class="toggle-btn"
title="Vue partagee"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="12" x2="21" y2="12"/>
</svg>
Vue partagee
</button>
<button
@click="setLayoutMode('chatbot-full')"
:class="{ active: layoutMode === 'chatbot-full' }"
class="toggle-btn"
title="Chatbot en plein ecran"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
Chatbot plein ecran
</button>
</div>
<!-- Poignee draggable (visible uniquement en mode split, pas sur mobile) -->
<div
v-if="layoutMode === 'split'"
class="split-handle"
@mousedown.prevent="onHandleMousedown"
title="Redimensionner"
>
<span class="split-handle-grip"></span>
</div>
<!-- Slot chatbot inline -->
<div
class="chatbot-slot"
:class="[
layoutMode === 'split' ? 'chatbot-split' : '',
layoutMode === 'chatbot-full' ? 'chatbot-full-mode' : '',
layoutMode === 'carte-full' ? 'chatbot-hidden' : '',
]"
:style="layoutMode === 'split' ? { flexBasis: chatbotFlexBasis } : {}"
>
<ClientOnly>
<ChatbotPensees :auteurContext="chatbotAuteur" :inline="true" />
</ClientOnly>
</div>
</div>
</main>
<!-- Fiche auteur modal -->
<FicheAuteur
:open="ficheOpen"
:auteurId="ficheAuteurId"
:data="penseesData"
@close="ficheOpen = false"
@interroger-rag="onInterrogerRag"
/>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
interface EcoleData { id: string; label: string; description: string; color: string; x_hint: number; y_hint: number }
interface LivreRag { slug: string; titre: string; annee: number; couches: string[] }
interface AuteurData { id: string; nom: string; dates: string; ecoles: string[]; ecole_principale: string; livres_rag: LivreRag[]; theses_cles: string[]; bio_courte: string }
interface PenseesData { meta: any; ecoles: EcoleData[]; auteurs: AuteurData[] }
// 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'
type LayoutMode = 'split' | 'carte-full' | 'chatbot-full'
const STORAGE_KEY = 'media-layout-mode'
const SPLIT_RATIO_KEY = 'media-split-ratio'
const DEFAULT_SPLIT_RATIO = 0.66
const ficheOpen = ref(false)
const ficheAuteurId = ref<string | null>(null)
const chatbotAuteur = ref<string | null>(null)
const penseesData = ref<PenseesData | null>(null)
const layoutMode = ref<LayoutMode>('split')
const cartePenseesRef = ref<{ triggerResize: () => void } | null>(null)
// Ratio de la carte vs chatbot en mode split (0.2 a 0.8)
const splitRatio = ref(DEFAULT_SPLIT_RATIO)
const carteFlexBasis = computed(() => `${splitRatio.value * 100}%`)
const chatbotFlexBasis = computed(() => `${(1 - splitRatio.value) * 100}%`)
const corpusCount = computed(() => penseesData.value?.auteurs.length ?? 0)
// Logique poignee draggable
let dragStartY = 0
let dragStartRatio = DEFAULT_SPLIT_RATIO
let containerHeight = 0
function onHandleMousedown(e: MouseEvent) {
dragStartY = e.clientY
dragStartRatio = splitRatio.value
// Hauteur du layout-container (carte + handle + chatbot)
const container = (e.target as HTMLElement)?.closest('.layout-container') as HTMLElement | null
containerHeight = container ? container.clientHeight : window.innerHeight
window.addEventListener('mousemove', onHandleMousemove)
window.addEventListener('mouseup', onHandleMouseup)
}
function onHandleMousemove(e: MouseEvent) {
const delta = e.clientY - dragStartY
const newRatio = dragStartRatio + delta / containerHeight
splitRatio.value = Math.min(0.80, Math.max(0.20, newRatio))
}
function onHandleMouseup() {
window.removeEventListener('mousemove', onHandleMousemove)
window.removeEventListener('mouseup', onHandleMouseup)
if (typeof window !== 'undefined') {
localStorage.setItem(SPLIT_RATIO_KEY, String(splitRatio.value))
}
// Notifier D3 du resize apres relachement
cartePenseesRef.value?.triggerResize()
}
onMounted(async () => {
// Restaurer le mode de layout depuis localStorage
if (typeof window !== 'undefined') {
const saved = localStorage.getItem(STORAGE_KEY) as LayoutMode | null
if (saved && ['split', 'carte-full', 'chatbot-full'].includes(saved)) {
layoutMode.value = saved
}
const savedRatio = parseFloat(localStorage.getItem(SPLIT_RATIO_KEY) ?? '')
if (!isNaN(savedRatio) && savedRatio >= 0.20 && savedRatio <= 0.80) {
splitRatio.value = savedRatio
}
}
try {
penseesData.value = await $fetch<PenseesData>('/data/auteurs-pensees.json')
} catch (e) {
console.error('Erreur chargement auteurs-pensees.json', e)
}
})
useHead(() => ({ title: tab.value === 'projets' ? 'AEP - Publications' : 'AEP - Recherche média' }))
// Persister + reset D3 apres transition
function setLayoutMode(mode: LayoutMode) {
layoutMode.value = mode
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, mode)
}
// Restart simulation D3 apres la fin de la transition CSS (300ms)
if (mode !== 'chatbot-full') {
setTimeout(() => {
cartePenseesRef.value?.triggerResize()
}, 350)
}
}
watch(layoutMode, (v) => {
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, v)
}
})
function onSelectAuteur(id: string) {
ficheAuteurId.value = id
ficheOpen.value = true
chatbotAuteur.value = null
}
function onInterrogerRag(auteurId: string) {
ficheOpen.value = false
const auteur = penseesData.value?.auteurs.find(a => a.id === auteurId)
chatbotAuteur.value = auteur?.nom ?? null
// Basculer en split pour que le chatbot soit visible
if (layoutMode.value === 'carte-full') {
setLayoutMode('split')
}
}
useHead({ title: 'AEP - Media - Carte FRACAS Bonpote' })
</script>
<style scoped>
.media-page { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
/* Page container : flex column, prend toute la hauteur viewport */
.media-page {
display: flex;
height: 100%;
overflow: hidden;
}
.media-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
/* Conteneur des slots carte + toggle + chatbot */
.layout-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
/* --- Slot carte --- */
.carte-slot {
overflow: hidden;
position: relative;
transition: opacity 0.2s ease;
}
.carte-split {
flex: 0 0 66%;
min-height: 0;
opacity: 1;
}
.carte-full {
flex: 1 1 100%;
min-height: 0;
opacity: 1;
}
.carte-hidden {
flex: 0 0 0;
height: 0;
opacity: 0;
overflow: hidden;
}
/* --- Barre de toggle --- */
.layout-toggle-bar {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
background: var(--nav-bg);
border-top: 1px solid rgba(180, 170, 160, 0.22);
border-bottom: 1px solid rgba(180, 170, 160, 0.22);
min-height: 38px;
}
.toggle-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
background: var(--nav-bg-alt);
color: var(--nav-text-muted);
border: 1px solid transparent;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.toggle-btn:hover {
background: var(--nav-surface);
color: var(--nav-text);
}
.toggle-btn.active {
background: var(--nav-primary);
color: var(--nav-text-on-primary);
border-color: var(--nav-primary);
}
/* --- Poignee draggable entre carte et chatbot --- */
.split-handle {
flex-shrink: 0;
height: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: row-resize;
background: transparent;
position: relative;
z-index: 10;
user-select: none;
}
.split-handle:hover {
background: rgba(180, 170, 160, 0.18);
}
.split-handle-grip {
display: block;
width: 32px;
height: 4px;
border-radius: 2px;
background: repeating-linear-gradient(
to bottom,
rgba(160, 150, 140, 0.55) 0px,
rgba(160, 150, 140, 0.55) 1px,
transparent 1px,
transparent 3px
);
}
/* Masquer la poignee sur mobile (ratio fixe) */
@media (max-width: 767px) {
.split-handle {
display: none;
}
}
/* --- Slot chatbot --- */
.chatbot-slot {
overflow: hidden;
position: relative;
transition: opacity 0.2s ease;
border-top: 1px solid rgba(180, 170, 160, 0.28);
}
.chatbot-split {
flex: 0 0 34%;
min-height: 0;
opacity: 1;
}
.chatbot-full-mode {
flex: 1 1 100%;
min-height: 0;
opacity: 1;
}
.chatbot-hidden {
flex: 0 0 0;
height: 0;
opacity: 0;
overflow: hidden;
}
/* --- Responsive mobile (<768px) --- */
/* Stack vertical : carte 60vh + chatbot 40vh en mode split */
@media (max-width: 767px) {
.carte-split {
flex: 0 0 60vh;
height: 60vh;
}
.chatbot-split {
flex: 0 0 calc(40vh - 38px);
height: calc(40vh - 38px);
}
.toggle-btn span,
.toggle-btn {
font-size: 0.7rem;
padding: 3px 7px;
}
}
</style>
-797
View File
@@ -1,797 +0,0 @@
<template>
<div class="outils-page">
<!-- ══════════════════════ EN-TÊTE PAGE ══════════════════════ -->
<header class="outils-header">
<div class="outils-header__inner">
<div class="outils-header__icon-wrap" aria-hidden="true">
<img src="/icons/outils-wrench.svg" alt="" class="outils-header__icon" />
</div>
<div>
<h1 class="outils-header__title">Outils</h1>
<EchelleTete echelle="S" :contribuer="false" />
<p class="outils-header__intro">
En tant qu'architecte, on jongle avec une multitude d'outils — simulation, dessin,
calcul, recherche, partage. Les mutualiser, se conseiller dessus, savoir lequel
utiliser quand : c'est une forme d'entraide concrète. Voici ceux que je propose
dans un premier temps. Chacun peut contribuer pour enrichir cette boîte à outils
commune.
</p>
</div>
</div>
</header>
<main class="outils-main">
<!-- ══════════════ SECTION 1 — Simulateurs métier ══════════════ -->
<section class="outils-section outils-section--simulateurs" aria-labelledby="sec-simulateurs">
<h2 id="sec-simulateurs" class="outils-section__title">
<span aria-hidden="true">🧮</span> Simulateurs métier
</h2>
<p class="outils-section__subtitle">Créés par AEP — outils de calcul situés.</p>
<div class="simu-grid">
<SimulateurFeature
v-for="s in outils?.simulateurs"
:key="s.id"
:icon="s.icon"
:titre="s.titre"
:url="s.url"
:description="s.description"
:cta="s.cta"
:tag="s.tag"
/>
</div>
<!-- Inspirations -->
<div v-if="outils?.simulateurs_inspirations?.length" class="simu-inspirations">
<p class="simu-inspirations__label">Inspiration externe</p>
<div class="outil-cards-grid">
<OutilCard
v-for="s in outils.simulateurs_inspirations"
:key="s.id"
:icon="s.icon"
:titre="s.titre"
:url="s.url"
:description="s.description"
:tag="s.tag"
/>
</div>
</div>
</section>
<!-- ══════════════ SECTION 2 — Open source recommandés ══════════════ -->
<section class="outils-section outils-section--opensource" aria-labelledby="sec-opensource">
<h2 id="sec-opensource" class="outils-section__title">
<span aria-hidden="true">🔧</span> Outils tech open source
</h2>
<p class="outils-section__subtitle">Quelques recommandations directes. Le cœur de l'onglet, c'est la section FMHY plus bas.</p>
<div class="outil-cards-grid">
<OutilCard
v-for="outil in outils?.opensource"
:key="outil.id"
:icon="outil.icon"
:titre="outil.titre"
:url="outil.url"
:description="outil.description"
:tag="outil.tag"
/>
</div>
</section>
<!-- ══════════════ SECTION 3 — Bifurcation ══════════════ -->
<section class="outils-section" aria-labelledby="sec-bifurcation">
<h2 id="sec-bifurcation" class="outils-section__title">
<span aria-hidden="true">🌿</span> Bifurcation post-études d'archi
</h2>
<p class="outils-section__desc">
{{ outils?.bifurcation?.intro }}
</p>
<!-- 3.1 Vidéos OFQA -->
<div class="bifurcation-block">
<h3 class="bifurcation-block__title">Série vidéo OFQA / ENSA-PB</h3>
<ul class="ofqa-list">
<li
v-for="ep in outils?.bifurcation?.videos_ofqa"
:key="ep.ep"
class="ofqa-list__item"
>
<component
:is="ep.url ? 'a' : 'span'"
v-bind="ep.url ? { href: ep.url, target: '_blank', rel: 'noopener noreferrer' } : {}"
class="ofqa-list__link"
:class="{ 'ofqa-list__link--disabled': !ep.url }"
>
<span class="ofqa-list__ep">EP/{{ ep.ep }}</span>
<span class="ofqa-list__titre">{{ ep.titre }}</span>
<span class="ofqa-list__personnes">— {{ ep.personnes }}</span>
<span v-if="ep.note" class="ofqa-list__note">({{ ep.note }})</span>
</component>
</li>
</ul>
</div>
<!-- 3.2 Coalition -->
<div v-if="outils?.bifurcation?.coalition_ensa_pb" class="bifurcation-block bifurcation-block--coalition">
<h3 class="bifurcation-block__title">{{ outils.bifurcation.coalition_ensa_pb.titre }}</h3>
<p class="bifurcation-block__desc">{{ outils.bifurcation.coalition_ensa_pb.description }}</p>
</div>
<!-- 3.3 Ressources externes -->
<div v-if="outils?.bifurcation?.ressources_externes?.length" class="bifurcation-block">
<h3 class="bifurcation-block__title">Ressources externes</h3>
<div class="outil-cards-grid">
<OutilCard
v-for="r in outils.bifurcation.ressources_externes"
:key="r.id"
:icon="r.icon"
:titre="r.titre"
:url="r.url"
:description="r.description"
/>
</div>
</div>
</section>
<!-- ══════════════ SECTION 4 — FMHY (cœur de la page) ══════════════ -->
<section class="outils-section outils-section--fmhy" aria-labelledby="sec-fmhy">
<h2 id="sec-fmhy" class="outils-section__title">
<span aria-hidden="true">🌳</span> Annuaire du web libre — la cartographie FMHY
</h2>
<p class="outils-section__desc">
FMHY (FreeMediaHeckYeah) est le plus grand index communautaire de ressources
gratuites du web : des milliers de liens classés et vérifiés par des centaines
de contributeurs bénévoles, mis à jour en continu. C'est une forme de
résistance numérique — accéder librement au savoir, à la culture et aux
outils, hors des catalogues marchands. J'en ai traduit et organisé les
entrées phares <strong>par usage</strong>, pour aller droit au but.
</p>
<!-- Note détaillée par usage -->
<div v-for="g in fmhyGuide" :key="g.id" class="fmhy-usage">
<h3 class="fmhy-usage__title">{{ g.titre }}</h3>
<p v-if="g.note" class="fmhy-usage__note">{{ g.note }}</p>
<ul class="fmhy-usage__list">
<li v-for="l in g.liens" :key="l.url" class="fmhy-usage__item">
<a
:href="l.url"
target="_blank"
rel="noopener noreferrer"
class="fmhy-usage__link"
>{{ l.nom }}</a>
<span class="fmhy-usage__desc">— {{ l.desc }}</span>
</li>
</ul>
</div>
<!-- Avertissement légal -->
<div class="fmhy-warning">
<h3 class="fmhy-warning__title">Avertissement</h3>
<p class="fmhy-warning__text">
Une partie des ressources indexées par FMHY relève du partage de contenus
protégés : la légalité dépend de ton pays et de l'usage que tu en fais.
Renseigne-toi avant d'utiliser ces liens. FMHY maintient une
<a href="https://fmhy.net/unsafe" target="_blank" rel="noopener noreferrer">liste des sites dangereux</a>
(malwares, arnaques) : consulte-la. En cas de doute, privilégie les sources
libres de droits (Project Gutenberg, Gallica, domaine public).
</p>
</div>
<p class="fmhy-snapshot">
Cartographie établie d'après un snapshot du 25 août 2026 — l'index fmhy
évolue en continu, voir
<a href="https://fmhy.net/" target="_blank" rel="noopener noreferrer">fmhy.net</a>.
</p>
<!-- Arbre curé interactif (sélection AEP) -->
<p class="fmhy-tree-label">Sélection interactive — clique sur les branches pour déplier</p>
<div class="fmhy-tree-wrap">
<div v-if="fmhyPending" class="fmhy-loading" aria-label="Chargement…">
<span>Chargement…</span>
</div>
<div v-else-if="fmhyError" class="fmhy-error">
Impossible de charger les ressources. <a href="https://fmhy.net/" target="_blank" rel="noopener noreferrer">Explorer fmhy.net →</a>
</div>
<TreeASCII v-else-if="fmhyData" :tree="fmhyData" :depth="0" />
</div>
<div class="fmhy-footer">
<a href="https://fmhy.net/" target="_blank" rel="noopener noreferrer" class="fmhy-footer__link">
Explorer tout l'arbre → fmhy.net
</a>
</div>
</section>
<!-- ══════════════ SECTION 5 — Placeholder login ══════════════ -->
<section class="outils-section outils-section--placeholder" aria-labelledby="sec-logiciels">
<div class="placeholder-block">
<span class="placeholder-block__badge">Bientôt — nécessite un compte</span>
<h2 id="sec-logiciels" class="placeholder-block__title">{{ outils?.section_5_placeholder?.titre }}</h2>
<p class="placeholder-block__desc">{{ outils?.section_5_placeholder?.description }}</p>
</div>
</section>
<!-- ══════════════ FOOTER CONTRIBUTION ══════════════ -->
<footer class="outils-footer">
<p class="outils-footer__text">
{{ outils?.footer_contribution }}
</p>
</footer>
</main>
</div>
</template>
<script setup lang="ts">
// Chargement des données
const { data: outils } = await useFetch('/data/outils.json')
const { data: fmhyData, pending: fmhyPending, error: fmhyError } = await useFetch('/data/fmhy-curated.json')
// Note détaillée FMHY — cartographie traduite et organisée par usage
// (entrées ★ « starred » de fmhy.net, snapshot du 25 août 2026)
const fmhyGuide = [
{
id: 'vie-privee',
titre: 'Vie privée & adblocking — les bases',
note: 'Le premier réflexe du guide débutant fmhy : un bon bloqueur de pubs rend la moitié du web à nouveau respirable.',
liens: [
{ nom: 'uBlock Origin', url: 'https://github.com/gorhill/uBlock', desc: 'bloqueur de pubs de référence, léger et open source' },
{ nom: 'SponsorBlock', url: 'https://sponsor.ajay.app/', desc: 'saute automatiquement les segments sponsorisés de YouTube' },
{ nom: 'LibreWolf', url: 'https://librewolf.net/', desc: 'Firefox durci vie privée, pré-configuré' },
{ nom: 'Proton VPN', url: 'https://protonvpn.com', desc: 'VPN suisse, offre gratuite illimitée' },
{ nom: 'Bitwarden', url: 'https://bitwarden.com/', desc: 'gestionnaire de mots de passe open source' },
{ nom: 'Cryptomator', url: 'https://cryptomator.org/', desc: 'chiffrement de tes fichiers avant envoi dans le cloud' },
{ nom: 'Tor Browser', url: 'https://www.torproject.org/', desc: 'navigation anonyme via le réseau Tor' },
{ nom: 'KeePassXC', url: 'https://keepassxc.org/', desc: 'coffre de mots de passe 100% local' }
]
},
{
id: 'livres',
titre: 'Trouver un livre / ebook',
liens: [
{ nom: "Anna's Archive", url: 'https://annas-archive.gl/', desc: 'la plus grosse bibliothèque du web : livres, BD, articles, thèses' },
{ nom: 'Z-Library', url: 'https://z-lib.gd/', desc: 'catalogue immense, interface soignée' },
{ nom: 'Library Genesis', url: 'https://libgen.li/', desc: 'référence pour les livres techniques et académiques' },
{ nom: 'Mobilism', url: 'https://forum.mobilism.org', desc: 'ebooks, audiobooks, magazines (forum)' },
{ nom: 'Project Gutenberg', url: 'https://www.gutenberg.org/', desc: '70 000+ classiques du domaine public, 100% légal' },
{ nom: 'Sci-Hub', url: 'https://sci-hub.se/', desc: 'accès libre aux articles scientifiques derrière paywall' },
{ nom: 'Calibre', url: 'https://calibre-ebook.com/', desc: 'gestion et conversion de bibliothèque numérique' },
{ nom: 'KOReader', url: 'https://koreader.rocks/', desc: 'lecteur ebook open source pour liseuses' }
]
},
{
id: 'films',
titre: 'Films, séries & anime',
liens: [
{ nom: 'Rive', url: 'https://www.rivestream.app/', desc: 'streaming films / séries / anime, auto-next' },
{ nom: 'Cineby', url: 'https://cineby.at/', desc: 'streaming films / séries / anime' },
{ nom: 'Flixer', url: 'https://flixer.gd', desc: 'streaming films / séries / anime' },
{ nom: 'Tubi', url: 'https://tubitv.com', desc: 'streaming légal et gratuit (financé par la pub)' },
{ nom: 'Stremio', url: 'https://www.stremio.com/', desc: 'centre multimédia extensible par extensions' },
{ nom: 'Miruro', url: 'https://www.miruro.com/', desc: 'anime en streaming' },
{ nom: 'Simkl', url: 'https://simkl.com/', desc: 'suivi de films, séries et anime déjà vus' }
]
},
{
id: 'musique',
titre: 'Musique & podcasts',
liens: [
{ nom: 'Monochrome', url: 'https://monochrome.tf/', desc: 'streaming musique dans le navigateur' },
{ nom: 'lucida', url: 'https://lucida.to/', desc: 'téléchargement de musiques' },
{ nom: 'SpotX', url: 'https://github.com/SpotX-Official/SpotX', desc: 'Spotify desktop sans pub (patch client)' },
{ nom: 'NTS Radio', url: 'https://www.nts.live/', desc: 'shows de DJ indépendants, archives immenses' },
{ nom: 'SomaFM', url: 'https://somafm.com/', desc: '30+ radios internet indépendantes' },
{ nom: 'Radio Garden', url: 'https://radio.garden/', desc: 'explorer les radios du monde entier sur un globe' },
{ nom: 'Pocket Casts', url: 'https://play.pocketcasts.com/', desc: 'lecteur de podcasts libre et complet, aussi dans le navigateur' }
]
},
{
id: 'logiciels',
titre: 'Logiciels & jeux vidéo',
liens: [
{ nom: 'qBittorrent', url: 'https://www.qbittorrent.org/', desc: 'client torrent libre, sans pub — la référence' },
{ nom: 'FitGirl Repacks', url: 'https://fitgirl-repacks.site/', desc: 'repacks de jeux ultra-compressés' },
{ nom: 'SteamRIP', url: 'https://steamrip.com/', desc: 'jeux pré-installés' },
{ nom: '1337x', url: 'https://1337x.to/', desc: 'annuaire torrents généraliste' }
]
},
{
id: 'mobile',
titre: 'Android / iOS',
liens: [
{ nom: 'Aurora Store', url: 'https://auroraoss.com/', desc: 'Google Play alternatif, sans compte Google' },
{ nom: 'Droid-ify', url: 'https://droidify.app/', desc: 'client F-Droid moderne — applications libres' },
{ nom: 'APKMirror', url: 'https://www.apkmirror.com/', desc: 'archives APK vérifiées' },
{ nom: 'Fossify', url: 'https://github.com/FossifyOrg', desc: 'suite d’apps Android simples et libres (galerie, agenda, fichiers…)' },
{ nom: 'Obtainium', url: 'https://obtainium.imranr.dev/', desc: 'installe et met à jour tes APKs depuis les sources officielles' }
]
},
{
id: 'education',
titre: 'Éducation, cours & recherche',
liens: [
{ nom: 'MIT OpenCourseWare', url: 'https://ocw.mit.edu/', desc: 'les cours du MIT en accès libre complet' },
{ nom: 'edX', url: 'https://www.edx.org/', desc: 'MOOCs de grandes universités' },
{ nom: 'Khan Academy', url: 'https://www.khanacademy.org/', desc: 'maths, sciences, économie — gratuit, du collège au supérieur' },
{ nom: 'Class Central', url: 'https://www.classcentral.com/', desc: 'moteur de recherche de cours en ligne' },
{ nom: 'WolframAlpha', url: 'https://www.wolframalpha.com/', desc: 'moteur de calcul et base de connaissances' },
{ nom: 'PhET', url: 'https://phet.colorado.edu/', desc: 'simulations interactives physique / chimie / maths' }
]
},
{
id: 'ia',
titre: 'Intelligence artificielle gratuite',
liens: [
{ nom: 'DeepSeek', url: 'https://chat.deepseek.com/', desc: 'modèle de pointe, usage illimité gratuit' },
{ nom: 'Qwen Studio', url: 'https://chat.qwen.ai/', desc: 'chat IA (Qwen) illimité gratuit' },
{ nom: 'Google AI Studio', url: 'https://aistudio.google.com/', desc: 'Gemini en accès généreux gratuit' },
{ nom: 'Mistral Le Chat', url: 'https://chat.mistral.ai/', desc: 'LLM français, gratuit' },
{ nom: 'LM Studio', url: 'https://lmstudio.ai/', desc: 'faire tourner des modèles en local, sans cloud' },
{ nom: 'Jan', url: 'https://jan.ai/', desc: 'application desktop libre pour IA locale' },
{ nom: 'Open WebUI', url: 'https://openwebui.com/', desc: 'interface web auto-hébergeable pour modèles locaux' }
]
},
{
id: 'francophone',
titre: 'Contenus francophones',
liens: [
{ nom: 'Gallica', url: 'https://gallica.bnf.fr/', desc: 'millions de documents numérisés de la BnF, domaine public' },
{ nom: 'Nos Livres', url: 'https://noslivres.net/', desc: 'classiques français du domaine public, formats propres' },
{ nom: 'Audiocite', url: 'https://www.audiocite.net/', desc: 'livres audio francophones du domaine public' },
{ nom: 'Littérature audio', url: 'https://www.litteratureaudio.com/', desc: 'autre grande bibliothèque audio francophone libre' },
{ nom: 'zone-ebook', url: 'https://zone-ebook.com/', desc: 'ebooks, magazines et journaux en français' },
{ nom: 'Dealabs', url: 'https://www.dealabs.com/', desc: 'bons plans et gratuités débusqués par la communauté' }
]
}
]
useSeoMeta({
title: 'Outils — AEP',
description: 'Outils partagés entre architectes : simulateurs, open source, ressources libres FMHY, bifurcation post-études.'
})
</script>
<style scoped>
/* ── Layout global ──────────────────────────────────────────── */
.outils-page {
max-width: 860px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
color: var(--nav-text);
}
/* ── Header ─────────────────────────────────────────────────── */
.outils-header {
margin-bottom: 2.5rem;
}
.outils-header__inner {
display: flex;
align-items: flex-start;
gap: 1.25rem;
}
.outils-header__icon-wrap {
width: 3rem;
height: 3rem;
border-radius: 10px;
background: var(--nav-bg-alt);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0.6rem;
}
.outils-header__icon {
width: 100%;
height: 100%;
object-fit: contain;
opacity: 0.75;
}
.outils-header__title {
font-size: 1.75rem;
font-weight: 700;
margin: 0 0 0.5rem;
color: var(--nav-text);
}
.outils-header__intro {
font-size: 0.9rem;
color: var(--nav-text-muted);
line-height: 1.65;
margin: 0;
max-width: 70ch;
}
/* ── Sections ───────────────────────────────────────────────── */
.outils-main {
display: flex;
flex-direction: column;
gap: 3rem;
}
.outils-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.outils-section__title {
font-size: 1.15rem;
font-weight: 700;
margin: 0;
color: var(--nav-text);
display: flex;
align-items: center;
gap: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 1.5px solid var(--nav-bg-alt);
}
.outils-section__subtitle {
font-size: 0.82rem;
color: var(--nav-text-muted);
margin: -0.5rem 0 0;
font-style: italic;
}
.outils-section__desc {
font-size: 0.88rem;
color: var(--nav-text-muted);
line-height: 1.65;
margin: 0;
max-width: 72ch;
}
/* ── Simulateurs ────────────────────────────────────────────── */
.outils-section--simulateurs {
gap: 1.25rem;
}
.simu-grid {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.simu-inspirations {
margin-top: 0.5rem;
}
.simu-inspirations__label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--nav-text-muted);
margin-bottom: 0.5rem;
}
/* ── Cards grid ─────────────────────────────────────────────── */
.outil-cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0.75rem;
}
/* ── Section FMHY ───────────────────────────────────────────── */
.outils-section--fmhy {
background: var(--nav-bg-alt);
border-radius: 14px;
padding: 1.75rem;
gap: 1.25rem;
margin: 0 -0.25rem;
}
.fmhy-tree-wrap {
background: var(--nav-surface);
border: 1px solid var(--nav-bg-alt);
border-radius: 10px;
padding: 1.25rem 1.5rem;
overflow-x: auto;
}
.fmhy-loading,
.fmhy-error {
font-size: 0.85rem;
color: var(--nav-text-muted);
padding: 0.5rem 0;
}
.fmhy-error a {
color: var(--nav-primary-solid);
}
.fmhy-footer {
text-align: right;
}
.fmhy-footer__link {
font-size: 0.82rem;
font-weight: 600;
color: var(--nav-primary-solid);
text-decoration: none;
transition: opacity 0.15s;
}
.fmhy-footer__link:hover {
opacity: 0.75;
text-decoration: underline;
}
/* ── Bifurcation ────────────────────────────────────────────── */
.bifurcation-block {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.bifurcation-block__title {
font-size: 0.92rem;
font-weight: 600;
color: var(--nav-text);
margin: 0;
}
.bifurcation-block__desc {
font-size: 0.84rem;
color: var(--nav-text-muted);
margin: 0;
line-height: 1.55;
}
.bifurcation-block--coalition {
background: var(--nav-bg-alt);
border-radius: 8px;
padding: 0.875rem 1rem;
}
/* ── Liste OFQA ─────────────────────────────────────────────── */
.ofqa-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.ofqa-list__item {
display: flex;
}
.ofqa-list__link {
display: inline-flex;
align-items: baseline;
gap: 0.4rem;
flex-wrap: wrap;
padding: 3px 6px;
border-radius: 5px;
font-size: 0.84rem;
text-decoration: none;
color: var(--nav-text);
transition: background 0.1s;
}
.ofqa-list__link:not(.ofqa-list__link--disabled):hover {
background: var(--nav-bg-alt);
color: var(--nav-primary-solid);
text-decoration: underline;
}
.ofqa-list__link--disabled {
color: var(--nav-text-muted);
cursor: default;
}
.ofqa-list__ep {
font-family: 'JetBrains Mono', 'Courier New', monospace;
font-size: 0.75rem;
font-weight: 600;
color: var(--nav-text-muted);
flex-shrink: 0;
min-width: 4.5rem;
}
.ofqa-list__titre {
font-weight: 500;
}
.ofqa-list__personnes {
color: var(--nav-text-muted);
font-size: 0.82rem;
}
.ofqa-list__note {
color: var(--nav-text-muted);
font-size: 0.78rem;
font-style: italic;
}
/* ── Section 5 placeholder ──────────────────────────────────── */
.outils-section--placeholder {
opacity: 0.6;
}
.placeholder-block {
border: 1.5px dashed var(--nav-bg-alt);
border-radius: 12px;
padding: 1.25rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.placeholder-block__badge {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--nav-text-muted);
}
.placeholder-block__title {
font-size: 0.95rem;
font-weight: 600;
color: var(--nav-text);
margin: 0;
}
.placeholder-block__desc {
font-size: 0.84rem;
color: var(--nav-text-muted);
margin: 0;
line-height: 1.5;
}
/* ── Footer ─────────────────────────────────────────────────── */
.outils-footer {
padding-top: 1rem;
border-top: 1px solid var(--nav-bg-alt);
text-align: center;
}
.outils-footer__text {
font-size: 0.84rem;
color: var(--nav-text-muted);
margin: 0;
}
/* ── Mobile ─────────────────────────────────────────────────── */
@media (max-width: 640px) {
.outils-page {
padding: 1.25rem 1rem 4rem;
}
.outils-header__inner {
flex-direction: column;
gap: 0.75rem;
}
.outils-header__title {
font-size: 1.4rem;
}
.outil-cards-grid {
grid-template-columns: 1fr;
}
.outils-section--fmhy {
padding: 1.25rem 1rem;
margin: 0 -0.5rem;
}
/* ── Note FMHY détaillée (par usage) ────────────────────────── */
.fmhy-usage {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.fmhy-usage__title {
font-size: 0.92rem;
font-weight: 650;
color: var(--nav-text);
margin: 0;
}
.fmhy-usage__note {
font-size: 0.8rem;
color: var(--nav-text-muted);
font-style: italic;
margin: 0;
line-height: 1.5;
}
.fmhy-usage__list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.fmhy-usage__item {
font-size: 0.84rem;
line-height: 1.5;
padding: 2px 6px;
border-radius: 5px;
}
.fmhy-usage__item:hover {
background: var(--nav-surface);
}
.fmhy-usage__link {
color: var(--nav-primary-solid);
font-weight: 500;
text-decoration: none;
}
.fmhy-usage__link:hover {
text-decoration: underline;
}
.fmhy-usage__desc {
color: var(--nav-text-muted);
}
.fmhy-warning {
background: var(--nav-surface);
border: 1px solid var(--nav-bg-alt);
border-left: 3px solid var(--nav-primary-solid);
border-radius: 8px;
padding: 0.875rem 1rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.fmhy-warning__title {
font-size: 0.85rem;
font-weight: 650;
color: var(--nav-text);
margin: 0;
}
.fmhy-warning__text {
font-size: 0.82rem;
color: var(--nav-text-muted);
line-height: 1.6;
margin: 0;
}
.fmhy-warning__text a {
color: var(--nav-primary-solid);
}
.fmhy-snapshot {
font-size: 0.78rem;
color: var(--nav-text-muted);
font-style: italic;
margin: 0;
}
.fmhy-snapshot a {
color: var(--nav-primary-solid);
}
.fmhy-tree-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--nav-text-muted);
margin: 0.5rem 0 -0.5rem;
}
.fmhy-tree-wrap {
padding: 0.875rem 0.75rem;
}
}
</style>
-248
View File
@@ -1,248 +0,0 @@
<template>
<div class="proposer-page">
<div class="proposer-inner">
<NuxtLink to="/" class="back-link">← Retour à la carte</NuxtLink>
<div class="proposer-header">
<h1>Proposer une ressource</h1>
<p class="proposer-subtitle">
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>
<div v-if="success" class="success-block" role="status" aria-live="polite">
<div class="success-icon">✓</div>
<h2>Merci !</h2>
<p>Ta proposition est en attente de modération.</p>
<p class="success-detail">Jules valide manuellement chaque entrée avant publication.</p>
<button type="button" class="btn-secondary" @click="reset">Proposer autre chose</button>
</div>
<template v-else>
<FormLibre :preset-type="presetType" @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>
</div>
</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' },
{ id: 'jobs', label: 'Jobs' },
{ id: 'outils', label: 'Outils' },
{ id: 'references', label: 'Références (bibliothèque)' },
] as const
// ?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() {
success.value = true
}
function reset() {
success.value = false
activeTab.value = 'ecosysteme'
detailleOuvert.value = false
}
useHead({ title: 'Proposer une ressource — AEP' })
</script>
<style scoped>
.proposer-page {
min-height: 100vh;
background: var(--nav-bg);
padding: 1.5rem 1rem 4rem;
}
.proposer-inner {
max-width: 680px;
margin: 0 auto;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--nav-primary-solid);
opacity: 0.7;
text-decoration: none;
margin-bottom: 1.5rem;
transition: opacity 0.15s;
}
.back-link:hover { opacity: 1; }
.proposer-header { margin-bottom: 1.5rem; }
.proposer-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--nav-text);
margin: 0 0 0.5rem;
}
.proposer-subtitle {
font-size: 0.9rem;
color: var(--nav-text-muted);
line-height: 1.5;
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;
gap: 0.25rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--nav-bg-alt);
padding-bottom: 0;
}
.tab-btn {
padding: 0.5rem 0.9rem;
font-size: 0.8rem;
font-weight: 500;
color: var(--nav-text-muted);
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-family: inherit;
transition: color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.tab-btn:hover { color: var(--nav-text); }
.tab-btn--active {
color: var(--nav-text);
border-bottom-color: var(--nav-primary-solid);
font-weight: 600;
}
.tab-content {
min-height: 300px;
}
.success-block {
background: var(--nav-surface);
border: 1px solid rgba(26, 34, 56, 0.15);
border-radius: 12px;
padding: 2rem 1.5rem;
text-align: center;
}
.success-icon {
width: 48px; height: 48px;
background: rgba(26, 34, 56, 0.1);
color: var(--nav-text);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
font-weight: 700;
margin: 0 auto 1rem;
}
.success-block h2 {
font-size: 1.25rem;
font-weight: 700;
color: var(--nav-text);
margin: 0 0 0.5rem;
}
.success-block p {
font-size: 0.9rem;
color: var(--nav-text-muted);
margin: 0 0 0.5rem;
line-height: 1.5;
}
.success-detail { font-size: 0.85rem !important; }
.btn-secondary {
padding: 0.75rem 1.25rem;
background: transparent;
color: var(--nav-text-muted);
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 8px;
font-size: 0.9rem;
cursor: pointer;
font-family: inherit;
text-decoration: none;
display: inline-flex;
align-items: center;
margin-top: 0.5rem;
transition: border-color 0.15s, color 0.15s;
}
.btn-secondary:hover {
border-color: var(--nav-primary-solid);
color: var(--nav-text);
}
@media (max-width: 480px) {
.proposer-page { padding: 1rem 0.75rem 3rem; }
.tabs-bar { overflow-x: auto; flex-wrap: nowrap; }
}
</style>
-3
View File
@@ -1,3 +0,0 @@
<script setup lang="ts">
navigateTo('/media', { redirectCode: 301 })
</script>
+1 -6
View File
@@ -5,7 +5,6 @@
<div class="taff-header">
<div class="taff-header-inner">
<h1 class="taff-title">Trouver du taf en archi</h1>
<EchelleTete echelle="S" :contribuer="false" class="taff-echelle-tete" />
<p class="taff-subtitle">
Annuaire critique des plateformes de mise en relation et d'appels d'offres,
pour les <strong>architectes indépendants</strong> — 70&nbsp;% de la profession et sa part la plus précaire économiquement.
@@ -297,7 +296,7 @@
</svg>
</button>
</div>
<p class="taff-chat-footer-note">20 questions/jour</p>
<p class="taff-chat-footer-note">Propulsé par Mistral · 20 questions/jour</p>
</div>
</Transition>
</Teleport>
@@ -650,10 +649,6 @@ const parsedDescription = computed(() => {
.taff-header { padding: 2.5rem 1.5rem 1.5rem; border-bottom: 1px solid var(--nav-bg-alt); text-align: center; }
.taff-header-inner { max-width: 680px; margin: 0 auto; }
/* En-tête Jobs centré : la tête d'échelle suit. */
.taff-echelle-tete { text-align: center; margin-bottom: var(--space-md); }
.taff-echelle-tete :deep(.echelle-tete__kicker),
.taff-echelle-tete :deep(.echelle-tete__mots) { justify-content: center; }
.taff-title { font-size: 1.875rem; font-weight: 800; color: var(--nav-text); margin-bottom: 0.5rem; letter-spacing: -0.02em; }
.taff-subtitle { font-size: 0.9375rem; color: var(--nav-text-muted); line-height: 1.6; margin-bottom: 0.625rem; }
.taff-cible { font-size: 0.875rem; color: var(--nav-text-muted); line-height: 1.55; margin-bottom: 1rem; font-style: italic; }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
-102
View File
@@ -1,102 +0,0 @@
{
"name": "FMHY — Sélection Architecte",
"description": "~50 ressources curées depuis FMHY (Free Media Heck Yeah) — pertinentes pour un architecte, un praticien de la transition, un créateur solo.",
"children": [
{
"name": "IA & Outils cognitifs",
"children": [
{ "name": "ChatGPT (OpenAI)", "url": "https://chat.openai.com/", "desc": "LLM généraliste, référence." },
{ "name": "Claude (Anthropic)", "url": "https://claude.ai/", "desc": "Excellent pour rédaction longue et analyse de documents." },
{ "name": "Mistral Le Chat", "url": "https://chat.mistral.ai/", "desc": "LLM français, gratuit." },
{ "name": "Perplexity", "url": "https://www.perplexity.ai/", "desc": "Moteur de recherche IA avec sources citées." },
{ "name": "Hugging Face", "url": "https://huggingface.co/", "desc": "Hub de modèles open source. Indispensable." },
{ "name": "LM Studio", "url": "https://lmstudio.ai/", "desc": "Faire tourner des LLM localement, sans cloud." }
]
},
{
"name": "Lecture & Documentation",
"children": [
{ "name": "Anna's Archive", "url": "https://annas-archive.org/", "desc": "Bibliothèque shadow la plus complète du web. Livres, articles, thèses." },
{ "name": "Sci-Hub", "url": "https://sci-hub.se/", "desc": "Accès libre aux articles scientifiques payants." },
{ "name": "Library Genesis", "url": "https://libgen.is/", "desc": "Livres techniques et académiques en PDF." },
{ "name": "Z-Library", "url": "https://z-lib.id/", "desc": "Bibliothèque numérique massive, interface soignée." },
{ "name": "OpenLibrary (Internet Archive)", "url": "https://openlibrary.org/", "desc": "Prêt numérique gratuit, millions de livres." },
{ "name": "Calibre", "url": "https://calibre-ebook.com/", "desc": "Gestion de bibliothèque numérique, convertisseur de formats." }
]
},
{
"name": "Dessin & Modélisation",
"children": [
{ "name": "FreeCAD", "url": "https://www.freecad.org/", "desc": "Modélisation 3D open source, paramétrique. Alternative à Rhino pour usage simple." },
{ "name": "Blender", "url": "https://www.blender.org/", "desc": "3D, rendu, animation. La référence open source." },
{ "name": "Inkscape", "url": "https://inkscape.org/", "desc": "Dessin vectoriel. Alternative à Illustrator." },
{ "name": "GIMP", "url": "https://www.gimp.org/", "desc": "Retouche photo. Alternative à Photoshop." },
{ "name": "Krita", "url": "https://krita.org/", "desc": "Dessin digital et croquis. Excellent pour les concepts." },
{ "name": "LibreOffice Draw", "url": "https://www.libreoffice.org/", "desc": "Diagrammes, plans rapides, sans suite Adobe." }
]
},
{
"name": "Productivité & Texte",
"children": [
{ "name": "Obsidian", "url": "https://obsidian.md/", "desc": "PKM / prise de notes en Markdown. Gratuit pour usage personnel." },
{ "name": "Logseq", "url": "https://logseq.com/", "desc": "PKM open source, graphe de connaissances." },
{ "name": "Zotero", "url": "https://www.zotero.org/", "desc": "Gestionnaire de références bibliographiques. Indispensable pour la recherche." },
{ "name": "Marktext", "url": "https://github.com/marktext/marktext", "desc": "Éditeur Markdown WYSIWYG, open source." },
{ "name": "Typst", "url": "https://typst.app/", "desc": "Alternative moderne à LaTeX pour la mise en page de documents." },
{ "name": "Pandoc", "url": "https://pandoc.org/", "desc": "Conversion universelle entre formats de documents (MD, DOCX, PDF, HTML...)." }
]
},
{
"name": "Dev & Infrastructure",
"children": [
{ "name": "VS Code", "url": "https://code.visualstudio.com/", "desc": "Éditeur de code. La référence, gratuit." },
{ "name": "Coolify", "url": "https://coolify.io/", "desc": "Self-hosting simplifié. Alternative à Heroku/Vercel sur son propre VPS." },
{ "name": "Hetzner Cloud", "url": "https://www.hetzner.com/cloud/", "desc": "VPS européen, tarifs très bas, data centers Allemagne." },
{ "name": "Caddy", "url": "https://caddyserver.com/", "desc": "Serveur web avec HTTPS automatique. Plus simple que Nginx." },
{ "name": "n8n", "url": "https://n8n.io/", "desc": "Automatisation open source (comme Zapier mais self-hostable)." },
{ "name": "Gitea", "url": "https://gitea.io/", "desc": "Hébergement Git self-hosted. Alternative à GitHub." }
]
},
{
"name": "Vie privée & Sécurité",
"children": [
{ "name": "Bitwarden", "url": "https://bitwarden.com/", "desc": "Gestionnaire de mots de passe open source. Self-hostable." },
{ "name": "ProtonMail", "url": "https://proton.me/mail", "desc": "Email chiffré, hébergement Suisse." },
{ "name": "Signal", "url": "https://signal.org/", "desc": "Messagerie chiffrée E2E. La référence." },
{ "name": "uBlock Origin", "url": "https://ublockorigin.com/", "desc": "Bloqueur de publicités et trackers, le plus efficace." },
{ "name": "Mullvad VPN", "url": "https://mullvad.net/", "desc": "VPN respectueux de la vie privée, sans compte email requis." },
{ "name": "Privacy Guides", "url": "https://www.privacyguides.org/", "desc": "Recommandations d'outils respectueux de la vie privée, par thème." }
]
},
{
"name": "Formation & Apprentissage",
"children": [
{ "name": "MIT OpenCourseWare", "url": "https://ocw.mit.edu/", "desc": "Cours du MIT en libre accès, toutes disciplines." },
{ "name": "Khan Academy", "url": "https://www.khanacademy.org/", "desc": "Maths, sciences, programmation — gratuit, pédagogie excellente." },
{ "name": "YouTube (canaux techniques)", "url": "https://www.youtube.com/", "desc": "Channals : The Coding Train, Fireship, 3Blue1Brown, etc." },
{ "name": "freeCodeCamp", "url": "https://www.freecodecamp.org/", "desc": "Apprendre le développement web de zéro, gratuit et certifiant." },
{ "name": "Coursera (audit gratuit)", "url": "https://www.coursera.org/", "desc": "Cours universitaires, audit gratuit disponible sur la plupart." }
]
},
{
"name": "Médias & Audio",
"children": [
{ "name": "Audacity", "url": "https://www.audacityteam.org/", "desc": "Enregistrement et édition audio. Référence open source." },
{ "name": "yt-dlp", "url": "https://github.com/yt-dlp/yt-dlp", "desc": "Télécharger des vidéos/audio depuis YouTube et 1000+ sites." },
{ "name": "VLC", "url": "https://www.videolan.org/vlc/", "desc": "Lecteur multimédia universel." },
{ "name": "Kdenlive", "url": "https://kdenlive.org/", "desc": "Montage vidéo open source, non linéaire." },
{ "name": "OBS Studio", "url": "https://obsproject.com/", "desc": "Enregistrement et streaming vidéo. La référence gratuite." }
]
},
{
"name": "Divers Utiles",
"children": [
{ "name": "Nextcloud", "url": "https://nextcloud.com/", "desc": "Cloud personnel self-hosted. Alternative à Google Drive/Dropbox." },
{ "name": "Joplin", "url": "https://joplinapp.org/", "desc": "Notes chiffrées, sync Nextcloud, open source." },
{ "name": "draw.io / diagrams.net", "url": "https://app.diagrams.net/", "desc": "Diagrammes et schémas, gratuit, pas de compte requis." },
{ "name": "Excalidraw", "url": "https://excalidraw.com/", "desc": "Tableau blanc collaboratif, style hand-drawn, open source." },
{ "name": "Fmhy.net (complet)", "url": "https://fmhy.net/", "desc": "L'arbre complet. Des milliers de ressources organisées par thème." }
]
}
]
}
-90
View File
@@ -1,90 +0,0 @@
{
"simulateurs": [
{
"id": "autonomie",
"icon": "🟢",
"titre": "Simulateur Autonomie",
"url": "https://calculs.trans-former.fr/autonomie/",
"description": "Évaluer le degré d'autonomie d'une famille à un site donné selon les ressources locales (eau, énergie, alimentation).",
"cta": "Lancer le simulateur →",
"tag": "outil-aep"
}
],
"simulateurs_inspirations": [
{
"id": "florquin-prix-m2",
"icon": "💡",
"titre": "Estimation prix au m² — Florquin Studio",
"url": "https://offre.florquinstudio.com/",
"description": "Une agence parisienne qui a construit un système d'estimation au m² assez fin. Pas dans nos outils, mais inspirant pour qui veut industrialiser son chiffrage.",
"tag": "inspiration-externe"
}
],
"opensource": [
{
"id": "dictee-universelle-groq",
"icon": "🎤",
"titre": "Dictée universelle Groq",
"url": "https://github.com/Jayjay-nene/dictee-universelle-groq",
"description": "Appuie sur une touche, parle, le texte apparaît au curseur avec ponctuation et majuscules auto. Transcription Whisper Groq < 1s. Marche dans toutes les applis Windows. Outil par Jayjay-nene.",
"tag": "recommande"
},
{
"id": "atis-voice",
"icon": "🎙",
"titre": "Atis Voice (text-to-speech)",
"url": null,
"description": "Pipeline TTS pour transformer un texte en audio.",
"tag": "disponible"
},
{
"id": "install-vps",
"icon": "🖥",
"titre": "Install VPS open source",
"url": null,
"description": "Setup pas-à-pas pour monter son propre VPS (Hetzner, Coolify, Caddy, Postgres…) en mode reproductible.",
"tag": "a-venir"
},
{
"id": "skills-claude-code",
"icon": "⚙",
"titre": "Skills Claude Code",
"url": null,
"description": "Skills custom pour booster sa pratique avec un agent IA.",
"tag": "a-venir"
}
],
"bifurcation": {
"intro": "Beaucoup de jeunes diplômés en archi cherchent des chemins alternatifs. Cette section rassemble des témoignages, expériences, et ressources sur ce que c'est que de bifurquer.",
"videos_ofqa": [
{ "ep": "01", "titre": "Architectes indépendants", "personnes": "Jules Nény & Imane Fatmi", "url": "https://youtu.be/aMreB5KdNhY" },
{ "ep": "02", "titre": "Artiste & Maçon", "personnes": "Romane Dutour & Maël Canal", "url": "https://youtu.be/9gpjokx2ndI" },
{ "ep": "03", "titre": "Social & BTP", "personnes": "Célia Berdy & Esilda Perrot", "url": null, "note": "vidéo perdue, doc PDF seulement" },
{ "ep": "04", "titre": "Menuisier & Paysagiste", "personnes": "Adel Mohamedi & Julie Bowie", "url": "https://youtu.be/yKaRQhA3Z6g" },
{ "ep": "05", "titre": "Éco-construction", "personnes": "Edouard Vermès", "url": "https://youtu.be/97bDg1BjeuQ" },
{ "ep": "06", "titre": "Musicien & Urbaniste", "personnes": "Ruben Madar & Antoine Troccaz", "url": "https://drive.google.com/drive/folders/14g8YBn5bZAy8aIkHzQlOTrTtnWOaqRO3" },
{ "ep": "07", "titre": "AMO & Réemploi", "personnes": "Domitille Chaigne & Clémence Bondon", "url": "https://drive.google.com/file/d/1Q9Za81CElszmMn5n8dBsG0pJkiWmB32c/view" },
{ "ep": "08", "titre": "Gouvernance école", "personnes": "Solenn Guével", "url": "https://drive.google.com/drive/folders/1UaLsSyQcJydkXyV71klrY1tv9KgAh-mG" },
{ "ep": "bonus", "titre": "PFE — invitation à faire collectif", "personnes": "Jules Nény & Imane Fatmi", "url": "https://youtu.be/4qTEIC2Lmqw" }
],
"coalition_ensa_pb": {
"titre": "Coalition inter-asso ENSA-PB — victoire salle des enseignants",
"description": "Une coalition d'associations étudiantes a obtenu l'usage de la salle des enseignants pour des temps de travail collectif."
},
"ressources_externes": [
{
"id": "drop-the-kutch",
"icon": "🎧",
"titre": "Podcast Drop the Kutch — Sâm Afchar",
"url": "https://podcasts-francais.fr/podcast/drop-the-kutsch",
"description": "Témoignages de bifurcations post études d'archi. Super taf."
}
]
},
"section_5_placeholder": {
"titre": "[V2 — Login] Logiciels pro",
"description": "Logiciels lourds (Adobe, AutoCAD…) — accès mutualisé. Disponible après création de compte AEP.",
"status": "bientot-login"
},
"footer_contribution": "Tu utilises un outil qui mérite d'être ici ? Écris-moi : contact@trans-former.fr"
}
-70
View File
@@ -1,70 +0,0 @@
{
"projets": [
{
"id": "quartier-2030",
"titre": "Votre quartier en 2030",
"auteurs": ["Inconnu"],
"annee": "2020",
"ecole": "Inconnu",
"url": "https://quartier-2030.firebaseapp.com/",
"description": "Exploration prospective confrontant smart city, no future, résilience et deep ecology à l'échelle du quartier. Le travail donne à voir plusieurs futurs urbains contrastés, de l'utopie technologique au retrait radical, en laissant le visiteur naviguer entre les scénarios. Un travail d'orfèvre pour sortir de la pensée linéaire sur la ville.",
"thumb": null,
"link_status": "ok"
},
{
"id": "seine-nature",
"titre": "Seine — nature urbaine",
"auteurs": ["Inconnu"],
"annee": "2019",
"ecole": "Inconnu",
"url": "http://www.seine.natureurbaine.com/00_index/page_theme/theme.html",
"description": "Projet de transformation territoriale collective autour de la Seine, pensé comme une démarche systémique et pluridisciplinaire. L'intervention se concentre sur les marges périurbaines, traitées par une logique d'acupuncture : des micro-interventions précises pour enclencher des dynamiques plus larges. L'approche refuse le grand projet unique au profit d'un réseau de petites transformations.",
"thumb": null,
"link_status": "ok"
},
{
"id": "tmip",
"titre": "TMIP — Transformation de la Maison Individuelle Périurbaine",
"auteurs": ["Jules Nény"],
"annee": "2019",
"ecole": "ENSA Paris-Belleville",
"url": "https://issuu.com/transformationresilientes/docs/tmip_archijeunes_cstb_",
"description": "Étude de la maison périurbaine sous l'angle des Gilets jaunes : comment ce lieu de vie concentre les tensions entre émancipation individuelle et dépendance structurelle (voiture, énergie, services). Le projet propose un réseau de micro-infrastructures partagées pour transformer ces maisons isolées en systèmes résilients interconnectés. Publié avec ARCHI'JEUNES et le CSTB.",
"thumb": null,
"link_status": "ok"
},
{
"id": "filiere-bois",
"titre": "Enquête sur les paysages forestiers franciliens",
"auteurs": ["Quid Architecture"],
"annee": "2021",
"ecole": "Inconnu",
"url": "https://www.faireparis.com/fr/projets/faire-2021/enquete-sur-les-paysages-forestiers-franciliens-2159.html",
"description": "Projet lauréat FAIRE 2021. Enquête sur les dysfonctionnements de la filière bois en Île-de-France, aux interfaces entre sylviculteurs, scieries, artisans et maîtres d'ouvrage. Le travail cartographie les ruptures de filière et propose des interventions concrètes pour réparer les liens entre forêt et construction. Une démarche systémique rare dans les études architecturales.",
"thumb": null,
"link_status": "ok"
},
{
"id": "jeu-champagne",
"titre": "Jeu de rôle Champagne PFE — Plateau",
"auteurs": ["Inconnu"],
"annee": "2020",
"ecole": "Inconnu",
"url": "https://campfe2020.wixsite.com/champagnepfe/plateau",
"description": "Dispositif ludique et coopératif développé comme outil de médiation entre acteurs d'un territoire. Le jeu de rôle permet de traverser des problèmes complexes en engageant simultanément des parties prenantes aux intérêts divergents. Une exploration de l'architecture comme processus collectif plutôt que comme objet produit.",
"thumb": null,
"link_status": "ok"
},
{
"id": "transition-agricole",
"titre": "Transition agricole — réinvestissement de fermes traditionnelles",
"auteurs": ["Inconnu"],
"annee": "2020",
"ecole": "Inconnu",
"url": "https://www.calameo.com/books/007306483e0b23edb1db7",
"description": "Projet sur la transformation de fermes traditionnelles dans une logique agricole moderne et diversifiée. L'étude explore comment l'architecture peut accompagner les transitions d'usage des bâtiments ruraux, en articulant patrimonial et fonctionnel. Voir aussi le projet complémentaire sur la Seine aval : https://www.calameo.com/books/007063623f4d4b800b01d",
"thumb": null,
"link_status": "ok"
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 527 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

-21
View File
@@ -1,21 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="AEP maillage">
<rect width="32" height="32" rx="7" fill="#14142B"/>
<g stroke="#E9A23B" stroke-width="1.4" stroke-linecap="round">
<line x1="16" y1="6" x2="26.5" y2="13.5"/>
<line x1="26.5" y1="13.5" x2="22.5" y2="26"/>
<line x1="22.5" y1="26" x2="9.5" y2="26"/>
<line x1="9.5" y1="26" x2="5.5" y2="13.5"/>
<line x1="5.5" y1="13.5" x2="16" y2="6"/>
<line x1="15" y1="16.5" x2="16" y2="6"/>
<line x1="15" y1="16.5" x2="22.5" y2="26"/>
<line x1="15" y1="16.5" x2="5.5" y2="13.5"/>
</g>
<g fill="#E9A23B">
<circle cx="16" cy="6" r="2.4"/>
<circle cx="26.5" cy="13.5" r="2.4"/>
<circle cx="22.5" cy="26" r="2.4"/>
<circle cx="9.5" cy="26" r="2.4"/>
<circle cx="5.5" cy="13.5" r="2.4"/>
</g>
<circle cx="15" cy="16.5" r="2.9" fill="#F5C560"/>
</svg>

Before

Width:  |  Height:  |  Size: 892 B

-12
View File
@@ -1,12 +0,0 @@
# Icons Credits
## outils-wrench.svg
**Source :** Heroicons — `wrench-screwdriver` icon
**Licence :** MIT
**URL :** https://heroicons.com/
**Note :** Fallback Heroicons utilisé car l'API Noun Project a retourné 403 Forbidden au moment du build (2026-05-22). L'icône `wrench-screwdriver` de Heroicons (MIT) est une clé à molette + tournevis, style line art minimaliste, compatible avec l'identité visuelle AEP.
Si tu veux substituer par une icône Noun Project, utilise l'endpoint :
`GET https://api.thenounproject.com/v2/icon?query=wrench&limit=20`
avec les credentials OAuth 1.0a stockées dans `_System/API-credentials.md`.
-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l5.654-4.654m5.598-2.337 3.07-3.293a2.25 2.25 0 0 0-3.182-3.182l-3.293 3.07M6.75 12.75l-2.25.75.75-2.25 2.25-.75-.75 2.25Z" />
</svg>

Before

Width:  |  Height:  |  Size: 493 B

-179
View File
@@ -1,179 +0,0 @@
#!/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)
-75
View File
@@ -1,75 +0,0 @@
#!/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)
-140
View File
@@ -1,140 +0,0 @@
#!/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)
-190
View File
@@ -1,190 +0,0 @@
#!/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)
+50 -93
View File
@@ -1,12 +1,19 @@
/**
* POST /api/chatbot-pensees
* Chatbot RAG Pensées Écologiques — corpus FRACAS Bonpote
* Appelle LightRAG PE (localhost:9621) avec contexte auteur optionnel.
*/
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import type { H3Event } from 'h3'
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
interface ChatbotPenseesRequest {
query: string
mode?: 'hybrid' | 'local' | 'global' | 'naive' | 'mix'
corpus?: 'pensees' | 'projets' | 'both'
filter_couche?: 'fond' | 'forme' | 'structure' | null
filter_ecole?: string | null
history?: Array<{ role: 'user' | 'assistant'; content: string }>
}
interface LightRAGQueryResponse {
response: string
}
const SYSTEM_PREFACE_PENSEES = `Tu es un agent du RAG Pensées Écologiques, infrastructure militante du collectif trans-former.fr.
Tu réponds en t'appuyant STRICTEMENT sur le corpus ingéré (auteurs FRACAS Bonpote : écosocialisme, éco-anarchisme, écoféminismes, écologies décoloniales, technocritique, pensées du vivant, décroissance...).
@@ -40,122 +47,72 @@ Règles :
- Ton praticien militant : direct, pas neutre, ancré dans la pratique architecturale.
- Réponse en français, dense, sans délayage.`
function buildPrefaceAuteur(nomAuteur: string, slug: string): string {
return `Tu réponds EXCLUSIVEMENT depuis les livres de ${nomAuteur} présents dans le RAG (fichiers commençant par "${slug}__").
Si la question sort du périmètre de cet auteur, indique-le et propose de l'aborder sans le hashtag pour interroger la carte entière. Reste fidèle au style et à la pensée de ${nomAuteur}. Cite toujours le livre.
export default defineEventHandler(async (event: H3Event) => {
const config = useRuntimeConfig(event)
Règles :
- Cite les sources (titre du livre) à chaque assertion.
- Pas d'hallucination. Si l'info n'est pas dans le corpus de cet auteur, dis-le.
- N'introduis JAMAIS d'autres auteurs sauf si ${nomAuteur} les commente explicitement.
- Ton politique direct, pas de neutralité fade.
- Réponse en français, dense, sans délayage.`
}
// 1. Rate limit (20 req/jour/IP, IP hashée RGPD)
const ip =
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
event.node.req.socket?.remoteAddress ||
'0.0.0.0'
interface AuteurIngere { id: string; nom: string; ingere: boolean }
let auteursIngeresCache: AuteurIngere[] | null = null
function loadAuteursIngeres(): AuteurIngere[] {
if (auteursIngeresCache) return auteursIngeresCache
try {
const jsonPath = join(process.cwd(), 'public', 'data', 'auteurs-pensees.json')
const raw = readFileSync(jsonPath, 'utf-8')
const data = JSON.parse(raw)
const list = (data.auteurs ?? [])
.filter((a: any) => a.ingere === true)
.map((a: any) => ({ id: String(a.id), nom: String(a.nom), ingere: true }))
auteursIngeresCache = list
return list
} catch {
auteursIngeresCache = []
return []
}
}
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ip = getHeader(event, 'x-forwarded-for')?.split(',')[0].trim()
|| event.node.req.socket?.remoteAddress
|| '0.0.0.0'
const allowed = checkRateLimitJson(ip, 'chatbot-pensees', 20)
if (!allowed) throw createError({ statusCode: 429, message: 'Limite de 20 questions par jour atteinte.' })
if (!allowed) {
throw createError({ statusCode: 429, message: 'Limite de 20 questions par jour atteinte.' })
}
const body = await readBody(event)
// 2. Body parse + validation
const body = await readBody<ChatbotPenseesRequest>(event)
if (!body?.query || body.query.trim().length < 3 || body.query.trim().length > 500) {
throw createError({ statusCode: 400, message: 'Query invalide (3-500 caractères).' })
}
const query: string = body.query.trim()
const mode: string = body.mode || 'hybrid'
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
const query = body.query.trim()
const mode = body.mode || 'hybrid'
const corpus = body.corpus || 'both'
const ragUrl = (config.ragPeUrl as string) || 'http://localhost:9621'
let nomAuteurMatch: string | null = null
if (auteurSlug) {
const ingeres = loadAuteursIngeres()
const auteur = ingeres.find(a => a.id === auteurSlug)
nomAuteurMatch = auteur?.nom ?? null
}
let systemPreface: string
if (auteurSlug && nomAuteurMatch) {
systemPreface = buildPrefaceAuteur(nomAuteurMatch, auteurSlug)
} else if (corpus === 'pensees') {
systemPreface = SYSTEM_PREFACE_PENSEES
} else if (corpus === 'projets') {
systemPreface = SYSTEM_PREFACE_PROJETS
} else {
systemPreface = SYSTEM_PREFACE_BOTH
}
// Préface adaptative selon corpus demandé
const systemPreface =
corpus === 'pensees'
? SYSTEM_PREFACE_PENSEES
: corpus === 'projets'
? SYSTEM_PREFACE_PROJETS
: SYSTEM_PREFACE_BOTH
// 3. Health guard — LightRAG down = erreur claire, pas de fallback hallucinatoire
try {
await $fetch(`${ragUrl}/health`, { timeout: 5000 })
} catch {
throw createError({ statusCode: 503, message: 'RAG indisponible pour l\'instant — réessaie dans quelques minutes.' })
throw createError({
statusCode: 503,
message: 'RAG indisponible pour l\'instant — réessaie dans quelques minutes.',
})
}
// 4. Call LightRAG VPS — préface système injectée dans la query
const ragQuery = `${systemPreface}\n\nQuestion : ${query}`
const ragBody: Record<string, any> = { query: ragQuery, mode }
if (auteurSlug && nomAuteurMatch) {
ragBody.hl_keywords = [nomAuteurMatch, auteurSlug]
ragBody.ll_keywords = [auteurSlug]
}
let ragResponse: any
let ragResponse: LightRAGQueryResponse
try {
ragResponse = await $fetch(`${ragUrl}/query`, {
ragResponse = await $fetch<LightRAGQueryResponse>(`${ragUrl}/query`, {
method: 'POST',
body: ragBody,
body: { query: ragQuery, mode },
timeout: 90000,
})
} catch (e: any) {
const status = e?.response?.status
if (status === 429) throw createError({ statusCode: 429, message: 'RAG saturé — réessaie dans quelques instants.' })
if (status === 429) {
throw createError({ statusCode: 429, message: 'RAG saturé — réessaie dans quelques instants.' })
}
throw createError({ statusCode: 504, message: 'RAG en cours de processing — réessaie dans quelques secondes.' })
}
let chunksOnTarget = 0
let chunksOffTarget = 0
if (auteurSlug && nomAuteurMatch && Array.isArray(ragResponse.references)) {
const slugPrefix = `${auteurSlug}__`
for (const ref of ragResponse.references) {
const fp = (ref.file_path ?? '').toLowerCase()
if (!fp) continue
if (fp.startsWith(slugPrefix)) chunksOnTarget++
else chunksOffTarget++
}
}
// 5. Retour formaté
return {
response: ragResponse.response ?? '',
mode,
corpus,
auteur: auteurSlug && nomAuteurMatch ? { slug: auteurSlug, nom: nomAuteurMatch } : null,
auteur_unmatched: auteurSlug && !nomAuteurMatch ? auteurSlug : null,
auteur_chunks: auteurSlug && nomAuteurMatch ? { on_target: chunksOnTarget, off_target: chunksOffTarget } : null,
filter: { couche: body.filter_couche ?? null, ecole: body.filter_ecole ?? null },
timestamp: new Date().toISOString(),
}
+9 -13
View File
@@ -1,12 +1,11 @@
/**
* POST /api/chatbot-reseaux
* Chatbot Réseaux AEP — Carte 2 "Réseaux de bifurcation"
* Keyword search sur reseaux-bifurcation.json + LLM via Bifrost (server/utils/bifrost.ts).
* Keyword search sur reseaux-bifurcation.json + Mistral Small.
*/
// @ts-ignore — JSON import résolu par Rollup
import reseauxData from '../../public/data/reseaux-bifurcation.json'
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { pickBifrostTier, bifrostContent, REASONING_MARGIN_TOKENS, type BifrostChatResponse } from '~/server/utils/bifrost'
interface Structure {
id: string
@@ -62,7 +61,6 @@ export default defineEventHandler(async (event) => {
const body = await readBody(event)
const question: string = (body?.question ?? '').trim()
if (!question || question.length < 5) throw createError({ statusCode: 400, message: 'Question trop courte.' })
const tier = pickBifrostTier(body?.mode)
const structures: Structure[] = ((reseauxData as any).structures ?? [])
const keywords = extractKeywords(question)
@@ -84,22 +82,20 @@ export default defineEventHandler(async (event) => {
const systemPrompt = SYSTEM_PROMPT.replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0))
const bifrostUrl = config.bifrostUrl as string
const bifrostVk = config.bifrostVk as string
if (!bifrostVk) throw createError({ statusCode: 500, message: 'Clé Bifrost manquante.' })
const mistralApiKey = config.mistralApiKey as string
if (!mistralApiKey) throw createError({ statusCode: 500, message: 'Clé API Mistral manquante.' })
let mistralRaw: string
try {
const res = await $fetch<BifrostChatResponse>(
`${bifrostUrl}/v1/chat/completions`,
const res = await $fetch<{ choices: { message: { content: string } }[] }>(
'https://api.mistral.ai/v1/chat/completions',
{
method: 'POST',
headers: { 'x-bf-vk': bifrostVk, 'Content-Type': 'application/json' },
headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: tier.model,
fallbacks: tier.fallbacks,
model: 'mistral-small-latest',
temperature: 0.3,
max_tokens: 700 + REASONING_MARGIN_TOKENS,
max_tokens: 700,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: systemPrompt },
@@ -108,7 +104,7 @@ export default defineEventHandler(async (event) => {
}),
}
)
mistralRaw = bifrostContent(res, 'chatbot-reseaux')
mistralRaw = res.choices?.[0]?.message?.content ?? '{}'
} catch {
throw createError({ statusCode: 502, message: 'Erreur IA — réessaie dans quelques instants.' })
}
+10 -14
View File
@@ -1,13 +1,12 @@
/**
* POST /api/chatbot-taff
* Chatbot d'aiguillage — Carte 3 "Trouver du taf"
* Lit plateformes-taff.json, appelle un LLM via Bifrost (server/utils/bifrost.ts), retourne recommandations.
* Lit plateformes-taff.json, appelle Mistral Small, retourne recommandations.
*/
// @ts-ignore — JSON import résolu par Vite/Rollup
import taffData from '../../public/data/plateformes-taff.json'
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { pickBifrostTier, bifrostContent, REASONING_MARGIN_TOKENS, type BifrostChatResponse } from '~/server/utils/bifrost'
interface PlateformeMinimal {
id: string
@@ -67,7 +66,6 @@ export default defineEventHandler(async (event) => {
if (!question || question.length < 5) {
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
}
const tier = pickBifrostTier(body?.mode)
// Données bundlées statiquement à la compilation (import JSON)
const plateformes: PlateformeMinimal[] = ((taffData as any).plateformes ?? []).map((p: any) => ({
@@ -93,24 +91,22 @@ export default defineEventHandler(async (event) => {
const systemPrompt = SYSTEM_PROMPT.replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0))
const bifrostUrl = config.bifrostUrl as string
const bifrostVk = config.bifrostVk as string
if (!bifrostVk) {
throw createError({ statusCode: 500, statusMessage: 'Clé Bifrost manquante.' })
const mistralApiKey = config.mistralApiKey as string
if (!mistralApiKey) {
throw createError({ statusCode: 500, statusMessage: 'Clé API Mistral manquante.' })
}
let mistralRaw: string
try {
const res = await $fetch<BifrostChatResponse>(
`${bifrostUrl}/v1/chat/completions`,
const res = await $fetch<{ choices: { message: { content: string } }[] }>(
'https://api.mistral.ai/v1/chat/completions',
{
method: 'POST',
headers: { 'x-bf-vk': bifrostVk, 'Content-Type': 'application/json' },
headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: tier.model,
fallbacks: tier.fallbacks,
model: 'mistral-small-latest',
temperature: 0.3,
max_tokens: 700 + REASONING_MARGIN_TOKENS,
max_tokens: 700,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: systemPrompt },
@@ -119,7 +115,7 @@ export default defineEventHandler(async (event) => {
}),
}
)
mistralRaw = bifrostContent(res, 'chatbot-taff')
mistralRaw = res.choices?.[0]?.message?.content ?? '{}'
} catch {
throw createError({ statusCode: 502, statusMessage: 'Erreur IA — réessaie dans quelques instants.' })
}
+20 -33
View File
@@ -1,14 +1,14 @@
/**
* POST /api/chatbot
*
* Chatbot recherche sémantique — LLM via Bifrost (tiers et replis : server/utils/bifrost.ts)
* Chatbot recherche sémantique — Mistral Small
* 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 Bifrost (tier RAPIDE par défaut) avec contexte JSON compact
* 4. Appel Mistral Small avec contexte JSON compact
* 5. Parse JSON → { reponse_texte, fiches_recommandees }
* 6. Log stats_usage
*
@@ -19,7 +19,6 @@
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
import { pickBifrostTier, bifrostContent, REASONING_MARGIN_TOKENS, type BifrostChatResponse } from '~/server/utils/bifrost'
// ── Types ──────────────────────────────────────────────────────────────────────
@@ -132,19 +131,18 @@ async function logUsage(params: {
nocodbUrl: string
nocodbToken: string
statsTableId: string
model: string
tokensIn: number
tokensOut: number
coutEur: number
}) {
const { nocodbUrl, nocodbToken, statsTableId, model, tokensIn, tokensOut, coutEur } = params
const { nocodbUrl, nocodbToken, statsTableId, tokensIn, tokensOut, coutEur } = params
const logUrl = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
try {
await $fetch(logUrl, {
method: 'POST',
headers: { 'xc-token': nocodbToken, 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
model: 'mistral-small-latest',
endpoint: 'chatbot',
tokens_in: tokensIn,
tokens_out: tokensOut,
@@ -182,7 +180,6 @@ export default defineEventHandler(async (event) => {
const body = await readBody(event)
const question: string = (body?.question ?? '').trim()
const filters: { fonction?: string; echelle?: string } = body?.filters ?? {}
const tier = pickBifrostTier(body?.mode)
if (!question || question.length < 3) {
throw createError({
@@ -250,34 +247,34 @@ export default defineEventHandler(async (event) => {
JSON.stringify(fichesContext, null, 0),
)
// 6. Appel Bifrost (gateway LLM — remplace l'appel Mistral direct)
const bifrostUrl = config.bifrostUrl as string
const bifrostVk = config.bifrostVk as string
// 6. Appel Mistral Small
const mistralApiKey = config.mistralApiKey as string
if (!bifrostVk) {
if (!mistralApiKey) {
throw createError({
statusCode: 500,
statusMessage: 'Clé Bifrost manquante.',
statusMessage: 'Clé API Mistral manquante.',
})
}
let mistralRaw: string
let tokensIn = 0
let tokensOut = 0
let realModel = tier.model
try {
const bifrostRes = await $fetch<BifrostChatResponse>(`${bifrostUrl}/v1/chat/completions`, {
const mistralRes = await $fetch<{
choices: { message: { content: string } }[]
usage?: { prompt_tokens: number; completion_tokens: number }
}>('https://api.mistral.ai/v1/chat/completions', {
method: 'POST',
headers: {
'x-bf-vk': bifrostVk,
Authorization: `Bearer ${mistralApiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: tier.model,
fallbacks: tier.fallbacks,
model: 'mistral-small-latest',
temperature: 0.3,
max_tokens: 600 + REASONING_MARGIN_TOKENS,
max_tokens: 600,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: systemPrompt },
@@ -286,16 +283,11 @@ export default defineEventHandler(async (event) => {
}),
})
mistralRaw = bifrostContent(bifrostRes, 'chatbot')
tokensIn = bifrostRes.usage?.prompt_tokens ?? 0
tokensOut = bifrostRes.usage?.completion_tokens ?? 0
realModel =
bifrostRes.extra_fields?.resolved_model_used ??
(bifrostRes.extra_fields?.provider
? `${bifrostRes.extra_fields.provider}/${tier.model.split('/').slice(1).join('/')}`
: tier.model)
mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}'
tokensIn = mistralRes.usage?.prompt_tokens ?? 0
tokensOut = mistralRes.usage?.completion_tokens ?? 0
} catch (e: any) {
console.error('[chatbot] Erreur Bifrost:', e?.message ?? e)
console.error('[chatbot] Erreur Mistral Small:', e?.message ?? e)
throw createError({
statusCode: 502,
statusMessage: 'Erreur appel IA — réessaie dans quelques instants.',
@@ -325,16 +317,11 @@ export default defineEventHandler(async (event) => {
}
// 8. Log usage (non bloquant)
// Coût réel calculable seulement si le provider ayant répondu est Mistral —
// les autres tiers Bifrost (Groq, Cerebras, Gemini free tier, Cohere) sont free-tier,
// donc cout_eur=0 pour ne pas fausser le circuit breaker avec un tarif Mistral inapplicable.
const isMistral = realModel.toLowerCase().startsWith('mistral')
const coutEur = isMistral ? calcCoutMistralSmall(tokensIn, tokensOut) : 0
const coutEur = calcCoutMistralSmall(tokensIn, tokensOut)
logUsage({
nocodbUrl: config.nocodbUrl as string,
nocodbToken: config.nocodbToken as string,
statsTableId,
model: realModel,
tokensIn,
tokensOut,
coutEur,
+89 -187
View File
@@ -1,8 +1,8 @@
/**
* POST /api/submit
*
* Soumission multi-type (ecosysteme / reseau / job / outil).
* - Validation Zod côté serveur (champs communs + par type)
* Soumission d'une nouvelle organisation par un utilisateur.
* - Validation Zod côté serveur
* - Rate limit Redis : 3 soumissions / IP / jour
* - Géocodage Nominatim (optionnel — fallback silencieux)
* - INSERT NocoDB : moderation_status=pending, ai_processed=false
@@ -14,52 +14,63 @@
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
// ── Schéma Zod ────────────────────────────────────────────────────────────────
const FONCTIONS = [
'Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier',
'Comptabilité', 'Développement', 'Formation', 'Gestion d\'agence', 'Santé mentale',
'Juridique',
'Technique',
'Économique',
'Administratif',
'Chantier',
'Comptabilité',
'Développement',
'Formation',
'Gestion d\'agence',
'Santé mentale',
] as const
const ECHELLES = ['National', 'Régional', 'Local'] as const
const TERRITOIRES = [
'Métropole', 'Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte',
'Métropole',
'Guadeloupe',
'Martinique',
'Guyane',
'La Réunion',
'Mayotte',
] as const
const TYPES_PRINCIPAUX = ['agence', 'collectif', 'reseau', 'asso', 'ressource'] as const
const TYPES_PLATEFORME = ['mise-en-relation', 'appel-offre-public', 'communaute-pro'] as const
const CATEGORIES_OUTIL = ['simulateur', 'logiciel', 'guide', 'autre'] as const
export const SubmitSchema = z.object({
// Champs communs
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
url: z.string().url('URL invalide').optional().or(z.literal('')).transform(v => v || undefined),
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')).transform(v => v || undefined),
submission_type: z.enum(SUBMISSION_TYPES, { errorMap: () => ({ message: 'Type de soumission invalide' }) }),
// Champs écosystème
echelle: z.enum(ECHELLES).optional(),
fonctions: z.array(z.enum(FONCTIONS)).min(1).max(5).optional(),
territoire: z.enum(TERRITOIRES).optional(),
localisation_ville: z.string().max(100).optional().transform(v => v?.trim() || undefined),
// Champs réseau
type_principal: z.enum(TYPES_PRINCIPAUX).optional(),
projet_lien: z.string().url('URL invalide').optional().or(z.literal('')),
projet_adresse: z.string().max(200).optional().transform(v => v?.trim() || undefined),
// Champs job
type_plateforme: z.enum(TYPES_PLATEFORME).optional(),
// Champs outil
categorie: z.enum(CATEGORIES_OUTIL).optional(),
url: z
.string()
.url('URL invalide')
.optional()
.or(z.literal(''))
.transform((v) => v || undefined),
description_user: z
.string()
.min(50, 'Minimum 50 caractères')
.max(500, 'Maximum 500 caractères')
.trim(),
echelle: z.enum(ECHELLES, { errorMap: () => ({ message: 'Échelle invalide' }) }),
fonctions: z
.array(z.enum(FONCTIONS))
.min(1, 'Sélectionne au moins une fonction')
.max(5, 'Maximum 5 fonctions'),
territoire: z.enum(TERRITOIRES, { errorMap: () => ({ message: 'Territoire invalide' }) }),
localisation_ville: z
.string()
.max(100)
.optional()
.transform((v) => v?.trim() || undefined),
submitted_by_email: z
.string()
.email('Email invalide')
.optional()
.or(z.literal(''))
.transform((v) => v || undefined),
})
export type SubmitInput = z.infer<typeof SubmitSchema>
@@ -105,15 +116,6 @@ 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) {
@@ -146,36 +148,22 @@ export default defineEventHandler(async (event) => {
const nocoBaseId = config.nocodbBase
const payload: Record<string, unknown> = {
submission_type: data.submission_type,
nom: data.nom,
url: data.url || null,
description_user: data.description_user,
echelle: data.echelle,
tags_fonction: data.fonctions.join(','),
territoire: data.territoire,
localisation_ville: data.localisation_ville || null,
submitted_by_email: data.submitted_by_email || null,
moderation_status: 'pending',
scrape_status: data.url ? 'pending' : 'no_link',
ai_processed: false,
submitted_at: new Date().toISOString(),
latitude,
longitude,
}
// Champs spécifiques selon le type
if (data.submission_type === 'ecosysteme') {
payload.echelle = data.echelle
payload.tags_fonction = data.fonctions?.join(',')
payload.territoire = data.territoire
payload.localisation_ville = data.localisation_ville || null
payload.scrape_status = data.url ? 'pending' : 'no_link'
} else if (data.submission_type === 'reseau') {
payload.type_principal = data.type_principal
payload.projet_lien = data.projet_lien || null
payload.projet_adresse = data.projet_adresse || null
payload.localisation_ville = data.localisation_ville || null
} else if (data.submission_type === 'job') {
payload.type_plateforme = data.type_plateforme
} else if (data.submission_type === 'outil') {
payload.categorie = data.categorie
}
// NocoDB v1 endpoint
const insertUrl = `${nocodbUrl}/api/v1/db/data/noco/${nocoBaseId}/${orgTableId}`
@@ -199,17 +187,10 @@ export default defineEventHandler(async (event) => {
const submissionId = insertedRecord?.Id ?? insertedRecord?.id ?? null
// 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))
// 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),
)
return {
status: 201,
@@ -221,119 +202,40 @@ export default defineEventHandler(async (event) => {
}
})
// ── Formulaire assoupli (B5-M1) ─────────────────────────────────────────────
async function notifyJules(
config: ReturnType<typeof useRuntimeConfig>,
data: SubmitInput,
submissionId: number | null,
): Promise<void> {
if (!config.resendApiKey) return
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,
})
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>
`,
}
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,
}
await $fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${config.resendApiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
}
-74
View File
@@ -1,74 +0,0 @@
import { z } from 'zod'
import { checkRateLimit } from '~/server/utils/rateLimit'
const ReferenceSchema = z.object({
titre: z.string().min(3, 'Minimum 3 caractères').max(200, 'Maximum 200 caractères').trim(),
auteur: z.string().min(2, 'Minimum 2 caractères').max(200, 'Maximum 200 caractères').trim(),
url: z.string().url('URL invalide').optional().or(z.literal('')).transform(v => v || undefined),
description: z.string().min(50, 'Minimum 50 caractères').max(1000, 'Maximum 1000 caractères').trim(),
pertinence_rag: z.string().max(500, 'Maximum 500 caractères').optional().transform(v => v?.trim() || undefined),
hashtags: z.string().max(300, 'Maximum 300 caractères').optional().transform(v => v?.trim() || undefined),
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')).transform(v => v || undefined),
})
export type ReferenceInput = z.infer<typeof ReferenceSchema>
export default defineEventHandler(async (event) => {
const ip = getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() || event.node.req.socket?.remoteAddress || '0.0.0.0'
const allowed = await checkRateLimit(ip, 'submit-reference', 3)
if (!allowed) {
throw createError({ statusCode: 429, statusMessage: 'Trop de soumissions. Réessaie demain.' })
}
const body = await readBody(event)
const parsed = ReferenceSchema.safeParse(body)
if (!parsed.success) {
throw createError({
statusCode: 422,
statusMessage: 'Validation échouée',
data: parsed.error.flatten().fieldErrors,
})
}
const data = parsed.data
const payload: Record<string, unknown> = {
titre: data.titre,
auteur: data.auteur,
url: data.url || null,
description: data.description,
pertinence_rag: data.pertinence_rag || null,
hashtags: data.hashtags ? data.hashtags.split(',').map(h => h.trim()).filter(Boolean).join(',') : null,
submitted_by_email: data.submitted_by_email || null,
moderation_status: 'pending',
submitted_at: new Date().toISOString(),
}
const config = useRuntimeConfig()
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('[reference] Erreur NocoDB insert:', e?.message ?? e)
throw createError({ statusCode: 502, statusMessage: 'Erreur serveur — réessaie dans quelques instants.' })
}
const referenceId = insertedRecord?.Id ?? insertedRecord?.id ?? null
return {
status: 201,
referenceId,
message: 'Ta référence est en attente de modération.',
}
})
@@ -1,10 +0,0 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
export default defineEventHandler((event) => {
const path = join(process.cwd(), 'public', 'data', 'auteurs-pensees.json')
const raw = readFileSync(path, 'utf-8')
setResponseHeader(event, 'content-type', 'application/json; charset=utf-8')
setResponseHeader(event, 'cache-control', 'public, max-age=300')
return raw
})
-82
View File
@@ -1,82 +0,0 @@
/**
* Bifrost — gateway LLM (remplace les appels directs Mistral)
* Endpoint OpenAI-compatible : POST {bifrostUrl}/v1/chat/completions
* Auth : header x-bf-vk
*
* 2 tiers (Mission M3 du build Bifrost ; modèles primaires changés le 28/09, session AEP front 4) :
* RAPIDE — défaut, pas de toggle UI mode rapide/approfondi sur le site actuellement
* APPROFONDI — activable via body.mode === 'approfondi' (prêt pour un futur toggle front)
*
* ⚠ 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
* réel (prompt + ~20 fiches), le modèle part en boucle de répétition dégénérée ("1 1 1...")
* dans ~75% des cas où il sert de fallback, produit un JSON invalide, et Bifrost ne bascule
* PAS plus loin dans la chaîne (HTTP 200 côté provider = pas une erreur pour Bifrost). Bug
* constaté en prod sur chatbot-reseaux (1 échec/3), reproduit 3/4 sur appel direct Bifrost.
* Cerebras reste configuré dans Bifrost (dispo pour tier CODE ou reconfiguration future),
* juste plus dans cette chaîne tant que ce n'est pas fiabilisé.
*/
export const BIFROST_TIER_RAPIDE = {
model: 'groq/openai/gpt-oss-20b',
fallbacks: [
'gemini-oai/models/gemini-2.5-flash-lite',
'cohere/command-r-08-2024',
],
}
export const BIFROST_TIER_APPROFONDI = {
model: 'groq/openai/gpt-oss-120b',
fallbacks: [
'gemini-oai/models/gemini-2.5-flash',
'mistral/mistral-large-latest',
'cohere/command-r-plus-08-2024',
],
}
/**
* 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 {
/** 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 ?? '{}'
}
+27 -100
View File
@@ -19,126 +19,53 @@ 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()
const url = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
let cumulEur = 0
let lignesLues = 0
let lignesMois = 0
let lignesSansDate = 0
// 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()
try {
for (let page = 0; page < MAX_PAGES; page++) {
const res = await fetchJson(url, {
// 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,
{
headers: { 'xc-token': nocodbToken },
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
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,
}
} 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, verified: false, lignesLues, lignesMois, lignesSansDate }
console.warn('[circuitBreaker] Erreur lecture stats_usage — budget non vérifié:', (e as Error).message)
return { cumulEur: 0, blocked: false, warning: false }
}
// 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
}
/**
-79
View File
@@ -1,79 +0,0 @@
/**
* 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
}
+9 -11
View File
@@ -11,18 +11,16 @@ export default {
extend: {
colors: {
/* Palette NAV V2 — Sobre institutionnel */
/* Pointent vers les variables CSS --nav-* (alias des rôles @transformer/ui,
voir assets/css/main.css) pour rester cohérentes clair/sombre. (B3-M0) */
nav: {
bg: 'var(--nav-bg)',
'bg-alt':'var(--nav-bg-alt)',
surface: 'var(--nav-surface)',
primary: 'var(--nav-primary)',
'primary-solid': 'var(--nav-primary-solid)',
accent: 'var(--nav-accent)',
text: 'var(--nav-text)',
'text-muted': 'var(--nav-text-muted)',
'text-on-primary': 'var(--nav-text-on-primary)',
bg: '#f8f6f1',
'bg-alt':'#eee9df',
surface: '#ffffff',
primary: 'rgba(26, 34, 56, 0.6)',
'primary-solid': '#1a2238',
accent: '#f5b342',
text: '#1a2238',
'text-muted': 'rgba(26, 34, 56, 0.55)',
'text-on-primary': '#f8f6f1',
},
/* Héritage V1 — gardé pour composants existants */
sage: {
-117
View File
@@ -1,117 +0,0 @@
/**
* 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 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
sousTitre?: string
mots: string[]
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',
titre: 'Partir de soi',
sousTitre: "pour s'émanciper",
mots: [
'quête de sens',
'où habiter, avec qui',
'gagner sa vie',
'santé mentale',
'mener ses projets',
'naviguer avec soi',
'les compétences psychosociales',
],
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',
titre: 'La communauté',
mots: [
'entraide',
'mutualisation',
'appartenance',
'être vu, soutenu',
'traverser les creux',
'accomplissement',
'le codev',
],
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',
titre: 'Contribuer',
mots: [
'contribution',
'affiliation, filiation',
'identité',
'sens',
'de nouveaux récits',
'un nouveau contrat social',
'la médecine du corps social',
],
phrase: 'La bibliothèque, les récits, la filiation entre projets.',
categorieProposer: 'references',
onglets: [
{ libelle: 'Recherche média', to: '/media', actif: (r) => sousChemin('/media')(r) && !surPublications(r) },
{ libelle: 'Publications', to: '/media?tab=projets', actif: (r) => sousChemin('/media')(r) && surPublications(r) },
],
},
}
/** Ordre des groupes dans la barre et le menu. */
export const ORDRE_ECHELLES: EchelleId[] = ['S', 'M', 'XL']
/** Slide 15, commune aux trois échelles. */
export const ECHELLES_CADRE = "Trois échelles, imbriquées. Pas un escalier : ça circule dans les deux sens. Entrez par celle qui presse."
/** Version courte de la slide 15 (tête du menu) : première et dernière phrase, tirées du texte ci-dessus. */
export const ECHELLES_CADRE_COURT = (() => {
const phrases = ECHELLES_CADRE.match(/[^.]+\./g) ?? [ECHELLES_CADRE]
return phrases.length > 1 ? `${phrases[0].trim()} ${phrases[phrases.length - 1].trim()}` : ECHELLES_CADRE
})()
/** Groupe de la page courante et son onglet actif, ou null hors groupe (Manifeste, À propos, Proposer, fiche…). */
export function echelleDeRoute(route: RouteNav): { echelle: EchelleId; onglet: OngletNav } | null {
for (const id of ORDRE_ECHELLES) {
const onglet = ECHELLES[id].onglets.find((o) => o.actif(route))
if (onglet) return { echelle: id, onglet }
}
return null
}
-2
View File
@@ -1,2 +0,0 @@
/** Lien de don (Liberapay), partagé par la barre, le tiroir et À propos. */
export const SOUTENIR_URL = 'https://liberapay.com/trans-former.fr/donate'
-158
View File
@@ -1,158 +0,0 @@
/**
* 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(),
}
}

Some files were not shown because too many files have changed in this diff Show More