13 Commits

Author SHA1 Message Date
Jules Neny
b36587cb08 feat(media): hashtag mentions chatbot #slug-auteur (Phase 8.E)
Frontend ChatbotPensees.vue :
- Parser regex #slug-auteur dans la query (case-insensitive)
- Auto-completion dropdown au-dessus de l'input (Slack/Discord pattern)
- Match fuzzy sur id et nom des auteurs ingeres (32 actuellement)
- Navigation ArrowDown/Up/Enter/Tab/Escape sur la dropdown
- send() extrait auteur_slug du premier hashtag matchant un ingere
- Si hashtag tape mais ne matche aucun ingere, on l'envoie comme unmatched
- Message info utilisateur si auteur_unmatched remonte

Backend chatbot-pensees.post.ts :
- Interface body etendue : auteur_slug?: string
- Cache local de la liste auteurs ingeres depuis public/data/auteurs-pensees.json
- Preface dediee buildPrefaceAuteur(nom, slug) si auteur_slug match un ingere
- LightRAG /query enrichi avec hl_keywords + ll_keywords (preflight OpenAPI :
  keyword_filter, ids et metadata_filter ne sont PAS supportes par cette version,
  hl_keywords / ll_keywords sont les seuls leviers natifs)
- Post-process references : compteur on_target / off_target sur slug__
- Fallback gracieux si auteur_slug ne matche pas : reponse normale + info front
- Response enrichie : auteur, auteur_unmatched, auteur_chunks

Pas d'em-dash sur le code modifie, accents francais preserves.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:04:03 +02:00
Jules Neny
89608d894c feat(media): transposition 1:1 Bonpote V2 + Voronoi blur + grisage (Phase 8.D)
- Positions x_hint/y_hint repos depuis OCR vision Sonnet sur PDF Bonpote V2
- Couleurs ecoles pastel Bonpote-aligned (10 clusters)
- Labels Bonpote V2 longs : Ecologies libertaires + Ecologies anti-industrielles
  (ids JSON eco-anarchisme/technocritique inchanges, compat code)
- CSS .voronoi-bg filter:blur(10px) + labels separes sur calque non-blurre
- Grisage auteurs ingere:false : #bbb opacity 0.35 non-cliquables
- Tooltip non-ingeres : "Present dans Bonpote, pas encore ingere dans le RAG ATIS."
- D3 sim ajustee pour 171 auteurs : linkDistance 85, charge -30, forceXY 0.15
- corpusCount = auteurs ingeres uniquement (32, pas 171 total)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:57:30 +02:00
Jules Neny
fdd9d02859 feat(media): JSON v3.0 avec 171 auteurs Bonpote + flag ingere (Phase 8.A)
Sync corpus auteurs-pensees pour transposition 1:1 carte Bonpote V2.
171 auteurs (vs 28 v2.1), 32 ingeres + 139 non-ingeres avec bio provisoire.
Flag ingere:true/false sur chaque auteur pour grisage Phase 8.D.
Preservation 100% des entrees enrichies v2.1 (theses_cles, bio_courte,
slugs compat D3 Phase 7).

scripts/build_authors_v3.mjs : helper Node.js reproductible pour re-runs
apres nouvelles ingestions LightRAG.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:50:47 +02:00
Jules Neny
a1c47002d5 fix(media): centre gravite auteurs + bouton vue partagee + poignee draggable barre
- CartePensees.vue : pre-positionner auteurs sur ecole.x_hint/y_hint + jitter 80px pour eviter le rush initial vers la droite au chargement
- media.vue : bouton Vue partagee repositionne entre Carte plein ecran et Chatbot plein ecran, style homogene avec les autres boutons
- media.vue : poignee draggable sur barre separation carte/chatbot en mode split - ratio clamp 20/80, localStorage media-split-ratio, triggerResize D3 au mouseup, desactivee sur mobile

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

V2 Phase 4

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:07:42 +02:00
47 changed files with 2996 additions and 22177 deletions

1
.gitignore vendored
View File

@@ -7,4 +7,3 @@ dist
*.log
*.tmp.*
.firecrawl/

View File

@@ -11,6 +11,56 @@ Journal technique de la V2. Décisions, anomalies, points bloquants, TODOs.
---
## 2026-05-08 — Fix mobile + chatbot prod (cause racine résolue)
**Commits :** session loggée sur main (pushé sur gitea)
**Pattern :** pilote direct, 2 batches successifs, ~3h, 11 fichiers
### Cause racine bug "chatbot Carte 1 == Carte 2"
`/api/chatbot-reseaux` était **404 en prod** (jamais déployé) — explique pourquoi 5 cycles de fix précédents (ChatbotReseaux.vue prop endpoint, useRoute fallback, useMarkdown direct, etc.) n'ont rien donné : le code source était correct depuis le début. Le rebuild + redeploy de cette session résout le bug.
**Verif :** `curl -s -X POST https://aep.trans-former.fr/api/chatbot-reseaux` → 200 + réponse distincte de `/api/chatbot`.
### Batch 1 — fixes mobile principaux
- Hamburger app.vue : ajout Jobs + Manifeste + Soutenir, ré-ordonnancement (Manifeste dans 2e groupe avec À propos/Soutenir/Signaler)
- BandeauBas.vue : FAB cœur jaune mobile retiré (Soutenir migré dans hamburger via lien Liberapay direct)
- agences.vue mobile : 3e onglet "Graphe" ajouté + masquage MobileSheet en mode graphe (canvas fullscreen)
- a-propos.vue : section 1 "Mission" retirée (devient pop-up Carte 1) + `overflow-x: hidden` sur `.apropos-page` + retrait `white-space: nowrap` problématique sur `.badge-detail`
- pages/manifeste.vue : nouvelle page (texte version `manifeste-page-carto-V1.md`, sans le diagramme ASCII pour V1 web)
- components/MissionPopup.vue : nouveau composant générique (props `title`, `ctaLabel`, `storageKey`, slot pour contenu, `:slotted()` pour styles)
- index.vue : intégration MissionPopup + bouton (i) `position:fixed` bottom-left + auto-show 1ère visite via `localStorage.aep_mission_seen`
- trouver-du-taf.vue : toggle "Filtres [N] [chevron]" mobile-only (`@media max-width: 767px`) avec `taff-filters-collapsible` max-height transition
- FicheModal.vue + FicheModalV2.vue : sur mobile `top: 76px` + `max-height: calc(100dvh - 92px)` au lieu de `top: 50% translate(-50%, -50%)` + `max-height: 90vh` qui mordait sur le header
### Batch 2 — pop-up Carte 2, logo, intro Jobs, labels graphe
- agences.vue : pop-up Réseaux AEP avec MissionPopup (storageKey `aep_reseaux_seen`, ctaLabel "Explorer les 120 réseaux") + bouton (i) flottant
- app.vue logo header : badge AEP + 2 spans `logo-line-1` ("Architecture") / `logo-line-2` ("d'Écologie Politique") avec font-size responsive (0.7rem mobile → 0.85rem ≥1024)
- trouver-du-taf.vue : `<details class="taff-pedago" open>` avec 3 blocs (deux onglets, trois étiquettes, cinq axes) + onglet "Plateformes B2C" → "Pour archi indépendants"
- GraphView.vue : `d3NodeSelection.filter(type==='structure').append('text')` avec class `graph-struct-label`, `dy: -(d.r + 5)`, font-size 9.5px, halo via `paint-order: stroke; stroke: var(--nav-bg)` (style global non-scoped pour piercer D3)
### Bug d'opération à retenir
Lors du 1er déploiement batch 2, `bash deploy.sh` semblait OK (HTTP 200) mais le HTML en prod ne contenait pas les modifs. **Cause** : Dropbox sync a effacé `.output/` entre `npm run build` et le tar SCP — le tar a uploadé un `.output` quasi-vide. Solution : 2e cycle clean (`Remove-Item .nuxt/dist + .output`) + rebuild + redeploy avec `yes y |` (skip confirm interactif `.env diff`).
**Réflexe à intégrer** : après build, vérifier `grep -o "<un-fragment-de-modif>" .output/public/_nuxt/*.js | head` AVANT le deploy. Si 0 match → ne pas deploy, rebuild.
### Bug de communication à retenir
Jules a signalé "le logo n'a pas marché", "B2C pas renommé", "hamburger pas modifié" alors que le HTML en prod contenait bien les modifs (vérifié curl avec `?nc=$(date +%s)`). **Cause** : cache navigateur / service worker Nuxt. Réflexe à mettre en place pour /done de toute session web : si Jules dit "ça n'apparaît pas", vérifier curl en bypass cache AVANT de chercher un bug. Si match curl → demander hard refresh (Ctrl+Shift+R).
### Reste à faire (batch 3)
Voir `0 INBOX/PROMPTS/cascade-megaboum/REPRISE-aep-carto-fix-batch3.md` :
- Bouton "+" → sélecteur 3 cartes (Entraide/Réseaux/Jobs)
- Pop-up explication 5 axes Jobs (paragraphe par axe)
- Pop-up Carte 1 visibilité (option à clarifier avec Jules)
- GraphView Carte 1 (centres = hashtags, couche échelle activable) — gros chantier session dédiée
---
## 2026-04-27 — Session V3 : Finition mobile + Blog Liberapay + 3 deploys
**Commit :** `a02a555` — feat(mobile): accordéon outremer, hamburger nav, logo AEP, fiches cliquables, chatbot fullscreen

141
app.vue
View File

@@ -22,19 +22,12 @@
<!-- ── Onglets desktop (≥1024px) — remplace la barre de recherche ── -->
<nav class="hidden lg:flex flex-1 justify-center items-end gap-0 mx-6" aria-label="Navigation projets">
<NuxtLink
to="/outils"
class="nav-tab"
:class="{ 'nav-tab--active': route.path === '/outils' }"
>
Outils
</NuxtLink>
<NuxtLink
to="/"
class="nav-tab"
:class="{ 'nav-tab--active': route.path === '/' }"
>
Écosystème Entraide
Écosystème Entraide Architecture
</NuxtLink>
<NuxtLink
to="/agences"
@@ -60,9 +53,9 @@
<NuxtLink
to="/media"
class="nav-tab"
:class="{ 'nav-tab--active': route.path.startsWith('/media') }"
:class="{ 'nav-tab--active': route.path === '/media' }"
>
recherche-média
MEDIA
</NuxtLink>
</nav>
@@ -114,14 +107,52 @@
>
Signaler
</NuxtLink>
<!-- Proposer une ressource (5 onglets) -->
<NuxtLink
to="/proposer"
class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 hidden sm:inline-flex items-center gap-1"
style="background: var(--nav-accent); color: var(--nav-text);"
>
+ Proposer
</NuxtLink>
<!-- Proposer — popover 3 choix -->
<div class="hidden sm:block relative" ref="proposerAnchor" data-proposer-popover>
<button
@click="proposerOpen = !proposerOpen"
class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 inline-flex items-center gap-1"
style="background: var(--nav-accent); color: var(--nav-text);"
aria-label="Proposer une contribution"
>
+ Proposer
</button>
<div
v-if="proposerOpen"
class="absolute right-0 top-full mt-1 rounded-lg shadow-lg min-w-[240px] py-1"
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
>
<NuxtLink
to="/contribuer"
class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70"
style="color: var(--nav-text);"
@click="proposerOpen = false"
>
<span>Fiche Entraide <span style="color: var(--nav-text-muted); font-weight: 400; font-size: 0.7rem; display: block;">Carte 1 — Écosystème archi</span></span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
</NuxtLink>
<div style="height: 1px; background: var(--nav-bg-alt); margin: 2px 0;"></div>
<NuxtLink
to="/contribuer-reseau"
class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70"
style="color: var(--nav-text);"
@click="proposerOpen = false"
>
<span>Réseau / collectif <span style="color: var(--nav-text-muted); font-weight: 400; font-size: 0.7rem; display: block;">Carte 2 — Réseaux AEP</span></span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
</NuxtLink>
<div style="height: 1px; background: var(--nav-bg-alt); margin: 2px 0;"></div>
<NuxtLink
to="/contribuer-job"
class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70"
style="color: var(--nav-text);"
@click="proposerOpen = false"
>
<span>Plateforme jobs <span style="color: var(--nav-text-muted); font-weight: 400; font-size: 0.7rem; display: block;">Carte 3 — Jobs archi</span></span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
</NuxtLink>
</div>
</div>
<!-- Toggle dark mode -->
<button
@@ -143,18 +174,40 @@
</svg>
</button>
<!-- Mobile : proposer icône -->
<NuxtLink
to="/proposer"
class="sm:hidden p-2 rounded-lg"
style="background: var(--nav-accent); color: var(--nav-text);"
title="Proposer une ressource"
aria-label="Proposer"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</NuxtLink>
<!-- Mobile : contribuer icône → popover -->
<div class="sm:hidden relative" data-proposer-popover>
<button
@click="proposerOpen = !proposerOpen"
class="p-2 rounded-lg"
style="background: var(--nav-accent); color: var(--nav-text);"
title="Contribuer"
aria-label="Contribuer"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
<div
v-if="proposerOpen"
class="absolute right-0 top-full mt-1 rounded-lg shadow-lg min-w-[220px] py-1"
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
>
<NuxtLink to="/contribuer" class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" style="color: var(--nav-text);" @click="proposerOpen = false">
<span>Fiche Entraide</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
</NuxtLink>
<div style="height: 1px; background: var(--nav-bg-alt); margin: 2px 0;"></div>
<NuxtLink to="/contribuer-reseau" class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" style="color: var(--nav-text);" @click="proposerOpen = false">
<span>Réseau / collectif</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
</NuxtLink>
<div style="height: 1px; background: var(--nav-bg-alt); margin: 2px 0;"></div>
<NuxtLink to="/contribuer-job" class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" style="color: var(--nav-text);" @click="proposerOpen = false">
<span>Plateforme jobs</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
</NuxtLink>
</div>
</div>
<!-- Hamburger mobile (lg:hidden) — toujours en dernier à droite -->
<div class="lg:hidden relative">
@@ -178,11 +231,10 @@
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
@click="hamburgerOpen = false"
>
<NuxtLink to="/outils" class="block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/outils' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Outils</NuxtLink>
<NuxtLink to="/" class="block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Écosystème Entraide Architecture</NuxtLink>
<NuxtLink to="/agences" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/agences' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Réseaux AEP</NuxtLink>
<NuxtLink to="/trouver-du-taf" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/trouver-du-taf' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Jobs</NuxtLink>
<NuxtLink to="/rag" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">RAG</NuxtLink>
<NuxtLink to="/media" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/media' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">MEDIA</NuxtLink>
<NuxtLink to="/codev" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/codev') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Codev</NuxtLink>
<div style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div>
<NuxtLink to="/manifeste" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/manifeste' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text-muted);'">Manifeste</NuxtLink>
@@ -212,6 +264,31 @@ const route = useRoute()
const hamburgerOpen = ref(false)
watch(() => route.path, () => { hamburgerOpen.value = false })
// ── Popover "+ Proposer" ─────────────────────────────────────────────────
const proposerOpen = ref(false)
const proposerAnchor = ref<HTMLElement | null>(null)
function onClickOutsideProposer(e: MouseEvent) {
// Ferme si le clic est hors de tout élément portant data-proposer-popover
const target = e.target as HTMLElement
if (!target.closest('[data-proposer-popover]')) {
proposerOpen.value = false
}
}
watch(proposerOpen, (open) => {
if (open) {
// Délai court pour ne pas attraper le clic d'ouverture lui-même
setTimeout(() => document.addEventListener('click', onClickOutsideProposer, true), 10)
} else {
document.removeEventListener('click', onClickOutsideProposer, true)
}
})
onUnmounted(() => {
document.removeEventListener('click', onClickOutsideProposer, true)
})
// ── Dark mode ─────────────────────────────────────────────────────────────
const isDark = ref(false)

View File

@@ -30,7 +30,7 @@ const LINKS_INFLUENCE = [
{ source: 'decroissance', target: 'pensees-vivant', auteurs_passerelle: ['Servigne', 'Despret'], type: 'filiation' },
{ source: 'pensees-vivant', target: 'ethiques-environnementales', auteurs_passerelle: ['Naess', 'Latour'], type: 'filiation' },
{ source: 'ecosocialisme', target: 'eco-anarchisme', auteurs_passerelle: ['Gorz', 'Graeber'], type: 'filiation' },
// liens de critique
// liens de critique (toutes les ecoles progressistes vs cap-vert / ecofascismes)
{ source: 'ecosocialisme', target: 'capitalisme-vert', auteurs_passerelle: ['Klein', 'Malm'], type: 'critique' },
{ source: 'decroissance', target: 'capitalisme-vert', auteurs_passerelle: ['Latouche', 'Meadows'], type: 'critique' },
{ source: 'eco-anarchisme', target: 'capitalisme-vert', auteurs_passerelle: ['Bookchin'], type: 'critique' },
@@ -39,7 +39,7 @@ const LINKS_INFLUENCE = [
]
const props = defineProps<{ data: PenseesData | null; active?: boolean }>()
const emit = defineEmits<{ 'select-auteur': [id: string]; 'select-ecole': [id: string] }>()
const emit = defineEmits<{ 'select-auteur': [id: string] }>()
const svgRef = ref<SVGElement | null>(null)
const tooltipRef = ref<HTMLElement | null>(null)
@@ -52,6 +52,7 @@ let d3EdgeLabelSel: any = null
async function initGraph() {
if (!svgRef.value || !props.data) return
const d3 = await import('d3')
const { Delaunay } = await import('d3-delaunay')
const svgEl = svgRef.value
const W = svgEl.clientWidth || 900
@@ -65,12 +66,73 @@ async function initGraph() {
const ecoleMap = new Map<string, EcoleData>(props.data.ecoles.map(e => [e.id, e]))
// Positions fixes des ecoles (base pour forces D3)
// Positions fixes des ecoles (base pour Voronoi)
const ecolePositions = new Map<string, { tx: number; ty: number }>()
props.data.ecoles.forEach(e => {
ecolePositions.set(e.id, { tx: W * e.x_hint, ty: H * e.y_hint })
})
// ---- VORONOI BACKGROUND (couche 1) ----
const ecolesArr = props.data.ecoles
const points: [number, number][] = ecolesArr.map(e => [W * e.x_hint, H * e.y_hint])
const delaunay = Delaunay.from(points)
const voronoi = delaunay.voronoi([0, 0, W, H])
// Groupe Voronoi : separation Phase 8.D
// - gVoronoi : cells colorees, BLURRED via CSS .voronoi-bg
// - gVoronoiLabels : labels ecoles, NOT blurred (lisibilite 17px)
const gVoronoi = g.append('g').attr('class', 'voronoi-bg')
const gVoronoiLabels = g.append('g').attr('class', 'voronoi-labels')
ecolesArr.forEach((ecole, i) => {
const cellPath = voronoi.renderCell(i)
const poly = voronoi.cellPolygon(i)
gVoronoi.append('path')
.attr('d', cellPath)
.attr('fill', ecole.color)
.attr('fill-opacity', 0.48)
.attr('class', 'voronoi-cell')
.attr('data-ecole', ecole.id)
.on('mouseenter', (e: any) => {
if (!tooltipRef.value) return
tooltipRef.value.innerHTML = `<strong>${ecole.label}</strong><br><span style="opacity:0.75;font-size:0.72rem;">${ecole.description}</span>`
tooltipRef.value.style.opacity = '1'
})
.on('mousemove', (e: any) => {
if (!tooltipRef.value || !svgEl) return
const rect = (svgEl as HTMLElement).getBoundingClientRect()
tooltipRef.value.style.left = (e.clientX - rect.left + 14) + 'px'
tooltipRef.value.style.top = (e.clientY - rect.top - 10) + 'px'
})
.on('mouseleave', () => { if (tooltipRef.value) tooltipRef.value.style.opacity = '0' })
// Label ecole dans la cellule (centroid du polygone) - calque non-blurre
if (poly && poly.length > 0) {
const centroid = d3.polygonCentroid(poly as [number, number][])
if (centroid && !isNaN(centroid[0]) && !isNaN(centroid[1])) {
const words = ecole.label.split(' ')
const labelEl = gVoronoiLabels.append('text')
.attr('class', 'voronoi-cell-label')
.attr('x', centroid[0])
.attr('y', centroid[1])
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.style('pointer-events', 'none')
.style('user-select', 'none')
if (words.length <= 2) {
labelEl.text(ecole.label)
} else {
const mid = Math.ceil(words.length / 2)
labelEl.append('tspan').attr('x', centroid[0]).attr('dy', '-0.55em').text(words.slice(0, mid).join(' '))
labelEl.append('tspan').attr('x', centroid[0]).attr('dy', '1.1em').text(words.slice(mid).join(' '))
}
}
}
})
// ---- LIENS D'INFLUENCE INTER-ECOLES (couche 3) ----
const gInfluence = g.append('g').attr('class', 'links-influence')
@@ -107,6 +169,7 @@ async function initGraph() {
})
// ---- SIMULATION D3 (auteurs) ----
// Pre-positionner chaque auteur pres de son ecole + jitter aleatoire pour eviter le rush initial vers la droite
const auteurNodes: any[] = props.data.auteurs.map(a => {
const ecole = ecoleMap.get(a.ecole_principale)
const jitter = () => (Math.random() - 0.5) * 80
@@ -138,20 +201,15 @@ async function initGraph() {
fx: W * e.x_hint, fy: H * e.y_hint,
}))
// Rayon proportionnel au nombre d'auteurs de l'ecole
const ecoleAuteurCounts = new Map<string, number>()
props.data.ecoles.forEach(e => ecoleAuteurCounts.set(e.id, 0))
props.data.auteurs.forEach(a => ecoleAuteurCounts.set(a.ecole_principale, (ecoleAuteurCounts.get(a.ecole_principale) ?? 0) + 1))
const ecoleRadius = (count: number) => Math.max(16, Math.min(36, 13 + count * 1.5))
const allNodes = [...ecoleFixedNodes, ...auteurNodes]
if (simulation) simulation.stop()
// Phase 8.D : sim ajustee pour 171 auteurs (vs 28 v2.1, densite 6x)
simulation = d3.forceSimulation(allNodes)
.force('link', d3.forceLink(links).id((d: any) => d.id).distance(120).strength((d: any) => d.strength ?? 0.5))
.force('charge', d3.forceManyBody().strength(-70))
.force('link', d3.forceLink(links).id((d: any) => d.id).distance(85).strength((d: any) => d.strength ?? 0.5))
.force('charge', d3.forceManyBody().strength(-30))
.force('center', d3.forceCenter(W / 2, H / 2).strength(0.02))
.force('collision', d3.forceCollide().radius((d: any) => d.type === 'ecole-fixed' ? ecoleRadius(ecoleAuteurCounts.get(d.ecoleId) ?? 0) + 4 : 12))
.force('collision', d3.forceCollide().radius((d: any) => d.type === 'auteur' ? 12 : 0))
.force('forceX', d3.forceX<any>((d: any) => {
if (d.type === 'auteur') {
const pos = ecolePositions.get(d.ecole_principale)
@@ -167,79 +225,13 @@ async function initGraph() {
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) ----
// Afficher label "decroissance" sur lien Servigne (sous-courant specifique - option C)
const subcourantLinks = links.filter((l: any) => l.isSubcourant)
d3EdgeLabelSel = gLinks.selectAll('text.pensees-edge-label')
.data(subcourantLinks)
@@ -260,7 +252,7 @@ async function initGraph() {
emit('select-auteur', d.id)
})
// Phase 8.D : grisage conditionnel auteurs non-ingeres
// Phase 8.D : grisage conditionnel auteurs non-ingeres (ingere:false)
d3NodeSel.append('circle')
.attr('r', (d: any) => d.r)
.attr('fill', (d: any) => d.ingere ? (d.color + 'cc') : '#bbbbbb')
@@ -268,24 +260,21 @@ async function initGraph() {
.attr('stroke-width', 1.5)
.attr('opacity', (d: any) => d.ingere ? 1 : 0.35)
// ---- LABELS AUTEURS (couche 6 - drop-shadow blanc) ----
// ---- LABELS AUTEURS (couche 6 - fix 7.1 : drop-shadow blanc) ----
d3NodeSel.append('text')
.attr('class', 'pensees-auteur-label')
.text((d: any) => d.nom.split(' ').pop() ?? d.nom)
.attr('text-anchor', 'middle')
.attr('dy', (d: any) => -(d.r + 4))
.style('pointer-events', 'none')
.style('opacity', (d: any) => d.ingere ? 1 : 0.3)
.style('fill', (d: any) => d.ingere ? '#1a1a1a' : '#777777')
d3NodeSel
.on('mouseenter', (e: any, d: any) => {
if (!tooltipRef.value) return
let tooltipHtml = ''
if (d.ingere) {
const rawBio = d.bio_courte || ''
const bio = rawBio.length > 90 ? rawBio.slice(0, 87) + '...' : rawBio
tooltipHtml = `<strong>${d.nom}</strong> <span style="opacity:0.6;font-size:0.7rem;">${d.dates}</span><br><span style="opacity:0.75;font-size:0.72rem;">${bio || 'Dans le RAG ATIS.'}</span>`
const bio = d.bio_courte.length > 90 ? d.bio_courte.slice(0, 87) + '...' : d.bio_courte
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}</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>`
}
@@ -305,6 +294,7 @@ async function initGraph() {
.attr('x1', (d: any) => d.source.x).attr('y1', (d: any) => d.source.y)
.attr('x2', (d: any) => d.target.x).attr('y2', (d: any) => d.target.y)
// Edge labels positions (milieu du lien)
d3EdgeLabelSel
.attr('x', (d: any) => (d.source.x + d.target.x) / 2)
.attr('y', (d: any) => (d.source.y + d.target.y) / 2)
@@ -317,20 +307,9 @@ async function initGraph() {
})
}
watch(() => props.active, (val) => {
if (val && import.meta.client && props.data)
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
})
watch(() => props.data, (val) => {
if (val && props.active && import.meta.client)
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
})
onMounted(async () => {
if (import.meta.client && props.data && props.active) {
await nextTick()
initGraph()
}
})
watch(() => props.active, (val) => { if (val && import.meta.client && props.data) requestAnimationFrame(() => requestAnimationFrame(() => initGraph())) })
watch(() => props.data, (val) => { if (val && props.active && import.meta.client) requestAnimationFrame(() => requestAnimationFrame(() => initGraph())) })
onMounted(async () => { if (import.meta.client && props.data && props.active) { await nextTick(); initGraph() } })
onUnmounted(() => { if (simulation) simulation.stop() })
function triggerResize() {
@@ -344,6 +323,7 @@ defineExpose({ triggerResize })
</script>
<style>
/* ---- Labels auteurs : fix 7.1 drop-shadow blanc pour lisibilite sur pastel ---- */
.pensees-auteur-label {
fill: #1a1a1a;
font-weight: 600;
@@ -352,6 +332,7 @@ defineExpose({ triggerResize })
user-select: none;
}
/* ---- Labels edge sous-courants (option C : seulement les liens secondaires) ---- */
.pensees-edge-label {
fill: #555;
font-size: 8.5px;
@@ -363,10 +344,29 @@ defineExpose({ triggerResize })
pointer-events: none;
}
.ecole-node {
transition: opacity 0.15s, r 0.15s;
/* ---- Voronoi cellules (Phase 8.D : blur 10px aquarelle Bonpote) ---- */
.voronoi-bg {
filter: blur(10px);
opacity: 0.65;
}
.ecole-node:hover {
opacity: 0.75;
.voronoi-cell {
stroke: rgba(255, 255, 255, 0.3);
stroke-width: 1px;
cursor: default;
}
/* ---- Labels ecoles : calque separe NON-blurre (Phase 8.D) ---- */
.voronoi-labels {
pointer-events: none;
}
.voronoi-cell-label {
fill: rgba(40,40,40,0.52);
font-size: 17px;
font-weight: 700;
letter-spacing: 0.3px;
pointer-events: none;
user-select: none;
}
</style>

View File

@@ -1,448 +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>
<!-- Mode overlay : bouton flottant bottom-right (legacy) -->
<template v-if="!inline">
<button v-if="!open" @click="open = true"
class="fixed bottom-6 right-6 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
style="height:48px;background:var(--nav-primary);color:var(--nav-text-on-primary);font-size:0.875rem;font-weight:600;"
aria-label="Chatbot Pensees Ecologiques">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<span>Pensees ?</span>
</button>
<Transition name="cpanel">
<div v-if="open" class="fixed bottom-6 right-6 z-[1000] flex flex-col"
style="width:min(360px,calc(100vw - 24px));max-height:60vh;background:var(--nav-surface);border-radius:14px;box-shadow:0 8px 32px rgba(26,34,56,0.22);overflow:hidden;border:1px solid var(--nav-bg-alt);"
role="dialog" aria-modal="true" aria-label="RAG Pensees Ecologiques">
<!-- Header overlay -->
<div class="flex items-center justify-between px-4 py-3 shrink-0" style="border-bottom:1px solid var(--nav-bg-alt);background:var(--nav-bg);">
<div>
<p class="text-sm font-bold" style="color:var(--nav-text);">RAG Pensees Ecologiques</p>
<p class="text-xs" style="color:var(--nav-text-muted);">{{ corpusCount }} auteurs ingeres</p>
</div>
<button @click="open = false" class="flex items-center justify-center w-7 h-7 rounded-full hover:opacity-70"
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);" aria-label="Fermer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<span class="chatbot-title">
<template v-if="auteurContext">{{ auteurContext }}</template>
<template v-else>Bibliothèque des pensées</template>
</span>
<span v-if="auteurContext" class="chatbot-corpus-badge">auteur</span>
<span v-else class="chatbot-corpus-badge">FRACAS</span>
<!-- Corpus toggle overlay -->
<div class="shrink-0 px-3 pt-2 pb-1" style="background:var(--nav-bg);border-bottom:1px solid var(--nav-bg-alt);">
<div class="flex gap-1" role="group" aria-label="Choisir le corpus">
<button v-for="opt in corpusOptions" :key="opt.value" @click="setCorpus(opt.value)" :title="opt.tooltip"
class="flex-1 px-2 py-1 rounded text-xs font-medium transition-colors"
:style="corpus === opt.value ? 'background:var(--nav-primary);color:var(--nav-text-on-primary);' : 'background:var(--nav-bg-alt);color:var(--nav-text-muted);'"
:aria-pressed="corpus === opt.value">{{ opt.label }}</button>
</div>
</div>
<!-- Messages overlay -->
<div ref="msgElOverlay" class="flex-1 overflow-y-auto px-4 py-3 flex flex-col gap-3" style="min-height:0;">
<div v-if="messages.length === 0" style="font-size:0.8rem;color:var(--nav-text-muted);line-height:1.5;">
<template v-if="corpus === 'pensees'">Pose une question sur les pensees ecologiques...</template>
<template v-else-if="corpus === 'projets'">Pose une question sur les projets d'architecture de Jules...</template>
<template v-else>Pose une question sur les pensees ecologiques ancrees dans les projets archi de Jules.</template>
</div>
<template v-for="(msg, i) in messages" :key="i">
<div v-if="msg.role === 'user'" class="self-end max-w-[85%] px-3 py-2 rounded-xl text-sm"
style="background:var(--nav-primary);color:var(--nav-text-on-primary);font-weight:500;">{{ msg.content }}</div>
<div v-else class="self-start max-w-full">
<div class="px-3 py-2 rounded-xl text-sm leading-relaxed" style="background:var(--nav-bg-alt);color:var(--nav-text);"
v-html="renderMd(stripSrc(msg.content))" />
<div v-if="filteredSources(msg.content).length" class="mt-1.5">
<button @click="toggled[i] = !toggled[i]" class="flex items-center gap-1 text-xs hover:opacity-70" style="color:var(--nav-text-muted);">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
:style="`transform:rotate(${toggled[i] ? 90 : 0}deg);transition:transform 0.15s`"><polyline points="9 18 15 12 9 6"/></svg>
Sources ({{ filteredSources(msg.content).length }})
</button>
<div v-if="toggled[i]" class="mt-1 flex flex-col gap-1">
<div v-for="(s, si) in filteredSources(msg.content)" :key="si" class="px-2 py-1 rounded text-xs"
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);border-left:2px solid var(--nav-primary-solid);">
<span style="font-weight:600;color:var(--nav-text);">[{{ si + 1 }}]</span> {{ s }}
</div>
</div>
</div>
</div>
</template>
<div v-if="loading" class="self-start px-3 py-2 rounded-xl" style="background:var(--nav-bg-alt);">
<span class="dots"><span/><span style="animation-delay:150ms"/><span style="animation-delay:300ms"/></span>
</div>
<div v-if="err" class="text-xs px-3 py-2 rounded-xl" style="background:#fee;color:#c0392b;">{{ err }}</div>
</div>
<!-- Input overlay -->
<div class="shrink-0 px-3 py-3" style="border-top:1px solid var(--nav-bg-alt);">
<div class="flex items-center gap-2" style="position:relative;">
<!-- Hashtag autocomplete dropdown (Slack/Discord pattern, au-dessus de l'input) -->
<div v-if="hashtagDropdownOpen && hashtagSuggestions.length"
class="hashtag-dropdown"
style="position:absolute;bottom:100%;left:0;right:0;margin-bottom:6px;max-height:220px;overflow-y:auto;background:var(--nav-surface);border:1px solid var(--nav-bg-alt);border-radius:8px;box-shadow:0 -4px 12px rgba(0,0,0,0.12);z-index:50;">
<div v-for="(auteur, idx) in hashtagSuggestions" :key="auteur.id"
@mousedown.prevent="applyHashtagSuggestion(auteur)"
@mouseenter="hashtagSelectedIndex = idx"
class="px-3 py-2 cursor-pointer text-sm"
:style="idx === hashtagSelectedIndex ? 'background:var(--nav-primary);color:var(--nav-text-on-primary);' : 'color:var(--nav-text);'">
<span style="font-weight:600;">#{{ auteur.id }}</span>
<span :style="idx === hashtagSelectedIndex ? 'opacity:0.85;margin-left:8px;font-size:0.78rem;' : 'opacity:0.65;margin-left:8px;font-size:0.78rem;color:var(--nav-text-muted);'">{{ auteur.nom }}</span>
</div>
</div>
<input ref="inputElOverlay" v-model="q" type="text" placeholder="Ta question, tape #auteur pour cibler" maxlength="500"
class="flex-1 px-3 py-2 rounded-lg text-sm outline-none"
style="background:var(--nav-bg);color:var(--nav-text);border:1px solid var(--nav-bg-alt);"
@keydown="onInputKeydown" />
<button @click="send" :disabled="loading || !q.trim()"
class="flex items-center justify-center w-9 h-9 rounded-lg"
:style="loading||!q.trim() ? 'background:var(--nav-bg-alt);opacity:0.5;cursor:not-allowed;' : 'background:var(--nav-primary);cursor:pointer;'"
aria-label="Envoyer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color:white;">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
</div>
</div>
<div class="chatbot-header-right">
<button
v-if="messages.length > 0"
class="chatbot-clear-btn"
title="Effacer la conversation"
@click="clearMessages"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/>
</svg>
</button>
<select v-model="corpusMode" class="chatbot-corpus-select" title="Corpus interrogé">
<option value="pensees">Pensées éco.</option>
<option value="both">Pensées + Projets</option>
<option value="projets">Projets</option>
</select>
</Transition>
</template>
<!-- Mode inline : remplit 100% de son parent slot -->
<div v-else
class="flex flex-col w-full h-full"
style="background:var(--nav-surface);overflow:hidden;"
role="region" aria-label="RAG Pensees Ecologiques">
<!-- Header inline -->
<div class="flex items-center justify-between px-4 py-2 shrink-0" style="border-bottom:1px solid var(--nav-bg-alt);background:var(--nav-bg);">
<div>
<p class="text-sm font-bold" style="color:var(--nav-text);">RAG Pensees Ecologiques</p>
<p class="text-xs" style="color:var(--nav-text-muted);">{{ corpusCount }} auteurs ingeres</p>
</div>
</div>
<!-- Zone messages -->
<div ref="messagesContainer" class="chatbot-messages">
<!-- Onboarding -->
<div v-if="messages.length === 0" class="chatbot-onboarding">
<template v-if="auteurContext">
<p>Interroge le corpus de <strong>{{ auteurContext }}</strong> ses livres, ses thèses, ses arguments.</p>
<p class="chatbot-onboarding-hint">Ex : "Quelle est sa critique du capitalisme vert ?"</p>
</template>
<template v-else>
<p>Bibliothèque des pensées écologiques <strong>~140 auteurs FRACAS</strong>, de l'écosocialisme à l'éthique environnementale.</p>
<p class="chatbot-onboarding-hint">Ex : "Quelles différences entre décroissance et doughnut économics ?"</p>
</template>
<!-- Corpus toggle inline -->
<div class="shrink-0 px-3 pt-2 pb-1" style="background:var(--nav-bg);border-bottom:1px solid var(--nav-bg-alt);">
<div class="flex gap-1" role="group" aria-label="Choisir le corpus">
<button v-for="opt in corpusOptions" :key="opt.value" @click="setCorpus(opt.value)" :title="opt.tooltip"
class="flex-1 px-2 py-1 rounded text-xs font-medium transition-colors"
:style="corpus === opt.value ? 'background:var(--nav-primary);color:var(--nav-text-on-primary);' : 'background:var(--nav-bg-alt);color:var(--nav-text-muted);'"
:aria-pressed="corpus === opt.value">{{ opt.label }}</button>
</div>
</div>
<!-- Messages -->
<!-- Messages inline -->
<div ref="msgElInline" class="flex-1 overflow-y-auto px-4 py-3 flex flex-col gap-3" style="min-height:0;">
<div v-if="messages.length === 0" style="font-size:0.8rem;color:var(--nav-text-muted);line-height:1.5;">
<template v-if="corpus === 'pensees'">Pose une question sur les pensees ecologiques : ecosocialisme, decroissance, ecofeminismes, technocritique, deep ecology...</template>
<template v-else-if="corpus === 'projets'">Pose une question sur les projets d'architecture de Jules : Butte Pinson, strategie thermique, partis pris constructifs...</template>
<template v-else>Pose une question sur les pensees ecologiques ancrees dans les projets archi de Jules (corpus croise, defaut).</template>
</div>
<template v-for="(msg, i) in messages" :key="i">
<div v-if="msg.role === 'user'" class="chatbot-bubble user">{{ msg.content }}</div>
<div v-else class="chatbot-bubble assistant">
<div class="chatbot-md" v-html="renderMd(msg.content)" />
<div v-if="msg.auteur" class="chatbot-auteur-tag">
<span>{{ msg.auteur.nom }}</span>
<div v-if="msg.role === 'user'" class="self-end max-w-[85%] px-3 py-2 rounded-xl text-sm"
style="background:var(--nav-primary);color:var(--nav-text-on-primary);font-weight:500;">{{ msg.content }}</div>
<div v-else class="self-start max-w-full">
<div class="px-3 py-2 rounded-xl text-sm leading-relaxed" style="background:var(--nav-bg-alt);color:var(--nav-text);"
v-html="renderMd(stripSrc(msg.content))" />
<div v-if="filteredSources(msg.content).length" class="mt-1.5">
<button @click="toggled[i] = !toggled[i]" class="flex items-center gap-1 text-xs hover:opacity-70" style="color:var(--nav-text-muted);">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
:style="`transform:rotate(${toggled[i] ? 90 : 0}deg);transition:transform 0.15s`"><polyline points="9 18 15 12 9 6"/></svg>
Sources ({{ filteredSources(msg.content).length }})
</button>
<div v-if="toggled[i]" class="mt-1 flex flex-col gap-1">
<div v-for="(s, si) in filteredSources(msg.content)" :key="si" class="px-2 py-1 rounded text-xs"
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);border-left:2px solid var(--nav-primary-solid);">
<span style="font-weight:600;color:var(--nav-text);">[{{ si + 1 }}]</span> {{ s }}
</div>
</div>
</div>
</div>
</template>
<!-- Chargement -->
<div v-if="loading" class="chatbot-bubble assistant loading">
<span class="dot" /><span class="dot" /><span class="dot" />
<div v-if="loading" class="self-start px-3 py-2 rounded-xl" style="background:var(--nav-bg-alt);">
<span class="dots"><span/><span style="animation-delay:150ms"/><span style="animation-delay:300ms"/></span>
</div>
<!-- Erreur -->
<div v-if="errorMsg" class="chatbot-error">{{ errorMsg }}</div>
<div v-if="err" class="text-xs px-3 py-2 rounded-xl" style="background:#fee;color:#c0392b;">{{ err }}</div>
</div>
<!-- Input -->
<div class="chatbot-input-row">
<input
v-model="inputText"
type="text"
:disabled="loading"
:placeholder="auteurContext ? `Interroger ${auteurContext}…` : 'Pose ta question sur les pensées éco…'"
class="chatbot-input"
@keydown.enter.prevent="sendMessage"
/>
<button
:disabled="loading || !inputText.trim()"
class="chatbot-send-btn"
aria-label="Envoyer"
@click="sendMessage"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
<!-- Input inline -->
<div class="shrink-0 px-3 py-3" style="border-top:1px solid var(--nav-bg-alt);">
<div class="flex items-center gap-2" style="position:relative;">
<!-- Hashtag autocomplete dropdown (Slack/Discord pattern, au-dessus de l'input) -->
<div v-if="hashtagDropdownOpen && hashtagSuggestions.length"
class="hashtag-dropdown"
style="position:absolute;bottom:100%;left:0;right:0;margin-bottom:6px;max-height:220px;overflow-y:auto;background:var(--nav-surface);border:1px solid var(--nav-bg-alt);border-radius:8px;box-shadow:0 -4px 12px rgba(0,0,0,0.12);z-index:50;">
<div v-for="(auteur, idx) in hashtagSuggestions" :key="auteur.id"
@mousedown.prevent="applyHashtagSuggestion(auteur)"
@mouseenter="hashtagSelectedIndex = idx"
class="px-3 py-2 cursor-pointer text-sm"
:style="idx === hashtagSelectedIndex ? 'background:var(--nav-primary);color:var(--nav-text-on-primary);' : 'color:var(--nav-text);'">
<span style="font-weight:600;">#{{ auteur.id }}</span>
<span :style="idx === hashtagSelectedIndex ? 'opacity:0.85;margin-left:8px;font-size:0.78rem;' : 'opacity:0.65;margin-left:8px;font-size:0.78rem;color:var(--nav-text-muted);'">{{ auteur.nom }}</span>
</div>
</div>
<input ref="inputElInline" v-model="q" type="text" placeholder="Ta question, tape #auteur pour cibler" maxlength="500"
class="flex-1 px-3 py-2 rounded-lg text-sm outline-none"
style="background:var(--nav-bg);color:var(--nav-text);border:1px solid var(--nav-bg-alt);"
@keydown="onInputKeydown" />
<button @click="send" :disabled="loading || !q.trim()"
class="flex items-center justify-center w-9 h-9 rounded-lg"
:style="loading||!q.trim() ? 'background:var(--nav-bg-alt);opacity:0.5;cursor:not-allowed;' : 'background:var(--nav-primary);cursor:pointer;'"
aria-label="Envoyer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color:white;">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useMarkdown } from '~/composables/useMarkdown'
interface Message { role: 'user' | 'assistant'; content: string }
interface AuteurMini { id: string; nom: string }
const { render: renderMd } = useMarkdown()
type CorpusMode = 'pensees' | 'projets' | 'both'
interface ChatMessage {
role: 'user' | 'assistant'
content: string
auteur?: { slug: string; nom: string } | null
const CORPUS_STORAGE_KEY = 'chatbot-pensees-corpus'
const PROJECT_SOURCE_PATTERNS = [/butte.?pinson/i, /butte_pinson/i]
function isProjectSource(s: string): boolean {
return PROJECT_SOURCE_PATTERNS.some(p => p.test(s))
}
const corpusOptions: { value: CorpusMode; label: string; tooltip: string }[] = [
{ value: 'pensees', label: 'Pensees', tooltip: 'Corpus FRACAS uniquement (auteurs ecologie politique)' },
{ value: 'projets', label: 'Projets', tooltip: 'Projets archi de Jules uniquement' },
{ value: 'both', label: 'Croise*', tooltip: 'Projets ancres + pensees en eclairage (defaut)' },
]
const props = defineProps<{
auteurContext: string | null
auteurContext?: string | null
inline?: boolean
}>()
const messages = ref<ChatMessage[]>([])
const inputText = ref('')
const open = ref(false)
const q = ref('')
const messages = ref<Message[]>([])
const loading = ref(false)
const errorMsg = ref('')
const corpusMode = ref<'pensees' | 'projets' | 'both'>('pensees')
const messagesContainer = ref<HTMLElement | null>(null)
const err = ref('')
const toggled = ref<Record<number, boolean>>({})
const msgElOverlay = ref<HTMLElement | null>(null)
const msgElInline = ref<HTMLElement | null>(null)
const inputElOverlay = ref<HTMLInputElement | null>(null)
const inputElInline = ref<HTMLInputElement | null>(null)
const corpusCount = 18
watch(() => props.auteurContext, () => {
messages.value = []
errorMsg.value = ''
})
const corpus = ref<CorpusMode>('both')
function clearMessages() {
messages.value = []
errorMsg.value = ''
// Phase 8.E : hashtag mentions
const auteursIngeres = ref<AuteurMini[]>([])
const hashtagSuggestions = ref<AuteurMini[]>([])
const hashtagDropdownOpen = ref(false)
const hashtagSelectedIndex = ref(0)
function getActiveInput(): HTMLInputElement | null {
return props.inline ? inputElInline.value : inputElOverlay.value
}
function scrollToBottom() {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
function detectHashtagAtCursor(input: string, cursorPos: number): { start: number; partial: string } | null {
const before = input.slice(0, cursorPos)
const m = before.match(/#([a-z0-9-]*)$/i)
if (!m) return null
return { start: m.index ?? 0, partial: (m[1] || '').toLowerCase() }
}
function updateHashtagSuggestions() {
const el = getActiveInput()
const cursorPos = el?.selectionStart ?? q.value.length
const detection = detectHashtagAtCursor(q.value, cursorPos)
// Ouvrir dès que le # est présent (partial vide accepté pour afficher la liste)
if (!detection) {
hashtagDropdownOpen.value = false
return
}
const partial = detection.partial
const list = partial.length === 0
? auteursIngeres.value.slice(0, 8)
: auteursIngeres.value
.filter(a => a.id.toLowerCase().includes(partial) || a.nom.toLowerCase().includes(partial))
.slice(0, 8)
hashtagSuggestions.value = list
hashtagDropdownOpen.value = list.length > 0
hashtagSelectedIndex.value = 0
}
function applyHashtagSuggestion(auteur: AuteurMini) {
const el = getActiveInput()
const cursorPos = el?.selectionStart ?? q.value.length
const detection = detectHashtagAtCursor(q.value, cursorPos)
if (!detection) return
const before = q.value.slice(0, detection.start)
const after = q.value.slice(cursorPos)
const insert = '#' + auteur.id + ' '
q.value = before + insert + after
hashtagDropdownOpen.value = false
nextTick(() => {
const focusEl = getActiveInput()
if (!focusEl) return
focusEl.focus()
const newPos = before.length + insert.length
focusEl.setSelectionRange(newPos, newPos)
})
}
function onInputKeydown(e: KeyboardEvent) {
if (hashtagDropdownOpen.value && hashtagSuggestions.value.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault()
hashtagSelectedIndex.value = (hashtagSelectedIndex.value + 1) % hashtagSuggestions.value.length
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
hashtagSelectedIndex.value = (hashtagSelectedIndex.value - 1 + hashtagSuggestions.value.length) % hashtagSuggestions.value.length
return
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
applyHashtagSuggestion(hashtagSuggestions.value[hashtagSelectedIndex.value])
return
}
if (e.key === 'Escape') {
e.preventDefault()
hashtagDropdownOpen.value = false
return
}
}
if (e.key === 'Enter') {
e.preventDefault()
send()
}
}
async function sendMessage() {
const query = inputText.value.trim()
watch(q, () => {
updateHashtagSuggestions()
})
onMounted(async () => {
const saved = window.localStorage.getItem(CORPUS_STORAGE_KEY) as CorpusMode | null
if (saved && ['pensees', 'projets', 'both'].includes(saved)) {
corpus.value = saved
}
// Chargement liste auteurs ingérés pour autocomplete hashtag
try {
const data = await $fetch<any>('/data/auteurs-pensees.json')
auteursIngeres.value = (data?.auteurs ?? [])
.filter((a: any) => a.ingere === true)
.map((a: any) => ({ id: String(a.id), nom: String(a.nom) }))
} catch (e) {
console.error('Erreur chargement auteurs-pensees.json pour hashtag', e)
}
})
function setCorpus(val: CorpusMode) {
corpus.value = val
window.localStorage.setItem(CORPUS_STORAGE_KEY, val)
}
watch(open, (val) => {
if (!val) return
nextTick(() => inputElOverlay.value?.focus())
if (props.auteurContext && messages.value.length === 0)
q.value = `Quelles sont les theses centrales de ${props.auteurContext} ?`
})
watch(() => props.auteurContext, (ctx) => {
if (!ctx) return
if (!props.inline && !open.value) open.value = true
if (messages.value.length === 0) q.value = `Quelles sont les theses centrales de ${ctx} ?`
})
async function send() {
const query = q.value.trim()
if (!query || loading.value) return
inputText.value = ''
errorMsg.value = ''
// Extraire le premier hashtag matchant un auteur ingéré
let auteurSlug: string | null = null
const matches = [...query.matchAll(/#([a-z0-9-]+)/gi)]
for (const m of matches) {
const slug = m[1].toLowerCase()
if (auteursIngeres.value.find(a => a.id === slug)) {
auteurSlug = slug
break
}
}
// Premier hashtag non-matché (pour info utilisateur si jamais ne match aucun)
let auteurSlugUnmatched: string | null = null
if (!auteurSlug && matches.length > 0) {
auteurSlugUnmatched = matches[0][1].toLowerCase()
}
err.value = ''
messages.value.push({ role: 'user', content: query })
q.value = ''
hashtagDropdownOpen.value = false
loading.value = true
await nextTick()
scrollToBottom()
scrollBottom()
try {
const res = await $fetch<{
response: string
auteur: { slug: string; nom: string } | null
}>('/api/chatbot-pensees', {
const res = await $fetch<any>('/api/chatbot-pensees', {
method: 'POST',
body: {
query,
corpus: corpusMode.value,
auteur_slug: props.auteurContext ? slugify(props.auteurContext) : undefined,
mode: 'hybrid',
corpus: corpus.value,
auteur_slug: auteurSlug ?? auteurSlugUnmatched,
},
})
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.'
let responseText = res.response ?? ''
if (res.auteur_unmatched) {
responseText = `*(Aucun livre de #${res.auteur_unmatched} n'est ingéré dans le RAG. Je réponds depuis la carte entière.)*\n\n` + responseText
}
messages.value.push({ role: 'assistant', content: responseText })
} catch (e: any) {
const s = e?.response?.status ?? e?.statusCode
err.value = s === 429 ? 'Limite atteinte.' : s === 503 ? 'RAG indisponible.' : 'Erreur, reessaie.'
} finally {
loading.value = false
await nextTick()
scrollToBottom()
scrollBottom()
}
}
function slugify(str: string): string {
return str.toLowerCase()
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
function scrollBottom() {
const el = props.inline ? msgElInline.value : msgElOverlay.value
if (el) el.scrollTop = el.scrollHeight
}
function renderMd(t: string) {
return '<p>' + t.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/\*(.+?)\*/g, '<em>$1</em>').replace(/\n\n/g, '</p><p>').replace(/\n/g, '<br>') + '</p>'
}
function stripSrc(t: string) { return t.replace(/\n*(?:Sources?|References?)\s*:[\s\S]*$/i, '').trim() }
function parseSrc(t: string): string[] {
const bloc = t.match(/\n*(?:Sources?|References?)\s*:\n?([\s\S]+?)$/i)
if (bloc) return bloc[1].split('\n').map(l => l.replace(/^[-*\d.[\]]+\s*/, '').trim()).filter(l => l.length > 3)
return [...new Set([...t.matchAll(/\[([^\]]{5,80})\]/g)].filter(m => m[1].includes(' - ')).map(m => m[1]))]
}
function filteredSources(t: string): string[] {
const all = parseSrc(t)
if (corpus.value === 'both') return all
if (corpus.value === 'projets') return all.filter(s => isProjectSource(s))
return all.filter(s => !isProjectSource(s))
}
</script>
<style scoped>
.chatbot-pensees {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--nav-bg);
}
/* Header */
.chatbot-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 12px;
background: var(--nav-surface);
border-bottom: 1px solid rgba(180, 170, 160, 0.22);
min-height: 36px;
}
.chatbot-header-left {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
overflow: hidden;
}
.chatbot-icon {
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--nav-primary);
display: flex;
align-items: center;
justify-content: center;
color: var(--nav-text-on-primary);
}
.chatbot-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--nav-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
}
.chatbot-corpus-badge {
flex-shrink: 0;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--nav-text-muted);
background: var(--nav-bg-alt);
border-radius: 4px;
padding: 1px 5px;
}
.chatbot-header-right {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.chatbot-clear-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 4px;
background: transparent;
color: var(--nav-text-muted);
border: none;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.chatbot-clear-btn:hover {
background: var(--nav-bg-alt);
color: var(--nav-text);
}
.chatbot-corpus-select {
font-size: 0.7rem;
padding: 2px 4px;
border-radius: 4px;
border: 1px solid rgba(180, 170, 160, 0.3);
background: var(--nav-bg-alt);
color: var(--nav-text-muted);
cursor: pointer;
max-width: 110px;
}
/* Messages */
.chatbot-messages {
flex: 1;
overflow-y: auto;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.chatbot-onboarding {
font-size: 0.8rem;
line-height: 1.55;
color: var(--nav-text-muted);
background: var(--nav-bg-alt);
border-radius: 8px;
padding: 10px 12px;
}
.chatbot-onboarding p { margin: 0 0 6px; }
.chatbot-onboarding p:last-child { margin-bottom: 0; }
.chatbot-onboarding strong { color: var(--nav-text); font-weight: 600; }
.chatbot-onboarding-hint { font-style: italic; font-size: 0.75rem; opacity: 0.75; }
/* Bulles */
.chatbot-bubble {
max-width: 88%;
font-size: 0.825rem;
line-height: 1.55;
border-radius: 10px;
padding: 8px 11px;
}
.chatbot-bubble.user {
align-self: flex-end;
background: var(--nav-primary);
color: var(--nav-text-on-primary);
border-radius: 10px 10px 3px 10px;
}
.chatbot-bubble.assistant {
align-self: flex-start;
background: var(--nav-surface);
border: 1px solid rgba(180, 170, 160, 0.22);
color: var(--nav-text);
border-radius: 10px 10px 10px 3px;
}
/* Markdown dans les bulles */
:deep(.chatbot-md) { font-size: inherit; line-height: 1.55; }
:deep(.chatbot-md p) { margin: 0 0 0.35em; }
:deep(.chatbot-md p:last-child) { margin-bottom: 0; }
:deep(.chatbot-md strong) { font-weight: 700; }
:deep(.chatbot-md em) { font-style: italic; }
:deep(.chatbot-md ul) { margin: 0.25em 0 0.25em 1em; list-style: disc; padding: 0; }
:deep(.chatbot-md li) { margin-bottom: 0.1em; }
.chatbot-auteur-tag {
margin-top: 6px;
font-size: 0.7rem;
color: var(--nav-text-muted);
font-style: italic;
}
/* Loading */
.chatbot-bubble.loading {
display: flex;
gap: 4px;
align-items: center;
padding: 10px 12px;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--nav-text-muted);
animation: blink 1.2s infinite ease-in-out;
}
.dot:nth-child(2) { animation-delay: 0.2s; }
.dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes blink {
0%, 80%, 100% { opacity: 0.25; }
40% { opacity: 1; }
}
/* Erreur */
.chatbot-error {
align-self: flex-start;
font-size: 0.775rem;
color: #a85d3e;
background: rgba(168, 93, 62, 0.08);
border-radius: 8px;
padding: 7px 10px;
max-width: 90%;
}
/* Input */
.chatbot-input-row {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-top: 1px solid rgba(180, 170, 160, 0.22);
background: var(--nav-surface);
}
.chatbot-input {
flex: 1;
padding: 6px 10px;
border-radius: 8px;
border: 1px solid rgba(180, 170, 160, 0.3);
background: var(--nav-bg);
color: var(--nav-text);
font-family: var(--nav-font);
font-size: 0.8rem;
min-width: 0;
transition: border-color 0.15s;
}
.chatbot-input:focus {
outline: none;
border-color: var(--nav-primary);
}
.chatbot-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.chatbot-send-btn {
flex-shrink: 0;
width: 30px;
height: 30px;
border-radius: 7px;
border: none;
background: var(--nav-primary);
color: var(--nav-text-on-primary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: opacity 0.15s;
}
.chatbot-send-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.chatbot-send-btn:not(:disabled):hover {
opacity: 0.85;
}
.cpanel-enter-active { transition: opacity 0.2s, transform 0.22s cubic-bezier(0.34,1.56,0.64,1); }
.cpanel-leave-active { transition: opacity 0.18s, transform 0.15s ease-in; }
.cpanel-enter-from { opacity: 0; transform: translateY(12px) scale(0.95); }
.cpanel-leave-to { opacity: 0; transform: translateY(8px) scale(0.97); }
.dots span { display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--nav-text-muted);margin:0 2px;animation:bounce 1s infinite; }
@keyframes bounce { 0%,80%,100%{transform:translateY(0)}40%{transform:translateY(-5px)} }
</style>

View File

@@ -52,9 +52,10 @@
<div class="chatbot-body-inner" ref="messagesContainer">
<!-- Onboarding -->
<div v-if="messages.length === 0" class="onboarding-bubble">
<p>Explore les 120 structures de la carte par la conversation. Je peux t'aider à trouver des collectifs, agences ou réseaux selon ta situation, ta pratique ou tes inspirations du moment.</p>
<p class="example">Exemple : "Je cherche des acteurs de la rénovation de maisons individuelles en France, plutôt en milieu rural, avec des approches biosourcées ou low-tech."</p>
<p style="margin-top: 8px; font-size: 0.72rem; opacity: 0.6;">Propulsé par Mistral FR - serveur européen souverain, zéro rétention.</p>
<p>Je connais les structures d'entraide pour architectes référencées sur cette carte — appui juridique, technique, économique, formation, santé mentale, gestion d'agence</p>
<p>Décris ta situation, je te propose les fiches les plus pertinentes.</p>
<p class="example">Exemple : "Architecte salarié, litige avec mon employeur, besoin d'un appui juridique droit du travail, Île-de-France."</p>
<p style="margin-top: 8px; font-size: 0.72rem; opacity: 0.6;">Propulsé par Mistral FR serveur européen souverain, zéro rétention.</p>
</div>
<!-- Messages -->

View File

@@ -1,38 +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
<div class="space-y-1">
<p class="filter-label">ÉCHELLE</p>
<div class="chips-row">
<span
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>
class="chip"
:style="isSelected(option)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggle(option)"
>{{ option }}</span>
</div>
</div>
</template>
@@ -61,3 +39,24 @@ function toggle(option: string) {
}
}
</script>
<style scoped>
.filter-label {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--nav-text-muted);
display: block;
margin-bottom: 4px;
text-transform: uppercase;
}
.chips-row { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }
.chip {
cursor: pointer;
padding: 3px 10px;
border-radius: 9999px;
font-size: 0.75rem;
transition: all 0.15s;
user-select: none;
}
</style>

View File

@@ -0,0 +1,98 @@
<template>
<Teleport to="body">
<Transition name="backdrop">
<div v-if="open && auteur" class="fixed inset-0 z-[1500]" style="background: rgba(26,34,56,0.55);" @click="emit('close')" aria-hidden="true" />
</Transition>
<Transition name="modal">
<div v-if="open && auteur" class="fixed z-[1501] left-1/2 flex flex-col"
style="top:50%;transform:translate(-50%,-50%);width:min(520px,94vw);max-height:85vh;background:var(--nav-bg);border-radius:14px;box-shadow:0 16px 64px rgba(26,34,56,0.28);overflow:hidden;"
role="dialog" aria-modal="true">
<!-- Header -->
<div class="flex items-start justify-between px-5 py-4 shrink-0"
:style="`border-bottom: 3px solid ${ecoleColor}; background: var(--nav-surface);`">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="px-2 py-0.5 rounded-full text-xs font-semibold" :style="`background:${ecoleColor}22;color:${ecoleColor};`">{{ ecoleLabel }}</span>
<span v-for="eid in auteur.ecoles.filter(e => e !== auteur.ecole_principale)" :key="eid"
class="px-2 py-0.5 rounded-full text-xs" :style="`background:${getEcoleColor(eid)}22;color:${getEcoleColor(eid)};`">{{ getEcoleLabel(eid) }}</span>
</div>
<h2 class="mt-2 font-bold text-lg leading-tight" style="color:var(--nav-text);">{{ auteur.nom }}</h2>
<p class="text-sm" style="color:var(--nav-text-muted);">{{ auteur.dates }}</p>
</div>
<button @click="emit('close')" class="ml-3 shrink-0 flex items-center justify-center w-8 h-8 rounded-full hover:opacity-70"
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);" aria-label="Fermer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<!-- Body -->
<div class="flex-1 overflow-y-auto px-5 py-4 flex flex-col gap-4">
<p class="text-sm leading-relaxed" style="color:var(--nav-text);">{{ auteur.bio_courte }}</p>
<div v-if="auteur.theses_cles.length">
<p class="text-xs font-bold uppercase tracking-widest mb-2" style="color:var(--nav-text-muted);">Theses cles</p>
<ul class="flex flex-col gap-1.5">
<li v-for="t in auteur.theses_cles" :key="t" class="flex items-start gap-2 text-sm" style="color:var(--nav-text);">
<span class="mt-1.5 w-1.5 h-1.5 rounded-full shrink-0" :style="`background:${ecoleColor};`"></span>
<span>{{ t }}</span>
</li>
</ul>
</div>
<div v-if="auteur.livres_rag.length">
<p class="text-xs font-bold uppercase tracking-widest mb-2" style="color:var(--nav-text-muted);">Livres dans le RAG</p>
<div class="flex flex-col gap-2">
<div v-for="l in auteur.livres_rag" :key="l.slug" class="flex items-start gap-3 p-3 rounded-lg" style="background:var(--nav-bg-alt);">
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold leading-snug" style="color:var(--nav-text);">{{ l.titre }}</p>
<p class="text-xs mt-0.5" style="color:var(--nav-text-muted);">{{ l.annee }}</p>
</div>
<div class="flex gap-1 shrink-0">
<span v-for="c in l.couches" :key="c" class="px-1.5 py-0.5 rounded text-xs" style="background:var(--nav-surface);color:var(--nav-text-muted);">{{ c }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="shrink-0 px-5 py-3 border-t" style="border-color:var(--nav-bg-alt);">
<button @click="emit('interroger-rag', auteurId!)" class="w-full py-2.5 rounded-lg text-sm font-semibold hover:opacity-80"
:style="`background:${ecoleColor};color:white;`">
Interroger le RAG sur {{ auteur.nom.split(' ').pop() }}
</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
interface LivreRag { slug: string; titre: string; annee: number; couches: string[] }
interface AuteurData { id: string; nom: string; dates: string; ecoles: string[]; ecole_principale: string; livres_rag: LivreRag[]; theses_cles: string[]; bio_courte: string }
interface EcoleData { id: string; label: string; color: string }
interface PenseesData { ecoles: EcoleData[]; auteurs: AuteurData[] }
const props = defineProps<{ open: boolean; auteurId: string | null; data: PenseesData | null }>()
const emit = defineEmits<{ close: []; 'interroger-rag': [auteurId: string] }>()
const auteur = computed<AuteurData | null>(() => {
if (!props.auteurId || !props.data) return null
return props.data.auteurs.find(a => a.id === props.auteurId) ?? null
})
const ecoleColor = computed(() => props.data?.ecoles.find(e => e.id === auteur.value?.ecole_principale)?.color ?? '#888')
const ecoleLabel = computed(() => props.data?.ecoles.find(e => e.id === auteur.value?.ecole_principale)?.label ?? '')
function getEcoleColor(id: string) { return props.data?.ecoles.find(e => e.id === id)?.color ?? '#888' }
function getEcoleLabel(id: string) { return props.data?.ecoles.find(e => e.id === id)?.label ?? id }
function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && props.open) emit('close') }
onMounted(() => window.addEventListener('keydown', onKey))
onUnmounted(() => window.removeEventListener('keydown', onKey))
</script>
<style scoped>
.backdrop-enter-active,.backdrop-leave-active { transition: opacity 0.2s; }
.backdrop-enter-from,.backdrop-leave-to { opacity: 0; }
.modal-enter-active { transition: opacity 0.2s, transform 0.22s cubic-bezier(0.34,1.56,0.64,1); }
.modal-leave-active { transition: opacity 0.18s, transform 0.18s ease-in; }
.modal-enter-from { opacity: 0; transform: translate(-50%,-48%) scale(0.94); }
.modal-leave-to { opacity: 0; transform: translate(-50%,-48%) scale(0.96); }
</style>

View File

@@ -1,35 +1,33 @@
<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">
<div class="space-y-1">
<!-- Label + toggle collapse -->
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
<p class="filter-label" style="margin-bottom: 0;">
FONCTION
<span v-if="modelValue.length" style="font-weight: 400; text-transform: none; letter-spacing: 0; font-size: 0.65rem; margin-left: 4px;">({{ modelValue.length }} active{{ modelValue.length > 1 ? 's' : '' }})</span>
</p>
<button
@click="toggleCollapse"
style="font-size: 0.7rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0; white-space: nowrap;"
>{{ isOpen ? 'Replier' : 'Fonctions (' + FONCTIONS.length + ')' }}</button>
</div>
<!-- Chips (visible si ouvert ou si des fonctions sont actives) -->
<div v-if="isOpen" class="chips-row">
<span
v-for="fn in FONCTIONS"
:key="fn"
class="chip"
:style="modelValue.includes(fn)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggle(fn)"
: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>
>{{ fn }}</span>
</div>
<!-- Effacer (visible même replié si filtres actifs) -->
<p v-if="modelValue.length" class="text-xs pt-0.5" style="color: var(--nav-text-muted);">
{{ 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="underline hover:opacity-70">Effacer</button>
</p>
</div>
</template>
@@ -57,6 +55,25 @@ const emit = defineEmits<{
'update:modelValue': [value: string[]]
}>()
// Replié par défaut, ouvre automatiquement quand des filtres sont actifs
const manuallyOpen = ref(false)
const isOpen = computed(() => {
return manuallyOpen.value || props.modelValue.length > 0
})
function toggleCollapse() {
// Si des filtres actifs forcent l'ouverture, on doit gérer le cas « forcer fermer »
if (isOpen.value) {
manuallyOpen.value = false
// Si des fonctions sont actives, le computed va les réouvrir — on les efface
// Non : on laisse le choix à l'utilisateur. On toggle juste manuallyOpen.
// Quand replié avec filtres actifs, l'indicateur "(N actives)" reste visible.
} else {
manuallyOpen.value = true
}
}
function toggle(fn: string) {
if (props.modelValue.includes(fn)) {
emit('update:modelValue', props.modelValue.filter(f => f !== fn))
@@ -65,3 +82,23 @@ function toggle(fn: string) {
}
}
</script>
<style scoped>
.filter-label {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--nav-text-muted);
display: block;
text-transform: uppercase;
}
.chips-row { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }
.chip {
cursor: pointer;
padding: 3px 10px;
border-radius: 9999px;
font-size: 0.75rem;
transition: all 0.15s;
user-select: none;
}
</style>

View File

@@ -1,184 +0,0 @@
<template>
<form @submit.prevent="submit" class="proposer-form" novalidate>
<div class="field-group" :class="{ 'field-error': errors.nom }">
<label for="eco-nom">Nom de l'organisation <span class="required">*</span></label>
<input id="eco-nom" v-model="form.nom" type="text" placeholder="Ex : UNSFA, Maison de l'Architecture..." autocomplete="organization" @blur="validateField('nom')" />
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.url }">
<label for="eco-url">Site web <span class="label-hint">(optionnel — recommandé pour l'enrichissement IA)</span></label>
<input id="eco-url" v-model="form.url" type="url" placeholder="https://..." @blur="validateField('url')" />
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.description_user }">
<label for="eco-desc">Description courte <span class="required">*</span> <span class="label-hint">(50 à 500 caractères)</span></label>
<textarea id="eco-desc" v-model="form.description_user" rows="4" placeholder="Présente l'organisation en quelques mots : ses missions, son public, ce qu'elle apporte..." @blur="validateField('description_user')" />
<div class="field-meta">
<span v-if="errors.description_user" class="error-msg" role="alert">{{ errors.description_user }}</span>
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">{{ form.description_user.length }}/500</span>
</div>
</div>
<div class="field-group" :class="{ 'field-error': errors.echelle }">
<fieldset>
<legend>Échelle <span class="required">*</span> <span class="label-hint">(une seule)</span></legend>
<div class="radio-group">
<label v-for="opt in ECHELLES" :key="opt" class="radio-label" :class="{ active: form.echelle === opt }">
<input type="radio" :value="opt" v-model="form.echelle" name="eco-echelle" @change="validateField('echelle')" />{{ opt }}
</label>
</div>
</fieldset>
<span v-if="errors.echelle" class="error-msg" role="alert">{{ errors.echelle }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.fonctions }">
<fieldset>
<legend>Fonctions <span class="required">*</span> <span class="label-hint">(1 à 5 l'ordre de clic = priorité)</span></legend>
<div class="checkbox-grid">
<label v-for="fn in FONCTIONS" :key="fn" class="checkbox-label" :class="{ active: form.fonctions.includes(fn), disabled: !form.fonctions.includes(fn) && form.fonctions.length >= 5 }">
<input type="checkbox" :value="fn" :checked="form.fonctions.includes(fn)" :disabled="!form.fonctions.includes(fn) && form.fonctions.length >= 5" @change="toggleFonction(fn)" />
<span class="fn-order" v-if="form.fonctions.includes(fn)">{{ form.fonctions.indexOf(fn) + 1 }}</span>{{ fn }}
</label>
</div>
</fieldset>
<span v-if="errors.fonctions" class="error-msg" role="alert">{{ errors.fonctions }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.territoire }">
<fieldset>
<legend>Territoire <span class="required">*</span></legend>
<div class="radio-group">
<label v-for="t in TERRITOIRES" :key="t" class="radio-label" :class="{ active: form.territoire === t }">
<input type="radio" :value="t" v-model="form.territoire" name="eco-territoire" @change="validateField('territoire')" />{{ t }}
</label>
</div>
</fieldset>
<span v-if="errors.territoire" class="error-msg" role="alert">{{ errors.territoire }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.localisation_ville }">
<label for="eco-ville">Ville principale <span class="label-hint">(optionnel — pour la géolocalisation sur la carte)</span></label>
<input id="eco-ville" v-model="form.localisation_ville" type="text" placeholder="Ex : Paris, Lyon, Bordeaux..." />
<span v-if="errors.localisation_ville" class="error-msg" role="alert">{{ errors.localisation_ville }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
<label for="eco-email">Ton email <span class="label-hint">(optionnel — pour le suivi de modération)</span></label>
<input id="eco-email" v-model="form.submitted_by_email" type="email" placeholder="ton@email.fr" autocomplete="email" @blur="validateField('submitted_by_email')" />
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">{{ errors.submitted_by_email }}</span>
</div>
<div v-if="serverError" class="server-error" role="alert"><strong>Erreur :</strong> {{ serverError }}</div>
<div class="form-actions">
<button type="submit" class="btn-primary" :disabled="submitting">{{ submitting ? 'Envoi en cours...' : 'Proposer la fiche ' }}</button>
</div>
<p class="form-note">Ta fiche sera examinée par l'équipe avant publication.</p>
</form>
</template>
<script setup lang="ts">
import { z } from 'zod'
const ECHELLES = ['National', 'Régional', 'Local'] as const
const TERRITOIRES = ['Métropole', 'Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte'] as const
const FONCTIONS = ['Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier', 'Comptabilité', 'Développement', 'Formation', 'Gestion d\'agence', 'Santé mentale'] as const
const Schema = z.object({
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
url: z.string().url('URL invalide (commencer par https://)').optional().or(z.literal('')),
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
echelle: z.enum(ECHELLES, { errorMap: () => ({ message: 'Sélectionne une échelle' }) }),
fonctions: z.array(z.string()).min(1, 'Sélectionne au moins une fonction').max(5, 'Maximum 5 fonctions'),
territoire: z.enum(TERRITOIRES, { errorMap: () => ({ message: 'Sélectionne un territoire' }) }),
localisation_ville: z.string().max(100).optional(),
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
})
const emit = defineEmits<{ success: [] }>()
const form = reactive({
nom: '', url: '', description_user: '', echelle: '' as string,
fonctions: [] as string[], territoire: '' as string,
localisation_ville: '', submitted_by_email: '',
})
const errors = reactive<Record<string, string>>({})
const submitting = ref(false)
const serverError = ref('')
function validateField(field: string) {
const partial = Schema.partial()
const result = partial.safeParse({ [field]: (form as any)[field] })
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors
errors[field] = fieldErrors[field]?.[0] ?? ''
} else {
delete errors[field]
}
}
function validateAll(): boolean {
const result = Schema.safeParse(form)
if (!result.success) {
const flat = result.error.flatten().fieldErrors
Object.assign(errors, Object.fromEntries(Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])))
return false
}
Object.keys(errors).forEach(k => delete errors[k])
return true
}
function toggleFonction(fn: string) {
const idx = form.fonctions.indexOf(fn)
if (idx >= 0) form.fonctions.splice(idx, 1)
else if (form.fonctions.length < 5) form.fonctions.push(fn)
validateField('fonctions')
}
async function submit() {
serverError.value = ''
if (!validateAll()) {
await nextTick()
const firstError = document.querySelector('.field-error')
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
return
}
submitting.value = true
try {
await $fetch('/api/submit', {
method: 'POST',
body: {
submission_type: 'ecosysteme',
nom: form.nom,
url: form.url || undefined,
description_user: form.description_user,
echelle: form.echelle,
fonctions: form.fonctions,
territoire: form.territoire,
localisation_ville: form.localisation_ville || undefined,
submitted_by_email: form.submitted_by_email || undefined,
},
})
emit('success')
} catch (e: any) {
const status = e?.status ?? e?.statusCode
if (status === 429) {
serverError.value = 'Tu as déjà soumis 3 fiches aujourd\'hui. Réessaie demain.'
} else if (status === 422 && e?.data) {
const fieldErrors = e.data
Object.entries(fieldErrors).forEach(([k, v]) => { errors[k] = Array.isArray(v) ? v[0] : String(v) })
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
} else {
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
}
} finally {
submitting.value = false
}
}
</script>
<style scoped>
@import './form-styles.css';
</style>

View File

@@ -1,134 +0,0 @@
<template>
<form @submit.prevent="submit" class="proposer-form" novalidate>
<div class="field-group" :class="{ 'field-error': errors.nom }">
<label for="job-nom">Nom de la plateforme / job board <span class="required">*</span></label>
<input id="job-nom" v-model="form.nom" type="text" placeholder="Ex : Houzz, Architoo, BOAMP..." @blur="validateField('nom')" />
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
</div>
<div class="field-group">
<label for="job-type">Type de plateforme <span class="label-hint">(optionnel)</span></label>
<div class="radio-group">
<label v-for="t in TYPES" :key="t" class="radio-label" :class="{ active: form.type_plateforme === t }">
<input type="radio" :value="t" v-model="form.type_plateforme" name="job-type" />{{ LABELS[t] }}
</label>
</div>
</div>
<div class="field-group" :class="{ 'field-error': errors.url }">
<label for="job-url">Site web <span class="required">*</span></label>
<input id="job-url" v-model="form.url" type="url" placeholder="https://..." @blur="validateField('url')" />
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.description_user }">
<label for="job-desc">Description courte <span class="required">*</span> <span class="label-hint">(50 à 500 caractères)</span></label>
<textarea id="job-desc" v-model="form.description_user" rows="4" placeholder="Décris la plateforme : qui la gère, quel type de missions, quel modèle économique..." @blur="validateField('description_user')" />
<div class="field-meta">
<span v-if="errors.description_user" class="error-msg" role="alert">{{ errors.description_user }}</span>
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">{{ form.description_user.length }}/500</span>
</div>
</div>
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
<label for="job-email">Ton email <span class="label-hint">(optionnel pour le suivi)</span></label>
<input id="job-email" v-model="form.submitted_by_email" type="email" placeholder="ton@email.fr" autocomplete="email" @blur="validateField('submitted_by_email')" />
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">{{ errors.submitted_by_email }}</span>
</div>
<div v-if="serverError" class="server-error" role="alert"><strong>Erreur :</strong> {{ serverError }}</div>
<div class="form-actions">
<button type="submit" class="btn-primary" :disabled="submitting">{{ submitting ? 'Envoi en cours...' : 'Proposer →' }}</button>
</div>
<p class="form-note">Ta proposition sera examinée par l'équipe avant publication.</p>
</form>
</template>
<script setup lang="ts">
import { z } from 'zod'
const TYPES = ['mise-en-relation', 'appel-offre-public', 'communaute-pro'] as const
const LABELS: Record<string, string> = { 'mise-en-relation': 'Mise en relation (B2C)', 'appel-offre-public': 'Appels d\'offres publics', 'communaute-pro': 'Communauté pro' }
const Schema = z.object({
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
url: z.string().url('URL invalide (commencer par https://)'),
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
type_plateforme: z.string().optional(),
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
})
const emit = defineEmits<{ success: [] }>()
const form = reactive({
nom: '', url: '', description_user: '', type_plateforme: '', submitted_by_email: '',
})
const errors = reactive<Record<string, string>>({})
const submitting = ref(false)
const serverError = ref('')
function validateField(field: string) {
const partial = Schema.partial()
const result = partial.safeParse({ [field]: (form as any)[field] })
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors
errors[field] = fieldErrors[field]?.[0] ?? ''
} else {
delete errors[field]
}
}
function validateAll(): boolean {
const result = Schema.safeParse(form)
if (!result.success) {
const flat = result.error.flatten().fieldErrors
Object.assign(errors, Object.fromEntries(Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])))
return false
}
Object.keys(errors).forEach(k => delete errors[k])
return true
}
async function submit() {
serverError.value = ''
if (!validateAll()) {
await nextTick()
const firstError = document.querySelector('.field-error')
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
return
}
submitting.value = true
try {
await $fetch('/api/submit', {
method: 'POST',
body: {
submission_type: 'job',
nom: form.nom,
url: form.url,
description_user: form.description_user,
type_plateforme: form.type_plateforme || undefined,
submitted_by_email: form.submitted_by_email || undefined,
},
})
emit('success')
} catch (e: any) {
const status = e?.status ?? e?.statusCode
if (status === 429) {
serverError.value = 'Tu as déjà soumis 3 fiches aujourd\'hui. Réessaie demain.'
} else if (status === 422 && e?.data) {
const fieldErrors = e.data
Object.entries(fieldErrors).forEach(([k, v]) => { errors[k] = Array.isArray(v) ? v[0] : String(v) })
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
} else {
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
}
} finally {
submitting.value = false
}
}
</script>
<style scoped>
@import './form-styles.css';
</style>

View File

@@ -1,134 +0,0 @@
<template>
<form @submit.prevent="submit" class="proposer-form" novalidate>
<div class="field-group" :class="{ 'field-error': errors.nom }">
<label for="out-nom">Nom de l'outil <span class="required">*</span></label>
<input id="out-nom" v-model="form.nom" type="text" placeholder="Ex : Simulateur Autonomie, FreeCAD..." @blur="validateField('nom')" />
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
</div>
<div class="field-group">
<label>Catégorie <span class="label-hint">(optionnelle)</span></label>
<div class="radio-group">
<label v-for="c in CATEGORIES" :key="c" class="radio-label" :class="{ active: form.categorie === c }">
<input type="radio" :value="c" v-model="form.categorie" name="out-categorie" />{{ LABELS[c] }}
</label>
</div>
</div>
<div class="field-group" :class="{ 'field-error': errors.url }">
<label for="out-url">Site web / lien <span class="label-hint">(optionnel — si pas de lien direct)</span></label>
<input id="out-url" v-model="form.url" type="url" placeholder="https://..." @blur="validateField('url')" />
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.description_user }">
<label for="out-desc">Description courte <span class="required">*</span> <span class="label-hint">(50 à 500 caractères)</span></label>
<textarea id="out-desc" v-model="form.description_user" rows="4" placeholder="Décris l'outil : à quoi il sert, pour quel usage, ce qui le rend utile..." @blur="validateField('description_user')" />
<div class="field-meta">
<span v-if="errors.description_user" class="error-msg" role="alert">{{ errors.description_user }}</span>
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">{{ form.description_user.length }}/500</span>
</div>
</div>
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
<label for="out-email">Ton email <span class="label-hint">(optionnel — pour le suivi)</span></label>
<input id="out-email" v-model="form.submitted_by_email" type="email" placeholder="ton@email.fr" autocomplete="email" @blur="validateField('submitted_by_email')" />
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">{{ errors.submitted_by_email }}</span>
</div>
<div v-if="serverError" class="server-error" role="alert"><strong>Erreur :</strong> {{ serverError }}</div>
<div class="form-actions">
<button type="submit" class="btn-primary" :disabled="submitting">{{ submitting ? 'Envoi en cours...' : 'Proposer →' }}</button>
</div>
<p class="form-note">Ta proposition sera examinée par l'équipe avant publication.</p>
</form>
</template>
<script setup lang="ts">
import { z } from 'zod'
const CATEGORIES = ['simulateur', 'logiciel', 'guide', 'autre'] as const
const LABELS: Record<string, string> = { simulateur: 'Simulateur', logiciel: 'Logiciel / app', guide: 'Guide / ressource', autre: 'Autre' }
const Schema = z.object({
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
url: z.string().url('URL invalide').optional().or(z.literal('')),
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
categorie: z.string().optional(),
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
})
const emit = defineEmits<{ success: [] }>()
const form = reactive({
nom: '', url: '', description_user: '', categorie: '', submitted_by_email: '',
})
const errors = reactive<Record<string, string>>({})
const submitting = ref(false)
const serverError = ref('')
function validateField(field: string) {
const partial = Schema.partial()
const result = partial.safeParse({ [field]: (form as any)[field] })
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors
errors[field] = fieldErrors[field]?.[0] ?? ''
} else {
delete errors[field]
}
}
function validateAll(): boolean {
const result = Schema.safeParse(form)
if (!result.success) {
const flat = result.error.flatten().fieldErrors
Object.assign(errors, Object.fromEntries(Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])))
return false
}
Object.keys(errors).forEach(k => delete errors[k])
return true
}
async function submit() {
serverError.value = ''
if (!validateAll()) {
await nextTick()
const firstError = document.querySelector('.field-error')
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
return
}
submitting.value = true
try {
await $fetch('/api/submit', {
method: 'POST',
body: {
submission_type: 'outil',
nom: form.nom,
url: form.url || undefined,
description_user: form.description_user,
categorie: form.categorie || undefined,
submitted_by_email: form.submitted_by_email || undefined,
},
})
emit('success')
} catch (e: any) {
const status = e?.status ?? e?.statusCode
if (status === 429) {
serverError.value = 'Tu as déjà soumis 3 fiches aujourd\'hui. Réessaie demain.'
} else if (status === 422 && e?.data) {
const fieldErrors = e.data
Object.entries(fieldErrors).forEach(([k, v]) => { errors[k] = Array.isArray(v) ? v[0] : String(v) })
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
} else {
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
}
} finally {
submitting.value = false
}
}
</script>
<style scoped>
@import './form-styles.css';
</style>

View File

@@ -1,142 +0,0 @@
<template>
<form @submit.prevent="submit" class="proposer-form" novalidate>
<div class="field-group" :class="{ 'field-error': errors.titre }">
<label for="ref-titre">Titre du livre / document <span class="required">*</span></label>
<input id="ref-titre" v-model="form.titre" type="text" placeholder="Ex : Repenser l'architecture..." @blur="validateField('titre')" />
<span v-if="errors.titre" class="error-msg" role="alert">{{ errors.titre }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.auteur }">
<label for="ref-auteur">Auteur·ice(s) <span class="required">*</span></label>
<input id="ref-auteur" v-model="form.auteur" type="text" placeholder="Ex : Philippe Rahm, Doina Petrescu..." @blur="validateField('auteur')" />
<span v-if="errors.auteur" class="error-msg" role="alert">{{ errors.auteur }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.url }">
<label for="ref-url">Lien <span class="label-hint">(optionnel éditeur, статье, PDF)</span></label>
<input id="ref-url" v-model="form.url" type="url" placeholder="https://..." @blur="validateField('url')" />
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.description }">
<label for="ref-desc">Description de l'ouvrage <span class="required">*</span> <span class="label-hint">(50 à 1000 caractères)</span></label>
<textarea id="ref-desc" v-model="form.description" rows="4" placeholder="Présente l'ouvrage : thèse principale, approche, pourquoi c'est important..." @blur="validateField('description')" />
<div class="field-meta">
<span v-if="errors.description" class="error-msg" role="alert">{{ errors.description }}</span>
<span v-else class="char-count" :class="{ 'char-warn': form.description.length > 900 }">{{ form.description.length }}/1000</span>
</div>
</div>
<div class="field-group">
<label for="ref-pertinence">Pertinence pour le RAG <span class="label-hint">(optionnel — en quoi cette référence est utile ?)</span></label>
<textarea id="ref-pertinence" v-model="form.pertinence_rag" rows="2" placeholder="Ex : Excellente source sur l'architecture participative..." maxlength="500" />
</div>
<div class="field-group">
<label for="ref-hashtags">Hashtags <span class="label-hint">(optionnel — séparés par des virgules)</span></label>
<input id="ref-hashtags" v-model="form.hashtags" type="text" placeholder="Ex : écologie, participation, matériaux bio..." />
</div>
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
<label for="ref-email">Ton email <span class="label-hint">(optionnel — pour le suivi)</span></label>
<input id="ref-email" v-model="form.submitted_by_email" type="email" placeholder="ton@email.fr" autocomplete="email" @blur="validateField('submitted_by_email')" />
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">{{ errors.submitted_by_email }}</span>
</div>
<div v-if="serverError" class="server-error" role="alert"><strong>Erreur :</strong> {{ serverError }}</div>
<div class="form-actions">
<button type="submit" class="btn-primary" :disabled="submitting">{{ submitting ? 'Envoi en cours...' : 'Proposer la référence →' }}</button>
</div>
<p class="form-note">Ta référence sera examinée avant d'être intégrée au fonds documentaire RAG.</p>
</form>
</template>
<script setup lang="ts">
import { z } from 'zod'
const Schema = z.object({
titre: z.string().min(3, 'Minimum 3 caractères').max(200, 'Maximum 200 caractères').trim(),
auteur: z.string().min(2, 'Minimum 2 caractères').max(200, 'Maximum 200 caractères').trim(),
url: z.string().url('URL invalide').optional().or(z.literal('')),
description: z.string().min(50, 'Minimum 50 caractères').max(1000, 'Maximum 1000 caractères').trim(),
pertinence_rag: z.string().max(500).optional(),
hashtags: z.string().max(300).optional(),
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
})
const emit = defineEmits<{ success: [] }>()
const form = reactive({
titre: '', auteur: '', url: '', description: '',
pertinence_rag: '', hashtags: '', submitted_by_email: '',
})
const errors = reactive<Record<string, string>>({})
const submitting = ref(false)
const serverError = ref('')
function validateField(field: string) {
const partial = Schema.partial()
const result = partial.safeParse({ [field]: (form as any)[field] })
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors
errors[field] = fieldErrors[field]?.[0] ?? ''
} else {
delete errors[field]
}
}
function validateAll(): boolean {
const result = Schema.safeParse(form)
if (!result.success) {
const flat = result.error.flatten().fieldErrors
Object.assign(errors, Object.fromEntries(Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])))
return false
}
Object.keys(errors).forEach(k => delete errors[k])
return true
}
async function submit() {
serverError.value = ''
if (!validateAll()) {
await nextTick()
const firstError = document.querySelector('.field-error')
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
return
}
submitting.value = true
try {
await $fetch('/api/submit/reference', {
method: 'POST',
body: {
titre: form.titre,
auteur: form.auteur,
url: form.url || undefined,
description: form.description,
pertinence_rag: form.pertinence_rag || undefined,
hashtags: form.hashtags || undefined,
submitted_by_email: form.submitted_by_email || undefined,
},
})
emit('success')
} catch (e: any) {
const status = e?.status ?? e?.statusCode
if (status === 429) {
serverError.value = 'Tu as déjà soumis 3 fiches aujourd\'hui. Réessaie demain.'
} else if (status === 422 && e?.data) {
const fieldErrors = e.data
Object.entries(fieldErrors).forEach(([k, v]) => { errors[k] = Array.isArray(v) ? v[0] : String(v) })
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
} else {
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
}
} finally {
submitting.value = false
}
}
</script>
<style scoped>
@import './form-styles.css';
</style>

View File

@@ -1,158 +0,0 @@
<template>
<form @submit.prevent="submit" class="proposer-form" novalidate>
<div class="field-group" :class="{ 'field-error': errors.nom }">
<label for="res-nom">Nom du réseau / collectif / ressource <span class="required">*</span></label>
<input id="res-nom" v-model="form.nom" type="text" placeholder="Ex : Collectif des Arpenteurs, Archi'Résilience..." @blur="validateField('nom')" />
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.type_principal }">
<fieldset>
<legend>Type <span class="label-hint">(optionnel)</span></legend>
<div class="radio-group">
<label v-for="t in TYPES" :key="t" class="radio-label" :class="{ active: form.type_principal === t }">
<input type="radio" :value="t" v-model="form.type_principal" name="res-type" />{{ LABELS[t] }}
</label>
</div>
</fieldset>
</div>
<div class="field-group" :class="{ 'field-error': errors.url }">
<label for="res-url">Site web <span class="label-hint">(optionnel)</span></label>
<input id="res-url" v-model="form.url" type="url" placeholder="https://..." @blur="validateField('url')" />
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
</div>
<div class="field-group" :class="{ 'field-error': errors.description_user }">
<label for="res-desc">Description courte <span class="required">*</span> <span class="label-hint">(50 à 500 caractères)</span></label>
<textarea id="res-desc" v-model="form.description_user" rows="4" placeholder="Présente ce réseau/collectif : son objet, son échelle, ce qui le distingue..." @blur="validateField('description_user')" />
<div class="field-meta">
<span v-if="errors.description_user" class="error-msg" role="alert">{{ errors.description_user }}</span>
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">{{ form.description_user.length }}/500</span>
</div>
</div>
<div class="field-group">
<label for="res-ville">Ville principale <span class="label-hint">(optionnel)</span></label>
<input id="res-ville" v-model="form.localisation_ville" type="text" placeholder="Ex : Paris, Lyon..." />
</div>
<div class="field-group">
<label for="res-projet-lien">Lien vers un projet / réalisation <span class="label-hint">(optionnel)</span></label>
<input id="res-projet-lien" v-model="form.projet_lien" type="url" placeholder="https://..." />
</div>
<div class="field-group">
<label for="res-projet-adresse">Adresse du projet <span class="label-hint">(optionnel)</span></label>
<input id="res-projet-adresse" v-model="form.projet_adresse" type="text" placeholder="Ex : 12 rue des Lilas, 75000 Paris" />
</div>
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
<label for="res-email">Ton email <span class="label-hint">(optionnel pour le suivi)</span></label>
<input id="res-email" v-model="form.submitted_by_email" type="email" placeholder="ton@email.fr" autocomplete="email" @blur="validateField('submitted_by_email')" />
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">{{ errors.submitted_by_email }}</span>
</div>
<div v-if="serverError" class="server-error" role="alert"><strong>Erreur :</strong> {{ serverError }}</div>
<div class="form-actions">
<button type="submit" class="btn-primary" :disabled="submitting">{{ submitting ? 'Envoi en cours...' : 'Proposer →' }}</button>
</div>
<p class="form-note">Ta proposition sera examinée par l'équipe avant publication.</p>
</form>
</template>
<script setup lang="ts">
import { z } from 'zod'
const TYPES = ['agence', 'collectif', 'reseau', 'asso', 'ressource'] as const
const LABELS: Record<string, string> = { agence: 'Agence', collectif: 'Collectif', reseau: 'Réseau', asso: 'Association', ressource: 'Ressource' }
const Schema = z.object({
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
type_principal: z.string().optional(),
url: z.string().url('URL invalide').optional().or(z.literal('')),
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
localisation_ville: z.string().max(100).optional(),
projet_lien: z.string().url('URL invalide').optional().or(z.literal('')),
projet_adresse: z.string().max(200).optional(),
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
})
const emit = defineEmits<{ success: [] }>()
const form = reactive({
nom: '', type_principal: '', url: '', description_user: '',
localisation_ville: '', projet_lien: '', projet_adresse: '', submitted_by_email: '',
})
const errors = reactive<Record<string, string>>({})
const submitting = ref(false)
const serverError = ref('')
function validateField(field: string) {
const partial = Schema.partial()
const result = partial.safeParse({ [field]: (form as any)[field] })
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors
errors[field] = fieldErrors[field]?.[0] ?? ''
} else {
delete errors[field]
}
}
function validateAll(): boolean {
const result = Schema.safeParse(form)
if (!result.success) {
const flat = result.error.flatten().fieldErrors
Object.assign(errors, Object.fromEntries(Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])))
return false
}
Object.keys(errors).forEach(k => delete errors[k])
return true
}
async function submit() {
serverError.value = ''
if (!validateAll()) {
await nextTick()
const firstError = document.querySelector('.field-error')
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
return
}
submitting.value = true
try {
await $fetch('/api/submit', {
method: 'POST',
body: {
submission_type: 'reseau',
nom: form.nom,
type_principal: form.type_principal || undefined,
url: form.url || undefined,
description_user: form.description_user,
localisation_ville: form.localisation_ville || undefined,
projet_lien: form.projet_lien || undefined,
projet_adresse: form.projet_adresse || undefined,
submitted_by_email: form.submitted_by_email || undefined,
},
})
emit('success')
} catch (e: any) {
const status = e?.status ?? e?.statusCode
if (status === 429) {
serverError.value = 'Tu as déjà soumis 3 fiches aujourd\'hui. Réessaie demain.'
} else if (status === 422 && e?.data) {
const fieldErrors = e.data
Object.entries(fieldErrors).forEach(([k, v]) => { errors[k] = Array.isArray(v) ? v[0] : String(v) })
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
} else {
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
}
} finally {
submitting.value = false
}
}
</script>
<style scoped>
@import './form-styles.css';
</style>

View File

@@ -877,6 +877,7 @@ onUnmounted(() => {
/* Labels des structures dans le graphe (D3 injecte les <text>, donc style global) */
.graph-view .graph-struct-label {
fill: var(--nav-text);
opacity: 0.7;
paint-order: stroke;
stroke: var(--nav-bg);
stroke-width: 3px;

View File

@@ -1,19 +0,0 @@
<template>
<div class="media-tab-backend" style="padding: 2rem; overflow-y: auto;">
<div style="max-width: 640px;">
<h2 style="font-weight: 700; font-size: 1.1rem; margin-bottom: 0.75rem; color: var(--nav-text);">LightRAG backend</h2>
<p style="font-size: 0.9rem; line-height: 1.6; color: var(--nav-text); margin-bottom: 0.5rem;">
Voici l'interface brute du <strong>LightRAG</strong> qui alimente la carte des pensées écologiques.
C'est la "cuisine" du RAG : ingestion de documents, extraction d'entités, relations, requêtes.
</p>
</div>
<iframe
src="https://lightrag.trans-former.fr/"
style="width: 100%; height: 70vh; border: 1px solid var(--nav-bg-alt, #ddd); border-radius: 8px; margin-top: 1.5rem;"
title="LightRAG backend AEP — lecture seule"
sandbox="allow-same-origin allow-scripts"
loading="lazy"
/>
</div>
</template>

View File

@@ -1,67 +0,0 @@
<template>
<div class="media-tab-projets" style="padding: 1.5rem; overflow-y: auto;">
<div style="max-width: 70ch; margin-bottom: 1.5rem;">
<h2 style="font-weight: 700; font-size: 1.1rem; margin-bottom: 0.5rem; color: var(--nav-text);">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>

View File

@@ -1,650 +0,0 @@
<template>
<div class="media-visuel">
<!-- Conteneur principal : carte toolbar chatbot (ou bonpote/rag-backend) -->
<div class="layout-container">
<!-- SLOT CARTE D3 (mode carte uniquement) -->
<div
v-if="contentView === 'carte'"
class="carte-slot"
:class="[
layoutMode === 'split' ? 'carte-split' : '',
layoutMode === 'carte-full' ? 'carte-full' : '',
layoutMode === 'chatbot-full' ? 'carte-hidden' : '',
]"
:style="layoutMode === 'split' ? { flexBasis: carteFlexBasis } : {}"
style="position: relative;"
>
<ClientOnly>
<CartePensees
ref="cartePenseesRef"
:data="penseesData"
:active="true"
@select-auteur="onSelectAuteur"
@select-ecole="onSelectEcole"
/>
<template #fallback>
<div class="w-full h-full flex items-center justify-center" style="color: var(--nav-text-muted);">
Chargement de la carte...
</div>
</template>
</ClientOnly>
<!-- Overlay PDF FRACAS -->
<div
v-if="showFracasPdf"
class="fracas-overlay"
:style="{ opacity: fracasOpacity / 100 }"
>
<embed
src="/cartes/carte-fracas-bonpote-v2.pdf"
type="application/pdf"
style="width: 100%; height: 100%;"
/>
</div>
</div>
<!-- BARRE DE TOGGLE (entre carte et chatbot, toujours visible) -->
<div class="layout-toggle-bar shrink-0">
<!-- Gauche : contrôles layout (seulement en mode carte) -->
<template v-if="contentView === 'carte'">
<button
@click="layoutMode = 'carte-full'"
:class="{ active: layoutMode === 'carte-full' }"
class="toggle-btn"
title="Carte en plein ecran"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
</svg>
Carte plein ecran
</button>
<button
v-if="layoutMode !== 'split'"
@click="layoutMode = 'split'"
class="toggle-btn"
title="Vue partagee"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="12" x2="21" y2="12"/>
</svg>
Vue partagee
</button>
<button
@click="layoutMode = 'chatbot-full'"
:class="{ active: layoutMode === 'chatbot-full' }"
class="toggle-btn"
title="Chatbot plein ecran"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
Chatbot plein ecran
</button>
</template>
<!-- Droite : contrôles contenu (toujours, indépendants du layoutMode) -->
<div style="margin-left: auto; display: flex; align-items: center; gap: 4px; flex-wrap: wrap;">
<!-- Slider opacité PDF -->
<input
v-if="showFracasPdf && contentView === 'carte'"
type="range"
min="0"
max="100"
v-model.number="fracasOpacity"
class="opacity-slider"
:title="`Opacité ${fracasOpacity}%`"
/>
<!-- CARTE PRINCIPALE -->
<button
@click="showCarte"
:class="{ active: contentView === 'carte' }"
class="toggle-btn"
title="Vue principale : carte D3 + chatbot"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="2"/><path d="M12 2a10 10 0 0 0-7.07 17.07M12 2a10 10 0 0 1 7.07 17.07M3.34 7h17.32M3.34 17h17.32"/>
</svg>
CARTE PRINCIPALE
</button>
<!-- Tickbox PDF + carte des pensées -->
<div class="carte-pensees-ctrl">
<input
type="checkbox"
v-model="showFracasPdf"
class="fracas-check"
title="Superposer la carte FRACAS en PDF"
/>
<button
@click="contentView = 'bonpote'"
:class="{ active: contentView === 'bonpote' }"
class="toggle-btn carte-pensees-btn"
title="Carte des pensées écologiques — référence FRACAS Bonpote V2"
>
📗 carte des pensées écologiques
</button>
</div>
<!-- RAG backend -->
<button
@click="contentView = 'rag-backend'"
:class="{ active: contentView === 'rag-backend' }"
class="toggle-btn"
title="Interface LightRAG backend"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
</svg>
RAG backend
</button>
</div>
</div>
<!-- POIGNEE DRAGGABLE (split uniquement) -->
<div
v-if="contentView === 'carte' && layoutMode === 'split'"
class="split-handle"
@mousedown.prevent="onHandleMousedown"
title="Redimensionner"
>
<span class="split-handle-grip"></span>
</div>
<!-- SLOT CHATBOT (mode carte uniquement) -->
<div
v-if="contentView === 'carte'"
class="chatbot-slot"
:class="[
layoutMode === 'split' ? 'chatbot-split' : '',
layoutMode === 'chatbot-full' ? 'chatbot-full-mode' : '',
layoutMode === 'carte-full' ? 'chatbot-hidden' : '',
]"
:style="layoutMode === 'split' ? { flexBasis: chatbotFlexBasis } : {}"
>
<ClientOnly>
<ChatbotPensees :auteurContext="chatbotAuteur" :inline="true" />
</ClientOnly>
</div>
<!-- VUE BONPOTE -->
<div
v-if="contentView === 'bonpote'"
class="flex-1 overflow-y-auto px-6 py-8"
style="max-width: 680px; margin: 0 auto;"
>
<div class="mb-6">
<p class="text-xs font-bold uppercase tracking-widest mb-2" style="color: var(--nav-text-muted);">Reference editoriale</p>
<h2 class="text-xl font-bold mb-3" style="color: var(--nav-text);">Carte FRACAS des pensees ecologiques</h2>
<p class="text-sm leading-relaxed mb-4" style="color: var(--nav-text);">
FRACAS (Familles, Racines et Arpentages des Courants et Alternatives Solidaires) est une carte des ecoles de pensee ecologique publiee par Bonpote en octobre 2024. Elle reference ~140 auteurs et autrices reparti-es en 10 ecoles de pensee, depuis l'ecosocialisme jusqu'a l'ethique environnementale.
</p>
<p class="text-sm leading-relaxed mb-6" style="color: var(--nav-text);">
Le RAG ATIS est construit sur cette reference : chaque auteur ingere dans la bibliotheque correspond a une entree de la carte FRACAS. Les ecoles de pensee, les positions et les couleurs de notre carte sont transposees 1:1 depuis Bonpote V2.
</p>
<div class="flex flex-col gap-3">
<a href="https://bonpote.com/la-carte-des-pensees-ecologiques/"
target="_blank" rel="noopener"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:opacity-80 transition-opacity"
style="background: var(--nav-primary, #3b6ea5); color: white; font-size: 0.875rem; font-weight: 600; text-decoration: none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Lire l'article Bonpote + carte interactive
</a>
<a href="https://bonpote.com/wp-content/uploads/2024/10/FRACAS_BONPOTE_CARTE_VERSO_V2-OCT2024.pdf"
target="_blank" rel="noopener"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:opacity-80 transition-opacity"
style="background: var(--nav-bg-alt); color: var(--nav-text); font-size: 0.875rem; font-weight: 500; text-decoration: none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Telecharger le poster PDF (recto/verso)
</a>
<button
@click="showCarte"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:opacity-80 transition-opacity text-left"
style="background: var(--nav-bg-alt); color: var(--nav-text); font-size: 0.875rem; font-weight: 500; border: none; cursor: pointer;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
Interroger le RAG ATIS sur ces pensees
</button>
</div>
</div>
<div>
<p class="text-xs font-bold uppercase tracking-widest mb-3" style="color: var(--nav-text-muted);">Les 10 ecoles de pensee (FRACAS V2)</p>
<div class="flex flex-col gap-2">
<div v-for="ecole in (penseesData?.ecoles ?? [])" :key="ecole.id"
class="flex items-start gap-3 px-3 py-2 rounded-lg"
style="background: var(--nav-bg-alt);">
<span class="w-3 h-3 rounded-full shrink-0 mt-1" :style="`background:${ecole.color};`"></span>
<div>
<p class="text-sm font-semibold" style="color: var(--nav-text);">{{ ecole.label }}</p>
<p class="text-xs mt-0.5 leading-relaxed" style="color: var(--nav-text-muted);">{{ ecole.description }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- VUE RAG BACKEND -->
<div
v-if="contentView === 'rag-backend'"
style="flex: 1; overflow: hidden; display: flex; flex-direction: column;"
>
<MediaTabBackend />
</div>
</div>
<!-- Fiche auteur modal -->
<FicheAuteur
:open="ficheOpen"
:auteurId="ficheAuteurId"
:data="penseesData"
@close="ficheOpen = false"
@interroger-rag="onInterrogerRag"
/>
<!-- Fiche ecole modal -->
<FicheEcole
:open="ficheEcoleOpen"
:ecoleId="ficheEcoleId"
:data="penseesData"
@close="ficheEcoleOpen = false"
@select-auteur="onSelectAuteurFromEcole"
@interroger-ecole="onInterrogerEcole"
/>
<!-- Modal info RAG -->
<Teleport to="body">
<Transition name="backdrop">
<div v-if="ragInfoOpen" class="fixed inset-0 z-[2000]" style="background:rgba(26,34,56,0.55);" @click="ragInfoOpen = false" aria-hidden="true" />
</Transition>
<Transition name="modal">
<div v-if="ragInfoOpen" class="fixed z-[2001] left-1/2 flex flex-col"
style="top:50%;transform:translate(-50%,-50%);width:min(580px,94vw);max-height:85vh;background:var(--nav-bg);border-radius:14px;box-shadow:0 16px 64px rgba(26,34,56,0.28);overflow:hidden;"
role="dialog" aria-modal="true" aria-label="A 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 - Bibliotheque des pensees ecologiques</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;">
<p class="mb-3">Une bibliotheque parlante politisee - des pensees ecologiques de gauche, organisees pour aider a creer une pensee complexe et nuancee, critiquer le recit dominant et soutenir des alternatives concretes et des projets collectifs.</p>
<p class="mb-4" style="color:var(--nav-text-muted);font-size:0.8rem;">Projet open source, ouvert a toutes et a 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>

View File

@@ -1,150 +0,0 @@
<template>
<component
:is="url ? 'a' : 'div'"
v-bind="url ? { href: url, target: '_blank', rel: 'noopener noreferrer' } : {}"
class="outil-card"
:class="{ 'outil-card--link': !!url, 'outil-card--disabled': !url }"
>
<div class="outil-card__header">
<span class="outil-card__icon" aria-hidden="true">{{ icon }}</span>
<span :class="['outil-card__badge', `outil-card__badge--${tag}`]">{{ tagLabel }}</span>
</div>
<h3 class="outil-card__titre">{{ titre }}</h3>
<p class="outil-card__desc">{{ description }}</p>
<span v-if="cta && url" class="outil-card__cta">{{ cta }}</span>
<span v-else-if="!url" class="outil-card__cta outil-card__cta--disabled">Bientôt disponible</span>
</component>
</template>
<script setup lang="ts">
const props = defineProps<{
icon?: string
titre: string
url?: string | null
description?: string
cta?: string
tag?: string
}>()
const tagLabels: Record<string, string> = {
'outil-aep': 'Outil AEP',
'inspiration-externe': 'Inspiration',
'disponible': 'Disponible',
'recommande': 'Recommandé',
'a-venir': 'À venir',
}
const tagLabel = computed(() => props.tag ? (tagLabels[props.tag] ?? props.tag) : '')
</script>
<style scoped>
.outil-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem 1.25rem;
border-radius: 10px;
border: 1px solid var(--nav-bg-alt);
background: var(--nav-surface);
text-decoration: none;
color: var(--nav-text);
transition: box-shadow 0.15s, border-color 0.15s;
}
.outil-card--link:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
border-color: var(--nav-primary-solid);
}
.outil-card--disabled {
opacity: 0.65;
}
.outil-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.outil-card__icon {
font-size: 1.3rem;
line-height: 1;
}
.outil-card__badge {
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 7px;
border-radius: 999px;
}
.outil-card__badge--outil-aep {
background: #d1fae5;
color: #065f46;
}
.outil-card__badge--inspiration-externe {
background: #fef3c7;
color: #92400e;
}
.outil-card__badge--disponible {
background: #d1fae5;
color: #065f46;
}
.outil-card__badge--recommande {
background: #dbeafe;
color: #1e40af;
}
.outil-card__badge--a-venir {
background: var(--nav-bg-alt);
color: var(--nav-text-muted);
}
.outil-card__titre {
font-size: 0.9rem;
font-weight: 600;
color: var(--nav-text);
margin: 0;
line-height: 1.35;
}
.outil-card__desc {
font-size: 0.82rem;
color: var(--nav-text-muted);
margin: 0;
line-height: 1.5;
}
.outil-card__cta {
font-size: 0.78rem;
font-weight: 600;
color: var(--nav-primary-solid);
margin-top: 0.25rem;
}
.outil-card__cta--disabled {
color: var(--nav-text-muted);
font-weight: 400;
font-style: italic;
}
/* Dark mode badge overrides */
:global(.dark) .outil-card__badge--outil-aep {
background: #064e3b;
color: #a7f3d0;
}
:global(.dark) .outil-card__badge--inspiration-externe {
background: #78350f;
color: #fde68a;
}
:global(.dark) .outil-card__badge--disponible {
background: #064e3b;
color: #a7f3d0;
}
:global(.dark) .outil-card__badge--recommande {
background: #1e3a5f;
color: #93c5fd;
}
</style>

View File

@@ -1,156 +0,0 @@
<template>
<component
:is="url ? 'a' : 'div'"
v-bind="url ? { href: url, target: '_blank', rel: 'noopener noreferrer' } : {}"
class="simu-feature"
:class="{ 'simu-feature--link': !!url }"
>
<div class="simu-feature__inner">
<div class="simu-feature__left">
<span class="simu-feature__icon" aria-hidden="true">{{ icon }}</span>
<div class="simu-feature__body">
<div class="simu-feature__header">
<h3 class="simu-feature__titre">{{ titre }}</h3>
<span v-if="tag" :class="['simu-feature__badge', `simu-feature__badge--${tag}`]">{{ tagLabel }}</span>
</div>
<p class="simu-feature__desc">{{ description }}</p>
</div>
</div>
<span v-if="cta && url" class="simu-feature__cta">{{ cta }}</span>
</div>
</component>
</template>
<script setup lang="ts">
const props = defineProps<{
icon?: string
titre: string
url?: string | null
description?: string
cta?: string
tag?: string
}>()
const tagLabels: Record<string, string> = {
'outil-aep': 'Outil AEP',
'inspiration-externe': 'Inspiration externe',
'disponible': 'Disponible',
'recommande': 'Recommandé',
'a-venir': 'À venir',
}
const tagLabel = computed(() => props.tag ? (tagLabels[props.tag] ?? props.tag) : '')
</script>
<style scoped>
.simu-feature {
display: block;
padding: 1.5rem 1.75rem;
border-radius: 14px;
border: 1.5px solid var(--nav-bg-alt);
background: var(--nav-surface);
text-decoration: none;
color: var(--nav-text);
transition: box-shadow 0.2s, border-color 0.2s, transform 0.15s;
}
.simu-feature--link:hover {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
border-color: var(--nav-primary-solid);
transform: translateY(-2px);
}
.simu-feature__inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
flex-wrap: wrap;
}
.simu-feature__left {
display: flex;
align-items: flex-start;
gap: 1rem;
flex: 1;
min-width: 0;
}
.simu-feature__icon {
font-size: 2rem;
line-height: 1;
flex-shrink: 0;
margin-top: 2px;
}
.simu-feature__body {
flex: 1;
min-width: 0;
}
.simu-feature__header {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 0.4rem;
}
.simu-feature__titre {
font-size: 1.05rem;
font-weight: 700;
color: var(--nav-text);
margin: 0;
line-height: 1.3;
}
.simu-feature__badge {
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 8px;
border-radius: 999px;
background: #d1fae5;
color: #065f46;
}
.simu-feature__badge--inspiration-externe {
background: #fef3c7;
color: #92400e;
}
.simu-feature__desc {
font-size: 0.88rem;
color: var(--nav-text-muted);
margin: 0;
line-height: 1.55;
}
.simu-feature__cta {
display: inline-flex;
align-items: center;
padding: 0.6rem 1.25rem;
background: var(--nav-primary-solid);
color: var(--nav-text-on-primary);
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
transition: opacity 0.15s;
flex-shrink: 0;
}
.simu-feature--link:hover .simu-feature__cta {
opacity: 0.88;
}
:global(.dark) .simu-feature__badge {
background: #064e3b;
color: #a7f3d0;
}
:global(.dark) .simu-feature__badge--inspiration-externe {
background: #78350f;
color: #fde68a;
}
</style>

View File

@@ -1,201 +0,0 @@
<template>
<ul class="tree-ascii" :class="{ 'tree-ascii--root': depth === 0 }" :style="{ '--depth': depth }">
<li
v-for="(node, i) in tree.children"
:key="i"
class="tree-ascii__node"
>
<!-- Nœud avec enfants : bouton toggle -->
<template v-if="node.children && node.children.length">
<button
class="tree-ascii__branch"
:aria-expanded="!!open[i]"
@click="toggle(i)"
>
<span class="tree-ascii__chevron" aria-hidden="true">{{ open[i] ? '▼' : '▶' }}</span>
<span class="tree-ascii__label">{{ node.name }}</span>
<span class="tree-ascii__count">({{ node.children.length }})</span>
</button>
<TreeASCII
v-if="open[i]"
:tree="node"
:depth="depth + 1"
/>
</template>
<!-- Feuille avec URL : lien cliquable -->
<template v-else-if="node.url">
<a
:href="node.url"
target="_blank"
rel="noopener noreferrer"
class="tree-ascii__leaf tree-ascii__leaf--link"
>
<span class="tree-ascii__prefix" aria-hidden="true"></span>
<span class="tree-ascii__label">{{ node.name }}</span>
<span v-if="node.desc" class="tree-ascii__desc"> {{ node.desc }}</span>
<svg class="tree-ascii__ext" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</a>
</template>
<!-- Feuille sans URL -->
<template v-else>
<span class="tree-ascii__leaf">
<span class="tree-ascii__prefix" aria-hidden="true"></span>
<span class="tree-ascii__label">{{ node.name }}</span>
<span v-if="node.desc" class="tree-ascii__desc"> {{ node.desc }}</span>
</span>
</template>
</li>
</ul>
</template>
<script setup lang="ts">
export interface TreeNode {
name: string
url?: string
desc?: string
children?: TreeNode[]
}
export interface TreeData {
name?: string
children?: TreeNode[]
}
const props = withDefaults(defineProps<{
tree: TreeData
depth?: number
}>(), {
depth: 0
})
// Toutes les branches fermées par défaut
const open = ref<Record<number, boolean>>({})
function toggle(i: number) {
open.value[i] = !open.value[i]
}
</script>
<style scoped>
.tree-ascii {
list-style: none;
padding: 0;
margin: 0;
font-family: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
font-size: 0.82rem;
padding-left: calc(var(--depth, 0) * 1.25rem + 0.5rem);
}
.tree-ascii--root {
padding-left: 0;
}
.tree-ascii__node {
margin: 2px 0;
line-height: 1.6;
}
/* Bouton branche (nœud avec enfants) */
.tree-ascii__branch {
display: inline-flex;
align-items: center;
gap: 0.35rem;
background: none;
border: none;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
color: var(--nav-text);
font-family: inherit;
font-size: inherit;
font-weight: 600;
transition: background 0.1s, color 0.1s;
text-align: left;
}
.tree-ascii__branch:hover {
background: var(--nav-bg-alt);
color: var(--nav-primary-solid);
}
.tree-ascii__chevron {
font-size: 0.65rem;
color: var(--nav-text-muted);
width: 12px;
text-align: center;
flex-shrink: 0;
}
.tree-ascii__count {
font-size: 0.7rem;
color: var(--nav-text-muted);
font-weight: 400;
}
/* Feuille */
.tree-ascii__leaf {
display: inline-flex;
align-items: baseline;
gap: 0.25rem;
padding: 1px 6px;
border-radius: 4px;
text-decoration: none;
color: var(--nav-text-muted);
}
.tree-ascii__leaf--link {
color: var(--nav-text);
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.tree-ascii__leaf--link:hover {
background: var(--nav-bg-alt);
color: var(--nav-primary-solid);
text-decoration: underline;
}
.tree-ascii__prefix {
color: var(--nav-text-muted);
opacity: 0.5;
font-size: 0.75rem;
flex-shrink: 0;
}
.tree-ascii__label {
font-weight: 500;
}
.tree-ascii__leaf--link .tree-ascii__label {
font-weight: 400;
}
.tree-ascii__desc {
color: var(--nav-text-muted);
font-size: 0.78rem;
font-style: italic;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 60ch;
}
.tree-ascii__ext {
opacity: 0.4;
flex-shrink: 0;
margin-left: 2px;
vertical-align: middle;
}
@media (max-width: 640px) {
.tree-ascii__desc {
display: none;
}
}
</style>

View File

@@ -1,240 +0,0 @@
.proposer-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.field-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.field-group label,
.field-group legend {
font-size: 0.875rem;
font-weight: 600;
color: var(--nav-text);
display: block;
}
.field-group fieldset {
border: none;
padding: 0;
margin: 0;
}
.required { color: #c0392b; }
.label-hint {
font-weight: 400;
color: var(--nav-text-muted);
font-size: 0.8rem;
margin-left: 0.25rem;
}
.field-group input[type="text"],
.field-group input[type="url"],
.field-group input[type="email"],
.field-group textarea {
width: 100%;
padding: 0.625rem 0.875rem;
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 8px;
font-size: 0.9rem;
color: var(--nav-text);
background: var(--nav-surface);
font-family: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
box-sizing: border-box;
}
.field-group input:focus,
.field-group textarea:focus {
outline: none;
border-color: var(--nav-primary-solid);
box-shadow: 0 0 0 2px rgba(245, 179, 66, 0.4);
}
.field-group textarea {
resize: vertical;
min-height: 100px;
}
.field-error input,
.field-error textarea {
border-color: #c0392b !important;
}
.error-msg {
font-size: 0.8rem;
color: #c0392b;
}
.field-meta {
display: flex;
justify-content: flex-end;
}
.char-count {
font-size: 0.75rem;
color: var(--nav-text-muted);
}
.char-warn {
color: #e67e22;
}
.radio-group {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.375rem;
}
.radio-label {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 6px;
font-size: 0.85rem;
color: var(--nav-text);
background: var(--nav-surface);
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.radio-label input[type="radio"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.radio-label:hover {
border-color: var(--nav-primary-solid);
background: var(--nav-bg-alt);
}
.radio-label.active {
background: var(--nav-primary);
border-color: transparent;
color: var(--nav-text-on-primary);
}
.checkbox-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
margin-top: 0.375rem;
}
@media (max-width: 400px) {
.checkbox-grid { grid-template-columns: 1fr; }
}
.checkbox-label {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 6px;
font-size: 0.85rem;
color: var(--nav-text);
background: var(--nav-surface);
cursor: pointer;
transition: all 0.15s;
user-select: none;
position: relative;
}
.checkbox-label input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.checkbox-label:hover:not(.disabled) {
border-color: var(--nav-primary-solid);
background: var(--nav-bg-alt);
}
.checkbox-label.active {
background: var(--nav-primary);
border-color: transparent;
color: var(--nav-text-on-primary);
}
.checkbox-label.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.fn-order {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: var(--nav-accent);
color: var(--nav-text);
border-radius: 50%;
font-size: 0.7rem;
font-weight: 700;
flex-shrink: 0;
}
.server-error {
padding: 0.875rem 1rem;
background: #fdf0ee;
border: 1px solid #e74c3c;
border-radius: 8px;
font-size: 0.875rem;
color: #c0392b;
}
.form-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 0.5rem;
}
.btn-primary {
padding: 0.75rem 1.5rem;
background: var(--nav-primary);
color: var(--nav-text-on-primary);
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: background 0.15s, opacity 0.15s;
}
.btn-primary:hover:not(:disabled) {
background: rgba(26, 34, 56, 0.75);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.form-note {
font-size: 0.75rem;
color: var(--nav-text-muted);
text-align: center;
margin: 0;
}
@media (max-width: 480px) {
.form-actions { flex-direction: column-reverse; }
.btn-primary { width: 100%; justify-content: center; }
}

View File

@@ -16,8 +16,6 @@ export default defineNuxtConfig({
commentTableId: process.env.COMMENT_TABLE_ID || process.env.AVIS_TABLE_ID,
statsTableId: process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc',
mistralApiKey: process.env.MISTRAL_API_KEY,
bifrostUrl: process.env.BIFROST_URL || 'http://127.0.0.1:8080',
bifrostVk: process.env.BIFROST_VK,
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
resendApiKey: process.env.RESEND_API_KEY,
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
@@ -25,8 +23,7 @@ export default defineNuxtConfig({
codevPassword: 'merci', // NUXT_CODEV_PASSWORD - défaut "merci", overridable
codevBaseId: '', // NUXT_CODEV_BASE_ID - base NocoDB (ex: pipilvsi7dibo80)
codevAdminPassword: 'admin2026', // NUXT_CODEV_ADMIN_PASSWORD
ragPeUrl: process.env.RAG_PE_URL || 'http://localhost:9621',
referencesTableId: process.env.REFERENCES_TABLE_ID || process.env.NUXT_REFERENCES_TABLE_ID || '',
ragPeUrl: process.env.NUXT_RAG_PE_URL || 'http://localhost:9621',
},
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client

View File

@@ -190,38 +190,12 @@
</a>
</section>
<!--
SECTION 8 - API publique
-->
<section class="section section-opensource">
<h2>API publique</h2>
<p class="section-text">
Les données de ce site internet sont publiques, toutes consultables via API directe, pour que des agents IA, des développeurs, ou d'autres sites puissent interroger la base sans passer par l'interface graphique.
</p>
<p class="section-text">
<strong>Données accessibles :</strong> organisations d'entraide, pratiques d'architecture d'écologie politique (graphe complet + projets), plateformes d'emploi, RAG et ses auteurs, et toutes autres données à venir.
</p>
<div class="api-copy-box">
<code class="api-url">https://api.trans-former.fr</code>
<button class="btn-copy" @click="copyApiUrl">{{ copied ? 'Copié !' : 'Copier' }}</button>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
useHead({ title: 'À propos - AEP' })
const copied = ref(false)
function copyApiUrl() {
navigator.clipboard.writeText('https://api.trans-former.fr')
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
</script>
<style scoped>
@@ -538,44 +512,6 @@ function copyApiUrl() {
background: var(--nav-bg-alt);
}
/* ── API copy box ────────────────────────────────────────────────────────────── */
.api-copy-box {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
padding: 0.5rem 0.75rem;
background: var(--nav-surface);
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 8px;
}
.api-url {
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: 0.875rem;
color: var(--nav-primary-solid);
user-select: all;
}
.btn-copy {
padding: 0.375rem 0.75rem;
background: var(--nav-bg-alt);
border: 1px solid rgba(26, 34, 56, 0.15);
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
color: var(--nav-text);
transition: background 0.15s;
white-space: nowrap;
}
.btn-copy:hover {
background: var(--nav-primary-solid);
color: #fff;
}
/* ── Responsive général ──────────────────────────────────────────────────────── */
@media (max-width: 480px) {

View File

@@ -128,6 +128,12 @@
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'graphe'"
>Vue graphique</button>
<NuxtLink
to="/media"
class="px-5 py-2 text-sm font-medium transition-colors"
style="color: var(--nav-text-muted); border-bottom: 2px solid transparent;"
active-class="!color-nav-text"
>Média</NuxtLink>
</div>
<!-- Carte Métropole desktop -->
@@ -219,6 +225,11 @@
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'graphe'"
>Graphe</button>
<NuxtLink
to="/media"
class="flex-1 py-2 text-sm font-medium transition-colors text-center"
style="color: var(--nav-text-muted); border-bottom: 2px solid transparent;"
>Média</NuxtLink>
</div>
<div class="lg:hidden flex-1 relative overflow-hidden">
@@ -403,6 +414,11 @@
@update:modelValue="chatbotOpen = $event"
/>
<!-- CHATBOT PENSEES (desktop, tous onglets) -->
<ClientOnly>
<ChatbotPensees />
</ClientOnly>
<!-- POP-UP MISSION RÉSEAUX AEP -->
<button
class="reseaux-info-btn"

View File

@@ -1,7 +1,795 @@
<template>
<div />
<div class="contribuer-page">
<div class="contribuer-inner">
<!-- Retour -->
<NuxtLink to="/" class="back-link">
Retour à la carte
</NuxtLink>
<!-- En-tête -->
<div class="contribuer-header">
<h1>Proposer une ressource</h1>
<p class="contribuer-subtitle">
Tu connais une organisation utile aux architectes qui n'est pas encore référencée ?
Soumets-la ici — une IA enrichira la fiche et on validera sous 7 jours.
</p>
<p class="contribuer-hint">
Si tu n'as pas le temps de tout remplir, laisse-nous juste le lien on extraira les infos du site.
Mais une description de toi, c'est toujours plus vivant et plus précis.
</p>
</div>
<!-- Message succès -->
<div v-if="success" class="success-block" role="status" aria-live="polite">
<div class="success-icon">✓</div>
<h2>Merci !</h2>
<p>Ta fiche est en cours de traitement.</p>
<p class="success-detail">
Une IA va scraper le site et enrichir la description.
Jules (et bientôt une équipe de modération) valide sous 7 jours.
</p>
<button type="button" class="btn-secondary" @click="reset">
Proposer une autre fiche
</button>
</div>
<!-- Formulaire -->
<form v-else @submit.prevent="submit" class="contribuer-form" novalidate>
<!-- Nom -->
<div class="field-group" :class="{ 'field-error': errors.nom }">
<label for="nom">Nom de l'organisation <span class="required">*</span></label>
<input
id="nom"
v-model="form.nom"
type="text"
placeholder="Ex : UNSFA, Maison de l'Architecture..."
autocomplete="organization"
@blur="validateField('nom')"
/>
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
</div>
<!-- URL -->
<div class="field-group" :class="{ 'field-error': errors.url }">
<label for="url">
Site web
<span class="label-hint">(optionnel recommandé pour l'enrichissement IA)</span>
</label>
<input
id="url"
v-model="form.url"
type="url"
placeholder="https://..."
@blur="validateField('url')"
/>
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
</div>
<!-- Description -->
<div class="field-group" :class="{ 'field-error': errors.description_user }">
<label for="description_user">
Description courte <span class="required">*</span>
<span class="label-hint">(50 à 500 caractères)</span>
</label>
<textarea
id="description_user"
v-model="form.description_user"
rows="4"
placeholder="Présente l'organisation en quelques mots : ses missions, son public, ce qu'elle apporte..."
@blur="validateField('description_user')"
/>
<div class="field-meta">
<span v-if="errors.description_user" class="error-msg" role="alert">
{{ errors.description_user }}
</span>
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">
{{ form.description_user.length }}/500
</span>
</div>
</div>
<!-- Échelle -->
<div class="field-group" :class="{ 'field-error': errors.echelle }">
<fieldset>
<legend>
Échelle <span class="required">*</span>
<span class="label-hint">(une seule)</span>
</legend>
<div class="radio-group">
<label
v-for="opt in ECHELLES"
:key="opt"
class="radio-label"
:class="{ active: form.echelle === opt }"
>
<input
type="radio"
:value="opt"
v-model="form.echelle"
name="echelle"
@change="validateField('echelle')"
/>
{{ opt }}
</label>
</div>
</fieldset>
<span v-if="errors.echelle" class="error-msg" role="alert">{{ errors.echelle }}</span>
</div>
<!-- Fonctions -->
<div class="field-group" :class="{ 'field-error': errors.fonctions }">
<fieldset>
<legend>
Fonctions <span class="required">*</span>
<span class="label-hint">(1 à 5 — l'ordre de clic = priorité)</span>
</legend>
<div class="checkbox-grid">
<label
v-for="fn in FONCTIONS"
:key="fn"
class="checkbox-label"
:class="{
active: form.fonctions.includes(fn),
disabled: !form.fonctions.includes(fn) && form.fonctions.length >= 5,
}"
>
<input
type="checkbox"
:value="fn"
:checked="form.fonctions.includes(fn)"
:disabled="!form.fonctions.includes(fn) && form.fonctions.length >= 5"
@change="toggleFonction(fn)"
/>
<span class="fn-order" v-if="form.fonctions.includes(fn)">
{{ form.fonctions.indexOf(fn) + 1 }}
</span>
{{ fn }}
</label>
</div>
</fieldset>
<span v-if="errors.fonctions" class="error-msg" role="alert">{{ errors.fonctions }}</span>
</div>
<!-- Territoire -->
<div class="field-group" :class="{ 'field-error': errors.territoire }">
<fieldset>
<legend>
Territoire <span class="required">*</span>
</legend>
<div class="radio-group">
<label
v-for="t in TERRITOIRES"
:key="t"
class="radio-label"
:class="{ active: form.territoire === t }"
>
<input
type="radio"
:value="t"
v-model="form.territoire"
name="territoire"
@change="validateField('territoire')"
/>
{{ t }}
</label>
</div>
</fieldset>
<span v-if="errors.territoire" class="error-msg" role="alert">{{ errors.territoire }}</span>
</div>
<!-- Ville -->
<div class="field-group" :class="{ 'field-error': errors.localisation_ville }">
<label for="localisation_ville">
Ville principale
<span class="label-hint">(optionnel pour la géolocalisation sur la carte)</span>
</label>
<input
id="localisation_ville"
v-model="form.localisation_ville"
type="text"
placeholder="Ex : Paris, Lyon, Bordeaux..."
/>
<span v-if="errors.localisation_ville" class="error-msg" role="alert">
{{ errors.localisation_ville }}
</span>
</div>
<!-- Email -->
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
<label for="submitted_by_email">
Ton email
<span class="label-hint">(optionnel pour le suivi de modération)</span>
</label>
<input
id="submitted_by_email"
v-model="form.submitted_by_email"
type="email"
placeholder="ton@email.fr"
autocomplete="email"
@blur="validateField('submitted_by_email')"
/>
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">
{{ errors.submitted_by_email }}
</span>
</div>
<!-- Erreur globale -->
<div v-if="serverError" class="server-error" role="alert">
<strong>Erreur :</strong> {{ serverError }}
</div>
<!-- Actions -->
<div class="form-actions">
<NuxtLink to="/" class="btn-secondary">Annuler</NuxtLink>
<button
type="submit"
class="btn-primary"
:disabled="submitting"
>
{{ submitting ? 'Envoi en cours...' : 'Proposer la fiche →' }}
</button>
</div>
<p class="form-note">
Ta fiche sera examinée par l'équipe avant publication.
</p>
</form>
</div>
</div>
</template>
<script setup lang="ts">
navigateTo('/proposer', { redirectCode: 301 })
import { z } from 'zod'
// ── Constantes ────────────────────────────────────────────────────────────────
const ECHELLES = ['National', 'Régional', 'Local'] as const
const TERRITOIRES = ['Métropole', 'Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte'] as const
const FONCTIONS = [
'Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier',
'Comptabilité', 'Développement', 'Formation', 'Gestion d\'agence', 'Santé mentale',
] as const
// ── Schéma Zod (côté client — miroir du serveur) ──────────────────────────────
const SubmitSchema = z.object({
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
url: z.string().url('URL invalide (commencer par https://)').optional().or(z.literal('')),
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
echelle: z.enum(ECHELLES, { errorMap: () => ({ message: 'Sélectionne une échelle' }) }),
fonctions: z.array(z.string()).min(1, 'Sélectionne au moins une fonction').max(5, 'Maximum 5 fonctions'),
territoire: z.enum(TERRITOIRES, { errorMap: () => ({ message: 'Sélectionne un territoire' }) }),
localisation_ville: z.string().max(100).optional(),
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
})
// ── État du formulaire ────────────────────────────────────────────────────────
const form = reactive({
nom: '',
url: '',
description_user: '',
echelle: '' as typeof ECHELLES[number] | '',
fonctions: [] as string[],
territoire: '' as typeof TERRITOIRES[number] | '',
localisation_ville: '',
submitted_by_email: '',
})
const errors = reactive<Record<string, string>>({})
const submitting = ref(false)
const success = ref(false)
const serverError = ref('')
const trackingUrl = ref<string | null>(null)
// ── Validation champ par champ ────────────────────────────────────────────────
function validateField(field: string) {
const partial = SubmitSchema.partial()
const result = partial.safeParse({ [field]: (form as any)[field] })
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors
errors[field] = fieldErrors[field]?.[0] ?? ''
} else {
delete errors[field]
}
}
function validateAll(): boolean {
const result = SubmitSchema.safeParse(form)
if (!result.success) {
const flat = result.error.flatten().fieldErrors
Object.assign(errors, Object.fromEntries(
Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])
))
return false
}
Object.keys(errors).forEach(k => delete errors[k])
return true
}
// ── Gestion fonctions (ordre de clic = priorité) ──────────────────────────────
function toggleFonction(fn: string) {
const idx = form.fonctions.indexOf(fn)
if (idx >= 0) {
form.fonctions.splice(idx, 1)
} else if (form.fonctions.length < 5) {
form.fonctions.push(fn)
}
validateField('fonctions')
}
// ── Soumission ────────────────────────────────────────────────────────────────
async function submit() {
serverError.value = ''
if (!validateAll()) {
// Scroll vers la première erreur
await nextTick()
const firstError = document.querySelector('.field-error')
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
return
}
submitting.value = true
try {
const result: any = await $fetch('/api/submit', {
method: 'POST',
body: {
nom: form.nom,
url: form.url || undefined,
description_user: form.description_user,
echelle: form.echelle,
fonctions: form.fonctions,
territoire: form.territoire,
localisation_ville: form.localisation_ville || undefined,
submitted_by_email: form.submitted_by_email || undefined,
},
})
trackingUrl.value = result.trackingUrl ?? null
success.value = true
} catch (e: any) {
const status = e?.status ?? e?.statusCode
if (status === 429) {
serverError.value = 'Tu as déjà soumis 3 fiches aujourd\'hui. Réessaie demain.'
} else if (status === 422 && e?.data) {
// Erreurs Zod serveur → mapper sur le formulaire
const fieldErrors = e.data
Object.entries(fieldErrors).forEach(([k, v]) => {
errors[k] = Array.isArray(v) ? v[0] : String(v)
})
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
} else {
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
}
} finally {
submitting.value = false
}
}
function reset() {
Object.assign(form, {
nom: '', url: '', description_user: '', echelle: '',
fonctions: [], territoire: '', localisation_ville: '', submitted_by_email: '',
})
Object.keys(errors).forEach(k => delete errors[k])
success.value = false
serverError.value = ''
trackingUrl.value = null
}
// ── Meta ──────────────────────────────────────────────────────────────────────
useHead({ title: 'Proposer une ressource — AEP' })
</script>
<style scoped>
/* ── Layout ─────────────────────────────────────────────────────────────────── */
.contribuer-page {
min-height: 100vh;
background: var(--nav-bg);
padding: 1.5rem 1rem 4rem;
}
.contribuer-inner {
max-width: 640px;
margin: 0 auto;
}
/* ── Retour ──────────────────────────────────────────────────────────────────── */
.back-link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--nav-primary-solid);
opacity: 0.7;
text-decoration: none;
margin-bottom: 1.5rem;
transition: opacity 0.15s;
}
.back-link:hover {
opacity: 1;
}
/* ── En-tête ─────────────────────────────────────────────────────────────────── */
.contribuer-header {
margin-bottom: 2rem;
}
.contribuer-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--nav-text);
margin: 0 0 0.5rem;
}
.contribuer-subtitle {
font-size: 0.9rem;
color: var(--nav-text-muted);
line-height: 1.5;
margin: 0 0 0.5rem;
}
.contribuer-hint {
font-size: 0.82rem;
color: var(--nav-text-muted);
opacity: 0.75;
line-height: 1.5;
margin: 0;
}
/* ── Succès ──────────────────────────────────────────────────────────────────── */
.success-block {
background: var(--nav-surface);
border: 1px solid rgba(26, 34, 56, 0.15);
border-radius: 12px;
padding: 2rem 1.5rem;
text-align: center;
}
.success-icon {
width: 48px;
height: 48px;
background: rgba(26, 34, 56, 0.1);
color: var(--nav-text);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
font-weight: 700;
margin: 0 auto 1rem;
}
.success-block h2 {
font-size: 1.25rem;
font-weight: 700;
color: var(--nav-text);
margin: 0 0 0.5rem;
}
.success-block p {
font-size: 0.9rem;
color: var(--nav-text-muted);
margin: 0 0 0.5rem;
line-height: 1.5;
}
.success-detail {
font-size: 0.85rem !important;
}
.success-tracking {
font-size: 0.85rem !important;
margin-top: 1rem !important;
}
.tracking-link {
color: var(--nav-primary-solid);
font-size: 0.8rem;
word-break: break-all;
}
/* ── Formulaire ──────────────────────────────────────────────────────────────── */
.contribuer-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* ── Champ générique ─────────────────────────────────────────────────────────── */
.field-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.field-group label,
.field-group legend {
font-size: 0.875rem;
font-weight: 600;
color: var(--nav-text);
display: block;
}
.field-group fieldset {
border: none;
padding: 0;
margin: 0;
}
.required {
color: #c0392b;
}
.label-hint {
font-weight: 400;
color: var(--nav-text-muted);
font-size: 0.8rem;
margin-left: 0.25rem;
}
.field-group input[type="text"],
.field-group input[type="url"],
.field-group input[type="email"],
.field-group textarea {
width: 100%;
padding: 0.625rem 0.875rem;
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 8px;
font-size: 0.9rem;
color: var(--nav-text);
background: var(--nav-surface);
font-family: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
box-sizing: border-box;
}
.field-group input:focus,
.field-group textarea:focus {
outline: none;
border-color: var(--nav-primary-solid);
box-shadow: 0 0 0 2px rgba(245, 179, 66, 0.4);
}
.field-group textarea {
resize: vertical;
min-height: 100px;
}
/* Erreur champ */
.field-error input,
.field-error textarea {
border-color: #c0392b !important;
}
.error-msg {
font-size: 0.8rem;
color: #c0392b;
}
.field-meta {
display: flex;
justify-content: flex-end;
}
.char-count {
font-size: 0.75rem;
color: var(--nav-text-muted);
}
.char-warn {
color: #e67e22;
}
/* ── Radio (Échelle + Territoire) ────────────────────────────────────────────── */
.radio-group {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.375rem;
}
.radio-label {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 6px;
font-size: 0.85rem;
color: var(--nav-text);
background: var(--nav-surface);
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.radio-label input[type="radio"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.radio-label:hover {
border-color: var(--nav-primary-solid);
background: var(--nav-bg-alt);
}
.radio-label.active {
background: var(--nav-primary);
border-color: transparent;
color: var(--nav-text-on-primary);
}
/* ── Checkboxes (Fonctions) ──────────────────────────────────────────────────── */
.checkbox-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
margin-top: 0.375rem;
}
@media (max-width: 400px) {
.checkbox-grid {
grid-template-columns: 1fr;
}
}
.checkbox-label {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 6px;
font-size: 0.85rem;
color: var(--nav-text);
background: var(--nav-surface);
cursor: pointer;
transition: all 0.15s;
user-select: none;
position: relative;
}
.checkbox-label input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.checkbox-label:hover:not(.disabled) {
border-color: var(--nav-primary-solid);
background: var(--nav-bg-alt);
}
.checkbox-label.active {
background: var(--nav-primary);
border-color: transparent;
color: var(--nav-text-on-primary);
}
.checkbox-label.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.fn-order {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: var(--nav-accent);
color: var(--nav-text);
border-radius: 50%;
font-size: 0.7rem;
font-weight: 700;
flex-shrink: 0;
}
/* ── Erreur serveur ──────────────────────────────────────────────────────────── */
.server-error {
padding: 0.875rem 1rem;
background: #fdf0ee;
border: 1px solid #e74c3c;
border-radius: 8px;
font-size: 0.875rem;
color: #c0392b;
}
/* ── Actions ──────────────────────────────────────────────────────────────────── */
.form-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 0.5rem;
}
.btn-primary {
padding: 0.75rem 1.5rem;
background: var(--nav-primary);
color: var(--nav-text-on-primary);
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: background 0.15s, opacity 0.15s;
}
.btn-primary:hover:not(:disabled) {
background: rgba(26, 34, 56, 0.75);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.75rem 1.25rem;
background: transparent;
color: var(--nav-text-muted);
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 8px;
font-size: 0.9rem;
cursor: pointer;
font-family: inherit;
text-decoration: none;
display: inline-flex;
align-items: center;
transition: border-color 0.15s, color 0.15s;
}
.btn-secondary:hover {
border-color: var(--nav-primary-solid);
color: var(--nav-text);
}
.form-note {
font-size: 0.75rem;
color: var(--nav-text-muted);
text-align: center;
margin: 0;
}
/* ── Responsive ──────────────────────────────────────────────────────────────── */
@media (max-width: 480px) {
.contribuer-page {
padding: 1rem 0.75rem 3rem;
}
.form-actions {
flex-direction: column-reverse;
}
.btn-primary,
.btn-secondary {
width: 100%;
justify-content: center;
}
}
</style>

View File

@@ -201,10 +201,19 @@
</div>
</div>
<!-- Filtres FONCTION chips flex-wrap -->
<!-- Filtres FONCTION chips flex-wrap + toggle collapse -->
<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">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
<span class="text-xs font-bold uppercase tracking-wide" style="color: var(--nav-text-muted);">
FONCTION
<span v-if="fonctions.length" style="font-weight: 400; text-transform: none; letter-spacing: 0; font-size: 0.65rem; margin-left: 4px;">({{ fonctions.length }} active{{ fonctions.length > 1 ? 's' : '' }})</span>
</span>
<button
@click="mobileFonctionsOpen = !mobileFonctionsOpen"
style="font-size: 0.65rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0; white-space: nowrap;"
>{{ mobileFonctionsOpen || fonctions.length ? (mobileFonctionsOpen ? 'Replier' : 'Afficher') : 'Fonctions (' + FONCTIONS.length + ')' }}</button>
</div>
<div v-if="mobileFonctionsOpen || fonctions.length" class="flex flex-wrap gap-1">
<span
v-for="fn in FONCTIONS"
:key="fn"
@@ -365,6 +374,7 @@ const ficheModalOpen = ref(false)
const ficheModalId = ref<number | null>(null)
const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
const missionOpen = ref(false)
const mobileFonctionsOpen = ref(false)
onMounted(() => {
try {

View File

@@ -1,56 +1,414 @@
<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'" />
<!-- ZONE PRINCIPALE (pleine largeur, pas de sidebar) -->
<main class="media-main">
<!-- Header onglet -->
<div class="shrink-0 px-5 py-3"
style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<h1 class="font-bold text-base" style="color: var(--nav-text);">ATIS Media</h1>
<p class="text-xs mt-0.5" style="color: var(--nav-text-muted);">
{{ corpusCount }} auteurs ingeres dans le RAG - carte FRACAS Bonpote V2
</p>
</div>
<!-- Conteneur split / plein ecran -->
<div class="layout-container">
<!-- Slot carte D3 -->
<div
class="carte-slot"
:class="[
layoutMode === 'split' ? 'carte-split' : '',
layoutMode === 'carte-full' ? 'carte-full' : '',
layoutMode === 'chatbot-full' ? 'carte-hidden' : '',
]"
:style="layoutMode === 'split' ? { flexBasis: carteFlexBasis } : {}"
>
<ClientOnly>
<CartePensees
ref="cartePenseesRef"
:data="penseesData"
:active="true"
@select-auteur="onSelectAuteur"
/>
<template #fallback>
<div class="w-full h-full flex items-center justify-center" style="color: var(--nav-text-muted);">
Chargement de la carte...
</div>
</template>
</ClientOnly>
</div>
<!-- Barre de toggle -->
<div class="layout-toggle-bar shrink-0">
<button
@click="setLayoutMode('carte-full')"
:class="{ active: layoutMode === 'carte-full' }"
class="toggle-btn"
title="Carte en plein ecran"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
</svg>
Carte plein ecran
</button>
<button
v-if="layoutMode !== 'split'"
@click="setLayoutMode('split')"
class="toggle-btn"
title="Vue partagee"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="12" x2="21" y2="12"/>
</svg>
Vue partagee
</button>
<button
@click="setLayoutMode('chatbot-full')"
:class="{ active: layoutMode === 'chatbot-full' }"
class="toggle-btn"
title="Chatbot en plein ecran"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
Chatbot plein ecran
</button>
</div>
<!-- Poignee draggable (visible uniquement en mode split, pas sur mobile) -->
<div
v-if="layoutMode === 'split'"
class="split-handle"
@mousedown.prevent="onHandleMousedown"
title="Redimensionner"
>
<span class="split-handle-grip"></span>
</div>
<!-- Slot chatbot inline -->
<div
class="chatbot-slot"
:class="[
layoutMode === 'split' ? 'chatbot-split' : '',
layoutMode === 'chatbot-full' ? 'chatbot-full-mode' : '',
layoutMode === 'carte-full' ? 'chatbot-hidden' : '',
]"
:style="layoutMode === 'split' ? { flexBasis: chatbotFlexBasis } : {}"
>
<ClientOnly>
<ChatbotPensees :auteurContext="chatbotAuteur" :inline="true" />
</ClientOnly>
</div>
</div>
</main>
<!-- Fiche auteur modal -->
<FicheAuteur
:open="ficheOpen"
:auteurId="ficheAuteurId"
:data="penseesData"
@close="ficheOpen = false"
@interroger-rag="onInterrogerRag"
/>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const router = useRouter()
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[] }
const tab = ref<'visuel' | 'projets'>(
(['visuel', 'projets'].includes(route.query.tab as string)
? route.query.tab as 'visuel' | 'projets'
: 'visuel')
)
type LayoutMode = 'split' | 'carte-full' | 'chatbot-full'
watch(tab, (newTab) => {
router.replace({ query: { ...route.query, tab: newTab } })
const STORAGE_KEY = 'media-layout-mode'
const SPLIT_RATIO_KEY = 'media-split-ratio'
const DEFAULT_SPLIT_RATIO = 0.66
const ficheOpen = ref(false)
const ficheAuteurId = ref<string | null>(null)
const chatbotAuteur = ref<string | null>(null)
const penseesData = ref<PenseesData | null>(null)
const layoutMode = ref<LayoutMode>('split')
const cartePenseesRef = ref<{ triggerResize: () => void } | null>(null)
// Ratio de la carte vs chatbot en mode split (0.2 a 0.8)
const splitRatio = ref(DEFAULT_SPLIT_RATIO)
const carteFlexBasis = computed(() => `${splitRatio.value * 100}%`)
const chatbotFlexBasis = computed(() => `${(1 - splitRatio.value) * 100}%`)
// Phase 8.D : compteur = auteurs ingere:true uniquement (32 reels, pas 171 total)
const corpusCount = computed(() => penseesData.value?.auteurs.filter(a => a.ingere).length ?? 0)
// Logique poignee draggable
let dragStartY = 0
let dragStartRatio = DEFAULT_SPLIT_RATIO
let containerHeight = 0
function onHandleMousedown(e: MouseEvent) {
dragStartY = e.clientY
dragStartRatio = splitRatio.value
// Hauteur du layout-container (carte + handle + chatbot)
const container = (e.target as HTMLElement)?.closest('.layout-container') as HTMLElement | null
containerHeight = container ? container.clientHeight : window.innerHeight
window.addEventListener('mousemove', onHandleMousemove)
window.addEventListener('mouseup', onHandleMouseup)
}
function onHandleMousemove(e: MouseEvent) {
const delta = e.clientY - dragStartY
const newRatio = dragStartRatio + delta / containerHeight
splitRatio.value = Math.min(0.80, Math.max(0.20, newRatio))
}
function onHandleMouseup() {
window.removeEventListener('mousemove', onHandleMousemove)
window.removeEventListener('mouseup', onHandleMouseup)
if (typeof window !== 'undefined') {
localStorage.setItem(SPLIT_RATIO_KEY, String(splitRatio.value))
}
// Notifier D3 du resize apres relachement
cartePenseesRef.value?.triggerResize()
}
onMounted(async () => {
// Restaurer le mode de layout depuis localStorage
if (typeof window !== 'undefined') {
const saved = localStorage.getItem(STORAGE_KEY) as LayoutMode | null
if (saved && ['split', 'carte-full', 'chatbot-full'].includes(saved)) {
layoutMode.value = saved
}
const savedRatio = parseFloat(localStorage.getItem(SPLIT_RATIO_KEY) ?? '')
if (!isNaN(savedRatio) && savedRatio >= 0.20 && savedRatio <= 0.80) {
splitRatio.value = savedRatio
}
}
try {
penseesData.value = await $fetch<PenseesData>('/data/auteurs-pensees.json')
} catch (e) {
console.error('Erreur chargement auteurs-pensees.json', e)
}
})
useHead({ title: 'AEP - Media' })
// Persister + reset D3 apres transition
function setLayoutMode(mode: LayoutMode) {
layoutMode.value = mode
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, mode)
}
// Restart simulation D3 apres la fin de la transition CSS (300ms)
if (mode !== 'chatbot-full') {
setTimeout(() => {
cartePenseesRef.value?.triggerResize()
}, 350)
}
}
watch(layoutMode, (v) => {
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, v)
}
})
function onSelectAuteur(id: string) {
ficheAuteurId.value = id
ficheOpen.value = true
chatbotAuteur.value = null
}
function onInterrogerRag(auteurId: string) {
ficheOpen.value = false
const auteur = penseesData.value?.auteurs.find(a => a.id === auteurId)
chatbotAuteur.value = auteur?.nom ?? null
// Basculer en split pour que le chatbot soit visible
if (layoutMode.value === 'carte-full') {
setLayoutMode('split')
}
}
useHead({ title: 'AEP - Media - Carte FRACAS Bonpote' })
</script>
<style scoped>
.media-page { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.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;
/* Page container : flex column, prend toute la hauteur viewport */
.media-page {
display: flex;
height: 100%;
overflow: hidden;
}
.media-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
/* Conteneur des slots carte + toggle + chatbot */
.layout-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
/* --- Slot carte --- */
.carte-slot {
overflow: hidden;
position: relative;
transition: opacity 0.2s ease;
}
.carte-split {
flex: 0 0 66%;
min-height: 0;
opacity: 1;
}
.carte-full {
flex: 1 1 100%;
min-height: 0;
opacity: 1;
}
.carte-hidden {
flex: 0 0 0;
height: 0;
opacity: 0;
overflow: hidden;
}
/* --- Barre de toggle --- */
.layout-toggle-bar {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
background: var(--nav-bg);
border-top: 1px solid rgba(180, 170, 160, 0.22);
border-bottom: 1px solid rgba(180, 170, 160, 0.22);
min-height: 38px;
}
.toggle-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
background: var(--nav-bg-alt);
color: var(--nav-text-muted);
border: 1px solid transparent;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.toggle-btn:hover {
background: var(--nav-surface);
color: var(--nav-text);
}
.toggle-btn.active {
background: var(--nav-primary);
color: var(--nav-text-on-primary);
border-color: var(--nav-primary);
}
/* --- Poignee draggable entre carte et chatbot --- */
.split-handle {
flex-shrink: 0;
height: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: row-resize;
background: transparent;
position: relative;
z-index: 10;
user-select: none;
}
.split-handle:hover {
background: rgba(180, 170, 160, 0.18);
}
.split-handle-grip {
display: block;
width: 32px;
height: 4px;
border-radius: 2px;
background: repeating-linear-gradient(
to bottom,
rgba(160, 150, 140, 0.55) 0px,
rgba(160, 150, 140, 0.55) 1px,
transparent 1px,
transparent 3px
);
}
/* Masquer la poignee sur mobile (ratio fixe) */
@media (max-width: 767px) {
.split-handle {
display: none;
}
}
/* --- Slot chatbot --- */
.chatbot-slot {
overflow: hidden;
position: relative;
transition: opacity 0.2s ease;
border-top: 1px solid rgba(180, 170, 160, 0.28);
}
.chatbot-split {
flex: 0 0 34%;
min-height: 0;
opacity: 1;
}
.chatbot-full-mode {
flex: 1 1 100%;
min-height: 0;
opacity: 1;
}
.chatbot-hidden {
flex: 0 0 0;
height: 0;
opacity: 0;
overflow: hidden;
}
/* --- Responsive mobile (<768px) --- */
/* Stack vertical : carte 60vh + chatbot 40vh en mode split */
@media (max-width: 767px) {
.carte-split {
flex: 0 0 60vh;
height: 60vh;
}
.chatbot-split {
flex: 0 0 calc(40vh - 38px);
height: calc(40vh - 38px);
}
.toggle-btn span,
.toggle-btn {
font-size: 0.7rem;
padding: 3px 7px;
}
}
.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>

View File

@@ -1,533 +0,0 @@
<template>
<div class="outils-page">
<!-- EN-TÊTE PAGE -->
<header class="outils-header">
<div class="outils-header__inner">
<div class="outils-header__icon-wrap" aria-hidden="true">
<img src="/icons/outils-wrench.svg" alt="" class="outils-header__icon" />
</div>
<div>
<h1 class="outils-header__title">Outils</h1>
<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> Bibliothèque de ressources libres
</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.
</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')
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;
}
.fmhy-tree-wrap {
padding: 0.875rem 0.75rem;
}
}
</style>

View File

@@ -1,194 +0,0 @@
<template>
<div class="proposer-page">
<div class="proposer-inner">
<NuxtLink to="/" class="back-link"> Retour à la carte</NuxtLink>
<div class="proposer-header">
<h1>Proposer une ressource</h1>
<p class="proposer-subtitle">
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 RAG' },
] as const
const activeTab = ref<string>('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>

View File

@@ -1,3 +0,0 @@
<script setup lang="ts">
navigateTo('/media', { redirectCode: 301 })
</script>

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1,102 +0,0 @@
{
"name": "FMHY — Sélection Architecte",
"description": "~50 ressources curées depuis FMHY (Free Media Heck Yeah) — pertinentes pour un architecte, un praticien de la transition, un créateur solo.",
"children": [
{
"name": "IA & Outils cognitifs",
"children": [
{ "name": "ChatGPT (OpenAI)", "url": "https://chat.openai.com/", "desc": "LLM généraliste, référence." },
{ "name": "Claude (Anthropic)", "url": "https://claude.ai/", "desc": "Excellent pour rédaction longue et analyse de documents." },
{ "name": "Mistral Le Chat", "url": "https://chat.mistral.ai/", "desc": "LLM français, souverain, 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." }
]
}
]
}

View File

@@ -1,90 +0,0 @@
{
"simulateurs": [
{
"id": "autonomie",
"icon": "🟢",
"titre": "Simulateur Autonomie",
"url": "https://calculs.trans-former.fr/autonomie/",
"description": "Évaluer le degré d'autonomie d'une famille à un site donné selon les ressources locales (eau, énergie, alimentation).",
"cta": "Lancer le simulateur →",
"tag": "outil-aep"
}
],
"simulateurs_inspirations": [
{
"id": "florquin-prix-m2",
"icon": "💡",
"titre": "Estimation prix au m² — Florquin Studio",
"url": "https://offre.florquinstudio.com/",
"description": "Une agence parisienne qui a construit un système d'estimation au m² assez fin. Pas dans nos outils, mais inspirant pour qui veut industrialiser son chiffrage.",
"tag": "inspiration-externe"
}
],
"opensource": [
{
"id": "dictee-universelle-groq",
"icon": "🎤",
"titre": "Dictée universelle Groq",
"url": "https://github.com/Jayjay-nene/dictee-universelle-groq",
"description": "Appuie sur une touche, parle, le texte apparaît au curseur avec ponctuation et majuscules auto. Transcription Whisper Groq < 1s. Marche dans toutes les applis Windows. Outil par Jayjay-nene.",
"tag": "recommande"
},
{
"id": "atis-voice",
"icon": "🎙",
"titre": "Atis Voice (text-to-speech)",
"url": null,
"description": "Pipeline TTS pour transformer un texte en audio.",
"tag": "disponible"
},
{
"id": "install-vps",
"icon": "🖥",
"titre": "Install VPS open source",
"url": null,
"description": "Setup pas-à-pas pour monter son propre VPS (Hetzner, Coolify, Caddy, Postgres…) en mode reproductible.",
"tag": "a-venir"
},
{
"id": "skills-claude-code",
"icon": "⚙",
"titre": "Skills Claude Code",
"url": null,
"description": "Skills custom pour booster sa pratique avec un agent IA.",
"tag": "a-venir"
}
],
"bifurcation": {
"intro": "Beaucoup de jeunes diplômés en archi cherchent des chemins alternatifs. Cette section rassemble des témoignages, expériences, et ressources sur ce que c'est que de bifurquer.",
"videos_ofqa": [
{ "ep": "01", "titre": "Architectes indépendants", "personnes": "Jules Nény & Imane Fatmi", "url": "https://youtu.be/aMreB5KdNhY" },
{ "ep": "02", "titre": "Artiste & Maçon", "personnes": "Romane Dutour & Maël Canal", "url": "https://youtu.be/9gpjokx2ndI" },
{ "ep": "03", "titre": "Social & BTP", "personnes": "Célia Berdy & Esilda Perrot", "url": null, "note": "vidéo perdue, doc PDF seulement" },
{ "ep": "04", "titre": "Menuisier & Paysagiste", "personnes": "Adel Mohamedi & Julie Bowie", "url": "https://youtu.be/yKaRQhA3Z6g" },
{ "ep": "05", "titre": "Éco-construction", "personnes": "Edouard Vermès", "url": "https://youtu.be/97bDg1BjeuQ" },
{ "ep": "06", "titre": "Musicien & Urbaniste", "personnes": "Ruben Madar & Antoine Troccaz", "url": "https://drive.google.com/drive/folders/14g8YBn5bZAy8aIkHzQlOTrTtnWOaqRO3" },
{ "ep": "07", "titre": "AMO & Réemploi", "personnes": "Domitille Chaigne & Clémence Bondon", "url": "https://drive.google.com/file/d/1Q9Za81CElszmMn5n8dBsG0pJkiWmB32c/view" },
{ "ep": "08", "titre": "Gouvernance école", "personnes": "Solenn Guével", "url": "https://drive.google.com/drive/folders/1UaLsSyQcJydkXyV71klrY1tv9KgAh-mG" },
{ "ep": "bonus", "titre": "PFE — invitation à faire collectif", "personnes": "Jules Nény & Imane Fatmi", "url": "https://youtu.be/4qTEIC2Lmqw" }
],
"coalition_ensa_pb": {
"titre": "Coalition inter-asso ENSA-PB — victoire salle des enseignants",
"description": "Une coalition d'associations étudiantes a obtenu l'usage de la salle des enseignants pour des temps de travail collectif."
},
"ressources_externes": [
{
"id": "drop-the-kutch",
"icon": "🎧",
"titre": "Podcast Drop the Kutch — Sâm Afchar",
"url": "https://podcasts-francais.fr/podcast/drop-the-kutsch",
"description": "Témoignages de bifurcations post études d'archi. Super taf."
}
]
},
"section_5_placeholder": {
"titre": "[V2 — Login] Logiciels pro",
"description": "Logiciels lourds (Adobe, AutoCAD…) — accès mutualisé. Disponible après création de compte AEP.",
"status": "bientot-login"
},
"footer_contribution": "Tu utilises un outil qui mérite d'être ici ? Écris-moi : contact@trans-former.fr"
}

View File

@@ -1,70 +0,0 @@
{
"projets": [
{
"id": "quartier-2030",
"titre": "Votre quartier en 2030",
"auteurs": ["Inconnu"],
"annee": "2020",
"ecole": "Inconnu",
"url": "https://quartier-2030.firebaseapp.com/",
"description": "Exploration prospective confrontant smart city, no future, résilience et deep ecology à l'échelle du quartier. Le travail donne à voir plusieurs futurs urbains contrastés, de l'utopie technologique au retrait radical, en laissant le visiteur naviguer entre les scénarios. Un travail d'orfèvre pour sortir de la pensée linéaire sur la ville.",
"thumb": null,
"link_status": "ok"
},
{
"id": "seine-nature",
"titre": "Seine — nature urbaine",
"auteurs": ["Inconnu"],
"annee": "2019",
"ecole": "Inconnu",
"url": "http://www.seine.natureurbaine.com/00_index/page_theme/theme.html",
"description": "Projet de transformation territoriale collective autour de la Seine, pensé comme une démarche systémique et pluridisciplinaire. L'intervention se concentre sur les marges périurbaines, traitées par une logique d'acupuncture : des micro-interventions précises pour enclencher des dynamiques plus larges. L'approche refuse le grand projet unique au profit d'un réseau de petites transformations.",
"thumb": null,
"link_status": "ok"
},
{
"id": "tmip",
"titre": "TMIP — Transformation de la Maison Individuelle Périurbaine",
"auteurs": ["Jules Nény"],
"annee": "2019",
"ecole": "ENSA Paris-Belleville",
"url": "https://issuu.com/transformationresilientes/docs/tmip_archijeunes_cstb_",
"description": "Étude de la maison périurbaine sous l'angle des Gilets jaunes : comment ce lieu de vie concentre les tensions entre émancipation individuelle et dépendance structurelle (voiture, énergie, services). Le projet propose un réseau de micro-infrastructures partagées pour transformer ces maisons isolées en systèmes résilients interconnectés. Publié avec ARCHI'JEUNES et le CSTB.",
"thumb": null,
"link_status": "ok"
},
{
"id": "filiere-bois",
"titre": "Enquête sur les paysages forestiers franciliens",
"auteurs": ["Quid Architecture"],
"annee": "2021",
"ecole": "Inconnu",
"url": "https://www.faireparis.com/fr/projets/faire-2021/enquete-sur-les-paysages-forestiers-franciliens-2159.html",
"description": "Projet lauréat FAIRE 2021. Enquête sur les dysfonctionnements de la filière bois en Île-de-France, aux interfaces entre sylviculteurs, scieries, artisans et maîtres d'ouvrage. Le travail cartographie les ruptures de filière et propose des interventions concrètes pour réparer les liens entre forêt et construction. Une démarche systémique rare dans les études architecturales.",
"thumb": null,
"link_status": "ok"
},
{
"id": "jeu-champagne",
"titre": "Jeu de rôle Champagne PFE — Plateau",
"auteurs": ["Inconnu"],
"annee": "2020",
"ecole": "Inconnu",
"url": "https://campfe2020.wixsite.com/champagnepfe/plateau",
"description": "Dispositif ludique et coopératif développé comme outil de médiation entre acteurs d'un territoire. Le jeu de rôle permet de traverser des problèmes complexes en engageant simultanément des parties prenantes aux intérêts divergents. Une exploration de l'architecture comme processus collectif plutôt que comme objet produit.",
"thumb": null,
"link_status": "ok"
},
{
"id": "transition-agricole",
"titre": "Transition agricole — réinvestissement de fermes traditionnelles",
"auteurs": ["Inconnu"],
"annee": "2020",
"ecole": "Inconnu",
"url": "https://www.calameo.com/books/007306483e0b23edb1db7",
"description": "Projet sur la transformation de fermes traditionnelles dans une logique agricole moderne et diversifiée. L'étude explore comment l'architecture peut accompagner les transitions d'usage des bâtiments ruraux, en articulant patrimonial et fonctionnel. Voir aussi le projet complémentaire sur la Seine aval : https://www.calameo.com/books/007063623f4d4b800b01d",
"thumb": null,
"link_status": "ok"
}
]
}

View File

@@ -1,12 +0,0 @@
# Icons Credits
## outils-wrench.svg
**Source :** Heroicons — `wrench-screwdriver` icon
**Licence :** MIT
**URL :** https://heroicons.com/
**Note :** Fallback Heroicons utilisé car l'API Noun Project a retourné 403 Forbidden au moment du build (2026-05-22). L'icône `wrench-screwdriver` de Heroicons (MIT) est une clé à molette + tournevis, style line art minimaliste, compatible avec l'identité visuelle AEP.
Si tu veux substituer par une icône Noun Project, utilise l'endpoint :
`GET https://api.thenounproject.com/v2/icon?query=wrench&limit=20`
avec les credentials OAuth 1.0a stockées dans `_System/API-credentials.md`.

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l5.654-4.654m5.598-2.337 3.07-3.293a2.25 2.25 0 0 0-3.182-3.182l-3.293 3.07M6.75 12.75l-2.25.75.75-2.25 2.25-.75-.75 2.25Z" />
</svg>

Before

Width:  |  Height:  |  Size: 493 B

View File

@@ -0,0 +1,376 @@
// Build auteurs-pensees.json v3.0 — Phase 8.A
// Sync corpus JSON unifié : Bonpote authors + LightRAG ingestion flags
import fs from 'node:fs';
import path from 'node:path';
const JSON_PATH = 'C:\\Users\\jules\\Dropbox\\ATIS - IPCJRA\\1 PROJETS\\TECH - infra VPS, website pro, RAG\\nav-carte\\public\\data\\auteurs-pensees.json';
// === LightRAG slug prefixes (from /documents endpoint 2026-05-12) ===
const LIGHTRAG_PREFIX_TO_AUTHOR_SLUG = {
bookchin: 'murray-bookchin',
brand: 'steward-brand',
carson: 'rachel-carson',
charbonneau: 'bernard-charbonneau',
descola: 'philippe-descola',
despret: 'vinciane-despret',
eaubonne: 'francoise-deaubonne',
ellul: 'jacques-ellul',
federici: 'silvia-federici',
ferdinand: 'malcolm-ferdinand',
figueres: 'christiana-figueres',
georgescu: 'nicholas-georgescu-roegen',
gorz: 'andre-gorz',
graeber: 'david-graeber',
keith: 'david-keith',
klein: 'naomi-klein',
kropotkine: 'pierre-kropotkine',
latouche: 'serge-latouche',
latour: 'bruno-latour',
lowy: 'michael-lowy',
malm: 'andreas-malm',
marx: 'karl-marx',
meadows: 'donella-meadows',
morizot: 'baptiste-morizot',
naess: 'arne-naess',
ouassak: 'fatima-ouassak',
reclus: 'elisee-reclus',
saito: 'kohei-saito',
servigne: 'pablo-servigne',
shiva: 'vandana-shiva',
stengers: 'isabelle-stengers',
vettese: 'troy-vettese',
};
const INGESTED_AUTHOR_SLUGS = new Set(Object.values(LIGHTRAG_PREFIX_TO_AUTHOR_SLUG));
// === Bonpote authors (nom, dates, ecole_principale, ecoles_secondaires[]) ===
const BONPOTE_AUTHORS = [
// Éco-anarchisme
['Pierre Kropotkine', '1842-1921', 'eco-anarchisme', []],
['Élisée Reclus', '1830-1905', 'eco-anarchisme', []],
['Murray Bookchin', '1921-2006', 'eco-anarchisme', []],
['David Graeber', '1961-2020', 'eco-anarchisme', []],
['James C. Scott', '1936-2024', 'eco-anarchisme', []],
['Marshall Sahlins', '1930-2021', 'eco-anarchisme', []],
['Pierre Clastres', '1934-1977', 'eco-anarchisme', []],
['Cornélius Castoriadis', '1922-1997', 'eco-anarchisme', []],
['David Harvey', '1935-', 'eco-anarchisme', ['ecosocialisme']],
['Henri Lefebvre', '1901-1991', 'eco-anarchisme', ['ecosocialisme']],
['Émile Gravelle', '1855-1920', 'eco-anarchisme', []],
['Henri Zisly', '1872-1945', 'eco-anarchisme', []],
['Edward Carpenter', '1844-1929', 'eco-anarchisme', []],
['William Morris', '1834-1896', 'eco-anarchisme', []],
['John Ruskin', '1819-1900', 'eco-anarchisme', []],
['Kirkpatrick Sale', '1937-', 'eco-anarchisme', []],
['Wendell Berry', '1934-', 'eco-anarchisme', []],
['Kristin Ross', '1953-', 'eco-anarchisme', []],
['Theodore Kaczynski', '1942-2023', 'eco-anarchisme', ['technocritique']],
['Saint-Simon', '1760-1825', 'eco-anarchisme', []],
['Auguste Comte', '1798-1857', 'eco-anarchisme', []],
['Alberto Magnaghi', '1941-2023', 'eco-anarchisme', []],
['Peter Berg', '1937-2011', 'eco-anarchisme', []],
['Andreas Malm', '1977-', 'ecosocialisme', ['eco-anarchisme']],
// Écosocialisme
['Karl Marx', '1818-1883', 'ecosocialisme', []],
['Friedrich Engels', '1820-1895', 'ecosocialisme', []],
['Rosa Luxemburg', '1871-1919', 'ecosocialisme', []],
['Walter Benjamin', '1892-1940', 'ecosocialisme', []],
['John Maynard Keynes', '1883-1946', 'ecosocialisme', []],
['Pascal Lamy', '1947-', 'ecosocialisme', []],
['Ann Pettifor', '1947-', 'ecosocialisme', []],
['Holly Jean Buck', '', 'ecosocialisme', []],
['Cédric Durand', '1975-', 'ecosocialisme', []],
['Kim Stanley Robinson', '1952-', 'ecosocialisme', []],
['André Gorz', '1923-2007', 'ecosocialisme', ['decroissance', 'technocritique']],
['Kohei Saito', '1987-', 'ecosocialisme', ['decroissance']],
['Razmig Keucheyan', '1975-', 'ecosocialisme', []],
['Dominique Méda', '1962-', 'ecosocialisme', []],
['Dominique Bourg', '1953-', 'ecosocialisme', []],
['Troy Vettese', '', 'ecosocialisme', []],
['Loïc Blondiaux', '1962-', 'ecosocialisme', []],
['Drew Pendergrass', '', 'ecosocialisme', []],
['Jason W. Moore', '', 'ecosocialisme', []],
["James O'Connor", '1930-2017', 'ecosocialisme', []],
['Herman Daly', '1938-2022', 'ecosocialisme', ['capitalisme-vert']],
['John Bellamy Foster', '1953-', 'ecosocialisme', []],
['Michael Löwy', '1938-', 'ecosocialisme', []],
['Joel Kovel', '1936-2018', 'ecosocialisme', []],
['Naomi Klein', '1970-', 'ecosocialisme', []],
// Technocritique
['Jacques Ellul', '1912-1994', 'technocritique', []],
['Bernard Charbonneau', '1910-1996', 'technocritique', []],
['Lewis Mumford', '1895-1990', 'technocritique', []],
['Alain Caillé', '1944-', 'technocritique', []],
['Hans Jonas', '1903-1993', 'technocritique', ['ethiques-environnementales']],
['Herbert Marcuse', '1898-1979', 'technocritique', []],
['Günther Anders', '1902-1992', 'technocritique', []],
['Pierre Fournier', '1937-1973', 'technocritique', []],
['Alexandre Grothendieck', '1928-2014', 'technocritique', []],
['Patrick Viveret', '1948-', 'technocritique', []],
['Philippe Bihouix', '1971-', 'technocritique', []],
['Jean Baudrillard', '1929-2007', 'technocritique', []],
['Serge Latouche', '1940-', 'decroissance', ['technocritique']],
['Ivan Illich', '1926-2002', 'technocritique', ['decroissance']],
['Leopold Kohr', '1909-1994', 'technocritique', ['decroissance']],
['Ernst Schumacher', '1911-1977', 'technocritique', ['decroissance']],
['Nicholas Georgescu-Roegen', '1906-1994', 'decroissance', ['technocritique']],
// Écoféminismes
["Françoise d'Eaubonne", '1920-2005', 'ecofeminismes', []],
['Vandana Shiva', '1952-', 'ecofeminismes', ['ecologies-decoloniales']],
['Starhawk', '1951-', 'ecofeminismes', []],
['Ariel Salleh', '1944-', 'ecofeminismes', []],
['Maria Mies', '1931-2023', 'ecofeminismes', []],
['Carolyn Merchant', '1936-', 'ecofeminismes', []],
['Silvia Federici', '1942-', 'ecofeminismes', []],
['Val Plumwood', '1939-2008', 'ecofeminismes', []],
['Susan Griffin', '1943-', 'ecofeminismes', []],
['Veronika Bennholdt-Thomsen', '1944-', 'ecofeminismes', []],
['Geneviève Pruvost', '1973-', 'ecofeminismes', []],
['Donna Haraway', '1944-', 'ecofeminismes', ['pensees-vivant']],
['Émilie Hache', '', 'ecofeminismes', []],
['Joanna Macy', '1929-', 'ecofeminismes', ['ethiques-environnementales']],
// Capitalisme vert
['Bill Gates', '1955-', 'capitalisme-vert', []],
['Christiana Figueres', '1956-', 'capitalisme-vert', []],
['Nicholas Stern', '1946-', 'capitalisme-vert', []],
['Jeffrey Sachs', '1954-', 'capitalisme-vert', []],
['Jared Diamond', '1937-', 'capitalisme-vert', ['decroissance']],
['Jørgen Randers', '1945-', 'capitalisme-vert', ['decroissance']],
['Donella Meadows', '1941-2001', 'decroissance', ['capitalisme-vert']],
['Dennis Meadows', '1942-', 'decroissance', ['capitalisme-vert']],
['Kate Raworth', '1970-', 'capitalisme-vert', []],
['Al Gore', '1948-', 'capitalisme-vert', []],
['Hal Harvey', '1960-', 'capitalisme-vert', []],
['Laurence Tubiana', '1951-', 'capitalisme-vert', []],
['Amory Lovins', '1947-', 'capitalisme-vert', []],
['David Pearce', '1959-', 'capitalisme-vert', []],
['Kerry Turner', '1948-', 'capitalisme-vert', []],
['David Keith', '1963-', 'capitalisme-vert', []],
['Ted Nordhaus', '1965-', 'capitalisme-vert', []],
['Michael Shellenberger', '1971-', 'capitalisme-vert', []],
['Pavan Sukhdev', '1960-', 'capitalisme-vert', []],
['Janine Benyus', '1958-', 'capitalisme-vert', []],
['Robert Costanza', '1950-', 'capitalisme-vert', []],
['Peter Kareiva', '1951-', 'capitalisme-vert', []],
['Michelle Marvier', '', 'capitalisme-vert', []],
['Robert Lalasz', '1915-2003', 'capitalisme-vert', []],
['Steward Brand', '1938-', 'capitalisme-vert', []],
['Paul Crutzen', '1933-2021', 'capitalisme-vert', []],
['Kenneth Boulding', '1910-1993', 'capitalisme-vert', []],
['Eugene Odum', '1913-2002', 'capitalisme-vert', []],
['Howard Odum', '1924-2002', 'capitalisme-vert', []],
['Jean-Marc Jancovici', '1962-', 'capitalisme-vert', []],
['Yves Cochet', '1946-', 'capitalisme-vert', ['decroissance']],
['Pablo Servigne', '1978-', 'decroissance', ['capitalisme-vert']],
['Gauthier Chapelle', '1968-', 'decroissance', ['capitalisme-vert']],
// Écologies décoloniales
['Malcom Ferdinand', '1985-', 'ecologies-decoloniales', []],
['Frantz Fanon', '1925-1961', 'ecologies-decoloniales', []],
['Édouard Glissant', '1928-2011', 'ecologies-decoloniales', []],
['Aimé Césaire', '1913-2008', 'ecologies-decoloniales', []],
['Mohamad Amer Meziane', '', 'ecologies-decoloniales', []],
['Chico Mendes', '1944-1988', 'ecologies-decoloniales', []],
['Joan Martínez Alier', '1939-', 'ecologies-decoloniales', []],
['Arturo Escobar', '1951-', 'ecologies-decoloniales', []],
['Sous-commandant Marcos', '1957-', 'ecologies-decoloniales', []],
['Alberto Acosta', '1948-', 'ecologies-decoloniales', []],
['Jérôme Baschet', '1960-', 'ecologies-decoloniales', []],
['Fatima Ouassak', '1976-', 'ecofeminismes', ['ecologies-decoloniales']],
['William Acker', '1991-', 'ecologies-decoloniales', []],
['Giorgos Kallis', '1972-', 'ecologies-decoloniales', ['decroissance']],
['Bernard Lambert', '1931-1984', 'ecologies-decoloniales', []],
// Écofascismes
['Alain de Benoist', '1943-', 'ecofascismes', []],
['Paul Ralph Ehrlich', '1932-', 'ecofascismes', []],
['Garrett Hardin', '1915-2003', 'ecofascismes', []],
['Edward Osborne Wilson', '1929-2021', 'ecofascismes', []],
['Thomas Malthus', '1803-1882', 'ecofascismes', []],
['David Foreman', '1946-2022', 'ecofascismes', []],
['Piero San Giorgio', '1971-', 'ecofascismes', []],
// Éthique environnementale
['Arne Næss', '1912-2009', 'ethiques-environnementales', []],
['Rachel Carson', '1907-1964', 'ethiques-environnementales', []],
['Aldo Leopold', '1887-1948', 'ethiques-environnementales', []],
['Imanishi Kinji', '1902-1992', 'ethiques-environnementales', []],
['Paul Watson', '1950-', 'ethiques-environnementales', []],
['John Muir', '1838-1914', 'ethiques-environnementales', []],
['Edward Abbey', '1927-1989', 'ethiques-environnementales', []],
['John Baird Callicott', '1941-', 'ethiques-environnementales', []],
['Bill Mollison', '1928-2016', 'ethiques-environnementales', []],
['David Holmgren', '1955-', 'ethiques-environnementales', []],
['Peter Singer', '1946-', 'ethiques-environnementales', []],
['Pierre Rabhi', '1938-2021', 'ethiques-environnementales', []],
['Rob Hopkins', '1968-', 'ethiques-environnementales', []],
['Cyril Dion', '1978-', 'ethiques-environnementales', []],
['Gandhi', '1869-1948', 'ethiques-environnementales', []],
['Gifford Pinchot', '1865-1946', 'ethiques-environnementales', []],
['Lanza del Vasto', '1901-1981', 'ethiques-environnementales', []],
['Jorge Mario Bergoglio', '1936-', 'ethiques-environnementales', []],
['Gary Snyder', '1930-', 'ethiques-environnementales', []],
['Henry David Thoreau', '1817-1862', 'ethiques-environnementales', []],
['Ralph Waldo Emerson', '1803-1882', 'ethiques-environnementales', []],
['José Bové', '1953-', 'ethiques-environnementales', []],
['Glenn Albrecht', '1953-', 'ethiques-environnementales', []],
// Pensées du vivant
['Bruno Latour', '1947-2022', 'pensees-vivant', []],
['Isabelle Stengers', '1949-', 'pensees-vivant', []],
['Vinciane Despret', '1959-', 'pensees-vivant', []],
['Baptiste Morizot', '1983-', 'pensees-vivant', []],
['Philippe Descola', '1949-', 'pensees-vivant', []],
['Eduardo Viveiros de Castro', '1951-', 'pensees-vivant', []],
['Anna Tsing', '1952-', 'pensees-vivant', []],
['Deborah Bird Rose', '1946-2018', 'pensees-vivant', []],
['Lynn Margulis', '1938-2011', 'pensees-vivant', []],
['James Lovelock', '1919-2022', 'pensees-vivant', []],
['Serge Moscovici', '1925-2014', 'pensees-vivant', []],
['Theodore Roszak', '1933-2011', 'pensees-vivant', []],
['Baruch Spinoza', '1632-1677', 'pensees-vivant', []],
];
// Special slug overrides (match v2.1 IDs + ligatures)
const NAME_TO_SLUG_OVERRIDES = {
'Malcom Ferdinand': 'malcolm-ferdinand',
"Françoise d'Eaubonne": 'francoise-deaubonne',
'Donella Meadows': 'donella-meadows',
'Dennis Meadows': 'dennis-meadows',
'Arne Næss': 'arne-naess',
'Jørgen Randers': 'jorgen-randers',
};
function slugify(name) {
// Pre-process special ligatures and chars not handled by NFKD
let pre = name
.replace(/[æÆ]/g, 'ae')
.replace(/[øØ]/g, 'o')
.replace(/[œŒ]/g, 'oe')
.replace(/ß/g, 'ss');
// Remove diacritical marks
const noAccent = pre.normalize('NFKD').replace(/[̀-ͯ]/g, '');
return noAccent
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function getAuthorSlug(name) {
if (NAME_TO_SLUG_OVERRIDES[name]) return NAME_TO_SLUG_OVERRIDES[name];
return slugify(name);
}
function main() {
const raw = fs.readFileSync(JSON_PATH, 'utf-8');
const data = JSON.parse(raw);
const existingBySlug = {};
for (const a of data.auteurs) existingBySlug[a.id] = a;
const newAuthors = [];
const seenSlugs = new Set();
for (const [nom, dates, ecolePrincipale, ecolesSecondaires] of BONPOTE_AUTHORS) {
const slug = getAuthorSlug(nom);
if (seenSlugs.has(slug)) {
console.error(`DUPLICATE SKIP: ${nom} -> ${slug}`);
continue;
}
seenSlugs.add(slug);
const ingere = INGESTED_AUTHOR_SLUGS.has(slug);
const ecoles = [ecolePrincipale, ...ecolesSecondaires];
if (existingBySlug[slug]) {
// Preserve enriched entry
const entry = { ...existingBySlug[slug], ingere };
newAuthors.push(entry);
} else {
// New minimal entry
const bioProvisoire = ingere
? `Auteur·ice ingéré·e dans le RAG ATIS, bio à enrichir lors de PRG-5.`
: `Théoricien·ne présent·e sur le poster Bonpote (${ecolePrincipale}), non ingéré·e dans le RAG ATIS.`;
newAuthors.push({
id: slug,
nom,
dates,
ecoles,
ecole_principale: ecolePrincipale,
livres_rag: [],
theses_cles_attendues: [],
bio_courte_provisoire: bioProvisoire,
ingere,
});
}
}
// Preserve any v2.1 author not in Bonpote list
for (const [slug, entry] of Object.entries(existingBySlug)) {
if (!seenSlugs.has(slug)) {
const copy = { ...entry };
if (!('ingere' in copy)) copy.ingere = INGESTED_AUTHOR_SLUGS.has(slug);
newAuthors.push(copy);
seenSlugs.add(slug);
console.error(`NOTE: preserved v2.1 author not in Bonpote canonical: ${slug}`);
}
}
const auteursCount = newAuthors.length;
const auteursIngeresCount = newAuthors.filter(a => a.ingere).length;
data.meta.version = '3.0';
data.meta.updated = '2026-05-12';
data.meta.auteurs_count = auteursCount;
data.meta.auteurs_ingeres_count = auteursIngeresCount;
data.meta.source = 'FRACAS Bonpote V2 oct 2024 + LightRAG corpus 12/05/2026 (v3.0 sync)';
data.meta.note_v3_0 = 'Phase 8.A sync corpus unifie : ~140 auteurs Bonpote integres, flag ingere:true/false selon LightRAG VPS. Auteurs non-ingeres = entrees minimales (bio provisoire, livres_rag vide), a enrichir lors de PRG-4/PRG-5.';
data.auteurs = newAuthors;
fs.writeFileSync(JSON_PATH, JSON.stringify(data, null, 2), 'utf-8');
// Validate parse-back
const parsedBack = JSON.parse(fs.readFileSync(JSON_PATH, 'utf-8'));
if (parsedBack.auteurs.length !== auteursCount) {
console.error('PARSE-BACK MISMATCH');
process.exit(1);
}
// Stats
const schoolsStats = {};
for (const a of newAuthors) {
const ep = a.ecole_principale || '?';
if (!schoolsStats[ep]) schoolsStats[ep] = { total: 0, ingere: 0 };
schoolsStats[ep].total++;
if (a.ingere) schoolsStats[ep].ingere++;
}
console.log('\n=== JSON v3.0 written ===');
console.log(`Total auteurs : ${auteursCount}`);
console.log(`Ingeres : ${auteursIngeresCount}`);
console.log(`Non-ingeres : ${auteursCount - auteursIngeresCount}`);
console.log(`Parse-back : OK (${parsedBack.auteurs.length} auteurs)`);
console.log('\nPer school (ecole_principale):');
const sortedSchools = Object.entries(schoolsStats).sort((a, b) => b[1].total - a[1].total);
for (const [school, st] of sortedSchools) {
console.log(` ${school.padEnd(30)} total=${String(st.total).padStart(3)} ingere=${String(st.ingere).padStart(3)} non-ing=${String(st.total - st.ingere).padStart(3)}`);
}
// Top 5 schools with most non-ingested
const nonIngStats = sortedSchools
.map(([k, v]) => [k, v.total - v.ingere])
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
console.log('\nTop 5 ecoles avec le plus de non-ingeres (PRG-4 priorities):');
for (const [school, count] of nonIngStats) {
console.log(` ${school.padEnd(30)} non-ing=${count}`);
}
}
main();

View File

@@ -1,12 +1,35 @@
/**
* POST /api/chatbot-pensees
* Chatbot RAG Pensées Écologiques — corpus FRACAS Bonpote
* Appelle LightRAG PE (localhost:9621) avec contexte auteur optionnel.
*/
import type { H3Event } from 'h3'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
interface ChatbotPenseesRequest {
query: string
mode?: 'hybrid' | 'local' | 'global' | 'naive' | 'mix'
corpus?: 'pensees' | 'projets' | 'both'
filter_couche?: 'fond' | 'forme' | 'structure' | null
filter_ecole?: string | null
auteur_slug?: string | null
history?: Array<{ role: 'user' | 'assistant'; content: string }>
}
interface LightRAGReference {
reference_id?: string
file_path?: string
content?: string | null
}
interface LightRAGQueryResponse {
response: string
references?: LightRAGReference[]
}
interface AuteurMini {
id: string
nom: string
ingere?: boolean
}
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...).
@@ -52,17 +75,15 @@ Règles :
- 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[] {
// Chargement (et cache) de la liste des auteurs ingérés pour validation du slug
let auteursIngeresCache: AuteurMini[] | null = null
function loadAuteursIngeres(): AuteurMini[] {
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 ?? [])
const list: AuteurMini[] = (data.auteurs ?? [])
.filter((a: any) => a.ingere === true)
.map((a: any) => ({ id: String(a.id), nom: String(a.nom), ingere: true }))
auteursIngeresCache = list
@@ -73,26 +94,33 @@ function loadAuteursIngeres(): AuteurIngere[] {
}
}
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
export default defineEventHandler(async (event: H3Event) => {
const config = useRuntimeConfig(event)
// 1. Rate limit (20 req/jour/IP, IP hashée RGPD)
const ip =
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
event.node.req.socket?.remoteAddress ||
'0.0.0.0'
const ip = getHeader(event, 'x-forwarded-for')?.split(',')[0].trim()
|| event.node.req.socket?.remoteAddress
|| '0.0.0.0'
const allowed = checkRateLimitJson(ip, 'chatbot-pensees', 20)
if (!allowed) throw createError({ statusCode: 429, message: 'Limite de 20 questions par jour atteinte.' })
if (!allowed) {
throw createError({ statusCode: 429, message: 'Limite de 20 questions par jour atteinte.' })
}
const body = await readBody(event)
// 2. Body parse + validation
const body = await readBody<ChatbotPenseesRequest>(event)
if (!body?.query || body.query.trim().length < 3 || body.query.trim().length > 500) {
throw createError({ statusCode: 400, message: 'Query invalide (3-500 caractères).' })
}
const query: string = body.query.trim()
const mode: string = body.mode || 'hybrid'
const corpus: string = body.corpus || 'both'
const ragUrl: string = (config.ragPeUrl as string) || 'http://localhost:9621'
const auteurSlug: string | null = body.auteur_slug?.trim().toLowerCase() || null
const query = body.query.trim()
const mode = body.mode || 'hybrid'
const corpus = body.corpus || 'both'
const ragUrl = (config.ragPeUrl as string) || 'http://localhost:9621'
// Validation auteur_slug (Phase 8.E) : match contre la liste des auteurs ingérés
const auteurSlug = body.auteur_slug?.trim().toLowerCase() || null
let nomAuteurMatch: string | null = null
if (auteurSlug) {
const ingeres = loadAuteursIngeres()
@@ -100,6 +128,7 @@ export default defineEventHandler(async (event) => {
nomAuteurMatch = auteur?.nom ?? null
}
// Préface adaptative : auteur prioritaire si slug matché, sinon corpus
let systemPreface: string
if (auteurSlug && nomAuteurMatch) {
systemPreface = buildPrefaceAuteur(nomAuteurMatch, auteurSlug)
@@ -111,34 +140,47 @@ export default defineEventHandler(async (event) => {
systemPreface = SYSTEM_PREFACE_BOTH
}
// 3. Health guard — LightRAG down = erreur claire, pas de fallback hallucinatoire
try {
await $fetch(`${ragUrl}/health`, { timeout: 5000 })
} catch {
throw createError({ statusCode: 503, message: 'RAG indisponible pour l\'instant — réessaie dans quelques minutes.' })
throw createError({
statusCode: 503,
message: 'RAG indisponible pour l\'instant — réessaie dans quelques minutes.',
})
}
// 4. Call LightRAG VPS — préface système injectée dans la query
const ragQuery = `${systemPreface}\n\nQuestion : ${query}`
const ragBody: Record<string, any> = { query: ragQuery, mode }
// Construction du body : hl_keywords + ll_keywords si auteur ciblé
// NB : LightRAG ne supporte ni keyword_filter ni ids ni metadata_filter (preflight OpenAPI confirmé).
// hl_keywords / ll_keywords sont les seuls leviers natifs de priorisation par auteur.
const ragBody: Record<string, unknown> = { query: ragQuery, mode }
if (auteurSlug && nomAuteurMatch) {
ragBody.hl_keywords = [nomAuteurMatch, auteurSlug]
ragBody.ll_keywords = [auteurSlug]
}
let ragResponse: any
let ragResponse: LightRAGQueryResponse
try {
ragResponse = await $fetch(`${ragUrl}/query`, {
ragResponse = await $fetch<LightRAGQueryResponse>(`${ragUrl}/query`, {
method: 'POST',
body: ragBody,
timeout: 90000,
})
} catch (e: any) {
const status = e?.response?.status
if (status === 429) throw createError({ statusCode: 429, message: 'RAG saturé — réessaie dans quelques instants.' })
if (status === 429) {
throw createError({ statusCode: 429, message: 'RAG saturé — réessaie dans quelques instants.' })
}
throw createError({ statusCode: 504, message: 'RAG en cours de processing — réessaie dans quelques secondes.' })
}
let chunksOnTarget = 0
// Fallback post-process : si auteur ciblé et que les references LightRAG remontent
// des chunks hors slug__, on l'indique pour transparence. La préface LLM est la garde principale.
let chunksOffTarget = 0
let chunksOnTarget = 0
if (auteurSlug && nomAuteurMatch && Array.isArray(ragResponse.references)) {
const slugPrefix = `${auteurSlug}__`
for (const ref of ragResponse.references) {
@@ -149,6 +191,7 @@ export default defineEventHandler(async (event) => {
}
}
// 5. Retour formaté
return {
response: ragResponse.response ?? '',
mode,

View File

@@ -6,7 +6,6 @@
// @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
@@ -62,7 +61,6 @@ export default defineEventHandler(async (event) => {
const body = await readBody(event)
const question: string = (body?.question ?? '').trim()
if (!question || question.length < 5) throw createError({ statusCode: 400, message: 'Question trop courte.' })
const tier = pickBifrostTier(body?.mode)
const structures: Structure[] = ((reseauxData as any).structures ?? [])
const keywords = extractKeywords(question)
@@ -84,20 +82,18 @@ export default defineEventHandler(async (event) => {
const systemPrompt = SYSTEM_PROMPT.replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0))
const bifrostUrl = config.bifrostUrl as string
const bifrostVk = config.bifrostVk as string
if (!bifrostVk) throw createError({ statusCode: 500, message: 'Clé Bifrost manquante.' })
const mistralApiKey = config.mistralApiKey as string
if (!mistralApiKey) throw createError({ statusCode: 500, message: 'Clé API Mistral manquante.' })
let mistralRaw: string
try {
const res = await $fetch<BifrostChatResponse>(
`${bifrostUrl}/v1/chat/completions`,
const res = await $fetch<{ choices: { message: { content: string } }[] }>(
'https://api.mistral.ai/v1/chat/completions',
{
method: 'POST',
headers: { 'x-bf-vk': bifrostVk, 'Content-Type': 'application/json' },
headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: tier.model,
fallbacks: tier.fallbacks,
model: 'mistral-small-latest',
temperature: 0.3,
max_tokens: 700,
response_format: { type: 'json_object' },

View File

@@ -7,7 +7,6 @@
// @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
@@ -67,7 +66,6 @@ export default defineEventHandler(async (event) => {
if (!question || question.length < 5) {
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
}
const tier = pickBifrostTier(body?.mode)
// Données bundlées statiquement à la compilation (import JSON)
const plateformes: PlateformeMinimal[] = ((taffData as any).plateformes ?? []).map((p: any) => ({
@@ -93,22 +91,20 @@ export default defineEventHandler(async (event) => {
const systemPrompt = SYSTEM_PROMPT.replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0))
const bifrostUrl = config.bifrostUrl as string
const bifrostVk = config.bifrostVk as string
if (!bifrostVk) {
throw createError({ statusCode: 500, statusMessage: 'Clé Bifrost manquante.' })
const mistralApiKey = config.mistralApiKey as string
if (!mistralApiKey) {
throw createError({ statusCode: 500, statusMessage: 'Clé API Mistral manquante.' })
}
let mistralRaw: string
try {
const res = await $fetch<BifrostChatResponse>(
`${bifrostUrl}/v1/chat/completions`,
const res = await $fetch<{ choices: { message: { content: string } }[] }>(
'https://api.mistral.ai/v1/chat/completions',
{
method: 'POST',
headers: { 'x-bf-vk': bifrostVk, 'Content-Type': 'application/json' },
headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: tier.model,
fallbacks: tier.fallbacks,
model: 'mistral-small-latest',
temperature: 0.3,
max_tokens: 700,
response_format: { type: 'json_object' },

View File

@@ -19,7 +19,6 @@
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost'
// ── Types ──────────────────────────────────────────────────────────────────────
@@ -132,19 +131,18 @@ async function logUsage(params: {
nocodbUrl: string
nocodbToken: string
statsTableId: string
model: string
tokensIn: number
tokensOut: number
coutEur: number
}) {
const { nocodbUrl, nocodbToken, statsTableId, model, tokensIn, tokensOut, coutEur } = params
const { nocodbUrl, nocodbToken, statsTableId, tokensIn, tokensOut, coutEur } = params
const logUrl = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
try {
await $fetch(logUrl, {
method: 'POST',
headers: { 'xc-token': nocodbToken, 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
model: 'mistral-small-latest',
endpoint: 'chatbot',
tokens_in: tokensIn,
tokens_out: tokensOut,
@@ -182,7 +180,6 @@ export default defineEventHandler(async (event) => {
const body = await readBody(event)
const question: string = (body?.question ?? '').trim()
const filters: { fonction?: string; echelle?: string } = body?.filters ?? {}
const tier = pickBifrostTier(body?.mode)
if (!question || question.length < 3) {
throw createError({
@@ -250,32 +247,32 @@ export default defineEventHandler(async (event) => {
JSON.stringify(fichesContext, null, 0),
)
// 6. Appel Bifrost (gateway LLM — remplace l'appel Mistral direct)
const bifrostUrl = config.bifrostUrl as string
const bifrostVk = config.bifrostVk as string
// 6. Appel Mistral Small
const mistralApiKey = config.mistralApiKey as string
if (!bifrostVk) {
if (!mistralApiKey) {
throw createError({
statusCode: 500,
statusMessage: 'Clé Bifrost manquante.',
statusMessage: 'Clé API Mistral manquante.',
})
}
let mistralRaw: string
let tokensIn = 0
let tokensOut = 0
let realModel = tier.model
try {
const bifrostRes = await $fetch<BifrostChatResponse>(`${bifrostUrl}/v1/chat/completions`, {
const mistralRes = await $fetch<{
choices: { message: { content: string } }[]
usage?: { prompt_tokens: number; completion_tokens: number }
}>('https://api.mistral.ai/v1/chat/completions', {
method: 'POST',
headers: {
'x-bf-vk': bifrostVk,
Authorization: `Bearer ${mistralApiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: tier.model,
fallbacks: tier.fallbacks,
model: 'mistral-small-latest',
temperature: 0.3,
max_tokens: 600,
response_format: { type: 'json_object' },
@@ -286,16 +283,11 @@ export default defineEventHandler(async (event) => {
}),
})
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)
mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}'
tokensIn = mistralRes.usage?.prompt_tokens ?? 0
tokensOut = mistralRes.usage?.completion_tokens ?? 0
} catch (e: any) {
console.error('[chatbot] Erreur Bifrost:', e?.message ?? e)
console.error('[chatbot] Erreur Mistral Small:', e?.message ?? e)
throw createError({
statusCode: 502,
statusMessage: 'Erreur appel IA — réessaie dans quelques instants.',
@@ -325,16 +317,11 @@ export default defineEventHandler(async (event) => {
}
// 8. Log usage (non bloquant)
// Coût réel calculable seulement si le provider ayant répondu est Mistral —
// les autres tiers Bifrost (Groq, Cerebras, Gemini free tier, Cohere) sont free-tier,
// donc cout_eur=0 pour ne pas fausser le circuit breaker avec un tarif Mistral inapplicable.
const isMistral = realModel.toLowerCase().startsWith('mistral')
const coutEur = isMistral ? calcCoutMistralSmall(tokensIn, tokensOut) : 0
const coutEur = calcCoutMistralSmall(tokensIn, tokensOut)
logUsage({
nocodbUrl: config.nocodbUrl as string,
nocodbToken: config.nocodbToken as string,
statsTableId,
model: realModel,
tokensIn,
tokensOut,
coutEur,

View File

@@ -1,8 +1,8 @@
/**
* POST /api/submit
*
* Soumission multi-type (ecosysteme / reseau / job / outil).
* - Validation Zod côté serveur (champs communs + par type)
* Soumission d'une nouvelle organisation par un utilisateur.
* - Validation Zod côté serveur
* - Rate limit Redis : 3 soumissions / IP / jour
* - Géocodage Nominatim (optionnel — fallback silencieux)
* - INSERT NocoDB : moderation_status=pending, ai_processed=false
@@ -15,49 +15,62 @@
import { z } from 'zod'
import { checkRateLimit } from '~/server/utils/rateLimit'
const SUBMISSION_TYPES = ['ecosysteme', 'reseau', 'job', 'outil'] as const
// ── Schéma Zod ────────────────────────────────────────────────────────────────
const FONCTIONS = [
'Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier',
'Comptabilité', 'Développement', 'Formation', 'Gestion d\'agence', 'Santé mentale',
'Juridique',
'Technique',
'Économique',
'Administratif',
'Chantier',
'Comptabilité',
'Développement',
'Formation',
'Gestion d\'agence',
'Santé mentale',
] as const
const ECHELLES = ['National', 'Régional', 'Local'] as const
const TERRITOIRES = [
'Métropole', 'Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte',
'Métropole',
'Guadeloupe',
'Martinique',
'Guyane',
'La Réunion',
'Mayotte',
] as const
const TYPES_PRINCIPAUX = ['agence', 'collectif', 'reseau', 'asso', 'ressource'] as const
const TYPES_PLATEFORME = ['mise-en-relation', 'appel-offre-public', 'communaute-pro'] as const
const CATEGORIES_OUTIL = ['simulateur', 'logiciel', 'guide', 'autre'] as const
export const SubmitSchema = z.object({
// Champs communs
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
url: z.string().url('URL invalide').optional().or(z.literal('')).transform(v => v || undefined),
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')).transform(v => v || undefined),
submission_type: z.enum(SUBMISSION_TYPES, { errorMap: () => ({ message: 'Type de soumission invalide' }) }),
// Champs écosystème
echelle: z.enum(ECHELLES).optional(),
fonctions: z.array(z.enum(FONCTIONS)).min(1).max(5).optional(),
territoire: z.enum(TERRITOIRES).optional(),
localisation_ville: z.string().max(100).optional().transform(v => v?.trim() || undefined),
// Champs réseau
type_principal: z.enum(TYPES_PRINCIPAUX).optional(),
projet_lien: z.string().url('URL invalide').optional().or(z.literal('')),
projet_adresse: z.string().max(200).optional().transform(v => v?.trim() || undefined),
// Champs job
type_plateforme: z.enum(TYPES_PLATEFORME).optional(),
// Champs outil
categorie: z.enum(CATEGORIES_OUTIL).optional(),
url: z
.string()
.url('URL invalide')
.optional()
.or(z.literal(''))
.transform((v) => v || undefined),
description_user: z
.string()
.min(50, 'Minimum 50 caractères')
.max(500, 'Maximum 500 caractères')
.trim(),
echelle: z.enum(ECHELLES, { errorMap: () => ({ message: 'Échelle invalide' }) }),
fonctions: z
.array(z.enum(FONCTIONS))
.min(1, 'Sélectionne au moins une fonction')
.max(5, 'Maximum 5 fonctions'),
territoire: z.enum(TERRITOIRES, { errorMap: () => ({ message: 'Territoire invalide' }) }),
localisation_ville: z
.string()
.max(100)
.optional()
.transform((v) => v?.trim() || undefined),
submitted_by_email: z
.string()
.email('Email invalide')
.optional()
.or(z.literal(''))
.transform((v) => v || undefined),
})
export type SubmitInput = z.infer<typeof SubmitSchema>
@@ -135,36 +148,22 @@ export default defineEventHandler(async (event) => {
const nocoBaseId = config.nocodbBase
const payload: Record<string, unknown> = {
submission_type: data.submission_type,
nom: data.nom,
url: data.url || null,
description_user: data.description_user,
echelle: data.echelle,
tags_fonction: data.fonctions.join(','),
territoire: data.territoire,
localisation_ville: data.localisation_ville || null,
submitted_by_email: data.submitted_by_email || null,
moderation_status: 'pending',
scrape_status: data.url ? 'pending' : 'no_link',
ai_processed: false,
submitted_at: new Date().toISOString(),
latitude,
longitude,
}
// Champs spécifiques selon le type
if (data.submission_type === 'ecosysteme') {
payload.echelle = data.echelle
payload.tags_fonction = data.fonctions?.join(',')
payload.territoire = data.territoire
payload.localisation_ville = data.localisation_ville || null
payload.scrape_status = data.url ? 'pending' : 'no_link'
} else if (data.submission_type === 'reseau') {
payload.type_principal = data.type_principal
payload.projet_lien = data.projet_lien || null
payload.projet_adresse = data.projet_adresse || null
payload.localisation_ville = data.localisation_ville || null
} else if (data.submission_type === 'job') {
payload.type_plateforme = data.type_plateforme
} else if (data.submission_type === 'outil') {
payload.categorie = data.categorie
}
// NocoDB v1 endpoint
const insertUrl = `${nocodbUrl}/api/v1/db/data/noco/${nocoBaseId}/${orgTableId}`

View File

@@ -1,74 +0,0 @@
import { z } from 'zod'
import { checkRateLimit } from '~/server/utils/rateLimit'
const ReferenceSchema = z.object({
titre: z.string().min(3, 'Minimum 3 caractères').max(200, 'Maximum 200 caractères').trim(),
auteur: z.string().min(2, 'Minimum 2 caractères').max(200, 'Maximum 200 caractères').trim(),
url: z.string().url('URL invalide').optional().or(z.literal('')).transform(v => v || undefined),
description: z.string().min(50, 'Minimum 50 caractères').max(1000, 'Maximum 1000 caractères').trim(),
pertinence_rag: z.string().max(500, 'Maximum 500 caractères').optional().transform(v => v?.trim() || undefined),
hashtags: z.string().max(300, 'Maximum 300 caractères').optional().transform(v => v?.trim() || undefined),
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')).transform(v => v || undefined),
})
export type ReferenceInput = z.infer<typeof ReferenceSchema>
export default defineEventHandler(async (event) => {
const ip = getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() || event.node.req.socket?.remoteAddress || '0.0.0.0'
const allowed = await checkRateLimit(ip, 'submit-reference', 3)
if (!allowed) {
throw createError({ statusCode: 429, statusMessage: 'Trop de soumissions. Réessaie demain.' })
}
const body = await readBody(event)
const parsed = ReferenceSchema.safeParse(body)
if (!parsed.success) {
throw createError({
statusCode: 422,
statusMessage: 'Validation échouée',
data: parsed.error.flatten().fieldErrors,
})
}
const data = parsed.data
const payload: Record<string, unknown> = {
titre: data.titre,
auteur: data.auteur,
url: data.url || null,
description: data.description,
pertinence_rag: data.pertinence_rag || null,
hashtags: data.hashtags ? data.hashtags.split(',').map(h => h.trim()).filter(Boolean).join(',') : null,
submitted_by_email: data.submitted_by_email || null,
moderation_status: 'pending',
submitted_at: new Date().toISOString(),
}
const config = useRuntimeConfig()
const insertUrl = `${config.nocodbUrl}/api/v1/db/data/noco/${config.nocodbBase}/${config.referencesTableId}`
let insertedRecord: any
try {
insertedRecord = await $fetch(insertUrl, {
method: 'POST',
headers: {
'xc-token': config.nocodbToken,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
} catch (e: any) {
console.error('[reference] Erreur NocoDB insert:', e?.message ?? e)
throw createError({ statusCode: 502, statusMessage: 'Erreur serveur — réessaie dans quelques instants.' })
}
const referenceId = insertedRecord?.Id ?? insertedRecord?.id ?? null
return {
status: 201,
referenceId,
message: 'Ta référence est en attente de modération.',
}
})

View File

@@ -1,10 +0,0 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
export default defineEventHandler((event) => {
const path = join(process.cwd(), 'public', 'data', 'auteurs-pensees.json')
const raw = readFileSync(path, 'utf-8')
setResponseHeader(event, 'content-type', 'application/json; charset=utf-8')
setResponseHeader(event, 'cache-control', 'public, max-age=300')
return raw
})

View File

@@ -1,47 +0,0 @@
/**
* Bifrost — gateway LLM (remplace les appels directs Mistral)
* Endpoint OpenAI-compatible : POST {bifrostUrl}/v1/chat/completions
* Auth : header x-bf-vk
*
* 2 tiers 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 }
}