Compare commits

..
Author SHA1 Message Date
JulesandClaude Opus 5.5 58937a04a3 feat(aep): Soutenir dans le menu déroulant, bandeau bas retiré, cartes Mistral et « souverain » retirés
Décisions Jules du 27/09 : ① À propos sans les cartes Mistral Nemo / Small
(modèles qui ne sont plus en service), Le Chat sans « souverain » (outils.vue
et fmhy-curated.json) ; ② le bandeau bas (transparence IA + don) est retiré,
le menu déroulant devient visible à toutes les largeurs et porte un bouton
Soutenir → liberapay.com/trans-former.fr/donate (onglets masqués dedans ≥1024px).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:49:26 +02:00
JulesandClaude Opus 5.5 38818f77ae feat(aep): têtes Jobs (S) et Réseaux AEP (M) + retrait des mentions Mistral
Arbitrages de Jules du 26/09 (table R §6) : 2A Jobs en S (tête ← slide 16),
1A Réseaux AEP en M (tête ← slide 17), 6 retrait de « Propulsé par Mistral »
et du badge « IA souveraine : Mistral AI », sans rien affirmer à la place.

- pages/trouver-du-taf.vue : EchelleTete S sous le titre, centrée comme l'en-tête ;
  note du chatbot réduite à « 20 questions/jour ».
- pages/agences.vue : popup Réseaux avec echelle="M".
- ChatbotPlaceholder, ChatbotSheet : phrase « serveur européen souverain
  (Mistral FR, zéro rétention) » retirée.
- a-propos : badge « IA souveraine » retiré.
Non touchés : cartes ia-card « Mistral Nemo / Small » d'À propos, « Mistral Le Chat »
d'Outils (hors consigne).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 18:42:19 +02:00
JulesandClaude Opus 5.5 d9f934a8b2 feat(aep): têtes d'onglet S / M / XL (slides 16-18 du deck du 23/09) + lien mobile recherche-média
- utils/echelles.ts : texte des slides 15 à 18, mot pour mot, source unique
  (la future nav S / M / XL lira ce fichier).
- EchelleTete.vue : badge d'échelle, titre, sept mots, phrase, ligne
  « Chaque section est ouverte aux contributions » vers /proposer.
- Placée en tête du texte existant, sans le remplacer : Outils (S),
  popup Écosystème Entraide (M), page Codev (M), popup média (XL).
  Réseaux AEP et Jobs attendent les arbitrages n° 1 et 2 (table R §6).
- /proposer?categorie=<id> présélectionne l'onglet.
- Menu mobile : « recherche-média » pointait sur /rag (301), pointe sur /media,
  état actif comme les autres liens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 17:58:31 +02:00
JulesandClaude Sonnet 5 baa602e5f8 fix(b3): M5 — textes alignés sur le deck du 23/09 (popup média, nav mobile, À propos, Proposer, familles)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 14:26:13 +02:00
JulesandClaude Sonnet 5 6087b8dd31 feat(b3): M4 — favicon maillage, Manifeste dans le header, hashtags repliés, DESIGN-SYSTEM.md
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 14:17:53 +02:00
JulesandClaude Sonnet 5 13af3fa1c5 fix(b3): M3 — vue Fiches mobile pleine hauteur, intention avant le compteur
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:23:52 +02:00
JulesandClaude Sonnet 5 1838f4b02d feat(b3): M3 — panneau « toutes les fiches » sur / et /agences (desktop + mobile), fiches sans géoloc visibles, ?vue=fiches
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:17:52 +02:00
JulesandClaude Sonnet 5 5b504b3e04 fix(config): retire vite.cacheDir Windows en dur (dossier C:/ parasite sur Linux)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:14:05 +02:00
JulesandClaude Sonnet 5 b2b8c13fee feat(b3): M2 — FicheMiniCard + adaptateur Org/Structure, story de test
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:10:54 +02:00
JulesandClaude Sonnet 5 4b1340f160 feat(b3): M1 — Chip.vue par dimension (échelle outline, fonction pleine, posture safran) + filtres / et /agences
- components/Chip.vue (nouveau) : label/variant(echelle|fonction|posture)/
  active/count/as(button|span)/removable, emits toggle+remove, aria-pressed
  natif sur as=button, croix "Retirer {label}" accessible. Styles 100% tokens
  DS (--primary/--on-primary/--bg-alt/--accent/--on-accent/--focus-ring/
  --dur-fast), hover translateY, focus-visible outline var(--focus-ring),
  transition coupée sous prefers-reduced-motion. Taille compacte 32px de haut
  mesurée (tap ok).
- components/ChipGroup.vue (nouveau) : role="group" + aria-label + flex-wrap
  gap — garantit le wrap propre exigé (jamais une ligne à virgules).
- EchelleFilter.vue, FonctionFilter.vue : rendu remplacé par Chip/ChipGroup,
  props modelValue/counts et événement update:modelValue strictement
  inchangés (NavSidebar.vue n'a rien à changer côté logique). FonctionFilter
  passe le rang de sélection (comme avant) via le prop count de Chip.
- HashtagFilter.vue : seule la liste HASHTAGS (togglable, grammaire fonction)
  passe en Chip/ChipGroup. Les chips FAMILLES gardent leur rendu et leurs
  couleurs propres par famille (hors grammaire échelle/fonction/posture,
  hors périmètre B3 — les changer aurait été une refonte, pas un branchement).
- pages/agences.vue : hashtags par structure (liste sidebar) en
  Chip as="span" variant="fonction" (non cliquables avant, le restent).
- pages/index.vue : la sheet mobile ne passe PAS par NavSidebar/EchelleFilter/
  FonctionFilter — elle a son propre bloc de chips dupliqué (découvert en
  testant le mobile, absent du périmètre écrit du brief). Direction du parti
  design ("chips codées par dimension", mobile-first explicite) : convertie
  en Chip/ChipGroup elle aussi, mêmes handlers toggleEchelle/toggleFonction
  inchangés. Effet de bord positif : le nombre par fonction (déjà calculé,
  jamais affiché sur mobile) apparaît désormais aussi en mobile, à parité
  avec le desktop.
- components/TagBadge.vue supprimé : son seul consommateur, OrgCard.vue,
  n'est importé nulle part dans l'app (grep vérifié) — code mort. OrgCard.vue
  supprimé avec lui plutôt que laissé avec une référence à un composant
  disparu.
- components/TypeBadge.vue : conservé tel quel — ce n'est pas un filtre, et
  sa grammaire couleur par type (7 couleurs Tailwind fixes) est orthogonale
  à échelle/fonction/posture ; le convertir en Chip aurait perdu l'info de
  type ou inventé une 4e variante non validée.

Vérifié : npm run build OK. Chips wrap avec gap en clair/sombre/mobile
(390px, hauteur mesurée 32px). Dark mode : contraste texte/fond conforme
(safran = texte sombre, jamais blanc).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:03:22 +02:00
JulesandClaude Sonnet 5 0903b8989b feat(b3): M0 — tokens @transformer/ui + thème aep, --nav-* en alias des rôles DS
- package.json : dépendance @transformer/ui (git+https design-system.git)
- nuxt.config.ts : css tokens.css + theme/aep.css chargés avant main.css
- assets/css/main.css : --nav-bg/-bg-alt/-surface/-primary/-accent/-text/
  -text-muted/-font deviennent des alias var(--role) DS ; focus ring sur
  var(--focus-ring). --nav-primary-raw, --nav-primary-solid, --nav-accent-soft
  et --nav-text-on-primary restent des valeurs propres à nav-carte (pas
  d'équivalent DS fiable en sombre — --nav-text-on-primary sert de contraste
  à la fois sur --nav-primary translucide et --nav-primary-solid opaque, deux
  fonds dont la légèreté s'inverse en sombre ; aliaser sur --on-primary DS
  cassait le contraste du logo AEP et de ~10 boutons en sombre, testé au
  pixel et reverti)
- tailwind.config.js : couleurs nav.* pointent vers les variables --nav-*
  (pas de darkMode:'class' ajouté — aucune classe dark: Tailwind en usage)

Vérifié : npm run build OK, diff pixel avant/après < 0.5% sur home + agences
en clair/sombre (bruit = badge Nuxt Devtools, pas les tokens).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 11:51:58 +02:00
JulesandClaude Opus 5.5 b5aca80bbc fix(carte): fond de carte avec clé CARTO, repli Plan IGN sans clé
CARTO Basemaps exige une clé depuis le 23/09/2026 : sans elle, chaque tuile
porte le filigrane « API KEY REQUIRED ». Clé lue au runtime
(NUXT_PUBLIC_CARTO_KEY) ; sans clé, repli sur le Plan IGN (Géoplateforme).
FicheDetail quitte aussi les tuiles OSM (418, politique d'usage).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:00:49 +02:00
Jules Neny 946ed53aba feat(outils): note détaillée annuaire fmhy — cartographie traduite par usage, avertissement légal, snapshot 2026-08-25 2026-08-25 19:03:16 +02:00
Jules Neny d06c09a476 fix(bifrost): retire cerebras/gemma-4-31b du fallback tier RAPIDE (JSON invalide ~75% du temps) 2026-07-15 22:53:54 +02:00
Jules NenyandClaude Sonnet 5 4291fe7529 feat(chatbot): branche Entraide/Réseaux/Jobs sur Bifrost au lieu de Mistral direct
Les 3 routes chatbot (chatbot.post.ts, chatbot-reseaux.post.ts, chatbot-taff.post.ts)
appellent désormais ${bifrostUrl}/v1/chat/completions (header x-bf-vk) au lieu de
api.mistral.ai direct. Tier RAPIDE par défaut (groq/llama-3.1-8b-instant + fallbacks
cerebras/gemini-flash-lite/cohere), tier APPROFONDI si body.mode === 'approfondi'
(prêt pour un futur toggle UI, hors-scope ici).

- server/utils/bifrost.ts (nouveau) : mutualise les 2 tiers pour les 3 routes.
- nuxt.config.ts : ajoute bifrostUrl/bifrostVk au runtimeConfig (mistralApiKey
  conservé, juste plus utilisé par ces 3 routes).
- chatbot.post.ts : garde son circuit breaker + logging stats_usage tels quels,
  mais logUsage reflète maintenant le provider/modèle réel ayant répondu
  (extra_fields de Bifrost) et ne calcule un coût que si ce provider est Mistral
  (les autres tiers Bifrost sont free-tier — cout_eur=0 sinon, pour ne pas fausser
  le circuit breaker budget).
- chatbot-reseaux.post.ts / chatbot-taff.post.ts : aucun circuit breaker/logging
  avant, aucun ajouté (asymétrie pré-existante préservée telle quelle).
- chatbot-v2.post.ts (orphelin) et chatbot-pensees.post.ts (proxy LightRAG,
  config runtime séparée) non touchés.

Testé en dev local contre Bifrost (IP Tailscale) : 1 appel réel par route,
réponses conformes, provider réel confirmé dans les logs de test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 22:39:16 +02:00
Jules Neny e5d57e0053 Merge branch 'feat/proposer-4-onglets'
# Conflicts:
#	public/data/auteurs-pensees.json
2026-06-23 13:03:29 +02:00
Jules NenyandClaude Opus 4.8 3cf9f721a7 fix(chatbot): versionne auteurs-pensees.json (UTF-8) — catalogue RAG
Catalogue lu au runtime par le chatbot par-auteur (171 auteurs, 10 ecoles,
corpus 277). Jamais versionne -> droppe des builds -> feature cassee en prod.
Desormais tracke + converti UTF-16->UTF-8 (le serveur lit en utf-8).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4VP2GCZfGY2zWs9NAC84X
2026-06-23 13:02:10 +02:00
Jules Neny 04b023518a fix(chatbot): restore auteurs-pensees.json (runtime dependance chatbot, etait perdue dans un worktree) 2026-06-23 12:58:16 +02:00
Jules Neny e61114d6ec fix(chatbot): restore auteurs-pensees.json (runtime dependance chatbot, etait perdue dans un worktree) 2026-06-23 12:58:04 +02:00
Jules Neny c860dabdc0 feat(proposer): interface 5 onglets + soumission Références vers NocoDB
- Page /proposer avec 5 onglets (Écosystème/Réseau/Jobs/Outils/Références RAG)
- 5 form components + form-styles.css partagé
- Submit multi-type avec submission_type vers NocoDB
- Server route references.post.ts réécrite pour NocoDB (table ressources_references)
- redirect 301 /contribuer → /proposer
- Suppression references-pending.json
2026-06-23 12:13:33 +02:00
Jules Neny f3ab28bd69 fix(aep): chatbot RAG pensées + config ragPeUrl + layout media + a-propos
Composant + route chatbot non committés, runtimeConfig.ragPeUrl,
ajustements layout MediaTabVisuel, contenu a-propos.
2026-06-23 11:15:54 +02:00
Jules NenyandClaude Sonnet 4.6 cd8fe9e258 fix(media): toolbar remise entre carte et chatbot + nav renommée
- fix: layout-toggle-bar à l'intérieur du layout-container (entre carte D3 et chatbot)
- fix: chatbot de nouveau visible en mode split
- feat: nav "Écosystème Entraide Architecture" → "Écosystème Entraide"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 15:50:22 +02:00
Jules NenyandClaude Sonnet 4.6 ea7c8cc91e fix(server): useEvent() → event param dans la server route auteurs-pensees
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 15:26:27 +02:00
Jules NenyandClaude Sonnet 4.6 538c490e76 fix(media): carte D3 + chatbot restaurés + refonte toolbar + nav
- fix: server route /data/auteurs-pensees.json (contournement bug manifest Nitro)
- fix: contentView indépendant du layoutMode — boutons CARTE PRINCIPALE / bonpote / RAG backend ne modifient pas l'état carte-full/chatbot-full
- feat: bouton CARTE PRINCIPALE → restaure la vue D3 + chatbot split
- fix: /rag redirige vers /media (301)
- feat: nav "RAG en construction" → "recherche-média" lien /media

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 15:24:29 +02:00
Jules NenyandClaude Sonnet 4.6 d584d04e3d merge(feat/outils-v1): page Outils V1 + bibliothèque pensées écologiques refonte
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 14:52:42 +02:00
Jules NenyandClaude Sonnet 4.6 bd95c0f00d fix(media): RAG visible + refonte interface bibliothèque pensées écologiques
- fix: penseesData chargé en interne dans MediaTabVisuel (bug prop jamais passée)
- feat: onglet renommé '📚 bibliothèque des pensées écologiques', suppression tab LightRAG backend
- feat: 'RAG backend' devient bouton inline dans toolbar → layout mode 'rag-backend'
- feat: fusion boutons 'Bonpote V2' + 'Carte FRACAS PDF' → contrôle unique avec tickbox intégré
- feat: iframe lightrag.trans-former.fr décommentée (DNS propagé)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 14:52:20 +02:00
Jules Neny db8f614928 fix(CartePensees): reformatage syntaxe - fichier condensé sur 1 ligne cassait le build Vite/Vue 2026-05-22 11:07:10 +02:00
Jules Neny 59cb81a055 Merge P3 media modif 2026-05-22 11:02:05 +02:00
Jules Neny 91e3466ec6 Merge P2 outils build 2026-05-22 11:01:30 +02:00
Jules Neny cb75889231 feat(media): 3 sous-onglets RAG/LightRAG/Projets + titres cercles D3 + layer PDF FRACAS + onglet PFE 2026-05-22 11:00:00 +02:00
Jules Neny 422f45116f feat(outils): page Outils V1 + composants TreeASCII/OutilCard/SimulateurFeature + nav premier onglet 2026-05-22 10:58:39 +02:00
74 changed files with 26882 additions and 1704 deletions
+1
View File
@@ -7,3 +7,4 @@ dist
*.log *.log
*.tmp.* *.tmp.*
.firecrawl/
+84
View File
@@ -0,0 +1,84 @@
# 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` — il n'a jamais besoin de connaître `Org` ou `StructureV2`. C'est ce découplage qui permet d'ajouter une source sans toucher au composant d'affichage.
## FichesPanel — le panneau « toutes les fiches »
`components/FichesPanel.vue` : props `fiches: MiniFiche[]` (déjà filtrée par la page appelante — le panneau ne refiltre pas, ne crée pas de second champ de recherche), `hashtags: string[]` (options de filtre), `selected: string[]`, `query: string` (sert seulement à nuancer le message d'état vide), `intention: string`. Émet `update:selected` et `open(fiche)`.
**Le panneau ne possède pas son propre état de filtre** : ses chips pilotent la variable réactive de la page appelante via `update:selected` (ex. `fonctions` sur `/`, `selectedHashtags` sur `/agences`). C'est ce qui garantit qu'une chip cochée dans le panneau et la même chip dans la sidebar (si elle est visible) affichent toujours le même état — une seule source de vérité, jamais deux.
**Hashtags repliés** (B3-M4) : au-delà de 14 chips (`COLLAPSE_THRESHOLD`), seules les premières s'affichent + un chip-bouton « voir les N autres » qui déplie. Les chips déjà actives restent visibles même repliées (union entre les 14 premières et les actives hors de ce lot). État déplié non persistant — `ref` locale, reset à chaque montage. `/` (10 fonctions) ne dépasse jamais le seuil ; `/agences` (~60 hashtags) se replie par défaut.
**Accès** : `?vue=fiches` dans l'URL, en plus des paramètres de filtre existants. Sur `/`, `desktopMapView`/`mobileMapView` gagnent une valeur `'fiches'` à côté de `'metropole'`/`'outremer'` ; sur `/agences`, à côté de `'metropole'`/`'outremer'`/`'graphe'`. Recharger l'URL avec `?vue=fiches` doit rouvrir directement le panneau, filtres compris.
**Mobile** : le panneau ne se monte JAMAIS dans un `MobileSheet` (le composant sheet demi-hauteur pensé pour flotter au-dessus d'une carte) — il n'y a pas de carte derrière la vue fiches, donc pas de sheet à demi-hauteur : le panneau occupe toute la zone sous la barre d'onglets, avec son propre scroll interne. Piège vécu en B3 : monter le panneau dans un `MobileSheet` par réflexe (comme les autres onglets) laisse ~45 % de l'écran vide au-dessus, puisque la sheet démarre repliée à mi-hauteur et qu'il n'y a rien à voir en dessous.
## Intention d'abord
Principe de design validé par Jules pour B3 : un panneau (ou une carte) s'ouvre sur **une ligne d'intention** avant l'outillage (filtres, compteur, grille). La ligne d'intention dit en une phrase ce que montre l'écran et le premier geste possible (« Toutes les structures d'entraide, y compris celles sans adresse sur la carte. Cochez une fonction pour filtrer. »). Dans `FichesPanel`, l'ordre de l'en-tête est fixe : intention → compteur → chips → grille. Ne pas remonter le compteur ou les chips au-dessus de l'intention, même pour gagner de la place.
## 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.
+34 -23
View File
@@ -22,12 +22,19 @@
<!-- ── Onglets desktop (≥1024px) — remplace la barre de recherche ── --> <!-- ── 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"> <nav class="hidden lg:flex flex-1 justify-center items-end gap-0 mx-6" aria-label="Navigation projets">
<NuxtLink
to="/outils"
class="nav-tab"
:class="{ 'nav-tab--active': route.path === '/outils' }"
>
Outils
</NuxtLink>
<NuxtLink <NuxtLink
to="/" to="/"
class="nav-tab" class="nav-tab"
:class="{ 'nav-tab--active': route.path === '/' }" :class="{ 'nav-tab--active': route.path === '/' }"
> >
Écosystème Entraide Architecture Écosystème Entraide
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
to="/agences" to="/agences"
@@ -51,12 +58,11 @@
Codev Codev
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
to="/rag" to="/media"
class="nav-tab" class="nav-tab"
:class="{ 'nav-tab--active': route.path === '/rag' }" :class="{ 'nav-tab--active': route.path.startsWith('/media') }"
> >
RAG recherche-média
<span class="nav-tab-badge">en construction</span>
</NuxtLink> </NuxtLink>
</nav> </nav>
@@ -94,6 +100,13 @@
<!-- Actions droite --> <!-- Actions droite -->
<div class="flex items-center gap-2 shrink-0"> <div class="flex items-center gap-2 shrink-0">
<NuxtLink
to="/manifeste"
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all hover:opacity-80 hidden md:inline-flex items-center gap-1"
:style="route.path === '/manifeste' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text-muted);'"
>
Manifeste
</NuxtLink>
<NuxtLink <NuxtLink
to="/a-propos" to="/a-propos"
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all hover:opacity-80 hidden md:inline-flex items-center gap-1" 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"
@@ -108,9 +121,9 @@
> >
Signaler Signaler
</NuxtLink> </NuxtLink>
<!-- Proposer une ressource --> <!-- Proposer une ressource (5 onglets) -->
<NuxtLink <NuxtLink
to="/contribuer" to="/proposer"
class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 hidden sm:inline-flex items-center gap-1" class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 hidden sm:inline-flex items-center gap-1"
style="background: var(--nav-accent); color: var(--nav-text);" style="background: var(--nav-accent); color: var(--nav-text);"
> >
@@ -137,21 +150,21 @@
</svg> </svg>
</button> </button>
<!-- Mobile : contribuer icône --> <!-- Mobile : proposer icône -->
<NuxtLink <NuxtLink
to="/contribuer" to="/proposer"
class="sm:hidden p-2 rounded-lg" class="sm:hidden p-2 rounded-lg"
style="background: var(--nav-accent); color: var(--nav-text);" style="background: var(--nav-accent); color: var(--nav-text);"
title="Contribuer une fiche" title="Proposer une ressource"
aria-label="Contribuer" aria-label="Proposer"
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/> <line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg> </svg>
</NuxtLink> </NuxtLink>
<!-- Hamburger mobile (lg:hidden) — toujours en dernier à droite --> <!-- Menu déroulant — toutes largeurs (les onglets n'y apparaissent qu'en dessous de 1024px) ; porte « Soutenir » -->
<div class="lg:hidden relative"> <div class="relative">
<button <button
@click="hamburgerOpen = !hamburgerOpen" @click="hamburgerOpen = !hamburgerOpen"
class="p-2 rounded-lg transition-all hover:opacity-80" class="p-2 rounded-lg transition-all hover:opacity-80"
@@ -172,15 +185,16 @@
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;" style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
@click="hamburgerOpen = false" @click="hamburgerOpen = false"
> >
<NuxtLink to="/" class="block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Écosystème Entraide Architecture</NuxtLink> <NuxtLink to="/outils" class="lg:hidden block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/outils' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Outils</NuxtLink>
<NuxtLink to="/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="/" class="lg:hidden block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Écosystème Entraide</NuxtLink>
<NuxtLink to="/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="/agences" class="lg:hidden block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/agences' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Réseaux AEP</NuxtLink>
<NuxtLink to="/rag" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">RAG</NuxtLink> <NuxtLink to="/trouver-du-taf" class="lg:hidden block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/trouver-du-taf' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Jobs</NuxtLink>
<NuxtLink to="/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> <NuxtLink to="/media" class="lg:hidden block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/media') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">recherche-média</NuxtLink>
<div style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div> <NuxtLink to="/codev" class="lg:hidden block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/codev') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Codev</NuxtLink>
<div class="lg:hidden" style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div>
<NuxtLink to="/manifeste" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/manifeste' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text-muted);'">Manifeste</NuxtLink> <NuxtLink to="/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> <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> <a href="https://liberapay.com/trans-former.fr/donate" target="_blank" rel="noopener noreferrer" class="block mx-3 my-2 px-4 py-2 rounded-lg text-sm font-semibold text-center transition-opacity hover:opacity-80" style="background: var(--nav-accent); color: var(--nav-text);">Soutenir</a>
<NuxtLink to="/signaler" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">Signaler</NuxtLink> <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> </div>
@@ -192,9 +206,6 @@
<NuxtPage /> <NuxtPage />
</div> </div>
<!-- Bandeau bas — transparence IA + soutien + compteurs semaine -->
<BandeauBas />
</div> </div>
</template> </template>
+32 -16
View File
@@ -3,28 +3,44 @@
@tailwind utilities; @tailwind utilities;
/* ── Palette NAV V2 — Sobre institutionnel ─────────────────────────────── */ /* ── 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 { :root {
/* Fonds */ /* Fonds */
--nav-bg: #f8f6f1; --nav-bg: var(--bg);
--nav-bg-alt: #eee9df; --nav-bg-alt: var(--bg-alt);
--nav-surface: #ffffff; --nav-surface: var(--surface);
/* Bleu nuit — 60% d'opacité sur les surfaces */ /* Bleu nuit — 60% d'opacité sur les surfaces */
--nav-primary-raw: 26, 34, 56; --nav-primary-raw: 26, 34, 56;
--nav-primary: rgba(26, 34, 56, 0.6); --nav-primary: var(--primary);
--nav-primary-solid: #1a2238; --nav-primary-solid: #1a2238;
/* Accent safran */ /* Accent safran */
--nav-accent: #f5b342; --nav-accent: var(--accent);
--nav-accent-soft: rgba(245, 179, 66, 0.85); --nav-accent-soft: rgba(245, 179, 66, 0.85);
/* Texte */ /* Texte */
--nav-text: #1a2238; --nav-text: var(--text);
--nav-text-muted: rgba(26, 34, 56, 0.55); --nav-text-muted: var(--text-muted);
--nav-text-on-primary: #f8f6f1; --nav-text-on-primary: #f8f6f1;
/* Typographie */ /* Typographie */
--nav-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; --nav-font: var(--font-body);
} }
@layer base { @layer base {
@@ -58,25 +74,25 @@
/* Focus ring accessibilité */ /* Focus ring accessibilité */
*:focus-visible { *:focus-visible {
outline: 2px solid var(--nav-accent); outline: 2px solid var(--focus-ring);
outline-offset: 2px; outline-offset: 2px;
} }
/* ── Dark mode ──────────────────────────────────────────────────────────── */ /* ── Dark mode ──────────────────────────────────────────────────────────── */
.dark { .dark {
--nav-bg: #111520; --nav-bg: var(--bg);
--nav-bg-alt: #1e2538; --nav-bg-alt: var(--bg-alt);
--nav-surface: #1a2238; --nav-surface: var(--surface);
--nav-primary-raw: 200, 210, 240; --nav-primary-raw: 200, 210, 240;
--nav-primary: rgba(200, 210, 240, 0.15); --nav-primary: var(--primary);
--nav-primary-solid: #c8d2f0; --nav-primary-solid: #c8d2f0;
--nav-accent: #f5b342; --nav-accent: var(--accent);
--nav-accent-soft: rgba(245, 179, 66, 0.85); --nav-accent-soft: rgba(245, 179, 66, 0.85);
--nav-text: #e8ecf5; --nav-text: var(--text);
--nav-text-muted: rgba(200, 210, 240, 0.55); --nav-text-muted: var(--text-muted);
--nav-text-on-primary: #111520; --nav-text-on-primary: #111520;
} }
-488
View File
@@ -1,488 +0,0 @@
<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>
+372
View File
@@ -0,0 +1,372 @@
<template>
<div style="width: 100%; height: 100%; position: relative; background: #f5f3f0;">
<svg ref="svgRef" style="width: 100%; height: 100%;"></svg>
<div ref="tooltipRef" style="
position: absolute; pointer-events: none;
background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);
border-radius: 6px; padding: 8px 12px; font-size: 0.78rem;
color: var(--nav-text); max-width: 240px; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
opacity: 0; transition: opacity 0.15s; z-index: 100;
"></div>
</div>
</template>
<script setup lang="ts">
interface EcoleData { id: string; label: string; description: string; color: string; x_hint: number; y_hint: number }
interface LivreRag { slug: string; titre: string; annee: number; couches: string[] }
interface AuteurData { id: string; nom: string; dates: string; ecoles: string[]; ecole_principale: string; ingere: boolean; livres_rag: LivreRag[]; theses_cles: string[]; bio_courte: string; bio_courte_provisoire?: string }
interface PenseesData { ecoles: EcoleData[]; auteurs: AuteurData[] }
// Liens d'influence inter-ecoles (Phase 7 - matrice de filiation)
const LINKS_INFLUENCE = [
// filiations directes
{ source: 'eco-anarchisme', target: 'technocritique', auteurs_passerelle: ['Bookchin', 'Illich'], type: 'filiation' },
{ source: 'eco-anarchisme', target: 'decroissance', auteurs_passerelle: ['Latouche', 'Kropotkine'], type: 'filiation' },
{ source: 'ecosocialisme', target: 'decroissance', auteurs_passerelle: ['Saito', 'Gorz'], type: 'filiation' },
{ source: 'ecosocialisme', target: 'ecologies-decoloniales', auteurs_passerelle: ['Klein', 'Ferdinand'], type: 'filiation' },
{ source: 'ecofeminismes', target: 'ecologies-decoloniales', auteurs_passerelle: ['Shiva', 'Ouassak'], type: 'filiation' },
{ source: 'ecofeminismes', target: 'pensees-vivant', auteurs_passerelle: ['Haraway', 'Despret'], type: 'filiation' },
{ source: 'technocritique', target: 'decroissance', auteurs_passerelle: ['Ellul', 'Latouche'], type: 'filiation' },
{ source: 'decroissance', target: 'pensees-vivant', auteurs_passerelle: ['Servigne', 'Despret'], type: 'filiation' },
{ source: 'pensees-vivant', target: 'ethiques-environnementales', auteurs_passerelle: ['Naess', 'Latour'], type: 'filiation' },
{ source: 'ecosocialisme', target: 'eco-anarchisme', auteurs_passerelle: ['Gorz', 'Graeber'], type: 'filiation' },
// liens de critique
{ source: 'ecosocialisme', target: 'capitalisme-vert', auteurs_passerelle: ['Klein', 'Malm'], type: 'critique' },
{ source: 'decroissance', target: 'capitalisme-vert', auteurs_passerelle: ['Latouche', 'Meadows'], type: 'critique' },
{ source: 'eco-anarchisme', target: 'capitalisme-vert', auteurs_passerelle: ['Bookchin'], type: 'critique' },
{ source: 'ethiques-environnementales', target: 'ecofascismes', auteurs_passerelle: ['Naess'], type: 'critique' },
{ source: 'capitalisme-vert', target: 'ecofascismes', auteurs_passerelle: [], type: 'critique' },
]
const props = defineProps<{ data: PenseesData | null; active?: boolean }>()
const emit = defineEmits<{ 'select-auteur': [id: string]; 'select-ecole': [id: string] }>()
const svgRef = ref<SVGElement | null>(null)
const tooltipRef = ref<HTMLElement | null>(null)
let simulation: any = null
let d3LinkSel: any = null
let d3InfluenceSel: any = null
let d3NodeSel: any = null
let d3EdgeLabelSel: any = null
async function initGraph() {
if (!svgRef.value || !props.data) return
const d3 = await import('d3')
const svgEl = svgRef.value
const W = svgEl.clientWidth || 900
const H = svgEl.clientHeight || 600
d3.select(svgEl).selectAll('*').remove()
const svg = d3.select(svgEl).attr('viewBox', `0 0 ${W} ${H}`)
const g = svg.append('g')
svg.call(d3.zoom<SVGElement, unknown>().scaleExtent([0.3, 4]).on('zoom', (e) => g.attr('transform', e.transform)) as any)
const ecoleMap = new Map<string, EcoleData>(props.data.ecoles.map(e => [e.id, e]))
// Positions fixes des ecoles (base pour forces D3)
const ecolePositions = new Map<string, { tx: number; ty: number }>()
props.data.ecoles.forEach(e => {
ecolePositions.set(e.id, { tx: W * e.x_hint, ty: H * e.y_hint })
})
// ---- LIENS D'INFLUENCE INTER-ECOLES (couche 3) ----
const gInfluence = g.append('g').attr('class', 'links-influence')
LINKS_INFLUENCE.forEach(link => {
const src = ecolePositions.get(link.source)
const tgt = ecolePositions.get(link.target)
if (!src || !tgt) return
const isCritique = link.type === 'critique'
const lineEl = gInfluence.append('line')
.attr('class', 'influence-link')
.attr('x1', src.tx).attr('y1', src.ty)
.attr('x2', tgt.tx).attr('y2', tgt.ty)
.attr('stroke', isCritique ? '#d99' : '#9aa')
.attr('stroke-width', 1)
.attr('stroke-dasharray', isCritique ? '4,3' : '6,4')
.attr('stroke-opacity', isCritique ? 0.2 : 0.22)
if (link.auteurs_passerelle && link.auteurs_passerelle.length > 0) {
lineEl
.on('mouseenter', (e: any) => {
if (!tooltipRef.value) return
tooltipRef.value.innerHTML = `<strong>Influence</strong><br><span style="opacity:0.8;font-size:0.72rem;">Passerelles : ${link.auteurs_passerelle.join(', ')}</span>`
tooltipRef.value.style.opacity = '1'
})
.on('mousemove', (e: any) => {
if (!tooltipRef.value || !svgEl) return
const rect = (svgEl as HTMLElement).getBoundingClientRect()
tooltipRef.value.style.left = (e.clientX - rect.left + 14) + 'px'
tooltipRef.value.style.top = (e.clientY - rect.top - 10) + 'px'
})
.on('mouseleave', () => { if (tooltipRef.value) tooltipRef.value.style.opacity = '0' })
}
})
// ---- SIMULATION D3 (auteurs) ----
const auteurNodes: any[] = props.data.auteurs.map(a => {
const ecole = ecoleMap.get(a.ecole_principale)
const jitter = () => (Math.random() - 0.5) * 80
return {
id: a.id, type: 'auteur', nom: a.nom, dates: a.dates,
bio_courte: a.bio_courte,
bio_provisoire: a.bio_courte_provisoire ?? '',
ingere: a.ingere,
ecole_principale: a.ecole_principale,
color: ecole?.color ?? '#888', r: 11,
x: W * (ecole?.x_hint ?? 0.5) + jitter(),
y: H * (ecole?.y_hint ?? 0.5) + jitter(),
}
})
// Liens appartenance auteur -> ecole (vers centroid fixe)
const links: any[] = []
props.data.auteurs.forEach(a => {
links.push({ source: a.id, target: a.ecole_principale, strength: 0.65, isSubcourant: false })
a.ecoles.filter(e => e !== a.ecole_principale).forEach(e => {
links.push({ source: a.id, target: e, strength: 0.25, isSubcourant: true })
})
})
// Nodes fictifs fixes pour les ecoles (cibles des liens appartenance)
const ecoleFixedNodes: any[] = props.data.ecoles.map(e => ({
id: e.id, type: 'ecole-fixed', ecoleId: e.id,
x: W * e.x_hint, y: H * e.y_hint,
fx: W * e.x_hint, fy: H * e.y_hint,
}))
// Rayon proportionnel au nombre d'auteurs de l'ecole
const ecoleAuteurCounts = new Map<string, number>()
props.data.ecoles.forEach(e => ecoleAuteurCounts.set(e.id, 0))
props.data.auteurs.forEach(a => ecoleAuteurCounts.set(a.ecole_principale, (ecoleAuteurCounts.get(a.ecole_principale) ?? 0) + 1))
const ecoleRadius = (count: number) => Math.max(16, Math.min(36, 13 + count * 1.5))
const allNodes = [...ecoleFixedNodes, ...auteurNodes]
if (simulation) simulation.stop()
simulation = d3.forceSimulation(allNodes)
.force('link', d3.forceLink(links).id((d: any) => d.id).distance(120).strength((d: any) => d.strength ?? 0.5))
.force('charge', d3.forceManyBody().strength(-70))
.force('center', d3.forceCenter(W / 2, H / 2).strength(0.02))
.force('collision', d3.forceCollide().radius((d: any) => d.type === 'ecole-fixed' ? ecoleRadius(ecoleAuteurCounts.get(d.ecoleId) ?? 0) + 4 : 12))
.force('forceX', d3.forceX<any>((d: any) => {
if (d.type === 'auteur') {
const pos = ecolePositions.get(d.ecole_principale)
return pos ? pos.tx : W / 2
}
return W / 2
}).strength(0.15))
.force('forceY', d3.forceY<any>((d: any) => {
if (d.type === 'auteur') {
const pos = ecolePositions.get(d.ecole_principale)
return pos ? pos.ty : H / 2
}
return H / 2
}).strength(0.15))
// ---- NOEUDS ECOLES visibles (couche 3.5) ----
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)
}
})
// ---- LIENS APPARTENANCE (couche 4) ----
const gLinks = g.append('g').attr('class', 'links-appartenance')
d3LinkSel = gLinks.selectAll('line').data(links).join('line')
.attr('stroke', 'rgba(150,150,150,0.28)').attr('stroke-width', 1.2)
// ---- EDGE LABELS - sous-courants (couche 4b) ----
const subcourantLinks = links.filter((l: any) => l.isSubcourant)
d3EdgeLabelSel = gLinks.selectAll('text.pensees-edge-label')
.data(subcourantLinks)
.join('text')
.attr('class', 'pensees-edge-label')
// ---- NODES AUTEURS (couche 5) ----
const gAuteurs = g.append('g').attr('class', 'auteurs')
d3NodeSel = gAuteurs.selectAll('g').data(auteurNodes).join('g')
.style('cursor', (d: any) => d.ingere ? 'pointer' : 'default')
.call(d3.drag<any, any>()
.on('start', (e: any, d: any) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y })
.on('drag', (e: any, d: any) => { d.fx = e.x; d.fy = e.y })
.on('end', (e: any, d: any) => { if (!e.active) simulation.alphaTarget(0); d.fx = null; d.fy = null }))
.on('click', (e: any, d: any) => {
if (!d.ingere) return
e.stopPropagation()
emit('select-auteur', d.id)
})
// Phase 8.D : grisage conditionnel auteurs non-ingeres
d3NodeSel.append('circle')
.attr('r', (d: any) => d.r)
.attr('fill', (d: any) => d.ingere ? (d.color + 'cc') : '#bbbbbb')
.attr('stroke', (d: any) => d.ingere ? d.color : '#999999')
.attr('stroke-width', 1.5)
.attr('opacity', (d: any) => d.ingere ? 1 : 0.35)
// ---- LABELS AUTEURS (couche 6 - drop-shadow blanc) ----
d3NodeSel.append('text')
.attr('class', 'pensees-auteur-label')
.text((d: any) => d.nom.split(' ').pop() ?? d.nom)
.attr('text-anchor', 'middle')
.attr('dy', (d: any) => -(d.r + 4))
.style('pointer-events', 'none')
.style('opacity', (d: any) => d.ingere ? 1 : 0.3)
.style('fill', (d: any) => d.ingere ? '#1a1a1a' : '#777777')
d3NodeSel
.on('mouseenter', (e: any, d: any) => {
if (!tooltipRef.value) return
let tooltipHtml = ''
if (d.ingere) {
const rawBio = d.bio_courte || ''
const bio = rawBio.length > 90 ? rawBio.slice(0, 87) + '...' : rawBio
tooltipHtml = `<strong>${d.nom}</strong> <span style="opacity:0.6;font-size:0.7rem;">${d.dates}</span><br><span style="opacity:0.75;font-size:0.72rem;">${bio || 'Dans le RAG ATIS.'}</span>`
} else {
tooltipHtml = `<strong>${d.nom}</strong> <span style="opacity:0.6;font-size:0.7rem;">${d.dates}</span><br><span style="opacity:0.65;font-size:0.72rem;font-style:italic;">Présent dans Bonpote, pas encore ingéré dans le RAG ATIS.</span>`
}
tooltipRef.value.innerHTML = tooltipHtml
tooltipRef.value.style.opacity = '1'
})
.on('mousemove', (e: any) => {
if (!tooltipRef.value || !svgEl) return
const rect = (svgEl as HTMLElement).getBoundingClientRect()
tooltipRef.value.style.left = (e.clientX - rect.left + 14) + 'px'
tooltipRef.value.style.top = (e.clientY - rect.top - 10) + 'px'
})
.on('mouseleave', () => { if (tooltipRef.value) tooltipRef.value.style.opacity = '0' })
simulation.on('tick', () => {
d3LinkSel
.attr('x1', (d: any) => d.source.x).attr('y1', (d: any) => d.source.y)
.attr('x2', (d: any) => d.target.x).attr('y2', (d: any) => d.target.y)
d3EdgeLabelSel
.attr('x', (d: any) => (d.source.x + d.target.x) / 2)
.attr('y', (d: any) => (d.source.y + d.target.y) / 2)
.text((d: any) => {
const targetId = typeof d.target === 'object' ? d.target.id : d.target
return targetId
})
d3NodeSel.attr('transform', (d: any) => `translate(${d.x},${d.y})`)
})
}
watch(() => props.active, (val) => {
if (val && import.meta.client && props.data)
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
})
watch(() => props.data, (val) => {
if (val && props.active && import.meta.client)
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
})
onMounted(async () => {
if (import.meta.client && props.data && props.active) {
await nextTick()
initGraph()
}
})
onUnmounted(() => { if (simulation) simulation.stop() })
function triggerResize() {
if (simulation) {
simulation.alpha(0.3).restart()
} else if (import.meta.client && props.data && props.active) {
initGraph()
}
}
defineExpose({ triggerResize })
</script>
<style>
.pensees-auteur-label {
fill: #1a1a1a;
font-weight: 600;
font-size: 10px;
filter: drop-shadow(0 0 2.5px rgba(255,255,255,0.95));
user-select: none;
}
.pensees-edge-label {
fill: #555;
font-size: 8.5px;
font-style: italic;
opacity: 0.7;
text-anchor: middle;
dominant-baseline: middle;
user-select: none;
pointer-events: none;
}
.ecole-node {
transition: opacity 0.15s, r 0.15s;
}
.ecole-node:hover {
opacity: 0.75;
}
</style>
+448
View File
@@ -0,0 +1,448 @@
<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>
</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>
</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>
</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>
</div>
<!-- Messages -->
<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>
</div>
</template>
<!-- Chargement -->
<div v-if="loading" class="chatbot-bubble assistant loading">
<span class="dot" /><span class="dot" /><span class="dot" />
</div>
<!-- Erreur -->
<div v-if="errorMsg" class="chatbot-error">{{ errorMsg }}</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>
</div>
</div>
</template>
<script setup lang="ts">
import { useMarkdown } from '~/composables/useMarkdown'
const { render: renderMd } = useMarkdown()
interface ChatMessage {
role: 'user' | 'assistant'
content: string
auteur?: { slug: string; nom: string } | null
}
const props = defineProps<{
auteurContext: string | null
inline?: boolean
}>()
const messages = ref<ChatMessage[]>([])
const inputText = ref('')
const loading = ref(false)
const errorMsg = ref('')
const corpusMode = ref<'pensees' | 'projets' | 'both'>('pensees')
const messagesContainer = ref<HTMLElement | null>(null)
watch(() => props.auteurContext, () => {
messages.value = []
errorMsg.value = ''
})
function clearMessages() {
messages.value = []
errorMsg.value = ''
}
function scrollToBottom() {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
async function sendMessage() {
const query = inputText.value.trim()
if (!query || loading.value) return
inputText.value = ''
errorMsg.value = ''
messages.value.push({ role: 'user', content: query })
loading.value = true
await nextTick()
scrollToBottom()
try {
const res = await $fetch<{
response: string
auteur: { slug: string; nom: string } | null
}>('/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,
})
} 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.'
}
} finally {
loading.value = false
await nextTick()
scrollToBottom()
}
}
function slugify(str: string): string {
return str.toLowerCase()
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
</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;
}
</style>
-1
View File
@@ -54,7 +54,6 @@
<div v-if="messages.length === 0" class="onboarding-bubble"> <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>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 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 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> </div>
<!-- Messages --> <!-- Messages -->
-1
View File
@@ -69,7 +69,6 @@
<div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3"> <div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
<!-- Message onboarding (avant la première question) --> <!-- Message onboarding (avant la première question) -->
<div v-if="messages.length === 0" class="onboarding-bubble"> <div v-if="messages.length === 0" class="onboarding-bubble">
<p>Ce chatbot fonctionne sur un serveur européen souverain (Mistral FR, zéro rétention), conçu sobre en énergie.</p>
<p>Pour m'aider à te répondre efficacement, formule ta requête ainsi :</p> <p>Pour m'aider à te répondre efficacement, formule ta requête ainsi :</p>
<ul> <ul>
<li>• Besoin : [ce que tu cherches]</li> <li>• Besoin : [ce que tu cherches]</li>
+170
View File
@@ -0,0 +1,170 @@
<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;
}
}
.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;
}
.chip--echelle:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
color: var(--on-primary);
}
.chip--echelle.chip--active {
background: var(--primary);
border-color: var(--primary);
color: var(--on-primary);
}
.chip--echelle.chip--active:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
/* ── Fonction/hashtag : pleine bleu, inactif = bg-alt ────────────────────── */
.chip--fonction {
border-color: transparent;
background: var(--bg-alt);
color: var(--text);
}
.chip--fonction:hover {
background: var(--primary-hover);
color: var(--on-primary);
}
.chip--fonction.chip--active {
background: var(--primary);
color: var(--on-primary);
}
.chip--fonction.chip--active:hover {
background: var(--primary-hover);
}
/* ── Posture (préparée pour plus tard) : outline safran ──────────────────── */
.chip--posture {
border-color: var(--accent);
color: var(--text);
background: transparent;
}
.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
@@ -0,0 +1,19 @@
<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>
+7 -30
View File
@@ -1,39 +1,16 @@
<template> <template>
<div class="space-y-1.5"> <div class="space-y-1.5">
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Échelle</p> <p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Échelle</p>
<!-- Inline sur 1 ligne — même pattern que FonctionFilter --> <ChipGroup aria-label="Filtrer par échelle">
<div class="flex flex-wrap gap-x-4 gap-y-1.5"> <Chip
<label
v-for="option in ECHELLES" v-for="option in ECHELLES"
:key="option" :key="option"
class="flex items-center gap-1.5 cursor-pointer select-none transition-opacity" :label="option"
> variant="echelle"
<!-- Case carrée --> :active="isSelected(option)"
<span @toggle="toggle(option)"
class="flex items-center justify-center shrink-0 transition-all"
style="width: 18px; height: 18px; border: 1.5px solid; border-radius: 3px;"
:style="isSelected(option)
? 'background: var(--nav-primary); border-color: var(--nav-primary); color: #ffffff;'
: 'background: var(--nav-bg-alt); border-color: rgba(26,34,56,0.25); color: transparent;'"
>
<svg v-if="isSelected(option)" width="11" height="11" viewBox="0 0 12 12" fill="none">
<polyline points="2,6 5,9 10,3" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
<!-- Label -->
<span
class="text-sm leading-tight"
:style="isSelected(option) ? 'color: var(--nav-text); font-weight: 600;' : 'color: var(--nav-text);'"
>{{ option }}</span>
<!-- Input réel (masqué) -->
<input
type="checkbox"
class="sr-only"
:checked="isSelected(option)"
@change="toggle(option)"
/> />
</label> </ChipGroup>
</div>
</div> </div>
</template> </template>
+121
View File
@@ -0,0 +1,121 @@
<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>
+2 -1
View File
@@ -183,6 +183,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const baseTiles = useBaseTiles()
import type { Org } from '~/types/org' import type { Org } from '~/types/org'
const props = defineProps<{ org: Org }>() const props = defineProps<{ org: Org }>()
@@ -283,7 +284,7 @@ onMounted(async () => {
attributionControl: false, attributionControl: false,
}) })
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { L.tileLayer(baseTiles.url(document.documentElement.classList.contains('dark')), {
maxZoom: 18, maxZoom: 18,
}).addTo(map) }).addTo(map)
+103
View File
@@ -0,0 +1,103 @@
<template>
<button
type="button"
class="fiche-mini-card"
:aria-label="fiche.nom"
@click="emit('open')"
>
<span class="fiche-mini-card__nom">{{ fiche.nom }}</span>
<span v-if="fiche.resume" class="fiche-mini-card__resume">{{ fiche.resume }}</span>
<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" />
<Chip v-for="chip in fiche.chips" :key="chip" as="span" variant="fonction" :label="chip" />
</ChipGroup>
<span v-if="!fiche.geoloc" class="fiche-mini-card__no-geo">Sans localisation</span>
</button>
</template>
<script setup lang="ts">
import type { MiniFiche } from '~/composables/useFicheAdapter'
defineProps<{
fiche: MiniFiche
}>()
const emit = defineEmits<{
open: []
}>()
</script>
<style scoped>
.fiche-mini-card {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
padding: var(--space-sm);
border: 1px solid transparent;
border-radius: var(--radius-md);
background: var(--surface);
color: var(--text);
text-align: left;
cursor: pointer;
font-family: inherit;
box-sizing: border-box;
transition: background-color var(--dur-fast) var(--ease-out),
box-shadow var(--dur-fast) var(--ease-out);
}
@media (prefers-reduced-motion: reduce) {
.fiche-mini-card {
transition: none;
}
}
.fiche-mini-card:hover {
background: var(--surface-2);
box-shadow: var(--shadow-sm);
}
.fiche-mini-card:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.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);
color: var(--text-muted);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.fiche-mini-card__chips {
margin-top: var(--space-xs);
}
.fiche-mini-card__no-geo {
margin-top: var(--space-2xs);
font-size: var(--fs-xs);
color: var(--text-subtle);
}
</style>
+168
View File
@@ -0,0 +1,168 @@
<!--
FichesPanel — B3-M3
Panneau « toutes les fiches » : reçoit une liste déjà filtrée (recherche +
filtres en cours côté page), l'affiche en grille de FicheMiniCard, et
expose ses propres chips de filtrage branchées sur le MÊME état que la
page appelante (pas un second état — voir vérification du brief).
Ne crée pas de second champ de recherche : `query` sert uniquement à
nuancer le message d'état vide.
-->
<template>
<div class="fiches-panel">
<div class="fiches-panel__head">
<p class="fiches-panel__intention">{{ intention }}</p>
<div class="fiches-panel__count">
{{ fiches.length }} fiche{{ fiches.length > 1 ? 's' : '' }}
</div>
<ChipGroup v-if="hashtags.length" aria-label="Filtrer les fiches" class="fiches-panel__chips">
<Chip
v-for="tag in visibleHashtags"
:key="tag"
:label="tag"
variant="fonction"
:active="selected.includes(tag)"
@toggle="toggle(tag)"
/>
<Chip
v-if="isCollapsible"
:label="expanded ? 'Réduire' : `Voir les ${hiddenCount} autres`"
variant="echelle"
:active="expanded"
@toggle="expanded = !expanded"
/>
</ChipGroup>
</div>
<div class="fiches-panel__body">
<p v-if="fiches.length === 0" class="fiches-panel__empty">
Aucun résultat<template v-if="query"> pour « {{ query }} »</template>.
</p>
<div v-else class="fiches-panel__grid">
<FicheMiniCard
v-for="fiche in fiches"
:key="`${fiche.kind}-${fiche.id}`"
:fiche="fiche"
@open="emit('open', fiche)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { MiniFiche } from '~/composables/useFicheAdapter'
const props = defineProps<{
fiches: MiniFiche[]
hashtags: string[]
selected: string[]
query: string
intention: string
}>()
const emit = defineEmits<{
'update:selected': [value: string[]]
open: [fiche: MiniFiche]
}>()
// Hashtags repliés (B3-M4) — au-delà de COLLAPSE_THRESHOLD chips, n'affiche
// que les premières + un Chip-bouton « voir les N autres ». Les chips déjà
// actives restent toujours visibles même repliées. `/` (10 fonctions) ne
// dépasse jamais le seuil ; `/agences` (~60 hashtags) se replie. État
// déplié non persistant (ref locale, reset à chaque montage du composant).
const COLLAPSE_THRESHOLD = 14
const expanded = ref(false)
const isCollapsible = computed(() => props.hashtags.length > COLLAPSE_THRESHOLD)
const visibleHashtags = computed(() => {
if (expanded.value || !isCollapsible.value) return props.hashtags
const head = props.hashtags.slice(0, COLLAPSE_THRESHOLD)
const headSet = new Set(head)
const activeOutsideHead = props.hashtags.filter(
(tag) => props.selected.includes(tag) && !headSet.has(tag)
)
return [...head, ...activeOutsideHead]
})
const hiddenCount = computed(() => props.hashtags.length - visibleHashtags.value.length)
function toggle(tag: string) {
if (props.selected.includes(tag)) {
emit('update:selected', props.selected.filter((t) => t !== tag))
} else {
emit('update:selected', [...props.selected, tag])
}
}
</script>
<style scoped>
.fiches-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.fiches-panel__head {
flex-shrink: 0;
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.fiches-panel__intention {
margin: 0 0 var(--space-xs);
font-size: var(--fs-sm);
line-height: var(--lh-base);
color: var(--text-muted);
}
.fiches-panel__count {
margin: 0 0 var(--space-xs);
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--text-subtle);
}
.fiches-panel__chips {
margin-top: var(--space-2xs);
}
.fiches-panel__body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--space-md);
}
.fiches-panel__empty {
margin: var(--space-xl) 0;
text-align: center;
font-size: var(--fs-sm);
color: var(--text-muted);
}
.fiches-panel__grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-sm);
}
@media (min-width: 768px) {
.fiches-panel__grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1280px) {
.fiches-panel__grid {
grid-template-columns: repeat(3, 1fr);
}
}
</style>
+9 -24
View File
@@ -1,32 +1,17 @@
<template> <template>
<div class="space-y-1.5"> <div class="space-y-1.5">
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Fonction</p> <p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Fonction</p>
<div class="space-y-1"> <ChipGroup aria-label="Filtrer par fonction">
<button <Chip
v-for="fn in FONCTIONS" v-for="fn in FONCTIONS"
:key="fn" :key="fn"
@click="toggle(fn)" :label="fn"
:aria-pressed="modelValue.includes(fn)" variant="fonction"
class="flex items-center gap-2.5 w-full rounded px-1 py-0.5 transition-all text-left hover:opacity-80" :active="modelValue.includes(fn)"
:style="modelValue.includes(fn) ? 'background: rgba(26,34,56,0.06);' : ''" :count="modelValue.includes(fn) ? (modelValue.indexOf(fn) + 1) : (counts[fn] ?? 0)"
> @toggle="toggle(fn)"
<!-- Case : affiche le rang de priorité si actif, sinon le nombre d'orgs --> />
<span </ChipGroup>
class="flex items-center justify-center shrink-0 text-xs font-bold transition-all"
style="width: 24px; height: 24px; border: 1.5px solid; border-radius: 4px;"
:style="modelValue.includes(fn)
? 'background: var(--nav-primary); border-color: var(--nav-primary); color: var(--nav-text-on-primary);'
: 'background: var(--nav-bg-alt); border-color: var(--nav-bg-alt); color: var(--nav-text-muted);'"
>
{{ modelValue.includes(fn) ? (modelValue.indexOf(fn) + 1) : (counts[fn] ?? 0) }}
</span>
<!-- Label -->
<span
class="text-sm leading-tight"
:style="modelValue.includes(fn) ? 'color: var(--nav-text); font-weight: 600;' : 'color: var(--nav-text);'"
>{{ fn }}</span>
</button>
</div>
<p v-if="modelValue.length" class="text-xs pt-0.5" style="color: var(--nav-text-muted);"> <p v-if="modelValue.length" class="text-xs pt-0.5" style="color: var(--nav-text-muted);">
{{ modelValue.length }} actif{{ modelValue.length > 1 ? 's' : '' }} {{ modelValue.length }} actif{{ modelValue.length > 1 ? 's' : '' }}
<button @click="emit('update:modelValue', [])" class="ml-2 underline hover:opacity-70">Effacer</button> <button @click="emit('update:modelValue', [])" class="ml-2 underline hover:opacity-70">Effacer</button>
+184
View File
@@ -0,0 +1,184 @@
<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
@@ -0,0 +1,134 @@
<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>
+134
View File
@@ -0,0 +1,134 @@
<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
@@ -0,0 +1,142 @@
<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
@@ -0,0 +1,158 @@
<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>
+9 -11
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="hashtag-filter" style="padding: 8px 12px; background: var(--nav-surface);"> <div class="hashtag-filter" style="padding: 8px 12px; background: var(--nav-surface);">
<!-- Filtres famille --> <!-- Filtres famille — couleurs propres par famille (hors grammaire dimension Chip, non touché B3-M1) -->
<div style="margin-bottom: 6px;"> <div style="margin-bottom: 6px;">
<span class="filter-label">FAMILLES</span> <span class="filter-label">FAMILLES</span>
<div class="chips-row"> <div class="chips-row">
@@ -25,17 +25,16 @@
style="font-size: 0.7rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0;" 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> >{{ hashtagsVisible ? 'Replier' : 'Afficher (' + props.allHashtags.length + ')' }}</button>
</div> </div>
<div v-if="hashtagsVisible" class="chips-row"> <ChipGroup v-if="hashtagsVisible" aria-label="Filtrer par hashtag">
<span <Chip
v-for="tag in props.allHashtags" v-for="tag in props.allHashtags"
:key="tag" :key="tag"
class="chip chip-small" :label="tag"
:style="selectedHashtags.includes(tag) variant="fonction"
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;' :active="selectedHashtags.includes(tag)"
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'" @toggle="toggleHashtag(tag)"
@click="toggleHashtag(tag)" />
>{{ tag }}</span> </ChipGroup>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -93,5 +92,4 @@ function toggleHashtag(tag: string) {
transition: all 0.15s; transition: all 0.15s;
user-select: none; user-select: none;
} }
.chip-small { font-size: 0.7rem; padding: 2px 8px; }
</style> </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;"> <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 : Elle ne prétend pas à l'exhaustivité. Elle est un geste politique :
rendre visible ce qui se transforme, comment, par qui, où. rendre visible ce qui se transforme, comment, par qui, où.
5 familles et des hashtags vous permettent d'explorer. Six familles et des hashtags vous permettent d'explorer.
</p> </p>
<button <button
@click="dismiss" @click="dismiss"
+19
View File
@@ -0,0 +1,19 @@
<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
@@ -0,0 +1,67 @@
<template>
<div class="media-tab-projets" style="padding: 1.5rem; overflow-y: auto;">
<div style="max-width: 70ch; margin-bottom: 1.5rem;">
<h2 style="font-weight: 700; font-size: 1.1rem; margin-bottom: 0.5rem; color: var(--nav-text);">PFE engagés</h2>
<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
@@ -0,0 +1,651 @@
<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,6 +32,7 @@
<div class="mission-body"> <div class="mission-body">
<h2 id="mission-title" class="mission-title">{{ title }}</h2> <h2 id="mission-title" class="mission-title">{{ title }}</h2>
<EchelleTete v-if="echelle" :echelle="echelle" compact @navigate="close" />
<slot> <slot>
<p class="mission-text"> <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é. 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é.
@@ -52,11 +53,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { EchelleId } from '~/utils/echelles'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
modelValue: boolean modelValue: boolean
title?: string title?: string
ctaLabel?: string ctaLabel?: string
storageKey?: 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", title: "L'écosystème d'entraide architecte",
ctaLabel: 'Explorer la carte', ctaLabel: 'Explorer la carte',
+4 -7
View File
@@ -5,6 +5,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const baseTiles = useBaseTiles()
import type { Map, Marker, DivIcon } from 'leaflet' import type { Map, Marker, DivIcon } from 'leaflet'
interface Org { interface Org {
@@ -90,12 +91,10 @@ async function initMap() {
// Fond de carte CartoDB Positron (light ou dark selon theme) // Fond de carte CartoDB Positron (light ou dark selon theme)
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark') const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
const tileUrl = isDark const tileUrl = baseTiles.url(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, { tileLayerInstance = L.tileLayer(tileUrl, {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>', attribution: baseTiles.attribution,
maxZoom: 19, maxZoom: 19,
}) })
tileLayerInstance.addTo(mapInstance!) tileLayerInstance.addTo(mapInstance!)
@@ -212,9 +211,7 @@ function updateTileTheme(dark: boolean) {
if (!mapInstance || !tileLayerInstance) return if (!mapInstance || !tileLayerInstance) return
const L = (window as any).L const L = (window as any).L
if (!L) return if (!L) return
const url = dark const url = baseTiles.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) tileLayerInstance.setUrl(url)
} }
+4 -7
View File
@@ -5,6 +5,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const baseTiles = useBaseTiles()
import type { Map, Marker, DivIcon } from 'leaflet' import type { Map, Marker, DivIcon } from 'leaflet'
import type { StructureV2 } from '~/types/structure-v2' import type { StructureV2 } from '~/types/structure-v2'
@@ -87,12 +88,10 @@ async function initMap() {
}) })
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark') const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
const tileUrl = isDark const tileUrl = baseTiles.url(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, { tileLayerInstance = L.tileLayer(tileUrl, {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>', attribution: baseTiles.attribution,
maxZoom: 19, maxZoom: 19,
}) })
tileLayerInstance.addTo(mapInstance!) tileLayerInstance.addTo(mapInstance!)
@@ -213,9 +212,7 @@ function updateTileTheme(dark: boolean) {
if (!mapInstance || !tileLayerInstance) return if (!mapInstance || !tileLayerInstance) return
const L = (window as any).L const L = (window as any).L
if (!L) return if (!L) return
const url = dark const url = baseTiles.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) tileLayerInstance.setUrl(url)
} }
-45
View File
@@ -1,45 +0,0 @@
<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
@@ -0,0 +1,150 @@
<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>
+3 -4
View File
@@ -38,6 +38,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const baseTiles = useBaseTiles()
import type { Map as LeafletMap, TileLayer } from 'leaflet' import type { Map as LeafletMap, TileLayer } from 'leaflet'
interface Org { interface Org {
@@ -111,9 +112,7 @@ function createPinIcon(L: any, isPrioritaire: boolean, isSelected = false) {
} }
function getTileUrl(dark: boolean) { function getTileUrl(dark: boolean) {
return dark return baseTiles.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'
} }
async function initSingleMap(domName: string) { async function initSingleMap(domName: string) {
@@ -132,7 +131,7 @@ async function initSingleMap(domName: string) {
touchZoom: true, keyboard: false, touchZoom: true, keyboard: false,
}) })
const tileLayer = L.tileLayer(getTileUrl(isDark), { const tileLayer = L.tileLayer(getTileUrl(isDark), {
attribution: '© OpenStreetMap contributors © CARTO', maxZoom: 19, attribution: baseTiles.attribution, maxZoom: 19,
}) })
tileLayer.addTo(map) tileLayer.addTo(map)
tileLayers[domName] = tileLayer as unknown as TileLayer tileLayers[domName] = tileLayer as unknown as TileLayer
+156
View File
@@ -0,0 +1,156 @@
<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
@@ -1,11 +0,0 @@
<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
@@ -0,0 +1,201 @@
<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>
+240
View File
@@ -0,0 +1,240 @@
.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
@@ -0,0 +1,22 @@
// 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 }
}
+84
View File
@@ -0,0 +1,84 @@
/**
* useFicheAdapter — B3-M2
* Adaptateur léger Org | StructureV2 → MiniFiche, format commun consommé par
* FicheMiniCard / FichesPanel. 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).
*/
import type { Org } from '~/types/org'
import type { StructureV2 } from '~/types/structure-v2'
export interface MiniFiche {
id: string | number
nom: string
resume: string
chips: string[]
echelle?: string
geoloc: boolean
kind: 'org' | 'structure'
}
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)
.slice(0, 3)
}
export function useFicheAdapter() {
function toMini(source: Org | StructureV2): MiniFiche {
if (isStructure(source)) {
return {
id: source.id,
nom: source.nom,
resume: buildResume([source.description_courte, source.description_longue]),
chips: (source.hashtags ?? []).slice(0, 3),
geoloc: source.latitude != null && source.longitude != null,
kind: 'structure',
}
}
return {
id: source.Id,
nom: source.nom,
resume: buildResume([source.description_enrichie, source.description]),
chips: orgFonctions(source),
echelle: source.echelle,
geoloc: source.latitude != null && source.longitude != null,
kind: 'org',
}
}
return { toMini }
}
+21 -1
View File
@@ -1,6 +1,20 @@
export default defineNuxtConfig({ export default defineNuxtConfig({
modules: ['@nuxtjs/tailwindcss'], modules: ['@nuxtjs/tailwindcss'],
app: {
head: {
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: [ css: [
'@transformer/ui/tokens.css',
'@transformer/ui/theme/aep.css',
'~/assets/css/main.css', '~/assets/css/main.css',
'leaflet/dist/leaflet.css', 'leaflet/dist/leaflet.css',
'leaflet.markercluster/dist/MarkerCluster.css', 'leaflet.markercluster/dist/MarkerCluster.css',
@@ -16,6 +30,8 @@ export default defineNuxtConfig({
commentTableId: process.env.COMMENT_TABLE_ID || process.env.AVIS_TABLE_ID, commentTableId: process.env.COMMENT_TABLE_ID || process.env.AVIS_TABLE_ID,
statsTableId: process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc', statsTableId: process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc',
mistralApiKey: process.env.MISTRAL_API_KEY, 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', redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
resendApiKey: process.env.RESEND_API_KEY, resendApiKey: process.env.RESEND_API_KEY,
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr', emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
@@ -23,13 +39,17 @@ export default defineNuxtConfig({
codevPassword: 'merci', // NUXT_CODEV_PASSWORD - défaut "merci", overridable codevPassword: 'merci', // NUXT_CODEV_PASSWORD - défaut "merci", overridable
codevBaseId: '', // NUXT_CODEV_BASE_ID - base NocoDB (ex: pipilvsi7dibo80) codevBaseId: '', // NUXT_CODEV_BASE_ID - base NocoDB (ex: pipilvsi7dibo80)
codevAdminPassword: 'admin2026', // NUXT_CODEV_ADMIN_PASSWORD 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
},
}, },
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client // Leaflet ne fonctionne pas en SSR — forcer le rendu côté client
ssr: true, ssr: true,
vite: { vite: {
cacheDir: 'C:/Users/jules/AppData/Local/nav-carte-vite-cache',
optimizeDeps: { optimizeDeps: {
include: ['leaflet', 'leaflet.markercluster', 'd3'], include: ['leaflet', 'leaflet.markercluster', 'd3'],
}, },
+6
View File
@@ -8,6 +8,7 @@
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@headlessui/vue": "^1.7.23", "@headlessui/vue": "^1.7.23",
"@transformer/ui": "git+https://git.trans-former.fr/jules/design-system.git",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@types/leaflet.markercluster": "^1.5.6", "@types/leaflet.markercluster": "^1.5.6",
"d3": "^7.9.0", "d3": "^7.9.0",
@@ -3625,6 +3626,11 @@
"vue": "^2.7.0 || ^3.0.0" "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": { "node_modules/@tybys/wasm-util": {
"version": "0.10.1", "version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
+1
View File
@@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@headlessui/vue": "^1.7.23", "@headlessui/vue": "^1.7.23",
"@transformer/ui": "git+https://git.trans-former.fr/jules/design-system.git",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@types/leaflet.markercluster": "^1.5.6", "@types/leaflet.markercluster": "^1.5.6",
"d3": "^7.9.0", "d3": "^7.9.0",
+65 -66
View File
@@ -13,7 +13,7 @@
<section class="section-mission"> <section class="section-mission">
<h1>À propos d'AEP</h1> <h1>À propos d'AEP</h1>
<p class="mission-text"> <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 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. 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.
</p> </p>
</section> </section>
@@ -27,14 +27,6 @@
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. 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> </p>
<div class="badges-row"> <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"> <div class="badge">
<span class="badge-icon" aria-hidden="true"> <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> <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>
@@ -103,22 +95,8 @@
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. 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> </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"> <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>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> </ul>
</section> </section>
@@ -190,12 +168,38 @@
</a> </a>
</section> </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>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'
useHead({ title: 'À propos - AEP' }) 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> </script>
<style scoped> <style scoped>
@@ -372,49 +376,6 @@ useHead({ title: 'À propos - AEP' })
/* ── IA grid ─────────────────────────────────────────────────────────────────── */ /* ── 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 { .ia-details {
list-style: none; list-style: none;
padding: 0; padding: 0;
@@ -512,6 +473,44 @@ useHead({ title: 'À propos - AEP' })
background: var(--nav-bg-alt); 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 ──────────────────────────────────────────────────────── */ /* ── Responsive général ──────────────────────────────────────────────────────── */
@media (max-width: 480px) { @media (max-width: 480px) {
+110 -18
View File
@@ -2,7 +2,15 @@
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);"> <div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
<!-- ═══════════════════════════════════════ SIDEBAR DESKTOP (>= 1024px) --> <!-- ═══════════════════════════════════════ 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%;"> <!-- Masquée en vue « Toutes les fiches » (B3-M3, décision Jules axe 2) :
le panneau EST la liste (+ ses propres chips hashtags), la sidebar
ferait doublon. IntentionBanner reste monté (Teleport, insensible au
v-show de son conteneur). -->
<div
v-show="desktopMapView !== 'fiches'"
class="hidden lg:block overflow-y-auto"
style="width: 320px; min-width: 320px; flex-shrink: 0; border-right: 1px solid var(--nav-bg-alt); height: 100%;"
>
<!-- IntentionBanner s'auto-affiche via Teleport (overlay plein ecran) --> <!-- IntentionBanner s'auto-affiche via Teleport (overlay plein ecran) -->
<IntentionBanner /> <IntentionBanner />
@@ -87,14 +95,15 @@
/> />
</div> </div>
<div class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">{{ structure.type_principal }} - {{ structure.ville }}</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"> <ChipGroup v-if="structure.hashtags.length" class="mt-1" :aria-label="`Hashtags de ${structure.nom}`">
<span <Chip
v-for="tag in structure.hashtags.slice(0, 2)" v-for="tag in structure.hashtags.slice(0, 2)"
:key="tag" :key="tag"
class="text-xs" as="span"
style="color: var(--nav-text-muted);" variant="fonction"
>{{ tag }}</span> :label="tag"
</div> />
</ChipGroup>
</div> </div>
</div> </div>
@@ -112,22 +121,29 @@
:style="desktopMapView === 'metropole' :style="desktopMapView === 'metropole'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);' ? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'" : 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'metropole'" @click="setDesktopView('metropole')"
>Métropolitain</button> >Métropolitain</button>
<button <button
class="px-5 py-2 text-sm font-medium transition-colors" class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'outremer' :style="desktopMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);' ? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'" : 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'outremer'" @click="setDesktopView('outremer')"
>Outre-mer</button> >Outre-mer</button>
<button <button
class="px-5 py-2 text-sm font-medium transition-colors" class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'graphe' :style="desktopMapView === 'graphe'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);' ? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'" : 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'graphe'" @click="setDesktopView('graphe')"
>Vue graphique</button> >Vue graphique</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'fiches'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="setDesktopView('fiches')"
>Toutes les fiches</button>
</div> </div>
<!-- Carte Métropole desktop --> <!-- Carte Métropole desktop -->
@@ -194,31 +210,53 @@
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }" @applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
/> />
</div> </div>
<!-- Panneau « Toutes les fiches » desktop (B3-M3) — la sidebar est
masquée dans cette vue (décision Jules axe 2), le panneau EST
la liste, avec ses propres chips hashtags sur le même état. -->
<div v-show="desktopMapView === 'fiches'" class="flex-1 flex flex-col overflow-hidden">
<FichesPanel
:fiches="allStructuresMini"
:hashtags="allHashtags"
:selected="selectedHashtags"
:query="search"
intention="Toutes les agences et réseaux du répertoire, filtrables par hashtag."
@update:selected="selectedHashtags = $event"
@open="(fiche) => onSelectStructure(String(fiche.id))"
/>
</div>
</div> </div>
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer/Graphique + sheet swipable ── --> <!-- ── VUE MOBILE : Onglets Métro/Outre-mer/Graphique/Fiches + sheet swipable ── -->
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);"> <div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<button <button
class="flex-1 py-2 text-sm font-medium transition-colors" class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'metropole' :style="mobileMapView === 'metropole'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);' ? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'" : 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'metropole'" @click="setMobileView('metropole')"
>Métropolitain</button> >Métropolitain</button>
<button <button
class="flex-1 py-2 text-sm font-medium transition-colors" class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'outremer' :style="mobileMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);' ? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'" : 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'outremer'" @click="setMobileView('outremer')"
>Outre-mer</button> >Outre-mer</button>
<button <button
class="flex-1 py-2 text-sm font-medium transition-colors" class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'graphe' :style="mobileMapView === 'graphe'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);' ? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'" : 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'graphe'" @click="setMobileView('graphe')"
>Graphe</button> >Graphe</button>
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'fiches'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="setMobileView('fiches')"
>Fiches</button>
</div> </div>
<div class="lg:hidden flex-1 relative overflow-hidden"> <div class="lg:hidden flex-1 relative overflow-hidden">
@@ -272,8 +310,30 @@
</ClientOnly> </ClientOnly>
</div> </div>
<!-- Bottom sheet swipable (masqué en vue graphique pour ne pas occulter le canvas) --> <!-- Zone « Fiches » mobile (B3-M3) — pleine hauteur sous la barre d'onglets,
<ClientOnly v-if="mobileMapView !== 'graphe'"> pas de carte dessous donc pas de sheet demi-hauteur (même logique que
la vue graphe qui masque déjà le MobileSheet). Fix 2026-09-25 : la
version précédente empilait un second MobileSheet (état half par
défaut) au-dessus d'un fond neutre plein écran, laissant ~45% de
hauteur vide au-dessus du panneau. -->
<div
v-show="mobileMapView === 'fiches'"
class="absolute inset-0 flex flex-col overflow-hidden"
style="background: var(--nav-bg-alt);"
>
<FichesPanel
:fiches="allStructuresMini"
:hashtags="allHashtags"
:selected="selectedHashtags"
:query="search"
intention="Toutes les agences et réseaux du répertoire, filtrables par hashtag."
@update:selected="selectedHashtags = $event"
@open="(fiche) => onSelectStructureMobile(String(fiche.id))"
/>
</div>
<!-- Bottom sheet swipable (masqué en vue graphique et en vue fiches) -->
<ClientOnly v-if="mobileMapView !== 'graphe' && mobileMapView !== 'fiches'">
<MobileSheet :resultCount="filtered.length" :pending="pending"> <MobileSheet :resultCount="filtered.length" :pending="pending">
<!-- Bandeau intention mobile --> <!-- 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);"> <div class="px-3 py-2" style="background: var(--bifurc-banner-bg, #faf8f5); border-bottom: 1px solid var(--bifurc-banner-border, #e0d8cc);">
@@ -424,6 +484,7 @@
title="Réseaux AEP — l'architecture qui s'engage" title="Réseaux AEP — l'architecture qui s'engage"
ctaLabel="Explorer les 120 réseaux" ctaLabel="Explorer les 120 réseaux"
storageKey="aep_reseaux_seen" storageKey="aep_reseaux_seen"
echelle="M"
> >
<p class="mission-text"> <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. 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.
@@ -454,15 +515,41 @@ function familleColor(f: number): string {
} }
// ── État UI ──────────────────────────────────────────────────────────────── // ── État UI ────────────────────────────────────────────────────────────────
type MapView = 'metropole' | 'outremer' | 'graphe' | 'fiches'
const route = useRoute()
const router = useRouter()
const selectedId = ref<string | null>(null) const selectedId = ref<string | null>(null)
const hoveredId = ref<string | null>(null) const hoveredId = ref<string | null>(null)
const ficheModalOpen = ref(false) const ficheModalOpen = ref(false)
const ficheModalId = ref<string | null>(null) const ficheModalId = ref<string | null>(null)
const chatbotOpen = ref(false) const chatbotOpen = ref(false)
const mobileMapView = ref<'metropole' | 'outremer' | 'graphe'>('metropole') const mobileMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
const desktopMapView = ref<'metropole' | 'outremer' | 'graphe'>('metropole') const desktopMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
const missionOpen = ref(false) const missionOpen = ref(false)
// Sync URL — B3-M3 : pose/retire ?vue=fiches uniquement (agences.vue n'avait
// aucune synchronisation URL avant B3 pour search/famille/hashtags/vue ; hors
// périmètre de cette mission de l'étendre, seul ?vue=fiches est ajouté ici).
function setDesktopView(v: MapView) {
desktopMapView.value = v
syncViewUrl()
}
function setMobileView(v: MapView) {
mobileMapView.value = v
syncViewUrl()
}
function syncViewUrl() {
const q: Record<string, string> = { ...(route.query as Record<string, string>) }
if (desktopMapView.value === 'fiches' || mobileMapView.value === 'fiches') {
q.vue = 'fiches'
} else {
delete q.vue
}
router.replace({ query: Object.keys(q).length ? q : undefined })
}
onMounted(() => { onMounted(() => {
try { try {
if (!localStorage.getItem('aep_reseaux_seen')) { if (!localStorage.getItem('aep_reseaux_seen')) {
@@ -555,6 +642,11 @@ function resetFilters() {
// Pour simplifier : toutes les structures (la carte gère les sans-coords) // Pour simplifier : toutes les structures (la carte gère les sans-coords)
const metropoleStructures = computed<StructureV2[]>(() => filtered.value) const metropoleStructures = computed<StructureV2[]>(() => filtered.value)
// Panneau « Toutes les fiches » (B3-M3) — mêmes structures que `filtered`,
// sans filtrer par géolocalisation (adaptateur mutualisé avec `/`).
const { toMini } = useFicheAdapter()
const allStructuresMini = computed(() => filtered.value.map((s) => toMini(s)))
// Outre-mer : pas de structures V2 DOM-TOM pour l'instant - garder le composant existant vide // 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 // OutremerMap attend le format Org legacy - on passe un tableau vide
const outremerOrgsLegacy = computed(() => []) const outremerOrgsLegacy = computed(() => [])
+8
View File
@@ -5,6 +5,7 @@
<div class="lock-header"> <div class="lock-header">
<h1>Co-développement</h1> <h1>Co-développement</h1>
<p class="lock-subtitle">Entraide entre pairs</p> <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> <p class="lock-intro">Cet espace est un cercle. Pour entrer, il y a un mot.</p>
</div> </div>
@@ -104,6 +105,13 @@ async function submit() {
margin: 0 0 1rem; margin: 0 0 1rem;
} }
.lock-echelle {
text-align: left;
padding: var(--space-md);
border-radius: var(--radius-md);
background: var(--bg-alt);
}
.lock-intro { .lock-intro {
font-size: 0.9rem; font-size: 0.9rem;
color: var(--nav-text-muted, #6b7280); color: var(--nav-text-muted, #6b7280);
+2 -790
View File
@@ -1,795 +1,7 @@
<template> <template>
<div class="contribuer-page"> <div />
<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> </template>
<script setup lang="ts"> <script setup lang="ts">
import { z } from 'zod' navigateTo('/proposer', { redirectCode: 301 })
// ── 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> </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
@@ -0,0 +1,150 @@
<!--
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>
+111 -30
View File
@@ -40,7 +40,7 @@
Mode dev — données seed Mode dev — données seed
</div> </div>
<!-- ── VUE DESKTOP : Onglets Métropole / Outre-mer ── --> <!-- ── VUE DESKTOP : Onglets Métropole / Outre-mer / Toutes les fiches ── -->
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden"> <div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
<!-- Barre onglets desktop --> <!-- Barre onglets desktop -->
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);"> <div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
@@ -49,15 +49,22 @@
:style="desktopMapView === 'metropole' :style="desktopMapView === 'metropole'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);' ? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'" : 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'metropole'" @click="setDesktopView('metropole')"
>Métropolitain</button> >Métropolitain</button>
<button <button
class="px-5 py-2 text-sm font-medium transition-colors" class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'outremer' :style="desktopMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);' ? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'" : 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'outremer'" @click="setDesktopView('outremer')"
>Outre-mer</button> >Outre-mer</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'fiches'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="setDesktopView('fiches')"
>Toutes les fiches</button>
</div> </div>
<!-- Carte Métropole desktop --> <!-- Carte Métropole desktop -->
@@ -94,26 +101,58 @@
</div> </div>
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" /> <ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
</div> </div>
<!-- Panneau « Toutes les fiches » desktop (B3-M3) — remplace la carte,
fiches sans géoloc incluses (gain principal de cette mission) -->
<div v-show="desktopMapView === 'fiches'" class="flex-1 flex flex-col overflow-hidden">
<div
class="shrink-0 px-4 py-2"
style="border-bottom: 1px solid var(--nav-bg-alt); background: var(--nav-surface);"
>
<button
type="button"
class="text-xs font-medium underline"
style="color: var(--nav-text-muted);"
@click="setDesktopView('metropole')"
>← Carte</button>
</div>
<FichesPanel
:fiches="allFichesMini"
:hashtags="[...FONCTIONS]"
:selected="fonctions"
:query="search"
intention="Toutes les structures d'entraide, y compris celles sans adresse sur la carte. Cochez une fonction pour filtrer."
@update:selected="onFonctions"
@open="(fiche) => onSelectOrg(Number(fiche.id))"
/>
</div>
</div> </div>
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + carte pleine hauteur + sheet swipable ── --> <!-- ── VUE MOBILE : Onglets Métro/Outre-mer + carte pleine hauteur + sheet swipable ── -->
<!-- Onglets Métropolitain / Outre-mer --> <!-- Onglets Métropolitain / Outre-mer / Toutes les fiches -->
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);"> <div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<button <button
class="flex-1 py-2 text-sm font-medium transition-colors" class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'metropole' :style="mobileMapView === 'metropole'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);' ? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'" : 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'metropole'" @click="setMobileView('metropole')"
>Métropolitain</button> >Métropolitain</button>
<button <button
class="flex-1 py-2 text-sm font-medium transition-colors" class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'outremer' :style="mobileMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);' ? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'" : 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'outremer'" @click="setMobileView('outremer')"
>Outre-mer</button> >Outre-mer</button>
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'fiches'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="setMobileView('fiches')"
>Fiches</button>
</div> </div>
<div class="lg:hidden flex-1 relative overflow-hidden"> <div class="lg:hidden flex-1 relative overflow-hidden">
@@ -154,8 +193,30 @@
</ClientOnly> </ClientOnly>
</div> </div>
<!-- Bottom sheet swipable (Métropole et Outre-mer) --> <!-- Zone « Fiches » mobile (B3-M3) — pleine hauteur sous la barre d'onglets,
<ClientOnly> pas de carte dessous donc pas de sheet demi-hauteur (même logique que
la vue graphe qui masque aussi le MobileSheet). Fix 2026-09-25 : la
version précédente empilait un second MobileSheet (état half par
défaut) au-dessus d'un fond neutre plein écran, laissant ~45% de
hauteur vide au-dessus du panneau. -->
<div
v-show="mobileMapView === 'fiches'"
class="absolute inset-0 flex flex-col overflow-hidden"
style="background: var(--nav-bg-alt);"
>
<FichesPanel
:fiches="allFichesMini"
:hashtags="[...FONCTIONS]"
:selected="fonctions"
:query="search"
intention="Toutes les structures d'entraide, y compris celles sans adresse sur la carte. Cochez une fonction pour filtrer."
@update:selected="onFonctions"
@open="(fiche) => onSelectOrgMobile(Number(fiche.id))"
/>
</div>
<!-- Bottom sheet swipable (Métropole et Outre-mer uniquement) -->
<ClientOnly v-if="mobileMapView !== 'fiches'">
<MobileSheet :resultCount="filtered.length" :pending="pending"> <MobileSheet :resultCount="filtered.length" :pending="pending">
<!-- Barre recherche --> <!-- Barre recherche -->
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);"> <div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
@@ -185,36 +246,36 @@
</button> </button>
</label> </label>
<!-- Filtres ÉCHELLE — chips style FONCTION --> <!-- Filtres ÉCHELLE — Chip variant echelle (B3-M1 : alignées sur EchelleFilter.vue,
dupliquées ici car la sheet mobile ne passe pas par NavSidebar) -->
<div class="mt-2"> <div class="mt-2">
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">ÉCHELLE</span> <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"> <ChipGroup aria-label="Filtrer par échelle">
<span <Chip
v-for="opt in ECHELLES" v-for="opt in ECHELLES"
:key="opt" :key="opt"
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all" :label="opt"
:style="echelle.includes(opt) variant="echelle"
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;' :active="echelle.includes(opt)"
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'" @toggle="toggleEchelle(opt)"
@click="toggleEchelle(opt)" />
>{{ opt }}</span> </ChipGroup>
</div>
</div> </div>
<!-- Filtres FONCTION — chips flex-wrap --> <!-- Filtres FONCTION — Chip variant fonction (B3-M1, idem) -->
<div class="mt-2"> <div class="mt-2">
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">FONCTION</span> <span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">FONCTION</span>
<div class="flex flex-wrap gap-1"> <ChipGroup aria-label="Filtrer par fonction">
<span <Chip
v-for="fn in FONCTIONS" v-for="fn in FONCTIONS"
:key="fn" :key="fn"
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all" :label="fn"
:style="fonctions.includes(fn) variant="fonction"
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;' :active="fonctions.includes(fn)"
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'" :count="fonctions.includes(fn) ? (fonctions.indexOf(fn) + 1) : (fonctionCount[fn] ?? 0)"
@click="toggleFonction(fn)" @toggle="toggleFonction(fn)"
>{{ fn }}</span> />
</div> </ChipGroup>
</div> </div>
<button <button
@@ -330,6 +391,7 @@
<MissionPopup <MissionPopup
:modelValue="missionOpen" :modelValue="missionOpen"
@update:modelValue="missionOpen = $event" @update:modelValue="missionOpen = $event"
echelle="M"
/> />
</div> </div>
@@ -358,12 +420,14 @@ const territoireMode = ref<string>(
(route.query.mode as string) === 'outremer' ? 'outremer' : 'metropole' (route.query.mode as string) === 'outremer' ? 'outremer' : 'metropole'
) )
const desktopMapView = ref<'metropole' | 'outremer'>('metropole') type MapView = 'metropole' | 'outremer' | 'fiches'
const desktopMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
const selectedId = ref<number | null>(null) const selectedId = ref<number | null>(null)
const chatbotOpen = ref(false) const chatbotOpen = ref(false)
const ficheModalOpen = ref(false) const ficheModalOpen = ref(false)
const ficheModalId = ref<number | null>(null) const ficheModalId = ref<number | null>(null)
const mobileMapView = ref<'metropole' | 'outremer'>('metropole') const mobileMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
const missionOpen = ref(false) const missionOpen = ref(false)
onMounted(() => { onMounted(() => {
@@ -411,9 +475,20 @@ function syncUrl() {
if (fonctions.value.length) q.fonctions = fonctions.value.join(',') if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
if (territoire.value) q.territoire = territoire.value if (territoire.value) q.territoire = territoire.value
if (territoireMode.value === 'outremer') q.mode = 'outremer' if (territoireMode.value === 'outremer') q.mode = 'outremer'
if (desktopMapView.value === 'fiches' || mobileMapView.value === 'fiches') q.vue = 'fiches'
router.replace({ query: Object.keys(q).length ? q : undefined }) router.replace({ query: Object.keys(q).length ? q : undefined })
} }
// Changement de vue (carte/fiches) — passe par syncUrl pour poser/retirer ?vue=fiches
function setDesktopView(v: MapView) {
desktopMapView.value = v
syncUrl()
}
function setMobileView(v: MapView) {
mobileMapView.value = v
syncUrl()
}
// Sauvegarde les filtres courants dans sessionStorage pour le bouton retour des fiches // Sauvegarde les filtres courants dans sessionStorage pour le bouton retour des fiches
function storeFiltersForBack() { function storeFiltersForBack() {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
@@ -541,6 +616,12 @@ const filtered = computed<Org[]>(() => {
return result return result
}) })
// Panneau « Toutes les fiches » (B3-M3) — mêmes fiches que `filtered`, sans
// filtrer par géolocalisation : c'est le gain principal de cette mission,
// les fiches sans latitude/longitude (invisibles sur la carte) y apparaissent.
const { toMini } = useFicheAdapter()
const allFichesMini = computed(() => filtered.value.map((org) => toMini(org)))
const DOM_TOM = ['Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte'] const DOM_TOM = ['Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte']
const DOM_TOM_LIST = DOM_TOM const DOM_TOM_LIST = DOM_TOM
+56
View File
@@ -0,0 +1,56 @@
<template>
<div class="media-page" style="background: var(--nav-bg);">
<nav class="subtabs" style="display:flex; gap:0; border-bottom: 1px solid var(--nav-bg-alt); background: var(--nav-surface); padding: 0 1rem;">
<button
:class="['subtab-btn', { active: tab === 'visuel' }]"
@click="tab = 'visuel'"
>
📚 bibliothèque des pensées écologiques
</button>
<button
:class="['subtab-btn', { active: tab === 'projets' }]"
@click="tab = 'projets'"
>
📐 Projets
</button>
</nav>
<MediaTabVisuel v-if="tab === 'visuel'" />
<MediaTabProjets v-else-if="tab === 'projets'" />
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const router = useRouter()
const tab = ref<'visuel' | 'projets'>(
(['visuel', 'projets'].includes(route.query.tab as string)
? route.query.tab as 'visuel' | 'projets'
: 'visuel')
)
watch(tab, (newTab) => {
router.replace({ query: { ...route.query, tab: newTab } })
})
useHead({ title: 'AEP - Media' })
</script>
<style scoped>
.media-page { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.subtabs { display: flex; gap: 0; flex-shrink: 0; }
.subtab-btn {
padding: 10px 18px;
font-size: 0.85rem;
font-weight: 500;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
color: var(--nav-text-muted);
transition: color 0.15s, border-color 0.15s;
}
.subtab-btn:hover { color: var(--nav-text); }
.subtab-btn.active { color: var(--nav-primary-solid); border-bottom-color: var(--nav-primary-solid); font-weight: 600; }
</style>
+797
View File
@@ -0,0 +1,797 @@
<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>
+197
View File
@@ -0,0 +1,197 @@
<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">
Tu connais quelque chose d'utile qui n'est pas encore référencé sur AEP ?
Choisis la catégorie ci-dessous et soumets-le — on valide sous 7 jours.
</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>
<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>
</template>
</div>
</div>
</template>
<script setup lang="ts">
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 l'onglet
const route = useRoute()
const categorie = String(route.query.categorie ?? '')
const activeTab = ref<string>(TABS.some(t => t.id === categorie) ? categorie : 'ecosysteme')
const success = ref(false)
function onSuccess() {
success.value = true
}
function reset() {
success.value = false
activeTab.value = 'ecosysteme'
}
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;
}
.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>
+1 -36
View File
@@ -1,38 +1,3 @@
<template>
<div class="flex flex-col items-center justify-center h-full gap-6" style="background: var(--nav-bg);">
<div class="text-center max-w-md px-6">
<div
class="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-5"
style="background: var(--nav-bg-alt);"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted);">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
</div>
<h1 class="text-2xl font-bold mb-3" style="color: var(--nav-text);">RAG — Retrieval Augmented Generation</h1>
<p class="text-sm leading-relaxed mb-6" style="color: var(--nav-text-muted);">
Une base de connaissances interrogeable par IA — textes, rapports, manifestes et ressources documentaires sur l'architecture d'écologie politique.
</p>
<p class="text-xs font-semibold uppercase tracking-widest mb-6" style="color: var(--nav-text-muted); opacity: 0.6;">
Bientôt disponible
</p>
<NuxtLink
to="/"
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold transition-all hover:opacity-80"
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
<line x1="19" y1="12" x2="5" y2="12"/>
<polyline points="12 19 5 12 12 5"/>
</svg>
Retour à l'écosystème
</NuxtLink>
</div>
</div>
</template>
<script setup lang="ts"> <script setup lang="ts">
useHead({ title: 'RAG — AEP (bientôt disponible)' }) navigateTo('/media', { redirectCode: 301 })
</script> </script>
+6 -1
View File
@@ -5,6 +5,7 @@
<div class="taff-header"> <div class="taff-header">
<div class="taff-header-inner"> <div class="taff-header-inner">
<h1 class="taff-title">Trouver du taf en archi</h1> <h1 class="taff-title">Trouver du taf en archi</h1>
<EchelleTete echelle="S" :contribuer="false" class="taff-echelle-tete" />
<p class="taff-subtitle"> <p class="taff-subtitle">
Annuaire critique des plateformes de mise en relation et d'appels d'offres, 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. pour les <strong>architectes indépendants</strong> — 70&nbsp;% de la profession et sa part la plus précaire économiquement.
@@ -296,7 +297,7 @@
</svg> </svg>
</button> </button>
</div> </div>
<p class="taff-chat-footer-note">Propulsé par Mistral · 20 questions/jour</p> <p class="taff-chat-footer-note">20 questions/jour</p>
</div> </div>
</Transition> </Transition>
</Teleport> </Teleport>
@@ -649,6 +650,10 @@ 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 { padding: 2.5rem 1.5rem 1.5rem; border-bottom: 1px solid var(--nav-bg-alt); text-align: center; }
.taff-header-inner { max-width: 680px; margin: 0 auto; } .taff-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-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-subtitle { font-size: 0.9375rem; color: var(--nav-text-muted); line-height: 1.6; margin-bottom: 0.625rem; }
.taff-cible { font-size: 0.875rem; color: var(--nav-text-muted); line-height: 1.55; margin-bottom: 1rem; font-style: italic; } .taff-cible { font-size: 0.875rem; color: var(--nav-text-muted); line-height: 1.55; margin-bottom: 1rem; font-style: italic; }
Binary file not shown.

After

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
@@ -0,0 +1,102 @@
{
"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
@@ -0,0 +1,90 @@
{
"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
@@ -0,0 +1,70 @@
{
"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.

After

Width:  |  Height:  |  Size: 527 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+21
View File
@@ -0,0 +1,21 @@
<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>

After

Width:  |  Height:  |  Size: 892 B

+12
View File
@@ -0,0 +1,12 @@
# 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
@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 493 B

+162
View File
@@ -0,0 +1,162 @@
/**
* 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 { checkRateLimitJson } from '~/server/utils/rateLimitJson'
const SYSTEM_PREFACE_PENSEES = `Tu es un agent du RAG Pensées Écologiques, infrastructure militante du collectif trans-former.fr.
Tu réponds en t'appuyant STRICTEMENT sur le corpus ingéré (auteurs FRACAS Bonpote : écosocialisme, éco-anarchisme, écoféminismes, écologies décoloniales, technocritique, pensées du vivant, décroissance...).
Règles :
- Cite les sources (auteur, livre) à chaque assertion importante.
- Si la question dépasse le corpus, dis-le clairement. Pas d'hallucination.
- Ton politique direct, pas de neutralité fade.
- Réponse en français, dense, sans délayage.
- Distingue les positions selon les écoles quand elles divergent.`
const SYSTEM_PREFACE_PROJETS = `Tu es un agent du RAG Projets de Jules Nény (architecte, collectif trans-former.fr).
Tu réponds STRICTEMENT à partir des documents projet (fichiers butte-pinson__*.md et autres projets archi de Jules).
N'utilise PAS le corpus FRACAS Pensées Écologiques pour répondre, sauf si l'usager te le demande explicitement.
Règles :
- Cite les sources (nom de projet, document) à chaque assertion importante.
- Si la question dépasse le corpus projet, dis-le clairement. Pas d'hallucination.
- Ton praticien réflexif : 1ère personne quand pertinent, narration située.
- Réponse en français, dense, sans délayage.`
const SYSTEM_PREFACE_BOTH = `Tu es un agent du RAG croisé Pensées x Projets de Jules Nény (architecte militant, collectif trans-former.fr).
CENTRE TA RÉPONSE sur les documents PROJETS (fichiers butte-pinson__*.md et autres projets archi).
Mobilise le corpus FRACAS Pensées (autres fichiers) UNIQUEMENT pour éclairer théoriquement les partis pris des projets, jamais l'inverse.
Pondération attendue : ~70% ancrage projet concret, ~30% éclairage théorique FRACAS.
Règles :
- Cite les sources (auteur ou nom de projet, document) à chaque assertion.
- Si un thème n'est pas couvert par les projets, dis-le clairement avant d'éventuellement étendre au corpus Pensées.
- Pas d'hallucination, pas d'extrapolation hors corpus.
- Ton praticien militant : direct, pas neutre, ancré dans la pratique architecturale.
- Réponse en français, dense, sans délayage.`
function buildPrefaceAuteur(nomAuteur: string, slug: string): string {
return `Tu réponds EXCLUSIVEMENT depuis les livres de ${nomAuteur} présents dans le RAG (fichiers commençant par "${slug}__").
Si la question sort du périmètre de cet auteur, indique-le et propose de l'aborder sans le hashtag pour interroger la carte entière. Reste fidèle au style et à la pensée de ${nomAuteur}. Cite toujours le livre.
Règles :
- Cite les sources (titre du livre) à chaque assertion.
- Pas d'hallucination. Si l'info n'est pas dans le corpus de cet auteur, dis-le.
- N'introduis JAMAIS d'autres auteurs sauf si ${nomAuteur} les commente explicitement.
- Ton politique direct, pas de neutralité fade.
- Réponse en français, dense, sans délayage.`
}
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.' })
const body = await readBody(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 || 'both'
const ragUrl: string = (config.ragPeUrl as string) || 'http://localhost:9621'
const auteurSlug: string | null = body.auteur_slug?.trim().toLowerCase() || null
let nomAuteurMatch: string | null = null
if (auteurSlug) {
const ingeres = loadAuteursIngeres()
const auteur = ingeres.find(a => a.id === auteurSlug)
nomAuteurMatch = auteur?.nom ?? null
}
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
}
try {
await $fetch(`${ragUrl}/health`, { timeout: 5000 })
} catch {
throw createError({ statusCode: 503, message: 'RAG indisponible pour l\'instant — réessaie dans quelques minutes.' })
}
const ragQuery = `${systemPreface}\n\nQuestion : ${query}`
const ragBody: Record<string, any> = { query: ragQuery, mode }
if (auteurSlug && nomAuteurMatch) {
ragBody.hl_keywords = [nomAuteurMatch, auteurSlug]
ragBody.ll_keywords = [auteurSlug]
}
let ragResponse: any
try {
ragResponse = await $fetch(`${ragUrl}/query`, {
method: 'POST',
body: ragBody,
timeout: 90000,
})
} catch (e: any) {
const status = e?.response?.status
if (status === 429) throw createError({ statusCode: 429, message: 'RAG saturé — réessaie dans quelques instants.' })
throw createError({ statusCode: 504, message: 'RAG en cours de processing — réessaie dans quelques secondes.' })
}
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++
}
}
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(),
}
})
+10 -6
View File
@@ -6,6 +6,7 @@
// @ts-ignore — JSON import résolu par Rollup // @ts-ignore — JSON import résolu par Rollup
import reseauxData from '../../public/data/reseaux-bifurcation.json' import reseauxData from '../../public/data/reseaux-bifurcation.json'
import { checkRateLimitJson } from '~/server/utils/rateLimitJson' import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost'
interface Structure { interface Structure {
id: string id: string
@@ -61,6 +62,7 @@ export default defineEventHandler(async (event) => {
const body = await readBody(event) const body = await readBody(event)
const question: string = (body?.question ?? '').trim() const question: string = (body?.question ?? '').trim()
if (!question || question.length < 5) throw createError({ statusCode: 400, message: 'Question trop courte.' }) 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 structures: Structure[] = ((reseauxData as any).structures ?? [])
const keywords = extractKeywords(question) const keywords = extractKeywords(question)
@@ -82,18 +84,20 @@ export default defineEventHandler(async (event) => {
const systemPrompt = SYSTEM_PROMPT.replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0)) const systemPrompt = SYSTEM_PROMPT.replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0))
const mistralApiKey = config.mistralApiKey as string const bifrostUrl = config.bifrostUrl as string
if (!mistralApiKey) throw createError({ statusCode: 500, message: 'Clé API Mistral manquante.' }) const bifrostVk = config.bifrostVk as string
if (!bifrostVk) throw createError({ statusCode: 500, message: 'Clé Bifrost manquante.' })
let mistralRaw: string let mistralRaw: string
try { try {
const res = await $fetch<{ choices: { message: { content: string } }[] }>( const res = await $fetch<BifrostChatResponse>(
'https://api.mistral.ai/v1/chat/completions', `${bifrostUrl}/v1/chat/completions`,
{ {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' }, headers: { 'x-bf-vk': bifrostVk, 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
model: 'mistral-small-latest', model: tier.model,
fallbacks: tier.fallbacks,
temperature: 0.3, temperature: 0.3,
max_tokens: 700, max_tokens: 700,
response_format: { type: 'json_object' }, response_format: { type: 'json_object' },
+11 -7
View File
@@ -7,6 +7,7 @@
// @ts-ignore — JSON import résolu par Vite/Rollup // @ts-ignore — JSON import résolu par Vite/Rollup
import taffData from '../../public/data/plateformes-taff.json' import taffData from '../../public/data/plateformes-taff.json'
import { checkRateLimitJson } from '~/server/utils/rateLimitJson' import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost'
interface PlateformeMinimal { interface PlateformeMinimal {
id: string id: string
@@ -66,6 +67,7 @@ export default defineEventHandler(async (event) => {
if (!question || question.length < 5) { if (!question || question.length < 5) {
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' }) throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
} }
const tier = pickBifrostTier(body?.mode)
// Données bundlées statiquement à la compilation (import JSON) // Données bundlées statiquement à la compilation (import JSON)
const plateformes: PlateformeMinimal[] = ((taffData as any).plateformes ?? []).map((p: any) => ({ const plateformes: PlateformeMinimal[] = ((taffData as any).plateformes ?? []).map((p: any) => ({
@@ -91,20 +93,22 @@ export default defineEventHandler(async (event) => {
const systemPrompt = SYSTEM_PROMPT.replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0)) const systemPrompt = SYSTEM_PROMPT.replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0))
const mistralApiKey = config.mistralApiKey as string const bifrostUrl = config.bifrostUrl as string
if (!mistralApiKey) { const bifrostVk = config.bifrostVk as string
throw createError({ statusCode: 500, statusMessage: 'Clé API Mistral manquante.' }) if (!bifrostVk) {
throw createError({ statusCode: 500, statusMessage: 'Clé Bifrost manquante.' })
} }
let mistralRaw: string let mistralRaw: string
try { try {
const res = await $fetch<{ choices: { message: { content: string } }[] }>( const res = await $fetch<BifrostChatResponse>(
'https://api.mistral.ai/v1/chat/completions', `${bifrostUrl}/v1/chat/completions`,
{ {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' }, headers: { 'x-bf-vk': bifrostVk, 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
model: 'mistral-small-latest', model: tier.model,
fallbacks: tier.fallbacks,
temperature: 0.3, temperature: 0.3,
max_tokens: 700, max_tokens: 700,
response_format: { type: 'json_object' }, response_format: { type: 'json_object' },
+30 -17
View File
@@ -19,6 +19,7 @@
import { checkRateLimitJson } from '~/server/utils/rateLimitJson' import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker' import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost'
// ── Types ────────────────────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────────────────────
@@ -131,18 +132,19 @@ async function logUsage(params: {
nocodbUrl: string nocodbUrl: string
nocodbToken: string nocodbToken: string
statsTableId: string statsTableId: string
model: string
tokensIn: number tokensIn: number
tokensOut: number tokensOut: number
coutEur: number coutEur: number
}) { }) {
const { nocodbUrl, nocodbToken, statsTableId, tokensIn, tokensOut, coutEur } = params const { nocodbUrl, nocodbToken, statsTableId, model, tokensIn, tokensOut, coutEur } = params
const logUrl = `${nocodbUrl}/api/v2/tables/${statsTableId}/records` const logUrl = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
try { try {
await $fetch(logUrl, { await $fetch(logUrl, {
method: 'POST', method: 'POST',
headers: { 'xc-token': nocodbToken, 'Content-Type': 'application/json' }, headers: { 'xc-token': nocodbToken, 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
model: 'mistral-small-latest', model,
endpoint: 'chatbot', endpoint: 'chatbot',
tokens_in: tokensIn, tokens_in: tokensIn,
tokens_out: tokensOut, tokens_out: tokensOut,
@@ -180,6 +182,7 @@ export default defineEventHandler(async (event) => {
const body = await readBody(event) const body = await readBody(event)
const question: string = (body?.question ?? '').trim() const question: string = (body?.question ?? '').trim()
const filters: { fonction?: string; echelle?: string } = body?.filters ?? {} const filters: { fonction?: string; echelle?: string } = body?.filters ?? {}
const tier = pickBifrostTier(body?.mode)
if (!question || question.length < 3) { if (!question || question.length < 3) {
throw createError({ throw createError({
@@ -247,32 +250,32 @@ export default defineEventHandler(async (event) => {
JSON.stringify(fichesContext, null, 0), JSON.stringify(fichesContext, null, 0),
) )
// 6. Appel Mistral Small // 6. Appel Bifrost (gateway LLM — remplace l'appel Mistral direct)
const mistralApiKey = config.mistralApiKey as string const bifrostUrl = config.bifrostUrl as string
const bifrostVk = config.bifrostVk as string
if (!mistralApiKey) { if (!bifrostVk) {
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: 'Clé API Mistral manquante.', statusMessage: 'Clé Bifrost manquante.',
}) })
} }
let mistralRaw: string let mistralRaw: string
let tokensIn = 0 let tokensIn = 0
let tokensOut = 0 let tokensOut = 0
let realModel = tier.model
try { try {
const mistralRes = await $fetch<{ const bifrostRes = await $fetch<BifrostChatResponse>(`${bifrostUrl}/v1/chat/completions`, {
choices: { message: { content: string } }[]
usage?: { prompt_tokens: number; completion_tokens: number }
}>('https://api.mistral.ai/v1/chat/completions', {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: `Bearer ${mistralApiKey}`, 'x-bf-vk': bifrostVk,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
model: 'mistral-small-latest', model: tier.model,
fallbacks: tier.fallbacks,
temperature: 0.3, temperature: 0.3,
max_tokens: 600, max_tokens: 600,
response_format: { type: 'json_object' }, response_format: { type: 'json_object' },
@@ -283,11 +286,16 @@ export default defineEventHandler(async (event) => {
}), }),
}) })
mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}' mistralRaw = bifrostRes.choices?.[0]?.message?.content ?? '{}'
tokensIn = mistralRes.usage?.prompt_tokens ?? 0 tokensIn = bifrostRes.usage?.prompt_tokens ?? 0
tokensOut = mistralRes.usage?.completion_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)
} catch (e: any) { } catch (e: any) {
console.error('[chatbot] Erreur Mistral Small:', e?.message ?? e) console.error('[chatbot] Erreur Bifrost:', e?.message ?? e)
throw createError({ throw createError({
statusCode: 502, statusCode: 502,
statusMessage: 'Erreur appel IA — réessaie dans quelques instants.', statusMessage: 'Erreur appel IA — réessaie dans quelques instants.',
@@ -317,11 +325,16 @@ export default defineEventHandler(async (event) => {
} }
// 8. Log usage (non bloquant) // 8. Log usage (non bloquant)
const coutEur = calcCoutMistralSmall(tokensIn, tokensOut) // 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
logUsage({ logUsage({
nocodbUrl: config.nocodbUrl as string, nocodbUrl: config.nocodbUrl as string,
nocodbToken: config.nocodbToken as string, nocodbToken: config.nocodbToken as string,
statsTableId, statsTableId,
model: realModel,
tokensIn, tokensIn,
tokensOut, tokensOut,
coutEur, coutEur,
+53 -52
View File
@@ -1,8 +1,8 @@
/** /**
* POST /api/submit * POST /api/submit
* *
* Soumission d'une nouvelle organisation par un utilisateur. * Soumission multi-type (ecosysteme / reseau / job / outil).
* - Validation Zod côté serveur * - Validation Zod côté serveur (champs communs + par type)
* - Rate limit Redis : 3 soumissions / IP / jour * - Rate limit Redis : 3 soumissions / IP / jour
* - Géocodage Nominatim (optionnel — fallback silencieux) * - Géocodage Nominatim (optionnel — fallback silencieux)
* - INSERT NocoDB : moderation_status=pending, ai_processed=false * - INSERT NocoDB : moderation_status=pending, ai_processed=false
@@ -15,62 +15,49 @@
import { z } from 'zod' import { z } from 'zod'
import { checkRateLimit } from '~/server/utils/rateLimit' import { checkRateLimit } from '~/server/utils/rateLimit'
// ── Schéma Zod ──────────────────────────────────────────────────────────────── const SUBMISSION_TYPES = ['ecosysteme', 'reseau', 'job', 'outil'] as const
const FONCTIONS = [ const FONCTIONS = [
'Juridique', 'Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier',
'Technique', 'Comptabilité', 'Développement', 'Formation', 'Gestion d\'agence', 'Santé mentale',
'Économique',
'Administratif',
'Chantier',
'Comptabilité',
'Développement',
'Formation',
'Gestion d\'agence',
'Santé mentale',
] as const ] as const
const ECHELLES = ['National', 'Régional', 'Local'] as const const ECHELLES = ['National', 'Régional', 'Local'] as const
const TERRITOIRES = [ const TERRITOIRES = [
'Métropole', 'Métropole', 'Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte',
'Guadeloupe',
'Martinique',
'Guyane',
'La Réunion',
'Mayotte',
] as const ] 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({ export const SubmitSchema = z.object({
// Champs communs
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(), nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
url: z url: z.string().url('URL invalide').optional().or(z.literal('')).transform(v => v || undefined),
.string() description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
.url('URL invalide') submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')).transform(v => v || undefined),
.optional() submission_type: z.enum(SUBMISSION_TYPES, { errorMap: () => ({ message: 'Type de soumission invalide' }) }),
.or(z.literal(''))
.transform((v) => v || undefined), // Champs écosystème
description_user: z echelle: z.enum(ECHELLES).optional(),
.string() fonctions: z.array(z.enum(FONCTIONS)).min(1).max(5).optional(),
.min(50, 'Minimum 50 caractères') territoire: z.enum(TERRITOIRES).optional(),
.max(500, 'Maximum 500 caractères') localisation_ville: z.string().max(100).optional().transform(v => v?.trim() || undefined),
.trim(),
echelle: z.enum(ECHELLES, { errorMap: () => ({ message: 'Échelle invalide' }) }), // Champs réseau
fonctions: z type_principal: z.enum(TYPES_PRINCIPAUX).optional(),
.array(z.enum(FONCTIONS)) projet_lien: z.string().url('URL invalide').optional().or(z.literal('')),
.min(1, 'Sélectionne au moins une fonction') projet_adresse: z.string().max(200).optional().transform(v => v?.trim() || undefined),
.max(5, 'Maximum 5 fonctions'),
territoire: z.enum(TERRITOIRES, { errorMap: () => ({ message: 'Territoire invalide' }) }), // Champs job
localisation_ville: z type_plateforme: z.enum(TYPES_PLATEFORME).optional(),
.string()
.max(100) // Champs outil
.optional() categorie: z.enum(CATEGORIES_OUTIL).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> export type SubmitInput = z.infer<typeof SubmitSchema>
@@ -148,22 +135,36 @@ export default defineEventHandler(async (event) => {
const nocoBaseId = config.nocodbBase const nocoBaseId = config.nocodbBase
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
submission_type: data.submission_type,
nom: data.nom, nom: data.nom,
url: data.url || null, url: data.url || null,
description_user: data.description_user, 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, submitted_by_email: data.submitted_by_email || null,
moderation_status: 'pending', moderation_status: 'pending',
scrape_status: data.url ? 'pending' : 'no_link',
ai_processed: false, ai_processed: false,
submitted_at: new Date().toISOString(), submitted_at: new Date().toISOString(),
latitude, latitude,
longitude, 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 // NocoDB v1 endpoint
const insertUrl = `${nocodbUrl}/api/v1/db/data/noco/${nocoBaseId}/${orgTableId}` const insertUrl = `${nocodbUrl}/api/v1/db/data/noco/${nocoBaseId}/${orgTableId}`
+74
View File
@@ -0,0 +1,74 @@
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.',
}
})
@@ -0,0 +1,10 @@
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
})
+47
View File
@@ -0,0 +1,47 @@
/**
* Bifrost — gateway LLM (remplace les appels directs Mistral)
* Endpoint OpenAI-compatible : POST {bifrostUrl}/v1/chat/completions
* Auth : header x-bf-vk
*
* 2 tiers validés (Mission M3, build Bifrost) :
* 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)
*
* ⚠ 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/llama-3.1-8b-instant',
fallbacks: [
'gemini-oai/models/gemini-2.5-flash-lite',
'cohere/command-r-08-2024',
],
}
export const BIFROST_TIER_APPROFONDI = {
model: 'groq/llama-3.3-70b-versatile',
fallbacks: [
'gemini-oai/models/gemini-2.5-flash',
'mistral/mistral-large-latest',
'cohere/command-r-plus-08-2024',
],
}
/** Sélectionne le tier selon le param optionnel body.mode. */
export function pickBifrostTier(mode?: string) {
return mode === 'approfondi' ? BIFROST_TIER_APPROFONDI : BIFROST_TIER_RAPIDE
}
export interface BifrostChatResponse {
choices: { message: { content: string } }[]
usage?: { prompt_tokens: number; completion_tokens: number }
extra_fields?: { provider?: string; resolved_model_used?: string }
}
+11 -9
View File
@@ -11,16 +11,18 @@ export default {
extend: { extend: {
colors: { colors: {
/* Palette NAV V2 — Sobre institutionnel */ /* 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: { nav: {
bg: '#f8f6f1', bg: 'var(--nav-bg)',
'bg-alt':'#eee9df', 'bg-alt':'var(--nav-bg-alt)',
surface: '#ffffff', surface: 'var(--nav-surface)',
primary: 'rgba(26, 34, 56, 0.6)', primary: 'var(--nav-primary)',
'primary-solid': '#1a2238', 'primary-solid': 'var(--nav-primary-solid)',
accent: '#f5b342', accent: 'var(--nav-accent)',
text: '#1a2238', text: 'var(--nav-text)',
'text-muted': 'rgba(26, 34, 56, 0.55)', 'text-muted': 'var(--nav-text-muted)',
'text-on-primary': '#f8f6f1', 'text-on-primary': 'var(--nav-text-on-primary)',
}, },
/* Héritage V1 — gardé pour composants existants */ /* Héritage V1 — gardé pour composants existants */
sage: { sage: {
+68
View File
@@ -0,0 +1,68 @@
/**
* Les trois échelles du deck du 23/09/2026 (conférence AEP), slides 15 à 18.
* Texte repris mot pour mot : c'est la tête de chaque onglet (directive Jules du 25/09, point 2).
* Source unique : la future nav S / M / XL (arbitrage n° 1, table R) lira aussi ce fichier.
*/
export type EchelleId = 'S' | 'M' | 'XL'
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'
}
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',
},
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',
},
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',
},
}
/** 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."