Compare commits

..
Author SHA1 Message Date
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
25 changed files with 701 additions and 416 deletions
-188
View File
@@ -1,188 +0,0 @@
---
type: pilote
projet: AEP — nav-carte (aep.trans-former.fr)
casquette: Stack technique
pilote: ATIS Dev
created: 2026-06-23
updated: 2026-06-28
---
# PILOTE — AEP / nav-carte
> Doc de pilotage vivant du site **aep.trans-former.fr**. Source de vérité opérationnelle : état déployé, chantiers, décisions, dette. Le journal de dev détaillé reste dans `JOURNAL-V2.md`. Ce doc est l'index de pilotage, pas le journal.
---
## 1. Ce que c'est
- **Site** : `https://aep.trans-former.fr` — cartographie politique AEP (Architecture En Plus / écosystème militant).
- **Stack** : Nuxt 3 (Nitro SSR) + Vue + Leaflet + D3. Backend RAG = LightRAG (Docker).
- **Repo** : `1 PROJETS/TECH .../nav-carte/` — remote Gitea `git.trans-former.fr/jules/nav-carte.git`.
- **Pilote** : ATIS Dev (ne code pas, dispatche des agents Sonnet).
---
## 2. Architecture déployée (VÉRIFIÉE 2026-06-23) ⚠ lire avant toute session
Source fréquente de confusion — état réel constaté sur le VPS :
```
aep.trans-former.fr
│ Caddy : reverse_proxy localhost:3333
▼
aep.service ← LE service réel (systemd, active/running, Nitro Node)
├── App dir : /opt/aep
├── ExecStart : node /opt/aep/.output/server/index.mjs
├── EnvironmentFile: /opt/aep/.env (contient RAG_PE_URL, MISTRAL_API_KEY, etc.)
└── Port : 3333
LightRAG PE : localhost:9621 (Docker, health 200 ✓) ← backend du chatbot RAG
PIÈGES / fausses pistes :
✗ nav-carte.service → inactive/dead (ANCIEN nom, abandonné — ne pas chercher de ce côté)
✗ nav-worker.service → FAILED (worker enrichissement IA, mort — à statuer)
✗ /opt/nav-carte → n'existe pas (le code n'est PAS sur le VPS en git)
```
**Mécanisme de déploiement = MANUEL, scp/tar (PAS de git pull sur le VPS) :**
```
1. npm run build (local → génère .output/)
2. scp/rsync .output/ → vps-hetzner:/opt/aep/.output/
3. ssh vps-hetzner "systemctl restart aep.service"
4. vérifier : curl -I https://aep.trans-former.fr/<route>
```
**Disque VPS : 91 % (3,5 G libres) — à surveiller, pas bloquant.**
---
## 3. État git du repo (2026-06-23)
- `main` == `gitea/main` == `cd8fe9e2` (0 ahead / 0 behind, parfaitement sync).
- **Le commit déployé sur le VPS est PÉRIMÉ** (antérieur à `cd8fe9e2`) → `/outils`, `/media`, routes chatbot = 404 live alors que le code existe sur `main`.
- **Piège repos imbriqués** : `nav-carte/` est un sous-repo dans le vault. Le vault parent n'est PLUS un repo git (chirurgie 2026-05-07). Travailler git uniquement au niveau `nav-carte/`.
### Travail local non committé au 2026-06-23
| Fichier | État | Destination |
|---------|------|-------------|
| `components/ChatbotPensees.vue` | untracked | **P0/P1 — à committer + déployer** (chatbot RAG) |
| `server/api/chatbot-pensees.post.ts` | untracked | **P0/P1 — à committer + déployer** (route chatbot, appelle LightRAG :9621) |
| `nuxt.config.ts` | modifié (+1) | **P0/P1** — ajoute `runtimeConfig.ragPeUrl` (requis par la route chatbot) |
| `components/MediaTabVisuel.vue` | modifié (CSS) | **P0/P1** — layout toolbar/carte/chatbot |
| `pages/a-propos.vue` | modifié (+64) | **P0/P1** — contenu À propos |
| `pages/contribuer-job.vue` | untracked (stub) | **P2** — laisser untracked (feature Proposer) |
| `pages/contribuer-reseau.vue` | untracked (stub) | **P2** — laisser untracked |
| `.firecrawl/` | untracked | junk — gitignore |
| `pages/index-v1-backup.vue.tmp.*` | untracked | junk — supprimer |
### Branches dispersées (dette — voir §6)
```
main ← prod
feat/outils-v1 / -build / -media ← travail Outils (en partie déjà sur main)
feat/aep-v1.1-nav-carte ← contient a6fff9a9 "PA3 bouton Proposer contextuel"
feat/aep-pratiques-regeneratives ← contient d10586c4 "page /proposer-pratique" + form
feat/aep-rag-pensees-ecologiques ← travail RAG/chatbot
feat/aep-taff-v1 / feat/aep-v2-restore / feat/codev-mvp / wip-stash-v2-onglet2-20260506
```
---
## 4. Chantiers (état au 2026-06-23)
| Prio | Chantier | Diagnostic | Statut | Nature |
|------|----------|-----------|--------|--------|
| **P0** | Onglet Outils + page /media + redirect /rag manquants live | Build VPS périmé (code committé, pas déployé) | ✅ **Fait 2026-06-23** (commit `f3ab28bd` déployé, routes 200, /rag→301) | Mécanique : build + deploy |
| **P1** | Chatbot RAG cassé | Route + composant non committés ; LightRAG PE :9621 = UP | ✅ **Fait 2026-06-23** (route déployée, testé public OK, réponses sourcées) | Mixte : commit + deploy + test |
| **P2** | Interface Proposer 5 onglets (Écosystème/Réseau/Jobs/Outils/Références RAG) | Feature dispersée sur branches feature jamais mergées ; `main` n'a qu'1 form (`contribuer.vue`) + 2 stubs untracked | ✅ **Fait 2026-06-23** (commit `c860dabd`, déployé, testé 5/5 types 201) | 4 types → NocoDB `orgas` + `submission_type` ; Réfs → `ressources_references` ; `/contribuer` → 301 → `/proposer` |
| **P3** | Google Search Console / indexation | Non diagnostiqué | Reporté (Jules) | Voir `pilotage-indexation-seo.md` (dossier TECH) |
| **dette** | Organisation docs/branches/services | Multi-build folders, 10+ Caddyfile.bak, services morts | À cadrer | Voir §6 |
### Mapping bugs Jules → vraie cause
```
"mauvais commit, RAG/Outils pas up" → FAUX : commit OK. Build VPS périmé. → P0
"bouton Proposer 1 carte / 4" → feature sur branches non mergées. → P2
"chatbot slug rag/media KO" → redirect /rag→/media en code (pas déployé) → P0/P1
+ composant chatbot non committé
+ backend LightRAG PE :9621 = UP ✓
```
---
## 5. Décisions techniques runtime
| # | Décision | Pourquoi |
|---|----------|----------|
| τ.1 | P0+P1 (deploy + chatbot) cette session ; P2 (Proposer) session dédiée | P2 = archéologie multi-branches, dilue le fix de deploy |
| τ.2 | Stubs `contribuer-*` laissés untracked au deploy P0 | Ne pas exposer de formulaires inachevés en prod |
| τ.3 | Commit P0 = chatbot route + composant + nuxt.config + MediaTabVisuel + a-propos uniquement | Périmètre minimal du « remettre up » ; pas de WIP Proposer |
---
## 6. Dette d'organisation (constatée 2026-06-23)
Le système tech est éclaté. Symptômes :
- Dossier projet `TECH - infra VPS, website pro, RAG/` mélange **6+ projets** (nav-carte/AEP, Website Pro/trans-former.fr, RAG Militant, Simulateur autonomie V2, codev, vps-kit…).
- Multiples dossiers `*-build` (cascades méga-dispatch passées : `aep-communaute-build`, `codev-build`, `astro-site-cerveau`).
- 10+ `Caddyfile.bak.*` sur le VPS, services systemd morts (`nav-carte.service`, `nav-worker.service`).
- Branches feature jamais mergées ni nettoyées.
- Plusieurs `.md` de pilotage épars à la racine TECH (`next-steps`, `stack-opencode-v2`, `pilotage-indexation-seo`…).
→ **Proposition de rangement : voir le plan dans le message de session du 2026-06-23 / à formaliser en note dédiée.**
---
## 7. Journal & refs
- Journal dev détaillé : `JOURNAL-V2.md` (même dossier).
- Mémoire pilote : `2 CASQUETTES/Stack technique/ATIS-Dev.md`.
- Prompt P2 : `0 INBOX/PROMPTS/aep-P2-proposer-4-onglets.md`.
- SEO/indexation : `1 PROJETS/TECH .../pilotage-indexation-seo.md`.
---
## Log de pilotage
### 2026-06-23 — Session diagnostic (ATIS Dev / Opus)
**Accompli :**
- Diagnostic complet : renversé l'hypothèse « mauvais commit » → build VPS périmé + travail local non committé.
- Cartographié l'archi de service réelle (`aep.service`, pas `nav-carte.service`).
- Confirmé LightRAG PE :9621 UP, deploy = scp manuel vers `/opt/aep/.output`.
- Créé ce doc pilote + prompt P2.
**Accompli (suite) :**
- ✅ P0+P1 déployés : commit `f3ab28bd` build+push (agent Sonnet), deploy prod (tar+scp → `/opt/aep/.output`, restart `aep.service`). Vérifié : `/outils`, `/media`, `/contribuer`, `/trouver-du-taf` = 200 ; `/rag` → 301 ; chatbot RAG public OK (réponses sourcées via LightRAG :9621).
- Prompts P2 + hygiène TECH écrits avec préflight git anti-superposition + clause supervision pilote.
**Prochaines étapes :**
- [x] P2 : Proposer 5 onglets — fait 2026-06-23 (commit c860dabd, deploye, teste 5/5 types 201).
- [x] Hygiene TECH : execute 2026-06-23 (agent Sonnet). Voir recap dans `_infra/TECH.md`.
- [ ] **Batch 3 (prochain chantier)** : bouton selecteur 3 cartes + popup 5 axes Jobs + GraphView Carte 1. Prompt : `REPRISE-aep-carto-fix-batch3.md`. Session dediee.
- [ ] AEP nav-carte — verifier chatbots EN PROD : `/` = chatbot Entraide / `/agences` = chatbot Reseaux AEP distincts.
- [ ] P3 : Google Search Console (reporte).
### 2026-06-23 — Session hygiène TECH (AXE 1+2+3) + fix chatbot (Agent Sonnet)
**Accompli :**
- **AXE 1 VPS** : docker prune (156M), décommission listmonk (conteneur + image + 2 volumes + 2 routes Caddy), décommission heyform (volume 528M + compose), décommission uptime (volume 88M + route status), `/opt/migration` supprimé (252M, aucune réf active), nav-worker + nav-carte services nettoyés, Caddyfile.bak réduit de 17→4 fichiers. Disque 91%→87% (+~1.3 GB).
- **AXE 2 Docs** : `_archive-build/` créé (3 dossiers `*-build` déplacés), `_infra/TECH.md` créé (inventaire VPS complet), `INDEX.md` créé, 3 squelettes `PILOTE.md` (Website Pro, RAG Militant, Simulateur autonomie V2). 4 `.md` épars routés.
- **AXE 3 Git** : 4 branches à 0 commit supprimées (`feat/outils-v1`, `-build`, `-media`, `feat/codev-mvp`). `feat/outils-v1-build` worktree retiré.
- **Fix chatbot régresse** : `auteurs-pensees.json` (171 auteurs) restauré en prod (scp), versionné sur `main` (commit `04b02351`), push gitea. Worktree `nav-carte-media` retiré, les 4 fichiers untrackés sauvés sur `feat/outils-v1-media` push gitea (branche préservée).
### 2026-06-23 — Session P2 : Proposer 5 onglets (ATIS Dev / Sonnet)
**Accompli :**
- Interface Proposer avec 5 tabs (Écosystème/Réseau/Jobs/Outils/Références RAG)
- 4 types (ecosysteme/reseau/job/outil) → NocoDB `orgas` table avec `submission_type`
- Références → table dédiée NocoDB `ressources_references`
- `/contribuer` → 301 redirect vers `/proposer`
- 6 corrections pilote appliquées
- Build + deploy VPS (scp .output/ + systemctl restart aep)
- Tests : 1 par type (4×201), 1 référence (201), test records supprimés (404)
- Routes vérifiées : /proposer (200), /contribuer (301), /api/submit (201), /api/submit/reference (201)
- ✅ Tout est bon, GO pilote obtenu, P2 terminé
**Prochaine étape :**
- [ ] Push `main` → `gitea/main` (P2 + fixes chatbot non encore poussés)
- [ ] P3 : Google Search Console (reporté)
+32 -16
View File
@@ -3,28 +3,44 @@
@tailwind utilities;
/* ── Palette NAV V2 — Sobre institutionnel ─────────────────────────────── */
/* --nav-* sont désormais des ALIAS vers les rôles @transformer/ui (tokens.css
+ theme/aep.css, chargés avant ce fichier dans nuxt.config.ts).
--nav-primary-raw, --nav-primary-solid et --nav-text-on-primary restent des
valeurs PROPRES à nav-carte (non aliasées) :
- le DS n'a pas de triplet RGB (--nav-primary-raw) ;
- le --text du DS en sombre (#e8ecf5) ne correspond pas à la teinte bleu
nuit voulue ici pour --nav-primary-solid (#c8d2f0) ;
- --nav-text-on-primary sert de texte de contraste à la fois sur
--nav-primary (translucide, alias DS) ET sur --nav-primary-solid (opaque,
propre à nav-carte) — deux fonds dont la légèreté s'inverse en sombre
(translucide ≈ fond sombre, solid = lavande claire). Le rôle --on-primary
du DS ne colle qu'au premier cas ; l'aliaser cassait le contraste du AEP
logo, des boutons trouver-du-taf, BandeauBas, SimulateurFeature,
FicheDetail (texte clair sur --primary-solid clair en sombre — testé au
pixel le 25/09, illisible). Gardé littéral = identique à la prod avant B3.
Idem --nav-accent-soft, sans équivalent DS. (B3-M0, 2026-09-25) */
:root {
/* Fonds */
--nav-bg: #f8f6f1;
--nav-bg-alt: #eee9df;
--nav-surface: #ffffff;
--nav-bg: var(--bg);
--nav-bg-alt: var(--bg-alt);
--nav-surface: var(--surface);
/* Bleu nuit — 60% d'opacité sur les surfaces */
--nav-primary-raw: 26, 34, 56;
--nav-primary: rgba(26, 34, 56, 0.6);
--nav-primary: var(--primary);
--nav-primary-solid: #1a2238;
/* Accent safran */
--nav-accent: #f5b342;
--nav-accent: var(--accent);
--nav-accent-soft: rgba(245, 179, 66, 0.85);
/* Texte */
--nav-text: #1a2238;
--nav-text-muted: rgba(26, 34, 56, 0.55);
--nav-text: var(--text);
--nav-text-muted: var(--text-muted);
--nav-text-on-primary: #f8f6f1;
/* Typographie */
--nav-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--nav-font: var(--font-body);
}
@layer base {
@@ -58,25 +74,25 @@
/* Focus ring accessibilité */
*:focus-visible {
outline: 2px solid var(--nav-accent);
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
/* ── Dark mode ──────────────────────────────────────────────────────────── */
.dark {
--nav-bg: #111520;
--nav-bg-alt: #1e2538;
--nav-surface: #1a2238;
--nav-bg: var(--bg);
--nav-bg-alt: var(--bg-alt);
--nav-surface: var(--surface);
--nav-primary-raw: 200, 210, 240;
--nav-primary: rgba(200, 210, 240, 0.15);
--nav-primary: var(--primary);
--nav-primary-solid: #c8d2f0;
--nav-accent: #f5b342;
--nav-accent: var(--accent);
--nav-accent-soft: rgba(245, 179, 66, 0.85);
--nav-text: #e8ecf5;
--nav-text-muted: rgba(200, 210, 240, 0.55);
--nav-text: var(--text);
--nav-text-muted: var(--text-muted);
--nav-text-on-primary: #111520;
}
+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>
<div class="space-y-1.5">
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Échelle</p>
<!-- Inline sur 1 ligne — même pattern que FonctionFilter -->
<div class="flex flex-wrap gap-x-4 gap-y-1.5">
<label
<ChipGroup aria-label="Filtrer par échelle">
<Chip
v-for="option in ECHELLES"
:key="option"
class="flex items-center gap-1.5 cursor-pointer select-none transition-opacity"
>
<!-- Case carrée -->
<span
class="flex items-center justify-center shrink-0 transition-all"
style="width: 18px; height: 18px; border: 1.5px solid; border-radius: 3px;"
: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="option"
variant="echelle"
:active="isSelected(option)"
@toggle="toggle(option)"
/>
</label>
</div>
</ChipGroup>
</div>
</template>
+2 -1
View File
@@ -183,6 +183,7 @@
</template>
<script setup lang="ts">
const baseTiles = useBaseTiles()
import type { Org } from '~/types/org'
const props = defineProps<{ org: Org }>()
@@ -283,7 +284,7 @@ onMounted(async () => {
attributionControl: false,
})
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
L.tileLayer(baseTiles.url(document.documentElement.classList.contains('dark')), {
maxZoom: 18,
}).addTo(map)
+9 -24
View File
@@ -1,32 +1,17 @@
<template>
<div class="space-y-1.5">
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Fonction</p>
<div class="space-y-1">
<button
<ChipGroup aria-label="Filtrer par fonction">
<Chip
v-for="fn in FONCTIONS"
:key="fn"
@click="toggle(fn)"
:aria-pressed="modelValue.includes(fn)"
class="flex items-center gap-2.5 w-full rounded px-1 py-0.5 transition-all text-left hover:opacity-80"
:style="modelValue.includes(fn) ? 'background: rgba(26,34,56,0.06);' : ''"
>
<!-- Case : affiche le rang de priorité si actif, sinon le nombre d'orgs -->
<span
class="flex items-center justify-center shrink-0 text-xs font-bold transition-all"
style="width: 24px; height: 24px; border: 1.5px solid; border-radius: 4px;"
: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>
:label="fn"
variant="fonction"
:active="modelValue.includes(fn)"
:count="modelValue.includes(fn) ? (modelValue.indexOf(fn) + 1) : (counts[fn] ?? 0)"
@toggle="toggle(fn)"
/>
</ChipGroup>
<p v-if="modelValue.length" class="text-xs pt-0.5" style="color: var(--nav-text-muted);">
{{ modelValue.length }} actif{{ modelValue.length > 1 ? 's' : '' }}
<button @click="emit('update:modelValue', [])" class="ml-2 underline hover:opacity-70">Effacer</button>
+9 -11
View File
@@ -1,6 +1,6 @@
<template>
<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;">
<span class="filter-label">FAMILLES</span>
<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;"
>{{ hashtagsVisible ? 'Replier' : 'Afficher (' + props.allHashtags.length + ')' }}</button>
</div>
<div v-if="hashtagsVisible" class="chips-row">
<span
<ChipGroup v-if="hashtagsVisible" aria-label="Filtrer par hashtag">
<Chip
v-for="tag in props.allHashtags"
:key="tag"
class="chip chip-small"
:style="selectedHashtags.includes(tag)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggleHashtag(tag)"
>{{ tag }}</span>
</div>
:label="tag"
variant="fonction"
:active="selectedHashtags.includes(tag)"
@toggle="toggleHashtag(tag)"
/>
</ChipGroup>
</div>
</div>
</template>
@@ -93,5 +92,4 @@ function toggleHashtag(tag: string) {
transition: all 0.15s;
user-select: none;
}
.chip-small { font-size: 0.7rem; padding: 2px 8px; }
</style>
+4 -7
View File
@@ -5,6 +5,7 @@
</template>
<script setup lang="ts">
const baseTiles = useBaseTiles()
import type { Map, Marker, DivIcon } from 'leaflet'
interface Org {
@@ -90,12 +91,10 @@ async function initMap() {
// Fond de carte CartoDB Positron (light ou dark selon theme)
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
const tileUrl = 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'
const tileUrl = baseTiles.url(isDark)
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,
})
tileLayerInstance.addTo(mapInstance!)
@@ -212,9 +211,7 @@ function updateTileTheme(dark: boolean) {
if (!mapInstance || !tileLayerInstance) return
const L = (window as any).L
if (!L) return
const url = dark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
const url = baseTiles.url(dark)
tileLayerInstance.setUrl(url)
}
+4 -7
View File
@@ -5,6 +5,7 @@
</template>
<script setup lang="ts">
const baseTiles = useBaseTiles()
import type { Map, Marker, DivIcon } from 'leaflet'
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 tileUrl = isDark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
const tileUrl = baseTiles.url(isDark)
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,
})
tileLayerInstance.addTo(mapInstance!)
@@ -213,9 +212,7 @@ function updateTileTheme(dark: boolean) {
if (!mapInstance || !tileLayerInstance) return
const L = (window as any).L
if (!L) return
const url = dark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
const url = baseTiles.url(dark)
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>
+3 -4
View File
@@ -38,6 +38,7 @@
</template>
<script setup lang="ts">
const baseTiles = useBaseTiles()
import type { Map as LeafletMap, TileLayer } from 'leaflet'
interface Org {
@@ -111,9 +112,7 @@ function createPinIcon(L: any, isPrioritaire: boolean, isSelected = false) {
}
function getTileUrl(dark: boolean) {
return dark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
return baseTiles.url(dark)
}
async function initSingleMap(domName: string) {
@@ -132,7 +131,7 @@ async function initSingleMap(domName: string) {
touchZoom: true, keyboard: false,
})
const tileLayer = L.tileLayer(getTileUrl(isDark), {
attribution: '© OpenStreetMap contributors © CARTO', maxZoom: 19,
attribution: baseTiles.attribution, maxZoom: 19,
})
tileLayer.addTo(map)
tileLayers[domName] = tileLayer as unknown as TileLayer
-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>
+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 }
}
+7
View File
@@ -1,6 +1,8 @@
export default defineNuxtConfig({
modules: ['@nuxtjs/tailwindcss'],
css: [
'@transformer/ui/tokens.css',
'@transformer/ui/theme/aep.css',
'~/assets/css/main.css',
'leaflet/dist/leaflet.css',
'leaflet.markercluster/dist/MarkerCluster.css',
@@ -16,6 +18,8 @@ export default defineNuxtConfig({
commentTableId: process.env.COMMENT_TABLE_ID || process.env.AVIS_TABLE_ID,
statsTableId: process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc',
mistralApiKey: process.env.MISTRAL_API_KEY,
bifrostUrl: process.env.BIFROST_URL || 'http://127.0.0.1:8080',
bifrostVk: process.env.BIFROST_VK,
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
resendApiKey: process.env.RESEND_API_KEY,
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
@@ -25,6 +29,9 @@ export default defineNuxtConfig({
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
+6
View File
@@ -8,6 +8,7 @@
"hasInstallScript": true,
"dependencies": {
"@headlessui/vue": "^1.7.23",
"@transformer/ui": "git+https://git.trans-former.fr/jules/design-system.git",
"@types/leaflet": "^1.9.21",
"@types/leaflet.markercluster": "^1.5.6",
"d3": "^7.9.0",
@@ -3625,6 +3626,11 @@
"vue": "^2.7.0 || ^3.0.0"
}
},
"node_modules/@transformer/ui": {
"version": "0.1.0",
"resolved": "git+https://git.trans-former.fr/jules/design-system.git#057de406c804315e09d28fb26f10941d61e461b1",
"license": "UNLICENSED"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
+1
View File
@@ -11,6 +11,7 @@
},
"dependencies": {
"@headlessui/vue": "^1.7.23",
"@transformer/ui": "git+https://git.trans-former.fr/jules/design-system.git",
"@types/leaflet": "^1.9.21",
"@types/leaflet.markercluster": "^1.5.6",
"d3": "^7.9.0",
+7 -6
View File
@@ -87,14 +87,15 @@
/>
</div>
<div class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">{{ structure.type_principal }} - {{ structure.ville }}</div>
<div v-if="structure.hashtags.length" class="mt-1 flex flex-wrap gap-1">
<span
<ChipGroup v-if="structure.hashtags.length" class="mt-1" :aria-label="`Hashtags de ${structure.nom}`">
<Chip
v-for="tag in structure.hashtags.slice(0, 2)"
:key="tag"
class="text-xs"
style="color: var(--nav-text-muted);"
>{{ tag }}</span>
</div>
as="span"
variant="fonction"
:label="tag"
/>
</ChipGroup>
</div>
</div>
+20 -20
View File
@@ -185,36 +185,36 @@
</button>
</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">
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">ÉCHELLE</span>
<div class="flex flex-wrap gap-1">
<span
<ChipGroup aria-label="Filtrer par échelle">
<Chip
v-for="opt in ECHELLES"
:key="opt"
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
:style="echelle.includes(opt)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggleEchelle(opt)"
>{{ opt }}</span>
</div>
:label="opt"
variant="echelle"
:active="echelle.includes(opt)"
@toggle="toggleEchelle(opt)"
/>
</ChipGroup>
</div>
<!-- Filtres FONCTION — chips flex-wrap -->
<!-- Filtres FONCTION — Chip variant fonction (B3-M1, idem) -->
<div class="mt-2">
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">FONCTION</span>
<div class="flex flex-wrap gap-1">
<span
<ChipGroup aria-label="Filtrer par fonction">
<Chip
v-for="fn in FONCTIONS"
:key="fn"
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
:style="fonctions.includes(fn)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggleFonction(fn)"
>{{ fn }}</span>
</div>
:label="fn"
variant="fonction"
:active="fonctions.includes(fn)"
:count="fonctions.includes(fn) ? (fonctions.indexOf(fn) + 1) : (fonctionCount[fn] ?? 0)"
@toggle="toggleFonction(fn)"
/>
</ChipGroup>
</div>
<button
+268 -5
View File
@@ -137,15 +137,55 @@
<!-- ══════════════ 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> Bibliothèque de ressources libres
<span aria-hidden="true">🌳</span> Annuaire du web libre — la cartographie FMHY
</h2>
<p class="outils-section__desc">
Le vrai trésor de l'onglet Outils. FMHY (Free Media Heck Yeah) est la plus grosse
base communautaire d'outils, services et ressources libres/gratuits du web. J'en ai
curé ~50 entrées pertinentes pour un architecte : IA, lecture, dev, vie privée,
formation, médias. Clique sur les branches pour explorer.
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>
@@ -188,6 +228,124 @@
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, souverain, 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.'
@@ -526,6 +684,111 @@ useSeoMeta({
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;
}
+10 -6
View File
@@ -6,6 +6,7 @@
// @ts-ignore — JSON import résolu par Rollup
import reseauxData from '../../public/data/reseaux-bifurcation.json'
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost'
interface Structure {
id: string
@@ -61,6 +62,7 @@ export default defineEventHandler(async (event) => {
const body = await readBody(event)
const question: string = (body?.question ?? '').trim()
if (!question || question.length < 5) throw createError({ statusCode: 400, message: 'Question trop courte.' })
const tier = pickBifrostTier(body?.mode)
const structures: Structure[] = ((reseauxData as any).structures ?? [])
const keywords = extractKeywords(question)
@@ -82,18 +84,20 @@ export default defineEventHandler(async (event) => {
const systemPrompt = SYSTEM_PROMPT.replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0))
const mistralApiKey = config.mistralApiKey as string
if (!mistralApiKey) throw createError({ statusCode: 500, message: 'Clé API Mistral manquante.' })
const bifrostUrl = config.bifrostUrl as string
const bifrostVk = config.bifrostVk as string
if (!bifrostVk) throw createError({ statusCode: 500, message: 'Clé Bifrost manquante.' })
let mistralRaw: string
try {
const res = await $fetch<{ choices: { message: { content: string } }[] }>(
'https://api.mistral.ai/v1/chat/completions',
const res = await $fetch<BifrostChatResponse>(
`${bifrostUrl}/v1/chat/completions`,
{
method: 'POST',
headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' },
headers: { 'x-bf-vk': bifrostVk, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'mistral-small-latest',
model: tier.model,
fallbacks: tier.fallbacks,
temperature: 0.3,
max_tokens: 700,
response_format: { type: 'json_object' },
+11 -7
View File
@@ -7,6 +7,7 @@
// @ts-ignore — JSON import résolu par Vite/Rollup
import taffData from '../../public/data/plateformes-taff.json'
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost'
interface PlateformeMinimal {
id: string
@@ -66,6 +67,7 @@ export default defineEventHandler(async (event) => {
if (!question || question.length < 5) {
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
}
const tier = pickBifrostTier(body?.mode)
// Données bundlées statiquement à la compilation (import JSON)
const plateformes: PlateformeMinimal[] = ((taffData as any).plateformes ?? []).map((p: any) => ({
@@ -91,20 +93,22 @@ export default defineEventHandler(async (event) => {
const systemPrompt = SYSTEM_PROMPT.replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0))
const mistralApiKey = config.mistralApiKey as string
if (!mistralApiKey) {
throw createError({ statusCode: 500, statusMessage: 'Clé API Mistral manquante.' })
const bifrostUrl = config.bifrostUrl as string
const bifrostVk = config.bifrostVk as string
if (!bifrostVk) {
throw createError({ statusCode: 500, statusMessage: 'Clé Bifrost manquante.' })
}
let mistralRaw: string
try {
const res = await $fetch<{ choices: { message: { content: string } }[] }>(
'https://api.mistral.ai/v1/chat/completions',
const res = await $fetch<BifrostChatResponse>(
`${bifrostUrl}/v1/chat/completions`,
{
method: 'POST',
headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' },
headers: { 'x-bf-vk': bifrostVk, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'mistral-small-latest',
model: tier.model,
fallbacks: tier.fallbacks,
temperature: 0.3,
max_tokens: 700,
response_format: { type: 'json_object' },
+30 -17
View File
@@ -19,6 +19,7 @@
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost'
// ── Types ──────────────────────────────────────────────────────────────────────
@@ -131,18 +132,19 @@ async function logUsage(params: {
nocodbUrl: string
nocodbToken: string
statsTableId: string
model: string
tokensIn: number
tokensOut: 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`
try {
await $fetch(logUrl, {
method: 'POST',
headers: { 'xc-token': nocodbToken, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'mistral-small-latest',
model,
endpoint: 'chatbot',
tokens_in: tokensIn,
tokens_out: tokensOut,
@@ -180,6 +182,7 @@ export default defineEventHandler(async (event) => {
const body = await readBody(event)
const question: string = (body?.question ?? '').trim()
const filters: { fonction?: string; echelle?: string } = body?.filters ?? {}
const tier = pickBifrostTier(body?.mode)
if (!question || question.length < 3) {
throw createError({
@@ -247,32 +250,32 @@ export default defineEventHandler(async (event) => {
JSON.stringify(fichesContext, null, 0),
)
// 6. Appel Mistral Small
const mistralApiKey = config.mistralApiKey as string
// 6. Appel Bifrost (gateway LLM — remplace l'appel Mistral direct)
const bifrostUrl = config.bifrostUrl as string
const bifrostVk = config.bifrostVk as string
if (!mistralApiKey) {
if (!bifrostVk) {
throw createError({
statusCode: 500,
statusMessage: 'Clé API Mistral manquante.',
statusMessage: 'Clé Bifrost manquante.',
})
}
let mistralRaw: string
let tokensIn = 0
let tokensOut = 0
let realModel = tier.model
try {
const mistralRes = await $fetch<{
choices: { message: { content: string } }[]
usage?: { prompt_tokens: number; completion_tokens: number }
}>('https://api.mistral.ai/v1/chat/completions', {
const bifrostRes = await $fetch<BifrostChatResponse>(`${bifrostUrl}/v1/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${mistralApiKey}`,
'x-bf-vk': bifrostVk,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'mistral-small-latest',
model: tier.model,
fallbacks: tier.fallbacks,
temperature: 0.3,
max_tokens: 600,
response_format: { type: 'json_object' },
@@ -283,11 +286,16 @@ export default defineEventHandler(async (event) => {
}),
})
mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}'
tokensIn = mistralRes.usage?.prompt_tokens ?? 0
tokensOut = mistralRes.usage?.completion_tokens ?? 0
mistralRaw = bifrostRes.choices?.[0]?.message?.content ?? '{}'
tokensIn = bifrostRes.usage?.prompt_tokens ?? 0
tokensOut = bifrostRes.usage?.completion_tokens ?? 0
realModel =
bifrostRes.extra_fields?.resolved_model_used ??
(bifrostRes.extra_fields?.provider
? `${bifrostRes.extra_fields.provider}/${tier.model.split('/').slice(1).join('/')}`
: tier.model)
} catch (e: any) {
console.error('[chatbot] Erreur Mistral Small:', e?.message ?? e)
console.error('[chatbot] Erreur Bifrost:', e?.message ?? e)
throw createError({
statusCode: 502,
statusMessage: 'Erreur appel IA — réessaie dans quelques instants.',
@@ -317,11 +325,16 @@ export default defineEventHandler(async (event) => {
}
// 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({
nocodbUrl: config.nocodbUrl as string,
nocodbToken: config.nocodbToken as string,
statsTableId,
model: realModel,
tokensIn,
tokensOut,
coutEur,
+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: {
colors: {
/* Palette NAV V2 — Sobre institutionnel */
/* Pointent vers les variables CSS --nav-* (alias des rôles @transformer/ui,
voir assets/css/main.css) pour rester cohérentes clair/sombre. (B3-M0) */
nav: {
bg: '#f8f6f1',
'bg-alt':'#eee9df',
surface: '#ffffff',
primary: 'rgba(26, 34, 56, 0.6)',
'primary-solid': '#1a2238',
accent: '#f5b342',
text: '#1a2238',
'text-muted': 'rgba(26, 34, 56, 0.55)',
'text-on-primary': '#f8f6f1',
bg: 'var(--nav-bg)',
'bg-alt':'var(--nav-bg-alt)',
surface: 'var(--nav-surface)',
primary: 'var(--nav-primary)',
'primary-solid': 'var(--nav-primary-solid)',
accent: 'var(--nav-accent)',
text: 'var(--nav-text)',
'text-muted': 'var(--nav-text-muted)',
'text-on-primary': 'var(--nav-text-on-primary)',
},
/* Héritage V1 — gardé pour composants existants */
sage: {